mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
feat: standardize health status checking to accept both 'ok' and 'healthy' (fixes #139)
- Add health_utils.py with is_success_status() helper function - Update CLI health-check command to treat both 'ok' and 'healthy' as success - Update doctor command health extractor to use shared helper - Update provider status command to use shared helper - Add comprehensive unit tests for both status strings - Document health status values in API documentation The system now consistently accepts both 'ok' and 'healthy' as success statuses, eliminating false alarms in CLI and doctor commands while maintaining compatibility with existing dashboard behavior.
This commit is contained in:
1 parent
6b565c7210
commit
2b9e78ea50
5 files changed
+353
-4
No files matched your search
@@ -0,0 +1,123 @@
|
||||
# Health API Reference
|
||||
|
||||
The AIUI health API provides endpoints to monitor server and provider health status.
|
||||
|
||||
## Health Status Values
|
||||
|
||||
The following status values are used throughout the health API:
|
||||
|
||||
| Status | Meaning | Color Code |
|
||||
|--------|---------|------------|
|
||||
| `"ok"` | Service is functioning normally | Green |
|
||||
| `"healthy"` | Service is functioning normally (legacy) | Green |
|
||||
| `"degraded"` | Service is partially functional | Yellow |
|
||||
| `"error"` | Service has errors | Red |
|
||||
| `"unknown"` | Status cannot be determined | Yellow |
|
||||
|
||||
> **Note:** Both `"ok"` and `"healthy"` indicate successful operation. The system accepts either value for backward compatibility.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### GET /health
|
||||
|
||||
Returns the overall server health status.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "2026-06-23T10:00:00Z",
|
||||
"integrated": true,
|
||||
"sdk_gaps": []
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/provider
|
||||
|
||||
Returns information about the active AI provider.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"name": "PraisonAIProvider",
|
||||
"module": "praisonaiui.providers",
|
||||
"status": "ok",
|
||||
"agents": []
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/provider/health
|
||||
|
||||
Returns detailed health information from the provider gateway.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"type": "gateway",
|
||||
"status": "ok",
|
||||
"agents": 5,
|
||||
"detail": "All agents operational"
|
||||
}
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### health-check
|
||||
|
||||
Check server health status:
|
||||
|
||||
```bash
|
||||
aiui health-check --server http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
The command displays:
|
||||
- Green panel for `"ok"` or `"healthy"` status
|
||||
- Yellow panel for other statuses
|
||||
- Red error message if server is unreachable
|
||||
|
||||
### doctor
|
||||
|
||||
Run comprehensive diagnostics:
|
||||
|
||||
```bash
|
||||
aiui doctor --server http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
The doctor command checks:
|
||||
1. Server Health
|
||||
2. Provider Status
|
||||
3. Gateway Status
|
||||
4. Features Loaded
|
||||
5. Config Store
|
||||
6. Datastore
|
||||
7. Channels
|
||||
|
||||
### provider status
|
||||
|
||||
Check provider health:
|
||||
|
||||
```bash
|
||||
aiui provider status --server http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
## Python API
|
||||
|
||||
```python
|
||||
from praisonaiui.health_utils import is_success_status
|
||||
|
||||
# Check if a status indicates success
|
||||
if is_success_status("ok"):
|
||||
print("Service is healthy")
|
||||
|
||||
# Available success statuses
|
||||
from praisonaiui.health_utils import SUCCESS_STATUSES
|
||||
# frozenset({'ok', 'healthy'})
|
||||
```
|
||||
|
||||
## JavaScript/Dashboard
|
||||
|
||||
The dashboard automatically accepts both `"ok"` and `"healthy"` as success statuses:
|
||||
|
||||
```javascript
|
||||
const ok = d && (d.status === 'ok' || d.status === 'healthy');
|
||||
```
|
||||
+10
-4
@@ -1818,15 +1818,17 @@ def health_check(
|
||||
import json as _json
|
||||
from urllib.request import urlopen
|
||||
|
||||
from praisonaiui.health_utils import is_success_status
|
||||
|
||||
try:
|
||||
with urlopen(f"{server}/health") as resp:
|
||||
data = _json.loads(resp.read())
|
||||
status = data.get("status", "unknown")
|
||||
ts = data.get("timestamp", "")
|
||||
if status == "healthy":
|
||||
if is_success_status(status):
|
||||
console.print(
|
||||
Panel.fit(
|
||||
f"Server: [green]healthy[/green] ({ts})",
|
||||
f"Server: [green]{status}[/green] ({ts})",
|
||||
title="Health Check",
|
||||
border_style="green",
|
||||
)
|
||||
@@ -1883,13 +1885,15 @@ def provider_status(
|
||||
import json as _json
|
||||
from urllib.request import urlopen
|
||||
|
||||
from praisonaiui.health_utils import is_success_status
|
||||
|
||||
try:
|
||||
with urlopen(f"{server}/api/provider") as resp:
|
||||
data = _json.loads(resp.read())
|
||||
console.print(f"[bold]Provider:[/bold] {data.get('name', 'unknown')}")
|
||||
console.print(f"[bold]Module:[/bold] {data.get('module', 'unknown')}")
|
||||
status = data.get("status", "unknown")
|
||||
color = "green" if status == "ok" else "red"
|
||||
color = "green" if is_success_status(status) else "red"
|
||||
console.print(f"[bold]Status:[/bold] [{color}]{status}[/{color}]")
|
||||
agents = data.get("agents", [])
|
||||
if agents:
|
||||
@@ -3018,9 +3022,11 @@ def doctor(
|
||||
return {"name": name, "status": "fail", "detail": str(e)}
|
||||
|
||||
# Check 1: Server Health
|
||||
from praisonaiui.health_utils import is_success_status
|
||||
|
||||
def _health_extractor(data):
|
||||
status = data.get("status", "unknown")
|
||||
if status == "healthy":
|
||||
if is_success_status(status):
|
||||
return "pass", f"running on {server.split('://')[-1]}"
|
||||
return "warn", f"status: {status}"
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Health status utilities for consistent health checking across AIUI."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
SUCCESS_STATUSES = frozenset({"ok", "healthy"})
|
||||
|
||||
def is_success_status(status: Optional[str]) -> bool:
|
||||
"""Check if a health status indicates success.
|
||||
|
||||
Args:
|
||||
status: The health status string to check
|
||||
|
||||
Returns:
|
||||
True if status is 'ok' or 'healthy', False otherwise
|
||||
"""
|
||||
return (status or "") in SUCCESS_STATUSES
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Unit tests for CLI health-check command."""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from praisonaiui.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
class TestHealthCheckCommand:
|
||||
"""Test the health-check CLI command."""
|
||||
|
||||
@pytest.mark.parametrize("status", ["ok", "healthy"])
|
||||
def test_health_check_shows_green_for_success_statuses(self, status):
|
||||
"""Test health-check command shows green panel for both ok and healthy."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(
|
||||
{"status": status, "timestamp": "2026-06-23T10:00:00Z"}
|
||||
).encode("utf-8")
|
||||
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
mock_urlopen.return_value.__enter__.return_value = mock_response
|
||||
|
||||
result = runner.invoke(app, ["health-check"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Health Check" in result.stdout
|
||||
# Check that status is displayed (Rich strips color codes in test output)
|
||||
assert status in result.stdout
|
||||
# The test would need more complex setup to verify colors in Rich output
|
||||
|
||||
@pytest.mark.parametrize("status", ["degraded", "error", "unknown"])
|
||||
def test_health_check_shows_yellow_for_non_success_statuses(self, status):
|
||||
"""Test health-check command shows yellow panel for non-success statuses."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(
|
||||
{"status": status, "timestamp": "2026-06-23T10:00:00Z"}
|
||||
).encode("utf-8")
|
||||
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
mock_urlopen.return_value.__enter__.return_value = mock_response
|
||||
|
||||
result = runner.invoke(app, ["health-check"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Health Check" in result.stdout
|
||||
# Check that status is displayed (Rich strips color codes in test output)
|
||||
assert status in result.stdout
|
||||
|
||||
def test_health_check_handles_server_unreachable(self):
|
||||
"""Test health-check command handles unreachable server."""
|
||||
with patch("urllib.request.urlopen", side_effect=Exception("Connection refused")):
|
||||
result = runner.invoke(app, ["health-check"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Server unreachable" in result.stdout
|
||||
assert "✗" in result.stdout # Rich strips color codes
|
||||
|
||||
|
||||
class TestDoctorCommand:
|
||||
"""Test the doctor CLI command."""
|
||||
|
||||
@pytest.mark.parametrize("status", ["ok", "healthy"])
|
||||
def test_doctor_health_extractor_accepts_success_statuses(self, status):
|
||||
"""Test doctor command's health extractor accepts both ok and healthy."""
|
||||
def mock_api_get(server, path):
|
||||
if path == "/health":
|
||||
return {"status": status}
|
||||
elif path == "/api/provider":
|
||||
return {"name": "TestProvider"}
|
||||
elif path == "/api/provider/health":
|
||||
return {"type": "gateway", "agents": 2}
|
||||
elif path == "/api/features":
|
||||
return {"features": []}
|
||||
elif path == "/api/config":
|
||||
return {"config": {}}
|
||||
elif path == "/sessions":
|
||||
return []
|
||||
elif path == "/api/channels":
|
||||
return {"channels": []}
|
||||
return {}
|
||||
|
||||
with patch("praisonaiui.cli._api_get", side_effect=mock_api_get):
|
||||
result = runner.invoke(app, ["doctor", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
# Find the Server Health check
|
||||
health_check = next(
|
||||
(c for c in output["checks"] if c["name"] == "Server Health"), None
|
||||
)
|
||||
assert health_check is not None
|
||||
assert health_check["status"] == "pass"
|
||||
assert "running on" in health_check["detail"]
|
||||
|
||||
@pytest.mark.parametrize("status", ["degraded", "error"])
|
||||
def test_doctor_health_extractor_warns_for_non_success_statuses(self, status):
|
||||
"""Test doctor command's health extractor warns for non-success statuses."""
|
||||
def mock_api_get(server, path):
|
||||
if path == "/health":
|
||||
return {"status": status}
|
||||
return {}
|
||||
|
||||
with patch("praisonaiui.cli._api_get", side_effect=mock_api_get):
|
||||
result = runner.invoke(app, ["doctor", "--json"])
|
||||
|
||||
# Since we're not providing all required endpoints, exit code might be 1
|
||||
# But we can still check the output
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
# Find the Server Health check
|
||||
health_check = next(
|
||||
(c for c in output["checks"] if c["name"] == "Server Health"), None
|
||||
)
|
||||
assert health_check is not None
|
||||
assert health_check["status"] == "warn"
|
||||
assert f"status: {status}" in health_check["detail"]
|
||||
|
||||
|
||||
class TestProviderStatusCommand:
|
||||
"""Test the provider status CLI command."""
|
||||
|
||||
@pytest.mark.parametrize("status", ["ok", "healthy"])
|
||||
def test_provider_status_shows_green_for_success_statuses(self, status):
|
||||
"""Test provider status command shows green for both ok and healthy."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(
|
||||
{
|
||||
"name": "TestProvider",
|
||||
"module": "test.provider",
|
||||
"status": status,
|
||||
"agents": []
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
mock_urlopen.return_value.__enter__.return_value = mock_response
|
||||
|
||||
result = runner.invoke(app, ["provider", "status"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert status in result.stdout # Rich strips color codes in test output
|
||||
|
||||
@pytest.mark.parametrize("status", ["error", "degraded"])
|
||||
def test_provider_status_shows_red_for_non_success_statuses(self, status):
|
||||
"""Test provider status command shows red for non-success statuses."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(
|
||||
{
|
||||
"name": "TestProvider",
|
||||
"module": "test.provider",
|
||||
"status": status,
|
||||
"agents": []
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
mock_urlopen.return_value.__enter__.return_value = mock_response
|
||||
|
||||
result = runner.invoke(app, ["provider", "status"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert status in result.stdout # Rich strips color codes in test output
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Unit tests for health status utilities."""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiui.health_utils import SUCCESS_STATUSES, is_success_status
|
||||
|
||||
|
||||
class TestHealthUtils:
|
||||
"""Test health status utility functions."""
|
||||
|
||||
def test_success_statuses_constant(self):
|
||||
"""Test SUCCESS_STATUSES contains expected values."""
|
||||
assert "ok" in SUCCESS_STATUSES
|
||||
assert "healthy" in SUCCESS_STATUSES
|
||||
assert len(SUCCESS_STATUSES) == 2
|
||||
|
||||
@pytest.mark.parametrize("status", ["ok", "healthy"])
|
||||
def test_is_success_status_with_valid_statuses(self, status):
|
||||
"""Test is_success_status returns True for valid success statuses."""
|
||||
assert is_success_status(status) is True
|
||||
|
||||
@pytest.mark.parametrize("status", ["error", "degraded", "unknown", "failing", ""])
|
||||
def test_is_success_status_with_invalid_statuses(self, status):
|
||||
"""Test is_success_status returns False for invalid statuses."""
|
||||
assert is_success_status(status) is False
|
||||
|
||||
def test_is_success_status_with_none(self):
|
||||
"""Test is_success_status handles None input gracefully."""
|
||||
assert is_success_status(None) is False
|
||||
|
||||
def test_is_success_status_case_sensitive(self):
|
||||
"""Test is_success_status is case sensitive."""
|
||||
assert is_success_status("OK") is False
|
||||
assert is_success_status("HEALTHY") is False
|
||||
assert is_success_status("Ok") is False
|
||||
assert is_success_status("Healthy") is False
|
||||
Reference in new issue
Block a user