from datetime import datetime, timedelta, timezone import json from typing import List from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy.orm import Session from app.core.config import get_db from app.api.deps import get_current_user_or_apikey from app.models import models from app.models.monitor import ProviderAccount, MonitoredServer, ServerState from app.services.monitoring import ( get_issue_stats_cached, get_provider_usage_view, get_server_states_view, test_provider_connection, ) router = APIRouter(prefix='/monitor', tags=['Monitor']) SUPPORTED_PROVIDERS = {'anthropic', 'openai', 'minimax', 'kimi', 'qwen'} class ProviderAccountCreate(BaseModel): provider: str label: str credential: str class ProviderTestRequest(BaseModel): provider: str credential: str class MonitoredServerCreate(BaseModel): identifier: str display_name: str | None = None def require_admin(current_user: models.User = Depends(get_current_user_or_apikey)): if not current_user.is_admin: raise HTTPException(status_code=403, detail='Admin required') return current_user @router.get('/public/overview') def public_overview(db: Session = Depends(get_db)): return { 'issues': get_issue_stats_cached(db, ttl_seconds=1800), 'providers': get_provider_usage_view(db), 'servers': get_server_states_view(db, offline_after_minutes=7), 'generated_at': datetime.now(timezone.utc).isoformat(), } @router.get('/admin/providers/accounts') def list_provider_accounts(db: Session = Depends(get_db), _: models.User = Depends(require_admin)): accounts = db.query(ProviderAccount).order_by(ProviderAccount.created_at.desc()).all() return [ { 'id': a.id, 'provider': a.provider, 'label': a.label, 'is_enabled': a.is_enabled, 'created_at': a.created_at, 'credential_masked': '***' + (a.credential[-4:] if a.credential else ''), } for a in accounts ] @router.post('/admin/providers/accounts', status_code=status.HTTP_201_CREATED) def create_provider_account(payload: ProviderAccountCreate, db: Session = Depends(get_db), user: models.User = Depends(require_admin)): provider = payload.provider.lower().strip() if provider not in SUPPORTED_PROVIDERS: raise HTTPException(status_code=400, detail=f'Unsupported provider: {provider}') obj = ProviderAccount( provider=provider, label=payload.label.strip(), credential=payload.credential.strip(), is_enabled=True, created_by=user.id, ) db.add(obj) db.commit() db.refresh(obj) return {'id': obj.id, 'provider': obj.provider, 'label': obj.label, 'is_enabled': obj.is_enabled} @router.post('/admin/providers/test') def test_provider(payload: ProviderTestRequest, _: models.User = Depends(require_admin)): ok, message = test_provider_connection(payload.provider.lower().strip(), payload.credential.strip()) return {'ok': ok, 'message': message} @router.delete('/admin/providers/accounts/{account_id}', status_code=status.HTTP_204_NO_CONTENT) def delete_provider_account(account_id: int, db: Session = Depends(get_db), _: models.User = Depends(require_admin)): obj = db.query(ProviderAccount).filter(ProviderAccount.id == account_id).first() if not obj: raise HTTPException(status_code=404, detail='Provider account not found') db.delete(obj) db.commit() return None @router.get('/admin/servers') def list_servers(db: Session = Depends(get_db), _: models.User = Depends(require_admin)): return get_server_states_view(db, offline_after_minutes=7) @router.post('/admin/servers', status_code=status.HTTP_201_CREATED) def add_server(payload: MonitoredServerCreate, db: Session = Depends(get_db), user: models.User = Depends(require_admin)): identifier = payload.identifier.strip() if not identifier: raise HTTPException(status_code=400, detail='identifier required') exists = db.query(MonitoredServer).filter(MonitoredServer.identifier == identifier).first() if exists: raise HTTPException(status_code=400, detail='identifier already exists') obj = MonitoredServer(identifier=identifier, display_name=payload.display_name, is_enabled=True, created_by=user.id) db.add(obj) db.commit() db.refresh(obj) return {'id': obj.id, 'identifier': obj.identifier, 'display_name': obj.display_name, 'is_enabled': obj.is_enabled} @router.delete('/admin/servers/{server_id}', status_code=status.HTTP_204_NO_CONTENT) def delete_server(server_id: int, db: Session = Depends(get_db), _: models.User = Depends(require_admin)): obj = db.query(MonitoredServer).filter(MonitoredServer.id == server_id).first() if not obj: raise HTTPException(status_code=404, detail='Server not found') state = db.query(ServerState).filter(ServerState.server_id == server_id).first() if state: db.delete(state) db.delete(obj) db.commit() return None # Temporary ingestion endpoint before WS plugin lands class ServerHeartbeat(BaseModel): identifier: str openclaw_version: str | None = None agents: List[dict] = [] cpu_pct: float | None = None mem_pct: float | None = None disk_pct: float | None = None swap_pct: float | None = None @router.post('/server/heartbeat') def server_heartbeat(payload: ServerHeartbeat, db: Session = Depends(get_db)): server = db.query(MonitoredServer).filter(MonitoredServer.identifier == payload.identifier, MonitoredServer.is_enabled == True).first() if not server: raise HTTPException(status_code=404, detail='unknown server identifier') st = db.query(ServerState).filter(ServerState.server_id == server.id).first() if not st: st = ServerState(server_id=server.id) db.add(st) st.openclaw_version = payload.openclaw_version st.agents_json = json.dumps(payload.agents, ensure_ascii=False) st.cpu_pct = payload.cpu_pct st.mem_pct = payload.mem_pct st.disk_pct = payload.disk_pct st.swap_pct = payload.swap_pct st.last_seen_at = datetime.now(timezone.utc) db.commit() return {'ok': True, 'server_id': server.id, 'last_seen_at': st.last_seen_at}