9e8f1bbeed
Dashboard / frontend (push) Failing after 0s
Dashboard / api (push) Failing after 0s
Lint PowerShell / powershell-lint (ubuntu-latest) (push) Failing after 1s
Python Lint / Lint Python with Ruff (push) Failing after 1s
ShellCheck / Lint shell scripts (push) Failing after 1s
Matrix Smoke / linux-smoke (push) Failing after 1s
Matrix Smoke / distro: cachyos (push) Failing after 15s
Matrix Smoke / distro: linux-mint-21.3 (push) Failing after 15s
Matrix Smoke / distro: debian-12 (push) Failing after 5m21s
Matrix Smoke / distro: fedora-41 (push) Failing after 4m56s
Matrix Smoke / distro: ubuntu-24.04 (push) Failing after 2m13s
Matrix Smoke / distro: rocky-9 (push) Failing after 10m39s
Matrix Smoke / distro: manjaro (push) Failing after 12m11s
Matrix Smoke / distro: opensuse-tw (push) Failing after 11m53s
Matrix Smoke / distro: archlinux (push) Failing after 20m3s
Matrix Smoke / distro: ubuntu-22.04 (push) Failing after 13m49s
Validate .env Schema / tier-1-env-validation (push) Successful in 52s
Validate .env Schema / tier-2-env-validation (push) Successful in 44s
Validate .env Schema / tier-3-env-validation (push) Successful in 52s
Validate .env Schema / tier-4-env-validation (push) Successful in 51s
Validate Extensions Catalog / Check catalog is up-to-date (push) Failing after 9m47s
Secret Scan / Scan for secrets (push) Failing after 21m4s
Validate Docker Compose / Validate Docker Compose files (push) Has been cancelled
Python Type Check / Type check with mypy (push) Has been cancelled
Validate .env Schema / tier-0-env-validation (push) Has been cancelled
Test Linux / integration-smoke (push) Has been cancelled
Lint PowerShell / powershell-lint (windows-latest) (push) Has been cancelled
Matrix Smoke / macos-smoke (push) Has been cancelled
1622 lines
56 KiB
Python
1622 lines
56 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
ODS Dashboard API
|
||
Lightweight backend providing system status for the Dashboard UI.
|
||
|
||
Default port: DASHBOARD_API_PORT (3002)
|
||
|
||
Modules:
|
||
config.py — Shared configuration and manifest loading
|
||
models.py — Pydantic response schemas
|
||
security.py — API key authentication
|
||
gpu.py — GPU detection (NVIDIA + AMD)
|
||
helpers.py — Service health, LLM metrics, system metrics
|
||
routers/ — Endpoint modules (workflows, features, setup, updates, agents, privacy)
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import socket
|
||
import shutil
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
from contextlib import asynccontextmanager
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any, Optional
|
||
|
||
import httpx
|
||
from fastapi import FastAPI, Depends, HTTPException, Body
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
|
||
# --- Local modules ---
|
||
from config import (
|
||
SERVICES, DATA_DIR, INSTALL_DIR, SIDEBAR_ICONS, MANIFEST_ERRORS,
|
||
AGENT_HOST, AGENT_PORT, AGENT_URL, ODS_AGENT_KEY,
|
||
_detect_container_default_gateway, _running_inside_container,
|
||
_read_env_from_file,
|
||
)
|
||
from models import (
|
||
GPUInfo, ServiceStatus, DiskUsage, ModelInfo, BootstrapStatus,
|
||
FullStatus, PortCheckRequest,
|
||
)
|
||
from security import verify_api_key
|
||
from gpu import get_gpu_info
|
||
from helpers import (
|
||
get_all_services, get_cached_services, set_services_cache,
|
||
get_disk_usage, dir_size_gb, get_model_info, get_bootstrap_status,
|
||
get_uptime, get_cpu_metrics, get_ram_metrics,
|
||
get_llama_metrics, get_loaded_model, get_llama_context_size,
|
||
_get_httpx_client,
|
||
)
|
||
from agent_monitor import collect_metrics
|
||
from routers import (
|
||
workflows, features, setup, updates, agents, privacy, extensions,
|
||
gpu as gpu_router, resources, voice, models as models_router, templates,
|
||
auth as auth_router,
|
||
magic_link,
|
||
oauth_passthrough,
|
||
talk,
|
||
tailscale,
|
||
usage,
|
||
)
|
||
from settings import (
|
||
_ENV_ASSIGNMENT_RE, _ENV_COMMENTED_ASSIGNMENT_RE, _SETTINGS_APPLY_ALLOWED_SERVICES, _parse_env_text, _read_env_map_from_path,
|
||
_slugify,
|
||
_build_env_fields, _validate_env_values, _serialize_form_values,
|
||
_empty_value_unsets_env_key,
|
||
_compute_env_apply_plan,
|
||
_check_host_agent_available,
|
||
)
|
||
|
||
|
||
# ================================================================
|
||
# TTL Cache — avoids redundant subprocess/IO calls every poll cycle
|
||
# ================================================================
|
||
|
||
_CACHE_MISS = object()
|
||
|
||
|
||
class TTLCache:
|
||
"""Simple in-memory cache with per-key TTL (seconds)."""
|
||
|
||
def __init__(self):
|
||
self._store: dict[str, tuple[float, object]] = {}
|
||
|
||
def get(self, key: str, default: object | None = None) -> object | None:
|
||
entry = self._store.get(key)
|
||
if entry is None:
|
||
return default
|
||
expires_at, value = entry
|
||
if time.monotonic() > expires_at:
|
||
del self._store[key]
|
||
return default
|
||
return value
|
||
|
||
def set(self, key: str, value: object, ttl: float):
|
||
self._store[key] = (time.monotonic() + ttl, value)
|
||
|
||
def invalidate(self, key: str) -> None:
|
||
"""Remove a single cache entry."""
|
||
self._store.pop(key, None)
|
||
|
||
def clear(self) -> None:
|
||
"""Remove all cache entries."""
|
||
self._store.clear()
|
||
|
||
|
||
_cache = TTLCache()
|
||
|
||
# Cache TTLs (seconds)
|
||
_GPU_CACHE_TTL = 3.0
|
||
_STATUS_CACHE_TTL = 2.0
|
||
_STORAGE_CACHE_TTL = 30.0
|
||
_SETTINGS_SUMMARY_CACHE_TTL = 5.0
|
||
_SETTINGS_CONFIG_CACHE_TTL = 15.0
|
||
_SETTINGS_ENV_CACHE_TTL = 5.0
|
||
_SERVICE_POLL_INTERVAL = 10.0 # background health check interval
|
||
_host_agent_probe_state: dict[str, Optional[str]] = {
|
||
"last_success_at": None,
|
||
"last_error": None,
|
||
}
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _resolve_install_root() -> Path:
|
||
host_root = Path("/ods")
|
||
if host_root.exists():
|
||
return host_root
|
||
return Path(INSTALL_DIR)
|
||
|
||
|
||
def _read_installed_version() -> str:
|
||
install_root = _resolve_install_root()
|
||
env_file = install_root / ".env"
|
||
if env_file.exists():
|
||
try:
|
||
for line in env_file.read_text().splitlines():
|
||
if line.startswith("ODS_VERSION="):
|
||
return line.split("=", 1)[1].strip().strip("\"'")
|
||
except OSError:
|
||
pass
|
||
|
||
version_file = install_root / ".version"
|
||
if version_file.exists():
|
||
try:
|
||
raw = version_file.read_text().strip()
|
||
if raw:
|
||
if raw.startswith("{"):
|
||
data = json.loads(raw)
|
||
if isinstance(data, dict) and data.get("version"):
|
||
return str(data["version"])
|
||
return raw
|
||
except (OSError, json.JSONDecodeError, ValueError):
|
||
pass
|
||
|
||
manifest_file = install_root / "manifest.json"
|
||
if manifest_file.exists():
|
||
try:
|
||
data = json.loads(manifest_file.read_text())
|
||
version = (
|
||
data.get("release", {}).get("version")
|
||
or data.get("ods_version")
|
||
or data.get("manifestVersion")
|
||
)
|
||
if version:
|
||
return str(version)
|
||
except (OSError, json.JSONDecodeError, ValueError, AttributeError):
|
||
pass
|
||
|
||
return app.version
|
||
|
||
|
||
def _probe_host_agent_health() -> dict[str, Any]:
|
||
"""Probe the host-agent health endpoint and update diagnostic state."""
|
||
started = time.monotonic()
|
||
url = f"{AGENT_URL}/health"
|
||
headers = {}
|
||
if ODS_AGENT_KEY:
|
||
headers["Authorization"] = f"Bearer {ODS_AGENT_KEY}"
|
||
request = urllib.request.Request(url, headers=headers)
|
||
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=3) as response:
|
||
status_code = int(getattr(response, "status", response.getcode()))
|
||
raw = response.read(2048).decode("utf-8", errors="replace")
|
||
try:
|
||
body: Any = json.loads(raw) if raw else None
|
||
except json.JSONDecodeError:
|
||
body = raw[:500]
|
||
|
||
latency_ms = round((time.monotonic() - started) * 1000)
|
||
success_at = datetime.now(timezone.utc).isoformat()
|
||
_host_agent_probe_state["last_success_at"] = success_at
|
||
_host_agent_probe_state["last_error"] = None
|
||
return {
|
||
"available": status_code == 200,
|
||
"status_code": status_code,
|
||
"latency_ms": latency_ms,
|
||
"response": body,
|
||
"error": None,
|
||
}
|
||
except urllib.error.HTTPError as exc:
|
||
latency_ms = round((time.monotonic() - started) * 1000)
|
||
status_code = exc.code
|
||
detail = f"HTTP {exc.code}: {exc.reason}"
|
||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||
latency_ms = round((time.monotonic() - started) * 1000)
|
||
status_code = None
|
||
detail = str(getattr(exc, "reason", exc))
|
||
|
||
_host_agent_probe_state["last_error"] = detail
|
||
return {
|
||
"available": False,
|
||
"status_code": status_code,
|
||
"latency_ms": latency_ms,
|
||
"response": None,
|
||
"error": detail,
|
||
}
|
||
|
||
|
||
def _normalize_timestamp_precision(timestamp: str) -> str:
|
||
match = re.match(r"^(.*?\.\d{6})\d+(.*)$", timestamp)
|
||
if match:
|
||
return f"{match.group(1)}{match.group(2)}"
|
||
return timestamp
|
||
|
||
|
||
def _service_by_id(statuses: list[ServiceStatus], service_id: str) -> Optional[ServiceStatus]:
|
||
for service in statuses:
|
||
if service.id == service_id:
|
||
return service
|
||
return None
|
||
|
||
|
||
def _service_is_healthy(statuses: list[ServiceStatus], service_id: str) -> bool:
|
||
service = _service_by_id(statuses, service_id)
|
||
return bool(service and service.status == "healthy")
|
||
|
||
|
||
def _readiness_check(
|
||
*,
|
||
check_id: str,
|
||
name: str,
|
||
required: bool,
|
||
ready: bool,
|
||
status: str,
|
||
detail: str,
|
||
repair: Optional[str] = None,
|
||
) -> dict[str, Any]:
|
||
payload: dict[str, Any] = {
|
||
"id": check_id,
|
||
"name": name,
|
||
"required": required,
|
||
"ready": ready,
|
||
"status": status,
|
||
"detail": detail,
|
||
}
|
||
if repair:
|
||
payload["repair"] = repair
|
||
return payload
|
||
|
||
|
||
def _build_readiness_payload(
|
||
*,
|
||
service_statuses: list[ServiceStatus],
|
||
loaded_model: Optional[str],
|
||
context_size: Optional[int],
|
||
bootstrap_info: BootstrapStatus,
|
||
host_agent: dict[str, Any],
|
||
stt_model_cached: Optional[bool],
|
||
stt_model_name: str,
|
||
) -> dict[str, Any]:
|
||
checks: list[dict[str, Any]] = []
|
||
|
||
llama_healthy = _service_is_healthy(service_statuses, "llama-server")
|
||
chat_ready = bool(llama_healthy and loaded_model and context_size)
|
||
if chat_ready:
|
||
chat_detail = f"{loaded_model} loaded with {context_size} context"
|
||
elif bootstrap_info.active:
|
||
chat_detail = "Full model is still downloading; bootstrap mode may be limited"
|
||
elif not llama_healthy:
|
||
chat_detail = "llama-server is not healthy"
|
||
elif not loaded_model:
|
||
chat_detail = "No loaded model reported by the inference server"
|
||
else:
|
||
chat_detail = "Inference context size is unavailable"
|
||
checks.append(_readiness_check(
|
||
check_id="chat",
|
||
name="Chat",
|
||
required=True,
|
||
ready=chat_ready,
|
||
status="ready" if chat_ready else "blocked",
|
||
detail=chat_detail,
|
||
repair="ods restart llama-server" if not chat_ready and not bootstrap_info.active else None,
|
||
))
|
||
|
||
webui_ready = _service_is_healthy(service_statuses, "open-webui")
|
||
checks.append(_readiness_check(
|
||
check_id="open-webui",
|
||
name="Open WebUI",
|
||
required=True,
|
||
ready=webui_ready,
|
||
status="ready" if webui_ready else "blocked",
|
||
detail="Open WebUI is reachable" if webui_ready else "Open WebUI is not healthy",
|
||
repair="ods restart open-webui" if not webui_ready else None,
|
||
))
|
||
|
||
checks.append(_readiness_check(
|
||
check_id="dashboard-api",
|
||
name="Dashboard API",
|
||
required=True,
|
||
ready=True,
|
||
status="ready",
|
||
detail="Dashboard API is serving this readiness response",
|
||
))
|
||
|
||
host_agent_ready = bool(host_agent.get("available"))
|
||
checks.append(_readiness_check(
|
||
check_id="host-agent",
|
||
name="Host Agent",
|
||
required=False,
|
||
ready=host_agent_ready,
|
||
status="ready" if host_agent_ready else "needs_repair",
|
||
detail="Host agent is reachable" if host_agent_ready else "Host agent is not reachable",
|
||
repair="ods agent restart" if not host_agent_ready else None,
|
||
))
|
||
|
||
hermes_service = _service_by_id(service_statuses, "hermes")
|
||
if hermes_service is None or hermes_service.status == "not_deployed":
|
||
checks.append(_readiness_check(
|
||
check_id="hermes",
|
||
name="Hermes",
|
||
required=False,
|
||
ready=False,
|
||
status="disabled",
|
||
detail="Hermes is not enabled in this stack",
|
||
))
|
||
else:
|
||
hermes_ready = hermes_service.status == "healthy"
|
||
checks.append(_readiness_check(
|
||
check_id="hermes",
|
||
name="Hermes",
|
||
required=False,
|
||
ready=hermes_ready,
|
||
status="ready" if hermes_ready else "needs_repair",
|
||
detail="Hermes is reachable" if hermes_ready else f"Hermes status is {hermes_service.status}",
|
||
repair="ods restart hermes" if not hermes_ready else None,
|
||
))
|
||
|
||
whisper = _service_by_id(service_statuses, "whisper")
|
||
tts = _service_by_id(service_statuses, "tts")
|
||
voice_enabled = bool(whisper or tts)
|
||
if not voice_enabled:
|
||
checks.append(_readiness_check(
|
||
check_id="voice",
|
||
name="Voice",
|
||
required=False,
|
||
ready=False,
|
||
status="disabled",
|
||
detail="Voice services are not enabled in this stack",
|
||
))
|
||
can_use_voice = False
|
||
else:
|
||
whisper_ready = bool(whisper and whisper.status == "healthy")
|
||
tts_ready = bool(tts and tts.status == "healthy")
|
||
model_ready = stt_model_cached is True
|
||
can_use_voice = whisper_ready and tts_ready and model_ready
|
||
if can_use_voice:
|
||
voice_detail = f"Whisper model {stt_model_name} cached and TTS is healthy"
|
||
elif not whisper_ready:
|
||
voice_detail = "Whisper STT is not healthy"
|
||
elif not model_ready:
|
||
voice_detail = f"Whisper STT model {stt_model_name} is not cached"
|
||
else:
|
||
voice_detail = "Kokoro TTS is not healthy"
|
||
checks.append(_readiness_check(
|
||
check_id="voice",
|
||
name="Voice",
|
||
required=False,
|
||
ready=can_use_voice,
|
||
status="ready" if can_use_voice else "needs_repair",
|
||
detail=voice_detail,
|
||
repair="ods repair voice" if not can_use_voice else None,
|
||
))
|
||
|
||
required_ready = all(check["ready"] for check in checks if check["required"])
|
||
optional_issues = [
|
||
check for check in checks
|
||
if not check["required"] and check["status"] not in {"ready", "disabled"}
|
||
]
|
||
status = "ready" if required_ready and not optional_issues else ("degraded" if required_ready else "blocked")
|
||
repair_hints = [check["repair"] for check in checks if check.get("repair")]
|
||
|
||
return {
|
||
"ready": required_ready,
|
||
"status": status,
|
||
"canChat": chat_ready,
|
||
"canUseVoice": can_use_voice,
|
||
"checks": checks,
|
||
"issues": [check for check in checks if not check["ready"] and check["status"] != "disabled"],
|
||
"repairHints": repair_hints,
|
||
}
|
||
|
||
|
||
async def _check_stt_model_cached() -> tuple[Optional[bool], str]:
|
||
model_name = os.environ.get("AUDIO_STT_MODEL") or _read_env_from_file("AUDIO_STT_MODEL") or "Systran/faster-whisper-base"
|
||
whisper_cfg = SERVICES.get("whisper")
|
||
if not whisper_cfg:
|
||
return None, model_name
|
||
|
||
host = whisper_cfg.get("host", "localhost")
|
||
port = whisper_cfg.get("port", 8000)
|
||
encoded = model_name.replace("/", "%2F")
|
||
try:
|
||
client = await _get_httpx_client()
|
||
resp = await client.get(f"http://{host}:{port}/v1/models/{encoded}")
|
||
return resp.status_code == 200, model_name
|
||
except (httpx.HTTPError, httpx.TimeoutException, OSError):
|
||
return False, model_name
|
||
|
||
|
||
def _read_install_date() -> Optional[str]:
|
||
install_root = _resolve_install_root()
|
||
env_file = install_root / ".env"
|
||
if env_file.exists():
|
||
try:
|
||
for line in env_file.read_text(encoding="utf-8").splitlines()[:8]:
|
||
if line.startswith("# Generated by ") and " on " in line:
|
||
raw_timestamp = line.split(" on ", 1)[1].strip()
|
||
normalized = _normalize_timestamp_precision(raw_timestamp)
|
||
try:
|
||
return datetime.fromisoformat(normalized).isoformat()
|
||
except ValueError:
|
||
return raw_timestamp
|
||
except OSError:
|
||
pass
|
||
|
||
for candidate in (
|
||
env_file,
|
||
install_root / ".version",
|
||
install_root / "manifest.json",
|
||
):
|
||
if candidate.exists():
|
||
try:
|
||
return datetime.fromtimestamp(candidate.stat().st_mtime, tz=timezone.utc).isoformat()
|
||
except OSError:
|
||
continue
|
||
|
||
return None
|
||
|
||
|
||
def _infer_tier(gpu_info) -> str:
|
||
if not gpu_info:
|
||
return "Unknown"
|
||
|
||
vram_gb = gpu_info.memory_total_mb / 1024
|
||
if gpu_info.memory_type == "unified" and gpu_info.gpu_backend == "amd":
|
||
return "Strix Halo 90+" if vram_gb >= 90 else "Strix Halo Compact"
|
||
if vram_gb >= 80:
|
||
return "Professional"
|
||
if vram_gb >= 24:
|
||
return "Prosumer"
|
||
if vram_gb >= 16:
|
||
return "Standard"
|
||
if vram_gb >= 8:
|
||
return "Entry"
|
||
return "Minimal"
|
||
|
||
|
||
def _infer_gpu_count(gpu_info) -> int:
|
||
"""Infer GPU count from the GPU_COUNT env var or the display name."""
|
||
gpu_count_env = os.environ.get("GPU_COUNT", "")
|
||
if gpu_count_env.isdigit():
|
||
return int(gpu_count_env)
|
||
if " × " in gpu_info.name:
|
||
try:
|
||
return int(gpu_info.name.rsplit(" × ", 1)[-1])
|
||
except ValueError:
|
||
pass
|
||
if " + " in gpu_info.name:
|
||
return gpu_info.name.count(" + ") + 1
|
||
return 1
|
||
|
||
|
||
def _serialize_gpu(gpu_info) -> Optional[dict]:
|
||
if not gpu_info:
|
||
return None
|
||
|
||
gpu_count = _infer_gpu_count(gpu_info)
|
||
|
||
gpu_data = {
|
||
"name": gpu_info.name,
|
||
"vramUsed": round(gpu_info.memory_used_mb / 1024, 1),
|
||
"vramTotal": round(gpu_info.memory_total_mb / 1024, 1),
|
||
"utilization": gpu_info.utilization_percent,
|
||
"temperature": gpu_info.temperature_c,
|
||
"memoryType": gpu_info.memory_type,
|
||
"backend": gpu_info.gpu_backend,
|
||
"gpu_count": gpu_count,
|
||
"memoryLabel": "VRAM Partition" if gpu_info.memory_type == "unified" else "VRAM",
|
||
}
|
||
if gpu_info.power_w is not None:
|
||
gpu_data["powerDraw"] = gpu_info.power_w
|
||
return gpu_data
|
||
|
||
|
||
def _serialize_model(model_info) -> Optional[dict]:
|
||
if not model_info:
|
||
return None
|
||
return {
|
||
"name": model_info.name,
|
||
"contextLength": model_info.context_length,
|
||
}
|
||
|
||
|
||
HERMES_MIN_CONTEXT = 65536
|
||
HERMES_TARGET_CONTEXT = 131072
|
||
|
||
|
||
def _build_model_readiness_payload(
|
||
*,
|
||
model_info: Optional[ModelInfo],
|
||
bootstrap_info: BootstrapStatus,
|
||
loaded_model: Optional[str],
|
||
runtime_context: Optional[int],
|
||
) -> dict[str, Any]:
|
||
configured_context = model_info.context_length if model_info else None
|
||
effective_context = runtime_context or configured_context
|
||
meets_hermes_minimum = bool(effective_context and effective_context >= HERMES_MIN_CONTEXT)
|
||
meets_hermes_target = bool(effective_context and effective_context >= HERMES_TARGET_CONTEXT)
|
||
has_loaded_model = bool(loaded_model)
|
||
ready = has_loaded_model and meets_hermes_minimum
|
||
|
||
issues: list[str] = []
|
||
if not has_loaded_model:
|
||
issues.append("No model is currently reported as loaded.")
|
||
if not meets_hermes_minimum:
|
||
issues.append(f"Context is below Hermes minimum ({HERMES_MIN_CONTEXT}).")
|
||
if bootstrap_info.active:
|
||
issues.append("Full model is still downloading; bootstrap model is serving first-run traffic.")
|
||
|
||
if ready and bootstrap_info.active:
|
||
status = "bootstrap"
|
||
elif ready:
|
||
status = "ready"
|
||
else:
|
||
status = "blocked"
|
||
|
||
return {
|
||
"ready": ready,
|
||
"status": status,
|
||
"activeModel": loaded_model,
|
||
"configuredModel": {
|
||
"name": model_info.name,
|
||
"contextLength": configured_context,
|
||
"quantization": model_info.quantization,
|
||
"sizeGb": model_info.size_gb,
|
||
} if model_info else None,
|
||
"bootstrap": {
|
||
"active": bootstrap_info.active,
|
||
"model": bootstrap_info.model_name,
|
||
"percent": bootstrap_info.percent,
|
||
"downloadedGb": bootstrap_info.downloaded_gb,
|
||
"totalGb": bootstrap_info.total_gb,
|
||
"etaSeconds": bootstrap_info.eta_seconds,
|
||
},
|
||
"context": {
|
||
"configured": configured_context,
|
||
"runtime": runtime_context,
|
||
"effective": effective_context,
|
||
"hermesMinimum": HERMES_MIN_CONTEXT,
|
||
"hermesTarget": HERMES_TARGET_CONTEXT,
|
||
"meetsHermesMinimum": meets_hermes_minimum,
|
||
"meetsHermesTarget": meets_hermes_target,
|
||
},
|
||
"hermes": {
|
||
"compatible": meets_hermes_minimum,
|
||
"targetReady": meets_hermes_target,
|
||
"minimumContext": HERMES_MIN_CONTEXT,
|
||
"targetContext": HERMES_TARGET_CONTEXT,
|
||
},
|
||
"issues": issues,
|
||
}
|
||
|
||
|
||
def _service_semantics(service_id: str, status: str) -> dict:
|
||
config = SERVICES.get(service_id, {})
|
||
category = config.get("category", "optional")
|
||
required = category == "core"
|
||
|
||
if status == "healthy":
|
||
state = "ready"
|
||
severity = "ok"
|
||
counts_as_issue = False
|
||
elif status == "not_deployed":
|
||
state = "disabled"
|
||
severity = "disabled"
|
||
counts_as_issue = False
|
||
elif status == "unknown" and not required:
|
||
state = "unknown"
|
||
severity = "unknown"
|
||
counts_as_issue = False
|
||
elif required:
|
||
state = "blocked"
|
||
severity = "critical"
|
||
counts_as_issue = True
|
||
else:
|
||
state = "attention"
|
||
severity = "warning"
|
||
counts_as_issue = True
|
||
|
||
return {
|
||
"category": category,
|
||
"required": required,
|
||
"impact": "core" if required else "optional",
|
||
"state": state,
|
||
"severity": severity,
|
||
"countsAsIssue": counts_as_issue,
|
||
}
|
||
|
||
|
||
def _serialize_services(service_statuses: list[ServiceStatus], uptime: int) -> list[dict]:
|
||
serialized = []
|
||
for service in service_statuses:
|
||
item = {
|
||
"id": service.id,
|
||
"name": service.name,
|
||
"status": service.status,
|
||
"port": service.external_port,
|
||
"uptime": uptime if service.status == "healthy" else None,
|
||
}
|
||
item.update(_service_semantics(service.id, service.status))
|
||
serialized.append(item)
|
||
return serialized
|
||
|
||
|
||
def _fallback_services() -> list[dict]:
|
||
links = []
|
||
for service_id, config in SERVICES.items():
|
||
external_port = config.get("external_port", config.get("port", 0))
|
||
if not external_port:
|
||
continue
|
||
item = {
|
||
"id": service_id,
|
||
"name": config.get("name", service_id),
|
||
"status": "unknown",
|
||
"port": external_port,
|
||
"uptime": None,
|
||
}
|
||
item.update(_service_semantics(service_id, "unknown"))
|
||
links.append(item)
|
||
return links
|
||
|
||
|
||
def _resolve_runtime_env_path() -> Path:
|
||
install_root = _resolve_install_root()
|
||
env_path = install_root / ".env"
|
||
if env_path.exists():
|
||
return env_path
|
||
return Path(INSTALL_DIR) / ".env"
|
||
|
||
|
||
def _resolve_bundled_path(name: str) -> Path:
|
||
return Path(__file__).resolve().parent / name
|
||
|
||
|
||
def _resolve_template_path(name: str) -> Path:
|
||
install_root = _resolve_install_root()
|
||
for candidate in (
|
||
install_root / name,
|
||
_resolve_bundled_path(name),
|
||
Path(INSTALL_DIR) / name,
|
||
):
|
||
if candidate.exists():
|
||
return candidate
|
||
return _resolve_bundled_path(name)
|
||
|
||
|
||
def _load_env_schema() -> tuple[dict[str, Any], set[str]]:
|
||
schema_path = _resolve_template_path(".env.schema.json")
|
||
if not schema_path.exists():
|
||
return {}, set()
|
||
|
||
try:
|
||
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||
except (OSError, json.JSONDecodeError, ValueError):
|
||
return {}, set()
|
||
|
||
properties = schema.get("properties", {})
|
||
required = set(schema.get("required", []))
|
||
if not isinstance(properties, dict):
|
||
properties = {}
|
||
return properties, required
|
||
|
||
|
||
def _build_env_sections(schema_keys: list[str]) -> list[dict[str, Any]]:
|
||
example_path = _resolve_template_path(".env.example")
|
||
if not example_path.exists():
|
||
return [{
|
||
"id": "configuration",
|
||
"title": "Configuration",
|
||
"keys": schema_keys,
|
||
}]
|
||
|
||
try:
|
||
lines = example_path.read_text(encoding="utf-8").splitlines()
|
||
except OSError:
|
||
return [{
|
||
"id": "configuration",
|
||
"title": "Configuration",
|
||
"keys": schema_keys,
|
||
}]
|
||
|
||
sections: list[dict[str, Any]] = []
|
||
section_index: dict[str, dict[str, Any]] = {}
|
||
current = {"id": "configuration", "title": "Configuration", "keys": []}
|
||
sections.append(current)
|
||
section_index[current["id"]] = current
|
||
|
||
def ensure_section(title: str) -> dict[str, Any]:
|
||
slug = _slugify(title) or "configuration"
|
||
if slug in section_index:
|
||
return section_index[slug]
|
||
section = {"id": slug, "title": title, "keys": []}
|
||
sections.append(section)
|
||
section_index[slug] = section
|
||
return section
|
||
|
||
idx = 0
|
||
while idx < len(lines):
|
||
if (
|
||
idx + 2 < len(lines)
|
||
and lines[idx].lstrip().startswith("#")
|
||
and set(lines[idx].replace("#", "").strip()) <= {"═"}
|
||
and lines[idx + 1].lstrip().startswith("#")
|
||
and set(lines[idx + 2].replace("#", "").strip()) <= {"═"}
|
||
):
|
||
title = lines[idx + 1].lstrip("#").strip()
|
||
if title:
|
||
current = ensure_section(title)
|
||
idx += 3
|
||
continue
|
||
|
||
match = _ENV_ASSIGNMENT_RE.match(lines[idx]) or _ENV_COMMENTED_ASSIGNMENT_RE.match(lines[idx])
|
||
if match:
|
||
key = match.group(1)
|
||
if key in schema_keys and key not in current["keys"]:
|
||
current["keys"].append(key)
|
||
idx += 1
|
||
|
||
remaining = [key for key in schema_keys if not any(key in section["keys"] for section in sections)]
|
||
if remaining:
|
||
extra = ensure_section("Advanced")
|
||
extra["keys"].extend(remaining)
|
||
|
||
return [section for section in sections if section["keys"]]
|
||
|
||
|
||
def _render_env_from_values(values: dict[str, str]) -> str:
|
||
example_path = _resolve_template_path(".env.example")
|
||
seen: set[str] = set()
|
||
output_lines: list[str] = []
|
||
|
||
if example_path.exists():
|
||
try:
|
||
example_lines = example_path.read_text(encoding="utf-8").splitlines()
|
||
except OSError:
|
||
example_lines = []
|
||
else:
|
||
example_lines = []
|
||
|
||
for line in example_lines:
|
||
assignment = _ENV_ASSIGNMENT_RE.match(line)
|
||
commented_assignment = _ENV_COMMENTED_ASSIGNMENT_RE.match(line)
|
||
|
||
if assignment:
|
||
key = assignment.group(1)
|
||
output_lines.append(f"{key}={values.get(key, '')}")
|
||
seen.add(key)
|
||
continue
|
||
|
||
if commented_assignment:
|
||
key = commented_assignment.group(1)
|
||
seen.add(key)
|
||
if key in values:
|
||
output_lines.append(f"{key}={values[key]}")
|
||
else:
|
||
output_lines.append(line)
|
||
continue
|
||
|
||
output_lines.append(line)
|
||
|
||
extras = [(key, value) for key, value in values.items() if key not in seen]
|
||
if extras:
|
||
if output_lines and output_lines[-1] != "":
|
||
output_lines.append("")
|
||
output_lines.extend([
|
||
"# Additional Local Overrides",
|
||
"# Values below were preserved because they are not part of .env.example.",
|
||
])
|
||
for key, value in extras:
|
||
output_lines.append(f"{key}={value}")
|
||
|
||
return "\n".join(output_lines).rstrip() + "\n"
|
||
|
||
|
||
def _clear_settings_caches():
|
||
for key in ("settings_summary", "settings_env", "status"):
|
||
_cache.invalidate(key)
|
||
|
||
|
||
def _call_agent_core_recreate(service_ids: list[str]) -> dict[str, Any]:
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"Authorization": f"Bearer {ODS_AGENT_KEY}",
|
||
}
|
||
data = json.dumps({"service_ids": service_ids}).encode("utf-8")
|
||
request = urllib.request.Request(
|
||
f"{AGENT_URL}/v1/core/recreate",
|
||
data=data,
|
||
headers=headers,
|
||
method="POST",
|
||
)
|
||
with urllib.request.urlopen(request, timeout=180) as response:
|
||
return json.loads(response.read().decode("utf-8"))
|
||
|
||
|
||
def _call_agent_env_update(raw_text: str) -> dict[str, Any]:
|
||
"""Route .env writes through the host agent (filesystem is :ro in container)."""
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"Authorization": f"Bearer {ODS_AGENT_KEY}",
|
||
}
|
||
data = json.dumps({"raw_text": raw_text, "backup": True}).encode("utf-8")
|
||
request = urllib.request.Request(
|
||
f"{AGENT_URL}/v1/env/update",
|
||
data=data,
|
||
headers=headers,
|
||
method="POST",
|
||
)
|
||
with urllib.request.urlopen(request, timeout=60) as response:
|
||
return json.loads(response.read().decode("utf-8"))
|
||
|
||
|
||
def _build_settings_env_payload(
|
||
*,
|
||
raw_text: Optional[str] = None,
|
||
backup_path: Optional[str] = None,
|
||
apply_plan: Optional[dict[str, Any]] = None,
|
||
) -> dict:
|
||
env_path = _resolve_runtime_env_path()
|
||
if raw_text is None:
|
||
try:
|
||
raw_text = env_path.read_text(encoding="utf-8")
|
||
except OSError:
|
||
raw_text = ""
|
||
|
||
values, parse_issues = _parse_env_text(raw_text)
|
||
schema_properties, required_keys = _load_env_schema()
|
||
fields = _build_env_fields(schema_properties, required_keys, values)
|
||
sections = _build_env_sections(list(fields.keys()))
|
||
issues = _validate_env_values(values, fields, parse_issues)
|
||
public_fields: dict[str, dict[str, Any]] = {}
|
||
public_values: dict[str, str] = {}
|
||
|
||
for key, field in fields.items():
|
||
public_field = {**field}
|
||
if field.get("secret"):
|
||
public_field["value"] = ""
|
||
public_values[key] = ""
|
||
else:
|
||
public_values[key] = field["value"]
|
||
public_fields[key] = public_field
|
||
|
||
return {
|
||
"path": _relative_install_path(env_path),
|
||
"raw": "",
|
||
"values": public_values,
|
||
"fields": public_fields,
|
||
"sections": sections,
|
||
"issues": issues,
|
||
"saveHint": "Saving writes the .env file directly, keeps existing secret values when left blank, never sends stored secrets back to the browser, and stores a timestamped backup under data/config-backups first.",
|
||
"restartHint": "Some ODS services need a container recreate before changed values fully take effect. Use Apply changes when it becomes available after saving.",
|
||
"backupPath": backup_path,
|
||
"applyPlan": apply_plan,
|
||
"agentAvailable": _check_host_agent_available(),
|
||
}
|
||
|
||
|
||
def _relative_install_path(path: Path) -> str:
|
||
try:
|
||
return str(path.relative_to(_resolve_install_root())).replace("\\", "/")
|
||
except ValueError:
|
||
return str(path).replace("\\", "/")
|
||
|
||
|
||
def _prepare_env_save(payload: dict[str, Any]) -> tuple[str, list[dict[str, Any]], dict[str, Any]]:
|
||
mode = payload.get("mode", "form")
|
||
env_path = _resolve_runtime_env_path()
|
||
current_values, _ = _read_env_map_from_path(env_path)
|
||
schema_properties, required_keys = _load_env_schema()
|
||
|
||
if mode != "form":
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"message": "Only form-based editing is supported for security reasons."},
|
||
)
|
||
|
||
submitted_values = payload.get("values", {})
|
||
if not isinstance(submitted_values, dict):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"message": "Form configuration payload must be an object."},
|
||
)
|
||
|
||
base_fields = _build_env_fields(schema_properties, required_keys, current_values)
|
||
invalid_keys = sorted(set(submitted_values.keys()) - set(base_fields.keys()))
|
||
if invalid_keys:
|
||
return _render_env_from_values(current_values), [
|
||
{
|
||
"key": key,
|
||
"message": "Field is not editable from the dashboard. Only schema-backed fields and existing local overrides can be changed here.",
|
||
}
|
||
for key in invalid_keys
|
||
], _compute_env_apply_plan(current_values, current_values)
|
||
|
||
normalized_values = _serialize_form_values(submitted_values, base_fields, current_values)
|
||
merged_values = {**current_values, **normalized_values}
|
||
for key, field in base_fields.items():
|
||
if _empty_value_unsets_env_key(key, field) and str(merged_values.get(key, "")).strip() == "":
|
||
merged_values.pop(key, None)
|
||
merged_fields = _build_env_fields(schema_properties, required_keys, merged_values)
|
||
issues = _validate_env_values(merged_values, merged_fields)
|
||
apply_plan = _compute_env_apply_plan(current_values, merged_values)
|
||
return _render_env_from_values(merged_values), issues, apply_plan
|
||
|
||
# --- App ---
|
||
|
||
@asynccontextmanager
|
||
async def _lifespan(app: FastAPI):
|
||
asyncio.create_task(collect_metrics())
|
||
asyncio.create_task(_poll_service_health())
|
||
asyncio.create_task(gpu_router.poll_gpu_history())
|
||
try:
|
||
yield
|
||
finally:
|
||
# Close any open Hermes WebSockets in the ODS Talk connection pool
|
||
# so a graceful uvicorn shutdown doesn't leak FDs into stale state.
|
||
try:
|
||
import hermes_bridge
|
||
await hermes_bridge.shutdown_pool()
|
||
except Exception:
|
||
logger.debug("hermes_bridge.shutdown_pool raised at app shutdown", exc_info=True)
|
||
|
||
|
||
app = FastAPI(
|
||
title="ODS Dashboard API",
|
||
version="2.5.3",
|
||
description="System status API for ODS Dashboard",
|
||
lifespan=_lifespan,
|
||
)
|
||
|
||
# --- CORS ---
|
||
|
||
def get_allowed_origins():
|
||
env_origins = os.environ.get("DASHBOARD_ALLOWED_ORIGINS", "")
|
||
if env_origins:
|
||
return env_origins.split(",")
|
||
origins = [
|
||
"http://localhost:3001", "http://127.0.0.1:3001",
|
||
"http://localhost:3000", "http://127.0.0.1:3000",
|
||
]
|
||
try:
|
||
hostname = socket.gethostname()
|
||
local_ips = socket.gethostbyname_ex(hostname)[2]
|
||
for ip in local_ips:
|
||
if ip.startswith(("192.168.", "10.", "172.")):
|
||
origins.append(f"http://{ip}:3001")
|
||
origins.append(f"http://{ip}:3000")
|
||
except (OSError, socket.gaierror):
|
||
logger.debug("Could not detect LAN IPs for CORS origins")
|
||
return origins
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=get_allowed_origins(),
|
||
allow_credentials=True,
|
||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||
allow_headers=["Authorization", "Content-Type", "X-Requested-With"],
|
||
)
|
||
|
||
# --- Include Routers ---
|
||
|
||
app.include_router(workflows.router)
|
||
app.include_router(features.router)
|
||
app.include_router(setup.router)
|
||
app.include_router(updates.router)
|
||
app.include_router(agents.router)
|
||
app.include_router(privacy.router)
|
||
app.include_router(extensions.router)
|
||
app.include_router(gpu_router.router)
|
||
app.include_router(resources.router)
|
||
app.include_router(voice.router)
|
||
app.include_router(models_router.router)
|
||
app.include_router(templates.router)
|
||
app.include_router(auth_router.router)
|
||
app.include_router(magic_link.router)
|
||
app.include_router(oauth_passthrough.router)
|
||
app.include_router(talk.router)
|
||
app.include_router(tailscale.router)
|
||
app.include_router(usage.router)
|
||
|
||
|
||
# ================================================================
|
||
# Core Endpoints (health, status, preflight, services)
|
||
# ================================================================
|
||
|
||
@app.get("/health")
|
||
async def health():
|
||
"""API health check."""
|
||
return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()}
|
||
|
||
|
||
@app.get("/api/host-agent/diagnostics", dependencies=[Depends(verify_api_key)])
|
||
async def host_agent_diagnostics():
|
||
"""Report how dashboard-api resolves and reaches the host agent."""
|
||
inside_container = await asyncio.to_thread(_running_inside_container)
|
||
default_gateway = None
|
||
if inside_container:
|
||
default_gateway = await asyncio.to_thread(_detect_container_default_gateway)
|
||
probe = await asyncio.to_thread(_probe_host_agent_health)
|
||
probe["last_success_at"] = _host_agent_probe_state["last_success_at"]
|
||
probe["last_error"] = _host_agent_probe_state["last_error"]
|
||
|
||
return {
|
||
"configured": {
|
||
"url": AGENT_URL,
|
||
"host": AGENT_HOST,
|
||
"port": AGENT_PORT,
|
||
"ods_agent_key_configured": bool(ODS_AGENT_KEY),
|
||
"ods_agent_host_explicit": bool(os.environ.get("ODS_AGENT_HOST", "").strip()),
|
||
},
|
||
"container": {
|
||
"inside_container": inside_container,
|
||
"default_gateway": default_gateway or None,
|
||
},
|
||
"probe": probe,
|
||
}
|
||
|
||
|
||
# --- Preflight ---
|
||
|
||
@app.get("/api/preflight/docker", dependencies=[Depends(verify_api_key)])
|
||
async def preflight_docker():
|
||
"""Check if Docker is available."""
|
||
if os.path.exists("/.dockerenv"):
|
||
return {"available": True, "version": "available (host)"}
|
||
try:
|
||
proc = await asyncio.create_subprocess_exec(
|
||
"docker", "--version",
|
||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||
)
|
||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
|
||
if proc.returncode == 0:
|
||
parts = stdout.decode().strip().split()
|
||
version = parts[2].rstrip(",") if len(parts) > 2 else "unknown"
|
||
return {"available": True, "version": version}
|
||
return {"available": False, "error": "Docker command failed"}
|
||
except FileNotFoundError:
|
||
return {"available": False, "error": "Docker not installed"}
|
||
except asyncio.TimeoutError:
|
||
return {"available": False, "error": "Docker check timed out"}
|
||
except OSError:
|
||
logger.exception("Docker preflight check failed")
|
||
return {"available": False, "error": "Docker check failed"}
|
||
|
||
|
||
@app.get("/api/preflight/gpu", dependencies=[Depends(verify_api_key)])
|
||
async def preflight_gpu():
|
||
"""Check GPU availability."""
|
||
gpu_info = await asyncio.to_thread(get_gpu_info)
|
||
if gpu_info:
|
||
vram_gb = round(gpu_info.memory_total_mb / 1024, 1)
|
||
result = {"available": True, "name": gpu_info.name, "vram": vram_gb, "backend": gpu_info.gpu_backend, "memory_type": gpu_info.memory_type}
|
||
if gpu_info.memory_type == "unified":
|
||
result["memory_label"] = f"{vram_gb} GB Unified"
|
||
return result
|
||
|
||
gpu_backend = os.environ.get("GPU_BACKEND", "").lower()
|
||
if gpu_backend == "amd":
|
||
return {"available": False, "error": "AMD GPU not detected via sysfs. Check /dev/kfd and /dev/dri access."}
|
||
return {"available": False, "error": "No GPU detected. Ensure NVIDIA drivers or AMD amdgpu driver is loaded."}
|
||
|
||
|
||
@app.get("/api/preflight/required-ports")
|
||
async def preflight_required_ports():
|
||
"""Return the list of service ports for preflight checking (no auth required)."""
|
||
# When health cache exists, filter out services not in the compose stack
|
||
cached = get_cached_services()
|
||
deployed = {s.id for s in cached if s.status != "not_deployed"} if cached else None
|
||
|
||
ports = []
|
||
for sid, cfg in SERVICES.items():
|
||
if deployed is not None and sid not in deployed:
|
||
continue
|
||
ext_port = cfg.get("external_port", cfg.get("port", 0))
|
||
if ext_port:
|
||
ports.append({"port": ext_port, "service": cfg.get("name", sid)})
|
||
return {"ports": ports}
|
||
|
||
|
||
@app.post("/api/preflight/ports", dependencies=[Depends(verify_api_key)])
|
||
async def preflight_ports(request: PortCheckRequest):
|
||
"""Check if required ports are available."""
|
||
port_services = {}
|
||
for sid, cfg in SERVICES.items():
|
||
ext_port = cfg.get("external_port", cfg.get("port", 0))
|
||
if ext_port:
|
||
port_services[ext_port] = cfg.get("name", sid)
|
||
|
||
conflicts = []
|
||
for port in request.ports:
|
||
try:
|
||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||
sock.settimeout(1)
|
||
sock.bind(("0.0.0.0", port))
|
||
except socket.error:
|
||
conflicts.append({"port": port, "service": port_services.get(port, "Unknown"), "in_use": True})
|
||
return {"conflicts": conflicts, "available": len(conflicts) == 0}
|
||
|
||
|
||
@app.get("/api/preflight/disk", dependencies=[Depends(verify_api_key)])
|
||
async def preflight_disk():
|
||
"""Check available disk space."""
|
||
try:
|
||
check_path = DATA_DIR if os.path.exists(DATA_DIR) else Path.home()
|
||
usage = shutil.disk_usage(check_path)
|
||
return {"free": usage.free, "total": usage.total, "used": usage.used, "path": str(check_path)}
|
||
except OSError:
|
||
logger.exception("Disk preflight check failed")
|
||
return {"error": "Disk check failed", "free": 0, "total": 0, "used": 0, "path": ""}
|
||
|
||
|
||
# --- Core Data ---
|
||
|
||
@app.get("/gpu", response_model=Optional[GPUInfo])
|
||
async def gpu(api_key: str = Depends(verify_api_key)):
|
||
"""Get GPU metrics (cached for a few seconds to avoid nvidia-smi spam)."""
|
||
cached = _cache.get("gpu_info", _CACHE_MISS)
|
||
if cached is not _CACHE_MISS:
|
||
if not cached:
|
||
raise HTTPException(status_code=503, detail="GPU not available")
|
||
return cached
|
||
info = await asyncio.to_thread(get_gpu_info)
|
||
_cache.set("gpu_info", info, _GPU_CACHE_TTL)
|
||
if not info:
|
||
raise HTTPException(status_code=503, detail="GPU not available")
|
||
return info
|
||
|
||
|
||
@app.get("/services", response_model=list[ServiceStatus])
|
||
async def services(api_key: str = Depends(verify_api_key)):
|
||
"""Get all service health statuses (from background poll cache)."""
|
||
cached = get_cached_services()
|
||
if cached is not None:
|
||
return cached
|
||
return await get_all_services()
|
||
|
||
|
||
@app.get("/disk", response_model=DiskUsage)
|
||
async def disk(api_key: str = Depends(verify_api_key)):
|
||
return await asyncio.to_thread(get_disk_usage)
|
||
|
||
|
||
@app.get("/model", response_model=Optional[ModelInfo])
|
||
async def model(api_key: str = Depends(verify_api_key)):
|
||
return await asyncio.to_thread(get_model_info)
|
||
|
||
|
||
@app.get("/bootstrap", response_model=BootstrapStatus)
|
||
async def bootstrap(api_key: str = Depends(verify_api_key)):
|
||
return await asyncio.to_thread(get_bootstrap_status)
|
||
|
||
|
||
@app.get("/api/model-readiness")
|
||
async def api_model_readiness(api_key: str = Depends(verify_api_key)):
|
||
"""Return first-run model/context readiness for Hermes and chat."""
|
||
model_info, bootstrap_info, loaded_model = await asyncio.gather(
|
||
asyncio.to_thread(get_model_info),
|
||
asyncio.to_thread(get_bootstrap_status),
|
||
get_loaded_model(),
|
||
)
|
||
runtime_context = await get_llama_context_size(model_hint=loaded_model)
|
||
return _build_model_readiness_payload(
|
||
model_info=model_info,
|
||
bootstrap_info=bootstrap_info,
|
||
loaded_model=loaded_model,
|
||
runtime_context=runtime_context,
|
||
)
|
||
|
||
|
||
@app.get("/status", response_model=FullStatus)
|
||
async def status(api_key: str = Depends(verify_api_key)):
|
||
"""Get full system status. Runs sync helpers in thread pool concurrently."""
|
||
service_statuses, gpu_info, disk_info, model_info, bootstrap_info, uptime = await asyncio.gather(
|
||
_get_services(),
|
||
asyncio.to_thread(get_gpu_info),
|
||
asyncio.to_thread(get_disk_usage),
|
||
asyncio.to_thread(get_model_info),
|
||
asyncio.to_thread(get_bootstrap_status),
|
||
asyncio.to_thread(get_uptime),
|
||
)
|
||
return FullStatus(
|
||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||
gpu=gpu_info, services=service_statuses,
|
||
disk=disk_info, model=model_info,
|
||
bootstrap=bootstrap_info, uptime_seconds=uptime
|
||
)
|
||
|
||
|
||
@app.get("/api/status")
|
||
async def api_status(api_key: str = Depends(verify_api_key)):
|
||
"""Dashboard-compatible status endpoint.
|
||
|
||
Catches transient I/O failures from sub-calls (GPU, health checks,
|
||
llama metrics …) and returns a safe fallback. Programming errors
|
||
(AttributeError, KeyError, TypeError) propagate so they surface in
|
||
tests instead of being masked.
|
||
"""
|
||
try:
|
||
return await _build_api_status()
|
||
except (asyncio.TimeoutError, OSError):
|
||
logger.exception("/api/status handler failed — returning safe fallback")
|
||
return {
|
||
"gpu": None, "services": [], "model": None,
|
||
"bootstrap": None, "uptime": 0,
|
||
"version": app.version, "tier": "Unknown",
|
||
"cpu": {"percent": 0, "temp_c": None},
|
||
"ram": {"used_gb": 0, "total_gb": 0, "percent": 0},
|
||
"disk": {"used_gb": 0, "total_gb": 0, "percent": 0},
|
||
"system": {"uptime": 0, "hostname": os.environ.get("HOSTNAME", "ods")},
|
||
"inference": {"tokensPerSecond": 0, "lifetimeTokens": 0,
|
||
"loadedModel": None, "contextSize": None},
|
||
"manifest_errors": MANIFEST_ERRORS,
|
||
}
|
||
|
||
|
||
@app.get("/api/readiness")
|
||
async def api_readiness(api_key: str = Depends(verify_api_key)):
|
||
"""Return user-workflow readiness, not just container health."""
|
||
(
|
||
service_statuses,
|
||
loaded_model,
|
||
context_size,
|
||
bootstrap_info,
|
||
host_agent,
|
||
stt_result,
|
||
) = await asyncio.gather(
|
||
_get_services(),
|
||
get_loaded_model(),
|
||
get_llama_context_size(),
|
||
asyncio.to_thread(get_bootstrap_status),
|
||
asyncio.to_thread(_probe_host_agent_health),
|
||
_check_stt_model_cached(),
|
||
)
|
||
stt_model_cached, stt_model_name = stt_result
|
||
return _build_readiness_payload(
|
||
service_statuses=service_statuses,
|
||
loaded_model=loaded_model,
|
||
context_size=context_size,
|
||
bootstrap_info=bootstrap_info,
|
||
host_agent=host_agent,
|
||
stt_model_cached=stt_model_cached,
|
||
stt_model_name=stt_model_name,
|
||
)
|
||
|
||
|
||
async def _build_api_status() -> dict:
|
||
"""Build the full status payload.
|
||
|
||
Runs ALL sync helpers (GPU, disk, CPU, RAM, model, bootstrap)
|
||
concurrently in the thread pool while async health checks and
|
||
llama-server queries run on the event loop — no serial blocking.
|
||
"""
|
||
# Fan out: sync helpers in threads + async health checks simultaneously
|
||
(
|
||
gpu_info, model_info, bootstrap_info, uptime,
|
||
cpu_metrics, ram_metrics, disk_info,
|
||
service_statuses, loaded_model,
|
||
) = await asyncio.gather(
|
||
asyncio.to_thread(get_gpu_info),
|
||
asyncio.to_thread(get_model_info),
|
||
asyncio.to_thread(get_bootstrap_status),
|
||
asyncio.to_thread(get_uptime),
|
||
asyncio.to_thread(get_cpu_metrics),
|
||
asyncio.to_thread(get_ram_metrics),
|
||
asyncio.to_thread(get_disk_usage),
|
||
_get_services(),
|
||
get_loaded_model(),
|
||
)
|
||
|
||
# Second fan-out: llama metrics + context size (need loaded_model)
|
||
llama_metrics_data, context_size = await asyncio.gather(
|
||
get_llama_metrics(model_hint=loaded_model),
|
||
get_llama_context_size(model_hint=loaded_model),
|
||
)
|
||
|
||
gpu_data = None
|
||
if gpu_info:
|
||
gpu_data = {
|
||
"name": gpu_info.name,
|
||
"vramUsed": round(gpu_info.memory_used_mb / 1024, 1),
|
||
"vramTotal": round(gpu_info.memory_total_mb / 1024, 1),
|
||
"utilization": gpu_info.utilization_percent,
|
||
"temperature": gpu_info.temperature_c,
|
||
"memoryType": gpu_info.memory_type,
|
||
"backend": gpu_info.gpu_backend,
|
||
"gpu_count": _infer_gpu_count(gpu_info),
|
||
}
|
||
if gpu_info.power_w is not None:
|
||
gpu_data["powerDraw"] = gpu_info.power_w
|
||
gpu_data["memoryLabel"] = "VRAM Partition" if gpu_info.memory_type == "unified" else "VRAM"
|
||
|
||
services_data = _serialize_services(service_statuses, uptime)
|
||
|
||
model_data = None
|
||
if model_info:
|
||
model_data = {"name": model_info.name, "tokensPerSecond": llama_metrics_data.get("tokens_per_second") or None, "contextLength": context_size or model_info.context_length}
|
||
|
||
bootstrap_data = None
|
||
if bootstrap_info.active:
|
||
bootstrap_data = {
|
||
"active": True, "model": bootstrap_info.model_name or "Full Model",
|
||
"percent": bootstrap_info.percent or 0,
|
||
"bytesDownloaded": int((bootstrap_info.downloaded_gb or 0) * 1024**3),
|
||
"bytesTotal": int((bootstrap_info.total_gb or 0) * 1024**3),
|
||
"eta": bootstrap_info.eta_seconds, "speedMbps": bootstrap_info.speed_mbps
|
||
}
|
||
|
||
tier = _infer_tier(gpu_info)
|
||
|
||
result = {
|
||
"gpu": gpu_data, "services": services_data, "model": model_data,
|
||
"bootstrap": bootstrap_data, "uptime": uptime,
|
||
"version": app.version, "tier": tier,
|
||
"cpu": cpu_metrics, "ram": ram_metrics,
|
||
"disk": {"used_gb": disk_info.used_gb, "total_gb": disk_info.total_gb, "percent": disk_info.percent},
|
||
"system": {"uptime": uptime, "hostname": os.environ.get("HOSTNAME", "ods")},
|
||
"inference": {
|
||
"tokensPerSecond": llama_metrics_data.get("tokens_per_second", 0),
|
||
"lifetimeTokens": llama_metrics_data.get("lifetime_tokens", 0),
|
||
"loadedModel": loaded_model or (model_data["name"] if model_data else None),
|
||
"contextSize": context_size or (model_data["contextLength"] if model_data else None),
|
||
},
|
||
"manifest_errors": MANIFEST_ERRORS,
|
||
}
|
||
return result
|
||
|
||
|
||
# --- Settings ---
|
||
|
||
@app.get("/api/service-tokens", dependencies=[Depends(verify_api_key)])
|
||
async def service_tokens():
|
||
"""Return connection tokens for services that need browser-side auth."""
|
||
def _read_tokens():
|
||
tokens = {}
|
||
oc_token = os.environ.get("OPENCLAW_TOKEN", "")
|
||
if not oc_token:
|
||
for path in [Path("/data/openclaw/home/gateway-token"), Path("/ods/.env")]:
|
||
try:
|
||
if path.suffix == ".env":
|
||
for line in path.read_text().splitlines():
|
||
if line.startswith("OPENCLAW_TOKEN="):
|
||
oc_token = line.split("=", 1)[1].strip()
|
||
break
|
||
else:
|
||
oc_token = path.read_text().strip()
|
||
except (OSError, ValueError):
|
||
continue
|
||
if oc_token:
|
||
break
|
||
if oc_token:
|
||
tokens["openclaw"] = oc_token
|
||
return tokens
|
||
|
||
return await asyncio.to_thread(_read_tokens)
|
||
|
||
|
||
@app.get("/api/external-links")
|
||
async def get_external_links(api_key: str = Depends(verify_api_key)):
|
||
"""Return sidebar-ready external links derived from service manifests."""
|
||
links = []
|
||
for sid, cfg in SERVICES.items():
|
||
ext_port = cfg.get("external_port", cfg.get("port", 0))
|
||
if not ext_port or sid == "dashboard-api" or cfg.get("external_link") is False:
|
||
continue
|
||
links.append({
|
||
"id": sid, "label": cfg.get("name", sid), "port": ext_port,
|
||
"ui_path": cfg.get("ui_path", "/"),
|
||
"icon": SIDEBAR_ICONS.get(sid, "ExternalLink"),
|
||
"healthNeedles": [sid, cfg.get("name", sid).lower()],
|
||
})
|
||
return links
|
||
|
||
|
||
@app.get("/api/storage")
|
||
async def api_storage(api_key: str = Depends(verify_api_key)):
|
||
"""Get storage breakdown for Settings page (cached, runs in thread pool)."""
|
||
cached = _cache.get("storage")
|
||
if cached is not None:
|
||
return cached
|
||
|
||
def _compute_storage():
|
||
models_dir = Path(DATA_DIR) / "models"
|
||
vector_dir = Path(DATA_DIR) / "qdrant"
|
||
data_dir = Path(DATA_DIR)
|
||
|
||
disk_info = get_disk_usage()
|
||
models_gb = dir_size_gb(models_dir)
|
||
vector_gb = dir_size_gb(vector_dir)
|
||
other_gb = dir_size_gb(data_dir) - models_gb - vector_gb
|
||
total_data_gb = models_gb + vector_gb + max(other_gb, 0)
|
||
|
||
return {
|
||
"models": {"formatted": f"{models_gb:.1f} GB", "gb": models_gb, "percent": round(models_gb / disk_info.total_gb * 100, 1) if disk_info.total_gb else 0},
|
||
"vector_db": {"formatted": f"{vector_gb:.1f} GB", "gb": vector_gb, "percent": round(vector_gb / disk_info.total_gb * 100, 1) if disk_info.total_gb else 0},
|
||
"total_data": {"formatted": f"{total_data_gb:.1f} GB", "gb": total_data_gb, "percent": round(total_data_gb / disk_info.total_gb * 100, 1) if disk_info.total_gb else 0},
|
||
"disk": {"used_gb": disk_info.used_gb, "total_gb": disk_info.total_gb, "percent": disk_info.percent}
|
||
}
|
||
|
||
result = await asyncio.to_thread(_compute_storage)
|
||
_cache.set("storage", result, _STORAGE_CACHE_TTL)
|
||
return result
|
||
|
||
|
||
@app.get("/api/settings/summary")
|
||
async def api_settings_summary(api_key: str = Depends(verify_api_key)):
|
||
"""Fast settings payload that avoids slow live service probes on first load."""
|
||
cached = _cache.get("settings_summary")
|
||
if cached is not None:
|
||
return cached
|
||
|
||
gpu_info, model_info, uptime, cpu_metrics, ram_metrics = await asyncio.gather(
|
||
asyncio.to_thread(get_gpu_info),
|
||
asyncio.to_thread(get_model_info),
|
||
asyncio.to_thread(get_uptime),
|
||
asyncio.to_thread(get_cpu_metrics),
|
||
asyncio.to_thread(get_ram_metrics),
|
||
)
|
||
|
||
cached_services = get_cached_services()
|
||
services_data = (
|
||
_serialize_services(cached_services, uptime)
|
||
if cached_services is not None
|
||
else _fallback_services()
|
||
)
|
||
|
||
result = {
|
||
"version": _read_installed_version(),
|
||
"install_date": _read_install_date(),
|
||
"tier": _infer_tier(gpu_info),
|
||
"uptime": uptime,
|
||
"cpu": cpu_metrics,
|
||
"ram": ram_metrics,
|
||
"gpu": _serialize_gpu(gpu_info),
|
||
"model": _serialize_model(model_info),
|
||
"services": services_data,
|
||
"system": {
|
||
"uptime": uptime,
|
||
"hostname": os.environ.get("HOSTNAME", "ods"),
|
||
},
|
||
"manifest_errors": MANIFEST_ERRORS,
|
||
}
|
||
_cache.set("settings_summary", result, _SETTINGS_SUMMARY_CACHE_TTL)
|
||
return result
|
||
|
||
|
||
@app.get("/api/settings/env")
|
||
async def api_settings_env(api_key: str = Depends(verify_api_key)):
|
||
cached = _cache.get("settings_env")
|
||
if cached is not None:
|
||
return cached
|
||
|
||
result = await asyncio.to_thread(_build_settings_env_payload)
|
||
_cache.set("settings_env", result, _SETTINGS_ENV_CACHE_TTL)
|
||
return result
|
||
|
||
|
||
@app.put("/api/settings/env")
|
||
async def api_settings_env_save(
|
||
payload: dict[str, Any] = Body(...),
|
||
api_key: str = Depends(verify_api_key),
|
||
):
|
||
raw_text, issues, apply_plan = await asyncio.to_thread(_prepare_env_save, payload)
|
||
if issues:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={
|
||
"message": "Configuration validation failed.",
|
||
"issues": issues,
|
||
},
|
||
)
|
||
|
||
try:
|
||
agent_resp = await asyncio.to_thread(_call_agent_env_update, raw_text)
|
||
except urllib.error.HTTPError as exc:
|
||
detail = f"Host agent returned HTTP {exc.code}."
|
||
try:
|
||
err_payload = json.loads(exc.read().decode("utf-8"))
|
||
detail = err_payload.get("error", detail)
|
||
except (json.JSONDecodeError, UnicodeDecodeError, AttributeError):
|
||
pass
|
||
raise HTTPException(status_code=503, detail={"message": detail}) from exc
|
||
except urllib.error.URLError as exc:
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail={"message": "ODS host agent is not reachable. Start the host agent, then try again."},
|
||
) from exc
|
||
except OSError as exc:
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail={"message": "Could not contact host agent to write environment file.", "reason": str(exc)},
|
||
) from exc
|
||
backup_relative = agent_resp.get("backup_path")
|
||
|
||
_clear_settings_caches()
|
||
result = await asyncio.to_thread(
|
||
_build_settings_env_payload,
|
||
raw_text=raw_text,
|
||
backup_path=backup_relative,
|
||
apply_plan=apply_plan,
|
||
)
|
||
_cache.set("settings_env", result, _SETTINGS_ENV_CACHE_TTL)
|
||
return result
|
||
|
||
|
||
@app.post("/api/settings/env/apply")
|
||
async def api_settings_env_apply(
|
||
payload: dict[str, Any] = Body(...),
|
||
api_key: str = Depends(verify_api_key),
|
||
):
|
||
service_ids = payload.get("service_ids", [])
|
||
if not isinstance(service_ids, list) or not service_ids:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"message": "service_ids must be a non-empty list."},
|
||
)
|
||
|
||
normalized: list[str] = []
|
||
for service_id in sorted(set(service_ids)):
|
||
if not isinstance(service_id, str) or service_id not in _SETTINGS_APPLY_ALLOWED_SERVICES:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"message": f"Service is not eligible for dashboard-triggered apply: {service_id}"},
|
||
)
|
||
normalized.append(service_id)
|
||
|
||
try:
|
||
await asyncio.to_thread(_call_agent_core_recreate, normalized)
|
||
_clear_settings_caches()
|
||
return {
|
||
"success": True,
|
||
"services": normalized,
|
||
"message": f"Applied runtime changes to {', '.join(normalized)}.",
|
||
}
|
||
except urllib.error.HTTPError as exc:
|
||
detail = f"Host agent returned HTTP {exc.code}."
|
||
try:
|
||
payload = json.loads(exc.read().decode("utf-8"))
|
||
detail = payload.get("error", detail)
|
||
except (json.JSONDecodeError, UnicodeDecodeError, AttributeError):
|
||
pass
|
||
raise HTTPException(status_code=503, detail={"message": detail}) from exc
|
||
except urllib.error.URLError as exc:
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail={"message": "ODS host agent is not reachable. Start the host agent, then try Apply changes again."},
|
||
) from exc
|
||
except OSError as exc:
|
||
logger.exception("Settings apply failed")
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail={"message": f"Could not apply runtime changes: {exc}"},
|
||
) from exc
|
||
|
||
|
||
# --- Service Health Polling ---
|
||
|
||
async def _get_services() -> list[ServiceStatus]:
|
||
"""Return cached service health, falling back to live check."""
|
||
cached = get_cached_services()
|
||
if cached is not None:
|
||
return cached
|
||
return await get_all_services()
|
||
|
||
|
||
async def _poll_service_health():
|
||
"""Background task: poll all service health on a timer.
|
||
|
||
Results stored via set_services_cache(). API endpoints read
|
||
cached results instead of running live checks. The poll can
|
||
take as long as it needs — nobody waits for it.
|
||
"""
|
||
await asyncio.sleep(2) # let services start
|
||
while True:
|
||
try:
|
||
statuses = await get_all_services()
|
||
set_services_cache(statuses)
|
||
except Exception:
|
||
logger.exception("Service health poll failed")
|
||
await asyncio.sleep(_SERVICE_POLL_INTERVAL)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("DASHBOARD_API_PORT", "3002")))
|