feat: add public monitor API + admin provider/server management scaffold
This commit is contained in:
169
app/api/routers/monitor.py
Normal file
169
app/api/routers/monitor.py
Normal file
@@ -0,0 +1,169 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user