Files
Unraid-AppOps-Gateway-Public/backend/app/api/routes_observability.py
T

670 lines
27 KiB
Python

from __future__ import annotations
import hashlib
import json
import math
import os
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime
from threading import Lock
from typing import Any
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from ..connectors.authentik import AuthentikConnector
from ..connectors.gpu_metrics import GpuMetricsConnector
from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector
from ..connectors.portainer import PortainerConnector
from ..connectors.unraid import UnraidConnector
from ..core.config import settings
from ..core.redaction import redact_message
from ..core.time import utc_now
from ..db.models import AppSetting, AuditEvent, Connector, JobRun, Policy, Secret
from ..db.session import get_session
from ..services.app_registry import list_active_apps
from ..services.incident_service import list_incidents as list_live_incidents
from ..services.incident_service import (
update_incident_status as update_live_incident_status,
)
from ..services.setup_service import apply_stored_connector_settings
from ..services.exposure_preferences import get_exposure_preferences, runtime_nginx_access_list_checks_enabled
router = APIRouter(prefix='/api')
NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION = 2
NETWORK_EXPOSURE_CACHE_KEY = 'network_exposure:cache'
NETWORK_EXPOSURE_CACHE_TTL_SECONDS = 60
NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS = 300
STATISTICS_HISTORY_MAX_POINTS = 361
_STATISTICS_HISTORY: list[dict[str, Any]] = []
_STATISTICS_HISTORY_LOCK = Lock()
@router.get('/incidents')
def list_incidents(session: Session = Depends(get_session)):
return list_live_incidents(session)
@router.post('/incidents/{incident_id}/status')
def update_incident_status(incident_id: str, payload: dict | None = None, session: Session = Depends(get_session)):
status = str(payload.get('status') or 'acknowledged') if isinstance(payload, dict) else 'acknowledged'
return update_live_incident_status(session, incident_id, status)
@router.get('/operations-map')
def operations_map(session: Session = Depends(get_session)):
apps = {app.id: app for app in list_active_apps(session, order_by='id')}
connectors = session.exec(select(Connector).order_by(Connector.id)).all()
policies = session.exec(select(Policy).order_by(Policy.id)).all()
secrets_count = len(session.exec(select(Secret.id)).all())
generated_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
nodes: set[str] = {'Connector Registry', 'Policy Engine', 'Secret Store'}
edges: set[tuple[str, str]] = {('Secret Store', 'Connector Registry')}
for connector in connectors:
app = apps.get(connector.id)
if app is None:
continue
nodes.add(app.name)
edges.add(('Connector Registry', app.name))
if connector.secret_ref:
edges.add(('Secret Store', app.name))
for policy in policies:
if policy.target == '*':
continue
app = apps.get(policy.target)
if app is None:
continue
nodes.add(app.name)
edges.add(('Policy Engine', app.name))
return {
'generatedAt': generated_at,
'sources': {
'apps': {'status': 'ok', 'count': len(apps)},
'connectors': {'status': 'ok', 'count': len(connectors)},
'policies': {'status': 'ok', 'count': len(policies)},
'secrets': {'status': 'metadata_only', 'count': secrets_count},
},
'nodes': sorted(nodes),
'edges': [
{'from': source, 'to': target}
for source, target in sorted(edges)
if source in nodes and target in nodes
],
}
def _nullable_percent(value: Any) -> float | None:
if value is None or isinstance(value, bool):
return None
if isinstance(value, str):
value = value.strip().strip('%')
try:
number = float(value)
except (ValueError, TypeError, OverflowError):
return None
return round(number, 2) if math.isfinite(number) and 0 <= number <= 100 else None
def _record_statistics_sample(generated_at: str, cpu_usage: float | None, ram_usage: float | None, gpu_usage: float | None) -> list[dict[str, Any]]:
sample = {'time': generated_at, 'cpu': cpu_usage, 'ram': ram_usage, 'gpu': gpu_usage}
sampled_at = datetime.fromisoformat(generated_at.replace('Z', '+00:00'))
with _STATISTICS_HISTORY_LOCK:
if _STATISTICS_HISTORY:
previous_at = datetime.fromisoformat(_STATISTICS_HISTORY[-1]['time'].replace('Z', '+00:00'))
elapsed = (sampled_at - previous_at).total_seconds()
if elapsed >= 0 and int(sampled_at.timestamp() // 5) == int(previous_at.timestamp() // 5):
# Multiple viewers refresh the latest real measurement, rather than exhausting the window.
_STATISTICS_HISTORY[-1] = sample
elif elapsed >= 0:
_STATISTICS_HISTORY.append(sample)
else:
_STATISTICS_HISTORY.append(sample)
_STATISTICS_HISTORY[:] = [
item for item in _STATISTICS_HISTORY
if (sampled_at - datetime.fromisoformat(item['time'].replace('Z', '+00:00'))).total_seconds() <= 1800
][-STATISTICS_HISTORY_MAX_POINTS:]
return list(_STATISTICS_HISTORY)
@router.get('/statistics/summary')
def statistics_summary(session: Session = Depends(get_session)):
apply_stored_connector_settings(session)
gpu_connector = GpuMetricsConnector(snapshot_url=settings.gpu_metrics_url, verify_tls=settings.verify_tls)
with ThreadPoolExecutor(max_workers=2) as executor:
unraid_future = executor.submit(UnraidConnector().get_system_metrics)
gpu_future = executor.submit(gpu_connector.get_metrics)
metrics = unraid_future.result()
gpu = gpu_future.result()
generated_at = datetime.now(UTC).isoformat(timespec='milliseconds').replace('+00:00', 'Z')
cpu_usage = _nullable_percent(metrics.get('cpuUsage'))
ram_usage = _nullable_percent(metrics.get('ramUsage'))
gpu_values = [_nullable_percent(device.get('utilizationPercent')) for device in gpu.get('devices', [])]
gpu_usage = max((value for value in gpu_values if value is not None), default=None)
gpu = {**gpu, 'receivedAt': generated_at}
samples = _record_statistics_sample(generated_at, cpu_usage, ram_usage, gpu_usage)
source_warnings = []
if metrics.get('status') != 'ok':
source_warnings.append({
'source': 'unraid',
'surface': 'metrics',
'status': metrics.get('status', 'unknown'),
'reason': redact_message(str(metrics.get('reason', 'metrics_unavailable')))[:240],
})
if gpu.get('status') != 'ok':
source_warnings.append({
'source': 'gpu', 'surface': 'metrics', 'status': gpu.get('status', 'unknown'),
'reason': redact_message(str(gpu.get('reason', 'gpu_metrics_unavailable')))[:240],
})
audit_events = session.exec(select(AuditEvent).order_by(AuditEvent.timestamp.desc())).all()
jobs = session.exec(select(JobRun).order_by(JobRun.started_at.desc())).all()
restarts: dict[str, int] = {}
durations_ms: list[int] = []
for job in jobs:
if job.action_id == 'container.restart':
restarts[job.target] = restarts.get(job.target, 0) + 1
if job.status == 'succeeded' and job.duration_ms is not None:
durations_ms.append(int(job.duration_ms))
blocked_actions = sum(
1
for event in audit_events
if event.policy_decision == 'blocked' or event.result == 'blocked'
)
voice_action_count = sum(
1 for event in audit_events
if event.source == 'voice' or event.identity == 'voice'
or (event.redacted_metadata or {}).get('requestSource') == 'voice'
)
mttr_hours = None
if durations_ms:
mttr_hours = round((sum(durations_ms) / len(durations_ms)) / 1000 / 60 / 60, 2)
return {
'generatedAt': generated_at,
'sources': {'unraid': metrics.get('status', 'unknown'), 'gpu': gpu.get('status', 'unknown'), 'audit': 'ok', 'jobs': 'ok'},
'sourceWarnings': source_warnings,
'cpuHistory': [{'time': sample['time'], 'value': sample.get('cpu')} for sample in samples],
'ramHistory': [{'time': sample['time'], 'value': sample.get('ram')} for sample in samples],
'gpuHistory': [{'time': sample['time'], 'value': sample.get('gpu')} for sample in samples],
'gpu': gpu,
'restartsByApp': [
{'appId': app_id, 'count': count}
for app_id, count in sorted(restarts.items(), key=lambda item: (-item[1], item[0]))
],
'blockedActions': blocked_actions,
'voiceActionCount': voice_action_count,
'mttrHours': mttr_hours,
}
@router.get('/storage/summary')
def storage_summary(session: Session = Depends(get_session)):
apply_stored_connector_settings(session)
unraid = UnraidConnector()
portainer = PortainerConnector()
cache_usage = 0
storage_pools: list[dict] = []
shares: list[dict] = []
disks: list[dict] = []
app_data_usage: list[dict] = []
source_warnings: list[dict] = []
cache_result = unraid.get_cache_status()
if cache_result.get('status') == 'ok':
cache_usage = int(cache_result.get('cacheUsage', 0) or 0)
storage_pools = [
{
'name': str(pool.get('name', 'cache')),
'status': str(pool.get('status', 'unknown')),
'healthy': pool.get('healthy'),
'type': pool.get('type'),
'usedGiB': pool.get('usedGiB'),
'sizeGiB': pool.get('sizeGiB'),
'freeGiB': pool.get('freeGiB'),
'usagePercent': pool.get('usagePercent'),
}
for pool in cache_result.get('pools', [])
if isinstance(pool, dict)
]
elif cache_result.get('status') not in {None, 'ok'}:
source_warnings.append({
'source': 'unraid',
'surface': 'cache',
'status': cache_result.get('status', 'error'),
'reason': redact_message(str(cache_result.get('reason', 'cache_unavailable')))[:240],
})
share_result = unraid.get_share_summary()
if share_result.get('status') == 'ok':
shares = [
{
'name': str(share.get('name', 'unknown')),
'usedGiB': float(share.get('usedGiB', 0) or 0),
'sizeGiB': float(share.get('sizeGiB', 0) or 0),
'critical': bool(share.get('critical', False)),
}
for share in share_result.get('shares', [])
if isinstance(share, dict)
]
elif share_result.get('status') not in {None, 'ok'}:
source_warnings.append({
'source': 'unraid',
'surface': 'shares',
'status': share_result.get('status', 'error'),
'reason': redact_message(str(share_result.get('reason', 'shares_unavailable')))[:240],
})
disk_result = unraid.get_disk_warnings()
if disk_result.get('status') == 'ok':
disks = [
{
'name': str(disk.get('name', 'unknown')),
'status': str(disk.get('status', 'unknown')),
'healthy': disk.get('healthy'),
'type': disk.get('type'),
'usedGiB': disk.get('usedGiB'),
'sizeGiB': disk.get('sizeGiB'),
'freeGiB': disk.get('freeGiB'),
'temperature': disk.get('temperature'),
}
for disk in disk_result.get('disks', [])
if isinstance(disk, dict)
]
elif disk_result.get('status') not in {None, 'ok'}:
source_warnings.append({
'source': 'unraid',
'surface': 'disks',
'status': disk_result.get('status', 'error'),
'reason': redact_message(str(disk_result.get('reason', 'disks_unavailable')))[:240],
})
share_names = {
str(share.get('name', '')).strip('/').lower(): share
for share in shares
if str(share.get('name', '')).strip()
}
containers_result = portainer.list_containers()
if containers_result.get('status') == 'ok':
seen: set[str] = set()
for container in containers_result.get('containers', []):
if not isinstance(container, dict):
continue
app_id = str(container.get('appId') or container.get('name') or '').strip()
if not app_id:
continue
container_name = str(container.get('name') or app_id).strip()
mounts = container.get('mounts', [])
if not isinstance(mounts, list) or not mounts:
key = f'{app_id}:{container_name}:container'
if key in seen:
continue
seen.add(key)
app_data_usage.append({
'appId': app_id,
'container': container_name,
'usedGiB': None,
'volume': container_name,
'source': None,
'destination': None,
'type': 'container',
'share': None,
'measurement': 'not_available',
})
continue
if isinstance(mounts, list):
for mount in mounts:
if not isinstance(mount, dict):
continue
source = str(mount.get('source') or '').strip()
destination = str(mount.get('destination') or '').strip()
mount_type = str(mount.get('type') or '').strip() or 'unknown'
if not source and not destination:
continue
key = f'{app_id}:{container_name}:{source}:{destination}'
if key in seen:
continue
seen.add(key)
parts = [part for part in source.replace('\\', '/').split('/') if part]
share_name = parts[2] if len(parts) >= 3 and parts[0] == 'mnt' and parts[1] in {'user', 'cache'} else None
share = share_names.get(str(share_name or '').lower())
app_data_usage.append({
'appId': app_id,
'container': container_name,
'usedGiB': None,
'volume': f'{source or mount_type} -> {destination or "container"}',
'source': source or None,
'destination': destination or None,
'type': mount_type,
'share': share_name,
'shareUsagePercent': round((float(share.get('usedGiB', 0) or 0) / float(share.get('sizeGiB', 0) or 1)) * 100) if share and float(share.get('sizeGiB', 0) or 0) > 0 else None,
'measurement': 'mount_discovered',
})
elif containers_result.get('status') not in {None, 'ok'}:
source_warnings.append({
'source': 'portainer',
'surface': 'containers',
'status': containers_result.get('status', 'error'),
'reason': redact_message(str(containers_result.get('reason', 'containers_unavailable')))[:240],
})
total_size = sum(float(share.get('sizeGiB', 0) or 0) for share in shares)
total_used = sum(float(share.get('usedGiB', 0) or 0) for share in shares)
array_usage = round((total_used / total_size) * 100) if total_size > 0 else 0
return {
'cacheUsage': cache_usage,
'arrayUsage': array_usage,
'disks': disks,
'storagePools': storage_pools,
'shares': shares,
'appDataUsage': app_data_usage,
'orphanedVolumes': [],
'forecastDays': None,
'sources': {
'unraid': share_result.get('status', cache_result.get('status', 'unknown')),
'unraidDisks': disk_result.get('status', 'unknown'),
'portainer': containers_result.get('status', 'unknown'),
},
'sourceWarnings': source_warnings,
}
def _parse_utc(value: str) -> datetime:
return datetime.fromisoformat(value.replace('Z', '+00:00'))
def _network_cache_ttl_seconds() -> int:
raw = os.getenv('NETWORK_EXPOSURE_CACHE_TTL_SECONDS', str(NETWORK_EXPOSURE_CACHE_TTL_SECONDS))
try:
return max(1, int(raw))
except (TypeError, ValueError):
return NETWORK_EXPOSURE_CACHE_TTL_SECONDS
def _network_cache_max_stale_seconds() -> int:
raw = os.getenv('NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS', str(NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS))
try:
return max(0, int(raw))
except (TypeError, ValueError):
return NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS
def _redact(value):
if isinstance(value, str):
return redact_message(value)
if isinstance(value, list):
return [_redact(item) for item in value]
if isinstance(value, dict):
return {
str(key): _redact(item)
for key, item in value.items()
if 'secret' not in str(key).lower() and 'token' not in str(key).lower() and 'password' not in str(key).lower()
}
return value
def _network_exposure_signature(session: Session) -> str:
connectors = [
{
'id': item.id,
'status': item.status,
'lastTest': item.last_test,
'secretRef': item.secret_ref,
'capabilities': item.capabilities,
}
for item in session.exec(select(Connector)).all()
if item.id in {'nginx_proxy_manager', 'authentik'}
]
secret_rows = [
{
'id': item.id,
'name': item.name,
'updatedAt': item.updated_at.isoformat() if item.updated_at else None,
'usedBy': item.used_by_json,
}
for item in session.exec(select(Secret)).all()
if any(connector_id in item.used_by_json for connector_id in ('nginx_proxy_manager', 'authentik'))
]
signature_payload = {
'schemaVersion': NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION,
'exposurePreferences': get_exposure_preferences(session).model_dump(),
'connectors': sorted(connectors, key=lambda item: item['id']),
'secrets': sorted(secret_rows, key=lambda item: item['id']),
}
serialized = json.dumps(signature_payload, sort_keys=True, default=str, separators=(',', ':'))
return hashlib.sha256(serialized.encode('utf-8')).hexdigest()
def _persistent_network_exposure(session: Session, signature: str) -> list[dict] | None:
row = session.get(AppSetting, NETWORK_EXPOSURE_CACHE_KEY)
if not row or not isinstance(row.value_json, dict):
return None
value = row.value_json
if value.get('schemaVersion') != NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION or value.get('signature') != signature:
return None
payload = value.get('payload')
expires_at_raw = value.get('expiresAt')
if not isinstance(payload, list) or not isinstance(expires_at_raw, str):
return None
expires_at = _parse_utc(expires_at_raw)
now_dt = datetime.now(UTC)
stale_until = expires_at.timestamp() + _network_cache_max_stale_seconds()
if now_dt >= expires_at and now_dt.timestamp() >= stale_until:
return None
return [item for item in payload if isinstance(item, dict)]
def _store_persistent_network_exposure(session: Session, signature: str, payload: list[dict]) -> None:
ttl_seconds = _network_cache_ttl_seconds()
expires_at = datetime.fromtimestamp(datetime.now(UTC).replace(microsecond=0).timestamp() + ttl_seconds, UTC).isoformat().replace('+00:00', 'Z')
row = session.get(AppSetting, NETWORK_EXPOSURE_CACHE_KEY)
if not row:
row = AppSetting(key=NETWORK_EXPOSURE_CACHE_KEY, is_secret=False)
row.value_json = {
'schemaVersion': NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION,
'signature': signature,
'payload': _redact(payload),
'generatedAt': datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z'),
'expiresAt': expires_at,
}
row.updated_at = utc_now()
session.add(row)
session.commit()
def _build_network_exposure() -> list[dict]:
access_list_checks_enabled = runtime_nginx_access_list_checks_enabled()
with ThreadPoolExecutor(max_workers=3) as executor:
hosts_future = executor.submit(lambda: NginxProxyManagerConnector().proxy_hosts())
certs_future = executor.submit(lambda: NginxProxyManagerConnector().certificates())
coverage_future = executor.submit(_auth_coverage_by_host)
hosts_payload = hosts_future.result()
certs_payload = certs_future.result()
coverage_by_host = coverage_future.result()
if not hosts_payload.get('success'):
return []
certs = certs_payload.get('items', []) if certs_payload.get('success') else []
cert_by_id = {item.get('id'): item for item in certs if isinstance(item, dict)}
exposures = []
for host in hosts_payload.get('items', []):
if not isinstance(host, dict):
continue
domains = host.get('domainNames') if isinstance(host.get('domainNames'), list) else []
label = ', '.join(str(item) for item in domains if str(item).strip()) or f"proxy-host-{host.get('id', 'unknown')}"
certificate = cert_by_id.get(host.get('certificateId')) if host.get('certificateId') else None
has_certificate = bool(host.get('certificateId'))
certificate_expired = bool(certificate and certificate.get('isExpired'))
certificate_expiring = bool(certificate and certificate.get('isExpiringSoon'))
coverage = coverage_by_host.get(str(host.get('id')))
auth_status = str((coverage or {}).get('coverageStatus') or 'unknown')
auth_protected = auth_status in {'protected', 'likely_protected'}
npm_protected = bool(host.get('accessListId'))
protected = npm_protected or auth_protected
protection_source = 'authentik' if auth_protected else ('npm_access_list' if npm_protected else 'none')
enabled = bool(host.get('enabled'))
ssl_forced = bool(host.get('sslForced'))
risk_reasons: list[str] = []
if enabled and not protected:
risk_reasons.append('No NPM access list or Authentik match' if access_list_checks_enabled else 'No Authentik or other verified access protection')
if enabled and not has_certificate:
risk_reasons.append('No certificate assigned')
if certificate_expired:
risk_reasons.append('Certificate expired')
elif certificate_expiring:
risk_reasons.append('Certificate expiring soon')
if enabled and has_certificate and not ssl_forced:
risk_reasons.append('SSL is not forced')
if not enabled:
risk = 'low'
elif certificate_expired:
risk = 'critical'
elif not protected or not has_certificate:
risk = 'high'
elif not ssl_forced or certificate_expiring:
risk = 'medium'
else:
risk = 'low'
exposures.append({
'source': 'reverse_proxy',
'host': label,
'port': int(host.get('forwardPort') or 0),
'protocol': str(host.get('forwardScheme') or 'http'),
'protected': protected,
'protectionSource': protection_source,
'authCoverageStatus': auth_status,
'authentikMatches': (coverage or {}).get('authentikMatches') or [],
'appId': str(host.get('forwardHost') or ''),
'risk': risk,
'riskReasons': risk_reasons,
'externalChangeRequired': bool(risk_reasons),
})
return exposures
@router.get('/network/exposure')
def network_exposure(refresh: bool = False, session: Session = Depends(get_session)):
apply_stored_connector_settings(session, {'nginx_proxy_manager', 'authentik'})
signature = _network_exposure_signature(session)
if not refresh:
persisted = _persistent_network_exposure(session, signature)
if persisted is not None:
return persisted
exposures = _build_network_exposure()
_store_persistent_network_exposure(session, signature, exposures)
return exposures
def _auth_coverage_by_host() -> dict[str, dict]:
try:
payload = AuthentikConnector().auth_coverage()
except Exception:
return {}
if not payload.get('success'):
return {}
result: dict[str, dict] = {}
for item in payload.get('publicHosts', []):
if not isinstance(item, dict):
continue
host_id = item.get('hostId')
if host_id is None:
continue
result[str(host_id)] = item
return result
@router.get('/healthchecks')
def healthchecks(session: Session = Depends(get_session)):
checked_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
connectors = {connector.id: connector for connector in session.exec(select(Connector)).all()}
return [
{'service': 'API Gateway', 'status': 'pass', 'checkedAt': checked_at, 'details': 'DB en route registratie OK'},
_connector_healthcheck('Portainer', connectors.get('portainer'), checked_at),
_connector_healthcheck('Unraid', connectors.get('unraid'), checked_at),
_voice_bridge_healthcheck(connectors.get('home_assistant'), checked_at),
]
def _connector_healthcheck(service: str, connector: Connector | None, checked_at: str) -> dict:
if connector is None:
return {
'service': service,
'status': 'warn',
'checkedAt': checked_at,
'details': 'Niet aanwezig in connector registry.',
}
status = str(connector.status or 'unknown').lower()
if status == 'connected':
return {
'service': service,
'status': 'pass',
'checkedAt': checked_at,
'details': 'Connector registry meldt verbonden.',
}
if status in {'missing_config', 'disconnected', 'unknown'}:
return {
'service': service,
'status': 'warn',
'checkedAt': checked_at,
'details': f'Connector registry meldt {status}.',
}
return {
'service': service,
'status': 'fail',
'checkedAt': checked_at,
'details': f'Connector registry meldt {status}.',
}
def _voice_bridge_healthcheck(home_assistant: Connector | None, checked_at: str) -> dict:
if not settings.voice_token:
return {
'service': 'Voice Bridge',
'status': 'warn',
'checkedAt': checked_at,
'details': 'Voice Bridge is niet geactiveerd.',
}
if home_assistant is None:
return {
'service': 'Voice Bridge',
'status': 'warn',
'checkedAt': checked_at,
'details': 'Home Assistant connector ontbreekt.',
}
status = str(home_assistant.status or 'unknown').lower()
if status == 'connected':
return {
'service': 'Voice Bridge',
'status': 'pass',
'checkedAt': checked_at,
'details': 'Home Assistant connector beschikbaar voor voice exports.',
}
if status in {'missing_config', 'disconnected', 'unknown'}:
return {
'service': 'Voice Bridge',
'status': 'warn',
'checkedAt': checked_at,
'details': f'Home Assistant connector meldt {status}.',
}
return {
'service': 'Voice Bridge',
'status': 'fail',
'checkedAt': checked_at,
'details': f'Home Assistant connector meldt {status}.',
}