Files
Gan, Jimmy bfa11b5f26
Deploy Dashboard (Dev) / Backend Tests (push) Successful in 1m15s
Deploy Dashboard (Dev) / Frontend Tests (push) Successful in 55s
Deploy Dashboard (Dev) / Build Dev Image (push) Failing after 13m49s
Deploy Dashboard (Dev) / Deploy to Dev (push) Has been skipped
feat(dashboard): service health widget, Cmd+K palette, CI runs, pinned shortcuts
2026-07-09 03:36:31 +08:00

218 lines
7.1 KiB
Python

import asyncio
import os
import platform
import shutil
import time
import httpx
import psutil
from fastapi import APIRouter, Depends, HTTPException, Query, Request
import auth_service as auth
import config
from config import GITEA_TOKEN, GITEA_URL, OPENCLAW_GATEWAY_TOKEN, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT
router = APIRouter()
@router.post("/notify")
async def send_notification(body: dict):
"""Send a Telegram notification. Body: {"message": "text"}"""
msg = body.get("message", "")
if not msg or not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
return {"ok": False, "error": "missing message or Telegram config"}
proxy_url = os.environ.get("TELEGRAM_PROXY")
async with httpx.AsyncClient(proxy=proxy_url) as client:
r = await client.post(
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
data={"chat_id": TELEGRAM_CHAT_ID, "text": msg, "parse_mode": "Markdown"},
)
return {"ok": r.status_code == 200}
@router.get("/stats")
def system_stats():
disk = shutil.disk_usage("/volume1")
volumes = []
seen_devices = set()
for mount in psutil.disk_partitions():
if mount.mountpoint.startswith(("/volume",)) and mount.device not in seen_devices:
seen_devices.add(mount.device)
try:
u = shutil.disk_usage(mount.mountpoint)
volumes.append(
{
"mount": mount.mountpoint,
"total": u.total,
"used": u.used,
"free": u.free,
"percent": round(u.used / u.total * 100, 1),
}
)
except Exception:
pass
mem = psutil.virtual_memory()
cpu_pct = psutil.cpu_percent(interval=0.1)
load_1, load_5, load_15 = psutil.getloadavg()
uptime_s = time.time() - psutil.boot_time()
days = int(uptime_s // 86400)
hours = int((uptime_s % 86400) // 3600)
return {
"cpu_percent": cpu_pct,
"cpu_count": psutil.cpu_count(),
"load_avg": [round(load_1, 2), round(load_5, 2), round(load_15, 2)],
"memory": {
"total": mem.total,
"used": mem.used,
"percent": mem.percent,
},
"disk": {
"total": disk.total,
"used": disk.used,
"free": disk.free,
"percent": round(disk.used / disk.total * 100, 1),
},
"uptime": f"{days}d {hours}h",
"platform": platform.platform(),
"volumes": volumes,
}
@router.get("/audit-log")
def audit_log(lines: int = Query(100, le=500), current_user=Depends(auth.get_current_user)):
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
log_path = os.path.join(VOLUME_ROOT, "docker/nas-dashboard/audit.log")
if not os.path.exists(log_path):
return {"entries": []}
with open(log_path, "rb") as f:
f.seek(0, 2)
size = f.tell()
buf = min(size, lines * 200)
f.seek(max(0, size - buf))
raw = f.read().decode(errors="replace").splitlines()[-lines:]
entries = []
for line in raw:
parts = line.split(" ", 6)
if len(parts) < 7:
continue
ts, ip, user, method, path, status_str, dur = parts
status = int(status_str) if status_str.isdigit() else 0
level = _classify(method, path, status, user)
if level == "noise":
continue
entries.append(
{
"ts": ts,
"ip": ip,
"user": user,
"method": method,
"path": path,
"status": status,
"duration": dur,
"level": level,
}
)
return {"entries": entries}
def _classify(method, path, status, user):
# High — failed auth attempts
if "/auth/login" in path and status in (401, 403):
return "high"
if "/auth/passkey/login/verify" in path and status == 400:
return "high"
# Medium — 403/404 on sensitive paths, or any 5xx
if status >= 500:
return "medium"
if status == 403 and "/files/" in path:
return "medium"
# Low — normal auth activity
if "/auth/" in path and status == 200:
return "low"
# Noise — health checks, static assets
if path in ("/api/health",) or path.startswith("/assets/") or path == "/favicon.ico" or path == "/":
return "noise"
# Info — everything else
return "info"
@router.get("/openclaw-token")
def openclaw_token(request: Request):
user = request.state.user
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
ip = config.get_client_ip(request)
if not config.is_lan_ip(ip):
raise HTTPException(status_code=403, detail="Access denied")
return {"token": OPENCLAW_GATEWAY_TOKEN}
@router.get("/service-health")
async def service_health(current_user=Depends(auth.get_current_user)):
"""Ping all external services and return their status with latency."""
async def _ping(key: str, url: str) -> dict:
start = time.monotonic()
try:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.head(url, follow_redirects=True)
latency_ms = round((time.monotonic() - start) * 1000)
status = "up" if r.status_code < 500 else "down"
except httpx.TimeoutException:
latency_ms = round((time.monotonic() - start) * 1000)
status = "down"
except Exception:
latency_ms = round((time.monotonic() - start) * 1000)
status = "error"
return key, {"url": url, "status": status, "latency_ms": latency_ms}
results = await asyncio.gather(
*(_ping(key, url) for key, url in config.EXTERNAL_SERVICES.items())
)
return {"services": dict(results)}
_CI_HEADERS = {"Authorization": f"token {GITEA_TOKEN}"} if GITEA_TOKEN else {}
@router.get("/ci-runs")
async def ci_runs():
"""Fetch recent Gitea Actions CI runs for jimmy/nas-tools."""
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(
f"{GITEA_URL}/api/v1/repos/jimmy/nas-tools/actions/runs",
headers=_CI_HEADERS,
params={"limit": 5},
)
r.raise_for_status()
data = r.json()
runs = []
for wf in data.get("workflow_runs", []):
runs.append(
{
"id": wf.get("id"),
"status": wf.get("status"),
"event": wf.get("event"),
"head_branch": wf.get("head_branch"),
"head_sha": (wf.get("head_sha") or "")[:12],
"display_title": wf.get("display_title"),
"run_number": wf.get("run_number"),
}
)
return {"workflow_runs": runs}
@router.get("/external-services")
def external_services():
"""Return external service URLs and network config for the frontend sidebar."""
return {
"lan_ip": config.LAN_IP,
"ts_ip": config.TS_IP,
"services": config.EXTERNAL_SERVICES,
}