Skip to content

Commit 3973bf9

Browse files
authored
Merge pull request #27 from lbedner/enhanced-health-checks
Enhanced Health Checks
2 parents cbb00ea + d684e2c commit 3973bf9

5 files changed

Lines changed: 373 additions & 26 deletions

File tree

aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/frontend/main.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,54 @@ async def refresh_status() -> None:
322322
),
323323
]
324324

325+
# Add database-specific metadata display
326+
if comp_name == "database" and component.metadata:
327+
db_info = []
328+
329+
# Show SQLite version if available
330+
if "version" in component.metadata:
331+
db_info.append(f"SQLite v{component.metadata['version']}")
332+
333+
# Show file size if available
334+
if "file_size_human" in component.metadata:
335+
size = component.metadata['file_size_human']
336+
db_info.append(f"Size: {size}")
337+
338+
# Show WAL status if available
339+
if "wal_enabled" in component.metadata:
340+
wal_enabled = component.metadata["wal_enabled"]
341+
wal_status = "WAL" if wal_enabled else "DELETE"
342+
db_info.append(f"Mode: {wal_status}")
343+
344+
# Show connection pool size if available
345+
if "connection_pool_size" in component.metadata:
346+
pool_size = component.metadata['connection_pool_size']
347+
db_info.append(f"Pool: {pool_size}")
348+
349+
# Add database info to card if we have any
350+
if db_info:
351+
card_content.append(
352+
ft.Container(
353+
content=ft.Column(
354+
[
355+
ft.Text(
356+
"Database Info:",
357+
size=10,
358+
weight=ft.FontWeight.BOLD,
359+
color=text_color,
360+
),
361+
ft.Text(
362+
" • ".join(db_info),
363+
size=10,
364+
color=text_color,
365+
),
366+
],
367+
spacing=2,
368+
),
369+
margin=ft.margin.only(top=8),
370+
)
371+
)
372+
325373
# Add sub-component indicators
326374
if component.sub_components:
327375
sub_status = []
@@ -486,6 +534,113 @@ async def refresh_status() -> None:
486534
)
487535
)
488536

537+
# Database details card (if database component exists)
538+
database_comp = components.get("database")
539+
if database_comp and database_comp.metadata:
540+
db_bg_color = (
541+
ft.Colors.CYAN_100 if is_light_mode else ft.Colors.CYAN_900
542+
)
543+
db_text_color = (
544+
ft.Colors.CYAN_800 if is_light_mode else ft.Colors.CYAN_100
545+
)
546+
547+
db_content = [
548+
ft.Text(
549+
"Database Details",
550+
size=14,
551+
weight=ft.FontWeight.BOLD,
552+
color=db_text_color,
553+
)
554+
]
555+
556+
# Show detailed database metadata
557+
metadata = database_comp.metadata
558+
559+
# Version and implementation
560+
if "version" in metadata and "implementation" in metadata:
561+
db_content.append(
562+
ft.Text(
563+
f"{metadata['implementation'].upper()} "
564+
f"v{metadata['version']}",
565+
size=12,
566+
weight=ft.FontWeight.BOLD,
567+
color=db_text_color,
568+
)
569+
)
570+
571+
# File info
572+
if "file_size_human" in metadata and "file_size_bytes" in metadata:
573+
db_content.append(
574+
ft.Text(
575+
f"File Size: {metadata['file_size_human']} "
576+
f"({metadata['file_size_bytes']:,} bytes)",
577+
size=11,
578+
color=db_text_color,
579+
)
580+
)
581+
582+
# Connection info
583+
if "connection_pool_size" in metadata:
584+
db_content.append(
585+
ft.Text(
586+
f"Connection Pool: "
587+
f"{metadata['connection_pool_size']} connections",
588+
size=11,
589+
color=db_text_color,
590+
)
591+
)
592+
593+
# SQLite PRAGMA settings
594+
if "pragma_settings" in metadata:
595+
pragma = metadata["pragma_settings"]
596+
pragma_info = []
597+
598+
if "foreign_keys" in pragma:
599+
fk_status = "ON" if pragma["foreign_keys"] else "OFF"
600+
pragma_info.append(f"Foreign Keys: {fk_status}")
601+
602+
if "journal_mode" in pragma:
603+
journal_mode = pragma["journal_mode"].upper()
604+
pragma_info.append(f"Journal: {journal_mode}")
605+
606+
if "cache_size" in pragma:
607+
# Remove negative sign
608+
cache_size = abs(pragma["cache_size"])
609+
if cache_size > 1000:
610+
cache_display = f"{cache_size // 1000}K pages"
611+
else:
612+
cache_display = f"{cache_size} pages"
613+
pragma_info.append(f"Cache: {cache_display}")
614+
615+
if pragma_info:
616+
db_content.append(
617+
ft.Text(
618+
"Configuration:",
619+
size=11,
620+
weight=ft.FontWeight.BOLD,
621+
color=db_text_color,
622+
)
623+
)
624+
for info in pragma_info:
625+
db_content.append(
626+
ft.Text(
627+
f" • {info}",
628+
size=10,
629+
color=db_text_color,
630+
)
631+
)
632+
633+
bottom_cards.append(
634+
ft.Container(
635+
content=ft.Column(db_content, spacing=4),
636+
padding=15,
637+
bgcolor=db_bg_color,
638+
border=ft.border.all(1, ft.Colors.CYAN),
639+
border_radius=8,
640+
width=300,
641+
)
642+
)
643+
489644
details_row.content.controls = bottom_cards
490645

491646
page.update()

aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/system/health.py.j2

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import asyncio
99
from collections.abc import Awaitable, Callable
1010
from datetime import UTC, datetime
1111
import os
12+
import sqlite3
1213
import sys
1314
from typing import Any, cast
1415

@@ -24,6 +25,22 @@ from .models import ComponentStatus, ComponentStatusType, SystemStatus
2425
_health_checks: dict[str, Callable[[], Awaitable[ComponentStatus]]] = {}
2526

2627

28+
def format_bytes(size: int) -> str:
29+
"""Format bytes into human-readable string."""
30+
if size == 0:
31+
return "0 B"
32+
33+
size_float = float(size)
34+
for unit in ['B', 'KB', 'MB', 'GB']:
35+
if size_float < 1024.0:
36+
if unit == 'B':
37+
return f"{int(size_float)} {unit}"
38+
else:
39+
return f"{size_float:.1f} {unit}"
40+
size_float /= 1024.0
41+
return f"{size_float:.1f} TB"
42+
43+
2744
def propagate_status(child_statuses: list[ComponentStatusType]) -> ComponentStatusType:
2845
"""
2946
Determine parent status from child statuses using standard hierarchy.
@@ -593,28 +610,88 @@ async def check_database_health() -> ComponentStatus:
593610
"database_exists": False,
594611
"expected_path": db_path,
595612
"url": settings.DATABASE_URL,
596-
"recommendation": "Run database migrations or create database file",
613+
"recommendation": (
614+
"Run database migrations or create database file"
615+
),
597616
},
598617
)
599618

600-
# Test database connection with simple query
619+
# Test database connection with simple query and collect enhanced metadata
620+
enhanced_metadata = {
621+
"implementation": "sqlite",
622+
"url": settings.DATABASE_URL,
623+
"database_exists": True,
624+
"engine_echo": settings.DATABASE_ENGINE_ECHO,
625+
}
626+
627+
# Collect additional metadata for SQLite databases
628+
if db_url.startswith("sqlite:///"):
629+
try:
630+
# Add SQLite version
631+
enhanced_metadata["version"] = sqlite3.sqlite_version
632+
633+
# Extract and add file size information
634+
db_path = db_url.replace("sqlite:///", "").lstrip("./")
635+
if Path(db_path).exists():
636+
file_size = Path(db_path).stat().st_size
637+
enhanced_metadata["file_size_bytes"] = file_size
638+
enhanced_metadata["file_size_human"] = format_bytes(file_size)
639+
640+
# Get engine and connection pool information
641+
from app.core.db import engine
642+
if hasattr(engine.pool, 'size'):
643+
enhanced_metadata["connection_pool_size"] = engine.pool.size()
644+
else:
645+
# SQLite typically uses NullPool or StaticPool with size 1
646+
enhanced_metadata["connection_pool_size"] = 1
647+
648+
except Exception as e:
649+
# If any enhanced metadata collection fails, log but don't break
650+
# health check
651+
logger.debug("Failed to collect enhanced database metadata", exc_info=True)
652+
653+
# Test database connection and collect PRAGMA settings
601654
with db_session(autocommit=False) as session:
602655
# Execute a simple query to test connectivity
603656
from sqlalchemy import text
604657
session.execute(text("SELECT 1"))
658+
659+
# Collect SQLite PRAGMA settings for additional metadata
660+
if db_url.startswith("sqlite:///"):
661+
try:
662+
pragma_settings = {}
663+
664+
# Get foreign keys setting
665+
result = session.execute(text("PRAGMA foreign_keys")).fetchone()
666+
if result:
667+
pragma_settings["foreign_keys"] = bool(result[0])
668+
669+
# Get journal mode
670+
result = session.execute(text("PRAGMA journal_mode")).fetchone()
671+
if result:
672+
journal_mode = result[0].lower()
673+
pragma_settings["journal_mode"] = journal_mode
674+
enhanced_metadata["wal_enabled"] = journal_mode == "wal"
675+
676+
# Add cache size if available
677+
result = session.execute(text("PRAGMA cache_size")).fetchone()
678+
if result:
679+
pragma_settings["cache_size"] = result[0]
680+
681+
enhanced_metadata["pragma_settings"] = pragma_settings
682+
683+
except Exception as e:
684+
# PRAGMA queries can fail in some SQLite configurations
685+
logger.debug("Failed to collect SQLite PRAGMA settings", exc_info=True)
686+
605687
# No need to commit since we're just testing connectivity
606688

607689
return ComponentStatus(
608690
name="database",
609691
status=ComponentStatusType.HEALTHY,
610692
message="Database connection successful",
611693
response_time_ms=None, # Will be set by caller
612-
metadata={
613-
"implementation": "sqlite",
614-
"url": settings.DATABASE_URL,
615-
"database_exists": True,
616-
"engine_echo": settings.DATABASE_ENGINE_ECHO,
617-
},
694+
metadata=enhanced_metadata,
618695
)
619696

620697
except ImportError:

aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/conftest.py.j2

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,16 @@ from sqlmodel import Session, SQLModel
2525
def engine() -> Engine:
2626
"""
2727
Create in-memory SQLite database engine for tests.
28-
28+
2929
Uses :memory: database that exists only in RAM for maximum speed
3030
and perfect test isolation. Each test session gets a fresh database.
31-
31+
3232
Returns:
3333
SQLAlchemy Engine connected to in-memory SQLite database
3434
"""
3535
engine = create_engine(
3636
"sqlite:///:memory:",
37-
echo=False, # Set to True for SQL debugging - can be overridden by DATABASE_ENGINE_ECHO
37+
echo=False, # Set to True for SQL debugging
3838
connect_args={"check_same_thread": False} # Allow multi-threaded access
3939
)
4040

@@ -56,21 +56,21 @@ def engine() -> Engine:
5656
def db_session(engine: Engine) -> Generator[Session, None, None]:
5757
"""
5858
Provide transactional database session with automatic rollback.
59-
59+
6060
Each test gets a fresh transaction that's rolled back after the test,
6161
ensuring perfect isolation between tests. Uses the same transaction
6262
pattern as PostgreSQL for consistency.
63-
63+
6464
Args:
6565
engine: Database engine from session-scoped fixture
66-
66+
6767
Yields:
6868
SQLModel Session for database operations
6969
"""
7070
connection = engine.connect()
7171
transaction = connection.begin()
72-
SessionLocal = sessionmaker(bind=connection)
73-
session = SessionLocal()
72+
session_local = sessionmaker(bind=connection)
73+
session = session_local()
7474

7575
yield session
7676

aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/test_component_integration.py renamed to aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/test_component_integration.py.j2

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,22 @@
1919
_check_cpu_usage,
2020
_check_disk_space,
2121
_check_memory,
22-
check_cache_health,
23-
check_worker_health,
2422
)
23+
{% if cookiecutter.include_redis == "yes" %}
24+
from app.services.system.health import check_cache_health
25+
{% endif %}
26+
{% if cookiecutter.include_database == "yes" %}
27+
from app.services.system.health import check_database_health
28+
{% endif %}
29+
{% if cookiecutter.include_worker == "yes" %}
30+
from app.services.system.health import check_worker_health
31+
{% endif %}
2532

2633

2734
class TestComponentIntegration:
2835
"""Test component health checks with controlled failure scenarios."""
2936

37+
{% if cookiecutter.include_redis == "yes" %}
3038
@pytest.mark.asyncio
3139
async def test_cache_health_redis_connection_failure(self) -> None:
3240
"""Test cache health check when Redis connection fails."""
@@ -91,7 +99,9 @@ async def mock_get(key: str) -> bytes:
9199
assert cache_status.metadata["implementation"] == "redis"
92100
assert cache_status.metadata["version"] == "7.0.0"
93101
assert cache_status.metadata["connected_clients"] == 2
102+
{% endif %}
94103

104+
{% if cookiecutter.include_worker == "yes" %}
95105
@pytest.mark.asyncio
96106
async def test_worker_health_redis_connection_failure(self) -> None:
97107
"""Test worker health check when Redis connection fails."""
@@ -165,6 +175,7 @@ def mock_get(key: str) -> bytes | None:
165175
assert (
166176
queue_sub_components["load_test"].status == ComponentStatusType.HEALTHY
167177
)
178+
{% endif %}
168179

169180
@pytest.mark.asyncio
170181
async def test_system_metrics_high_thresholds(self) -> None:

0 commit comments

Comments
 (0)