aegis-stack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of aegis-stack might be problematic. Click here for more details.

Files changed (103) hide show
  1. aegis/__init__.py +5 -0
  2. aegis/__main__.py +374 -0
  3. aegis/core/CLAUDE.md +365 -0
  4. aegis/core/__init__.py +6 -0
  5. aegis/core/components.py +115 -0
  6. aegis/core/dependency_resolver.py +119 -0
  7. aegis/core/template_generator.py +163 -0
  8. aegis/templates/CLAUDE.md +306 -0
  9. aegis/templates/cookiecutter-aegis-project/cookiecutter.json +27 -0
  10. aegis/templates/cookiecutter-aegis-project/hooks/post_gen_project.py +172 -0
  11. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/.dockerignore +71 -0
  12. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/.env.example.j2 +70 -0
  13. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/.gitignore +127 -0
  14. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
  15. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/Makefile +211 -0
  16. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/README.md.j2 +196 -0
  17. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/__init__.py +5 -0
  18. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/cli/__init__.py +6 -0
  19. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/cli/health.py +321 -0
  20. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/cli/load_test.py +638 -0
  21. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/cli/main.py +41 -0
  22. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/__init__.py +0 -0
  23. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/api/__init__.py +0 -0
  24. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/api/health.py +134 -0
  25. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/api/models.py.j2 +247 -0
  26. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/api/routing.py.j2 +14 -0
  27. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/api/tasks.py.j2 +596 -0
  28. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/hooks.py +133 -0
  29. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/main.py +16 -0
  30. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/middleware/__init__.py +1 -0
  31. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/middleware/cors.py +20 -0
  32. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/shutdown/__init__.py +1 -0
  33. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/shutdown/cleanup.py +14 -0
  34. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/startup/__init__.py +1 -0
  35. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/startup/component_health.py.j2 +190 -0
  36. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/frontend/__init__.py +0 -0
  37. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/frontend/core/__init__.py +1 -0
  38. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/frontend/core/theme.py +46 -0
  39. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/frontend/main.py +687 -0
  40. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/scheduler/__init__.py +1 -0
  41. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/scheduler/main.py +138 -0
  42. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/CLAUDE.md +213 -0
  43. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/__init__.py +6 -0
  44. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/constants.py.j2 +30 -0
  45. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/pools.py +78 -0
  46. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/queues/__init__.py +1 -0
  47. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/queues/load_test.py +48 -0
  48. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/queues/media.py +41 -0
  49. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/queues/system.py +36 -0
  50. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/registry.py +139 -0
  51. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/tasks/__init__.py +119 -0
  52. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/tasks/load_tasks.py +526 -0
  53. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/tasks/simple_system_tasks.py +32 -0
  54. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/tasks/system_tasks.py +279 -0
  55. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/core/config.py.j2 +119 -0
  56. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/core/constants.py +60 -0
  57. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/core/db.py +67 -0
  58. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/core/log.py +85 -0
  59. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/entrypoints/__init__.py +1 -0
  60. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/entrypoints/webserver.py +40 -0
  61. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/entrypoints/{% if cookiecutter.include_scheduler == /"yes/" %}scheduler.py{% endif %}" +21 -0
  62. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/integrations/__init__.py +0 -0
  63. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/integrations/main.py +61 -0
  64. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/py.typed +0 -0
  65. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/__init__.py +1 -0
  66. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/load_test.py +661 -0
  67. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/load_test_models.py +269 -0
  68. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/shared/__init__.py +15 -0
  69. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/shared/models.py +26 -0
  70. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/system/__init__.py +52 -0
  71. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/system/alerts.py +94 -0
  72. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/system/health.py.j2 +1105 -0
  73. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/system/models.py +169 -0
  74. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/system/ui.py +52 -0
  75. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docker-compose.yml.j2 +195 -0
  76. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docs/api.md +191 -0
  77. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docs/components/scheduler.md +414 -0
  78. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docs/development.md +215 -0
  79. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docs/health.md +240 -0
  80. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docs/javascripts/mermaid-config.js +62 -0
  81. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docs/stylesheets/mermaid.css +95 -0
  82. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/mkdocs.yml.j2 +62 -0
  83. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/pyproject.toml.j2 +156 -0
  84. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/scripts/entrypoint.sh +87 -0
  85. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/scripts/entrypoint.sh.j2 +104 -0
  86. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/scripts/gen_docs.py +16 -0
  87. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/api/__init__.py +1 -0
  88. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/api/test_health_endpoints.py.j2 +239 -0
  89. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/components/test_scheduler.py +76 -0
  90. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/conftest.py.j2 +81 -0
  91. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/__init__.py +1 -0
  92. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/test_component_integration.py.j2 +376 -0
  93. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/test_health_logic.py.j2 +633 -0
  94. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/test_load_test_models.py +665 -0
  95. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/test_load_test_service.py +602 -0
  96. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/test_system_service.py +96 -0
  97. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/test_worker_health_registration.py.j2 +224 -0
  98. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/test_core.py +50 -0
  99. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/uv.lock +1673 -0
  100. aegis_stack-0.1.0.dist-info/METADATA +114 -0
  101. aegis_stack-0.1.0.dist-info/RECORD +103 -0
  102. aegis_stack-0.1.0.dist-info/WHEEL +4 -0
  103. aegis_stack-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,196 @@
1
+ # {{ cookiecutter.project_name }} 🛡️
2
+
3
+ {{ cookiecutter.project_description }}
4
+
5
+ ## Components
6
+
7
+ This Aegis Stack project includes the following components:
8
+
9
+ - **✅ Backend**: FastAPI-based async web server
10
+ - **✅ Frontend**: Flet-based Python UI framework
11
+ {%- if cookiecutter.include_scheduler == "yes" %}
12
+ - **✅ Scheduler**: APScheduler-based async task scheduling
13
+ {%- endif %}
14
+ {%- if cookiecutter.include_database == "yes" %}
15
+ - **✅ Database**: SQLite database with SQLModel ORM
16
+ {%- endif %}
17
+ {%- if cookiecutter.include_cache == "yes" %}
18
+ - **✅ Cache**: Redis-based async caching
19
+ {%- endif %}
20
+
21
+ ## Getting Started
22
+
23
+ ### Prerequisites
24
+
25
+ - Python {{ cookiecutter.python_version }}+
26
+ - [uv](https://docs.astral.sh/uv/) (recommended) or pip
27
+
28
+ ### Installation
29
+
30
+ 1. **Install dependencies:**
31
+ ```bash
32
+ uv sync # Core dependencies only
33
+ # Or for development: uv sync --all-extras (includes testing, linting, docs)
34
+ ```
35
+
36
+ 2. **Activate virtual environment:**
37
+ ```bash
38
+ source .venv/bin/activate
39
+ ```
40
+
41
+ 3. **Set up environment:**
42
+ ```bash
43
+ cp .env.example .env
44
+ # Edit .env with your configuration
45
+ ```
46
+
47
+ {%- if cookiecutter.include_database == "yes" %}
48
+
49
+ 4. **Set up database:**
50
+ ```bash
51
+ # Create data directory for SQLite database
52
+ mkdir -p data
53
+
54
+ # Database will be created automatically on first run
55
+ # Default location: data/app.db
56
+ ```
57
+
58
+ 5. **Run the application:**
59
+ {%- else %}
60
+
61
+ 4. **Run the application:**
62
+ {%- endif %}
63
+ ```bash
64
+ make run-local
65
+ ```
66
+
67
+ The application will be available at `http://127.0.0.1:8000`.
68
+
69
+ ## Development
70
+
71
+ ### Running Tests
72
+ ```bash
73
+ make test
74
+ ```
75
+
76
+ ### Code Quality
77
+ ```bash
78
+ make check # Run linting, type checking, and tests
79
+ ```
80
+
81
+ ### CLI Commands
82
+
83
+ Your project gets its own CLI command **automatically installed** when first used - no setup required! 🎉
84
+
85
+ ```bash
86
+ # Health monitoring (auto-installs CLI if needed)
87
+ make health # Basic health check
88
+ make health-detailed # Detailed component status
89
+ make health-json # JSON output for scripts
90
+ make health-quick # Quick healthy/unhealthy check
91
+
92
+ # Direct CLI usage (after first make command)
93
+ {{ cookiecutter.project_slug }} health check # Basic health check
94
+ {{ cookiecutter.project_slug }} health check --detailed # Detailed component status
95
+ {{ cookiecutter.project_slug }} health check --json # JSON output for scripts
96
+ {{ cookiecutter.project_slug }} health quick # Quick healthy/unhealthy check
97
+
98
+ # Explore all commands
99
+ {{ cookiecutter.project_slug }} --help # See all available commands
100
+ {{ cookiecutter.project_slug }} health --help # See health subcommand options
101
+ ```
102
+
103
+ **Environment Issues?** If CLI commands don't work:
104
+ ```bash
105
+ # Ensure you're in the project's virtual environment
106
+ unset VIRTUAL_ENV # Clear Docker contamination
107
+ source .venv/bin/activate # Activate local venv
108
+ make health # This will auto-install CLI
109
+ # Or manually: make install-cli
110
+ ```
111
+
112
+ ### Documentation
113
+ ```bash
114
+ make docs-serve # Serve docs at http://localhost:8001/{{ cookiecutter.project_slug }}/
115
+ ```
116
+
117
+ ## Docker
118
+
119
+ ### Development
120
+ ```bash
121
+ docker compose --profile dev up
122
+ ```
123
+
124
+ ### Production
125
+ ```bash
126
+ docker compose --profile prod up
127
+ ```
128
+
129
+ {%- if cookiecutter.include_database == "yes" %}
130
+
131
+ ## Configuration
132
+
133
+ ### Database Settings
134
+
135
+ The database component uses SQLite with the following default configuration:
136
+
137
+ ```bash
138
+ # Environment variables (add to .env file)
139
+ DATABASE_URL=sqlite:///data/app.db # Database file location
140
+ DATABASE_ENGINE_ECHO=false # Enable SQL query logging
141
+ ```
142
+
143
+ ### Database Usage
144
+
145
+ ```python
146
+ from app.core.db import db_session
147
+ from sqlmodel import SQLModel, Field, select
148
+
149
+ # Define a model
150
+ class User(SQLModel, table=True):
151
+ id: int | None = Field(default=None, primary_key=True)
152
+ name: str
153
+ email: str
154
+
155
+ # Use the database
156
+ with db_session() as session:
157
+ user = User(name="John Doe", email="john@example.com")
158
+ session.add(user)
159
+ # Auto-committed by context manager
160
+ ```
161
+
162
+ {%- endif %}
163
+
164
+ ## Project Structure
165
+
166
+ ```
167
+ {{ cookiecutter.project_slug }}/
168
+ ├── app/ # Application code
169
+ │ ├── components/ # Component implementations
170
+ │ │ ├── backend/ # FastAPI backend
171
+ │ │ └── frontend/ # Flet frontend
172
+ {%- if cookiecutter.include_scheduler == "yes" %}
173
+ │ │ └── scheduler/ # APScheduler background tasks
174
+ {%- endif %}
175
+ │ ├── cli/ # CLI commands and health monitoring
176
+ │ ├── core/ # Core utilities
177
+ │ │ ├── config.py # Environment-dependent configuration
178
+ │ │ ├── constants.py # Immutable constants (12-Factor App)
179
+ {%- if cookiecutter.include_database == "yes" %}
180
+ │ │ ├── db.py # Database connection and session management
181
+ {%- endif %}
182
+ │ │ └── log.py # Structured logging
183
+ │ ├── entrypoints/ # Execution entry points
184
+ │ ├── integrations/ # App composition layer
185
+ {%- if cookiecutter._has_additional_components == "yes" %}
186
+ │ └── services/ # Business logic services
187
+ {%- endif %}
188
+ ├── tests/ # Test suite
189
+ ├── docs/ # Documentation
190
+ ├── docker-compose.yml # Docker services
191
+ └── pyproject.toml # Project configuration
192
+ ```
193
+
194
+ ---
195
+
196
+ Generated with [Aegis Stack](https://github.com/lbedner/aegis-stack) 🛡️
@@ -0,0 +1,5 @@
1
+ """
2
+ {{ cookiecutter.project_name }} - Main application package.
3
+ """
4
+
5
+ __version__ = "{{ cookiecutter.version }}"
@@ -0,0 +1,6 @@
1
+ """
2
+ CLI management commands for {{cookiecutter.project_name}}.
3
+
4
+ This module provides command-line utilities for managing and monitoring
5
+ the application outside of the main runtime components.
6
+ """
@@ -0,0 +1,321 @@
1
+ """
2
+ Health monitoring CLI commands.
3
+
4
+ Command-line interface for system health checking and monitoring via API endpoints.
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import sys
10
+ from typing import Any
11
+
12
+ import httpx
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+ import typer
16
+
17
+ from app.core.config import settings
18
+ from app.core.constants import CLI, APIEndpoints, Defaults
19
+ from app.core.log import setup_logging
20
+ from app.services.system.models import (
21
+ ComponentStatusType,
22
+ DetailedHealthResponse,
23
+ HealthResponse,
24
+ )
25
+ from app.services.system.ui import get_status_icon, get_status_color_name
26
+
27
+ app = typer.Typer(name="health", help="System health monitoring commands")
28
+ console = Console()
29
+
30
+
31
+ def _get_status_icon_and_color(status: ComponentStatusType) -> tuple[str, str]:
32
+ """Get the appropriate icon and color for a component status (shared mapping)."""
33
+ return get_status_icon(status), get_status_color_name(status)
34
+
35
+
36
+ async def get_health_data(
37
+ endpoint: str = APIEndpoints.HEALTH_BASIC,
38
+ ) -> HealthResponse | DetailedHealthResponse:
39
+ """Get health data from the API endpoint with Pydantic validation."""
40
+ base_url = getattr(settings, "API_BASE_URL", "http://localhost:8000")
41
+ url = f"{base_url}{endpoint}"
42
+
43
+ timeout = httpx.Timeout(Defaults.API_TIMEOUT)
44
+ async with httpx.AsyncClient(timeout=timeout) as client:
45
+ try:
46
+ response = await client.get(url)
47
+ response.raise_for_status()
48
+ json_data = response.json()
49
+
50
+ # Validate response with appropriate Pydantic model
51
+ if endpoint == APIEndpoints.HEALTH_DETAILED:
52
+ return DetailedHealthResponse.model_validate(json_data)
53
+ else:
54
+ return HealthResponse.model_validate(json_data)
55
+
56
+ except httpx.ConnectError:
57
+ raise ConnectionError(
58
+ f"Cannot connect to API server at {base_url}. "
59
+ "Make sure the application is running with "
60
+ "'make run-local' or 'make run-dev'."
61
+ ) from None
62
+ except httpx.TimeoutException:
63
+ raise TimeoutError(
64
+ f"API request to {url} timed out after {Defaults.API_TIMEOUT} seconds."
65
+ ) from None
66
+ except httpx.HTTPStatusError as e:
67
+ # Handle structured error responses from health endpoint
68
+ if e.response.status_code == 503:
69
+ try:
70
+ error_data = e.response.json()
71
+ if "detail" in error_data and isinstance(
72
+ error_data["detail"], dict
73
+ ):
74
+ detail = error_data["detail"]
75
+ message = detail.get("message", "System is unhealthy")
76
+ unhealthy_components = detail.get("unhealthy_components", [])
77
+ health_percentage = detail.get("health_percentage", 0)
78
+
79
+ error_msg = f"{message}"
80
+ if unhealthy_components:
81
+ components_str = ", ".join(unhealthy_components)
82
+ error_msg += f" (Unhealthy: {components_str})"
83
+ if health_percentage is not None:
84
+ error_msg += f" - Health: {health_percentage:.1f}%"
85
+
86
+ raise RuntimeError(error_msg) from None
87
+ except (ValueError, KeyError, TypeError):
88
+ # Fall back to generic error message if JSON parsing fails
89
+ pass
90
+
91
+ raise RuntimeError(
92
+ f"API returned error {e.response.status_code}: {e.response.text}"
93
+ ) from None
94
+
95
+
96
+ async def is_system_healthy() -> bool:
97
+ """Quick check if system is healthy via API."""
98
+ try:
99
+ health_data = await get_health_data(APIEndpoints.HEALTH_BASIC)
100
+ return health_data.healthy
101
+ except Exception:
102
+ return False
103
+
104
+
105
+ @app.command("status")
106
+ def health_status(
107
+ detailed: bool = typer.Option(
108
+ False, "--detailed", "-d", help="Show detailed component information"
109
+ ),
110
+ json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
111
+ ) -> None:
112
+ """Show system health status (always exits 0 for inspection)."""
113
+ setup_logging()
114
+
115
+ try:
116
+ endpoint = (
117
+ APIEndpoints.HEALTH_DETAILED if detailed else APIEndpoints.HEALTH_BASIC
118
+ )
119
+ health_data = asyncio.run(get_health_data(endpoint))
120
+
121
+ if json_output:
122
+ print(json.dumps(health_data.model_dump(), indent=2))
123
+ else:
124
+ _display_health_status(health_data, detailed)
125
+
126
+ # Always exit 0 for status command (informational)
127
+
128
+ except Exception as e:
129
+ if json_output:
130
+ error_data = {"error": str(e), "status": "error"}
131
+ print(json.dumps(error_data, indent=2))
132
+ else:
133
+ console.print(f"[red]❌ Health status failed: {e}[/red]")
134
+ # Exit 1 only on actual errors (connection failures, etc), not unhealthy status
135
+ sys.exit(1)
136
+
137
+
138
+ @app.command("probe")
139
+ def health_probe() -> None:
140
+ """Health probe for monitoring - exits 1 if unhealthy (like k8s probes)."""
141
+ setup_logging()
142
+
143
+ try:
144
+ healthy = asyncio.run(is_system_healthy())
145
+
146
+ if healthy:
147
+ console.print("[green]✅ System is healthy[/green]")
148
+ sys.exit(0)
149
+ else:
150
+ console.print("[red]❌ System is unhealthy[/red]")
151
+ sys.exit(1)
152
+
153
+ except Exception as e:
154
+ console.print(f"[red]💥 Health probe failed: {e}[/red]")
155
+ sys.exit(1)
156
+
157
+
158
+ def _display_sub_components(
159
+ sub_components: dict[str, Any], detailed: bool, level: int
160
+ ) -> None:
161
+ """Recursively display sub-components with appropriate tree indentation."""
162
+ sub_items = list(sub_components.items())
163
+
164
+ # Calculate tree indentation based on level
165
+ base_indent = " " * level
166
+
167
+ for i, (sub_name, sub_component) in enumerate(sub_items):
168
+ sub_icon, sub_color = _get_status_icon_and_color(sub_component.status)
169
+
170
+ # Tree connector: ├── for middle items, └── for last item
171
+ is_last = i == len(sub_items) - 1
172
+ tree_connector = f"{base_indent}└── " if is_last else f"{base_indent}├── "
173
+
174
+ sub_line = f"{tree_connector}[{sub_color}]{sub_icon} {sub_name}[/{sub_color}]"
175
+ if detailed and sub_component.response_time_ms is not None:
176
+ sub_line += f" ([dim]{sub_component.response_time_ms:.1f}ms[/dim])"
177
+ sub_line += f" {sub_component.message}"
178
+ console.print(sub_line)
179
+
180
+ # Recursively display sub-sub-components
181
+ if hasattr(sub_component, "sub_components") and sub_component.sub_components:
182
+ _display_sub_components(sub_component.sub_components, detailed, level + 1)
183
+
184
+ # Show metadata for sub-components if detailed and available
185
+ elif detailed and sub_component.metadata:
186
+ metadata_str = json.dumps(sub_component.metadata, separators=(",", ":"))
187
+ max_length = CLI.MAX_METADATA_DISPLAY_LENGTH
188
+ if len(metadata_str) > max_length:
189
+ metadata_str = metadata_str[: max_length - 3] + "..."
190
+ # Adjust tree indent based on whether this is the last item
191
+ tree_indent = f"{base_indent} " if is_last else f"{base_indent}│ "
192
+ console.print(f"{tree_indent}[dim]({metadata_str})[/dim]")
193
+
194
+
195
+ def _display_health_status(
196
+ health_data: HealthResponse | DetailedHealthResponse, detailed: bool = False
197
+ ) -> None:
198
+ """Display health status with rich formatting."""
199
+
200
+ # Extract data from Pydantic model
201
+ overall_healthy = health_data.healthy
202
+ components = health_data.components
203
+ timestamp = health_data.timestamp
204
+
205
+ # Use health percentage from API response if available (DetailedHealthResponse)
206
+ # Otherwise calculate from top-level components (HealthResponse)
207
+ if hasattr(health_data, "health_percentage"):
208
+ health_percentage = health_data.health_percentage
209
+ # For detailed response, use the component counts from API
210
+ if hasattr(health_data, "healthy_components"):
211
+ if hasattr(health_data, 'unhealthy_components'):
212
+ healthy_count = len(health_data.healthy_components)
213
+ total_count = len(health_data.healthy_components) + len(
214
+ health_data.unhealthy_components
215
+ )
216
+ else:
217
+ # HealthResponse doesn't have detailed component lists
218
+ healthy_count = 1 if health_data.healthy else 0
219
+ total_count = 1
220
+ else:
221
+ # Fallback for detailed response without component lists
222
+ healthy_count = sum(1 for comp in components.values() if comp.healthy)
223
+ total_count = len(components)
224
+ else:
225
+ # Basic health response - count main sub-components for better overview
226
+ if components and "aegis" in components:
227
+ aegis_component = components["aegis"]
228
+ if (
229
+ hasattr(aegis_component, "sub_components")
230
+ and aegis_component.sub_components
231
+ ):
232
+ healthy_count = sum(
233
+ 1
234
+ for comp in aegis_component.sub_components.values()
235
+ if comp.healthy
236
+ )
237
+ total_count = len(aegis_component.sub_components)
238
+ health_percentage = (
239
+ (healthy_count / total_count) * 100 if total_count > 0 else 100.0
240
+ )
241
+ else:
242
+ # Fallback to aegis component only
243
+ healthy_count = 1 if aegis_component.healthy else 0
244
+ total_count = 1
245
+ health_percentage = 100.0 if aegis_component.healthy else 0.0
246
+ else:
247
+ # No components or no aegis component
248
+ healthy_count = 0
249
+ total_count = 0
250
+ health_percentage = 0.0
251
+
252
+ overall_color = "green" if overall_healthy else "red"
253
+ overall_icon = "✅" if overall_healthy else "❌"
254
+
255
+ title = f"System Health - {overall_icon}"
256
+ if overall_healthy:
257
+ title += " Healthy"
258
+ else:
259
+ title += " Unhealthy"
260
+
261
+ panel_content = [
262
+ f"Overall Status: [bold {overall_color}]"
263
+ + ("Healthy" if overall_healthy else "Unhealthy")
264
+ + f"[/bold {overall_color}]",
265
+ f"Health Percentage: [bold]"
266
+ f"{health_percentage:.{CLI.HEALTH_PERCENTAGE_DECIMALS}f}%[/bold]",
267
+ f"Components: {healthy_count}/" + f"{total_count} healthy",
268
+ f"Timestamp: {timestamp}",
269
+ ]
270
+
271
+ console.print(
272
+ Panel("\n".join(panel_content), title=title, border_style=overall_color)
273
+ )
274
+
275
+ # Component Tree Display
276
+ console.print("\n[bold magenta]Component Tree:[/bold magenta]")
277
+
278
+ # Sort components: unhealthy first, then by name
279
+ sorted_components = sorted(components.items(), key=lambda x: (x[1].healthy, x[0]))
280
+
281
+ for name, component in sorted_components:
282
+ status_icon, status_color = _get_status_icon_and_color(component.status)
283
+
284
+ # Display main component
285
+ component_line = f"[{status_color}]{status_icon} {name}[/{status_color}]"
286
+ if detailed and component.response_time_ms is not None:
287
+ component_line += f" ([dim]{component.response_time_ms:.1f}ms[/dim])"
288
+ component_line += f" {component.message}"
289
+ console.print(component_line)
290
+
291
+ # Display sub-components with tree structure (recursive)
292
+ if hasattr(component, "sub_components") and component.sub_components:
293
+ _display_sub_components(component.sub_components, detailed, level=1)
294
+
295
+ # Show metadata for main components if detailed and available
296
+ elif detailed and component.metadata:
297
+ metadata_str = json.dumps(component.metadata, separators=(",", ":"))
298
+ max_length = CLI.MAX_METADATA_DISPLAY_LENGTH
299
+ if len(metadata_str) > max_length:
300
+ metadata_str = metadata_str[: max_length - 3] + "..."
301
+ console.print(f" [dim]({metadata_str})[/dim]")
302
+
303
+ # System information (only in detailed mode)
304
+ if detailed and isinstance(health_data, DetailedHealthResponse):
305
+ system_info = health_data.system_info
306
+ if system_info:
307
+ sys_info_content = []
308
+ for key, value in system_info.items():
309
+ sys_info_content.append(f"{key.replace('_', ' ').title()}: {value}")
310
+
311
+ console.print(
312
+ Panel(
313
+ "\n".join(sys_info_content),
314
+ title="System Information",
315
+ border_style="blue",
316
+ )
317
+ )
318
+
319
+
320
+ if __name__ == "__main__":
321
+ app()