From 26156543f6cdcbc7075558887caaed565c268c05 Mon Sep 17 00:00:00 2001 From: Eric Jungbauer Date: Mon, 13 Apr 2026 02:21:45 +0000 Subject: [PATCH] v2.0: Multi-user platform with agent catalog, admin panel, LLM providers --- agents/angela_briefing.py | 3 + agents/daily_briefing.py | 22 +- agents/eric_briefing.py | 4 + agents/shared.py | 22 +- dashboard/app.py | 612 +++++++++++++++++++++++----------- dashboard/models.py | 58 +++- dashboard/static/admin.html | 221 +++++++++++++ dashboard/static/index.html | 640 ++++++++++++++++-------------------- 8 files changed, 1002 insertions(+), 580 deletions(-) create mode 100644 dashboard/static/admin.html diff --git a/agents/angela_briefing.py b/agents/angela_briefing.py index 4a4c8fe..d8ed3f6 100644 --- a/agents/angela_briefing.py +++ b/agents/angela_briefing.py @@ -3,9 +3,12 @@ from daily_briefing import run +INSTANCE_ID = int(__import__('os').environ.get("ANGELA_INSTANCE_ID", "0")) + CONFIG = { "person": "Angela", "agent_id": "angela-daily-briefing", + "instance_id": INSTANCE_ID, "wiki_parent_doc_id": "65966bd6-4ef8-4b79-9b79-e4aa62b94e96", "location": { "name": "Providence", diff --git a/agents/daily_briefing.py b/agents/daily_briefing.py index 31f5f87..f9c1dbd 100644 --- a/agents/daily_briefing.py +++ b/agents/daily_briefing.py @@ -121,15 +121,19 @@ def run(config): location (dict): {name, state, country, lat, lon} """ agent_id = config["agent_id"] + instance_id = config.get("instance_id", 0) # Fetch live config from dashboard API, merge over defaults - try: - live_config = api_request(f"{DASHBOARD_API}/api/agents/{agent_id}/config") - if live_config.get("location"): - config["location"] = live_config["location"] - print(f"Using live config location: {config['location'].get('name', '?')}") - except Exception as e: - print(f"Could not fetch live config, using defaults: {e}") + if instance_id: + try: + live_config = api_request(f"{DASHBOARD_API}/api/instances/{instance_id}/config") + if live_config.get("location"): + config["location"] = live_config["location"] + print(f"Using live config location: {config['location'].get('name', '?')}") + if live_config.get("calendars"): + config["calendars"] = live_config["calendars"] + except Exception as e: + print(f"Could not fetch live config, using defaults: {e}") try: print(f"Collecting sub-agent data for {config['person']}...") @@ -145,7 +149,7 @@ def run(config): summaries = "; ".join(f"{name}: {s}" for name, _, s in sections) output = f"Briefing {action}. {summaries}" - log_run(agent_id, "success", output=output, metadata={ + log_run(agent_id, "success", output=output, instance_id=instance_id, metadata={ "wiki_doc_id": doc_id, "action": action, "sub_agents": [name for name, _, _ in sections], @@ -155,5 +159,5 @@ def run(config): except Exception as e: err_msg = f"{type(e).__name__}: {e}" print(f"Error: {err_msg}", file=sys.stderr) - log_run(agent_id, "failed", err=err_msg) + log_run(agent_id, "failed", err=err_msg, instance_id=instance_id) sys.exit(1) diff --git a/agents/eric_briefing.py b/agents/eric_briefing.py index 45e88ec..7503fc2 100644 --- a/agents/eric_briefing.py +++ b/agents/eric_briefing.py @@ -3,9 +3,13 @@ from daily_briefing import run +# Instance ID will be set after migration (updated by deploy script) +INSTANCE_ID = int(__import__('os').environ.get("ERIC_INSTANCE_ID", "0")) + CONFIG = { "person": "Eric", "agent_id": "eric-daily-briefing", + "instance_id": INSTANCE_ID, "wiki_parent_doc_id": "2a891fe8-579b-450b-a663-de93915896b7", "location": { "name": "Providence", diff --git a/agents/shared.py b/agents/shared.py index 801542d..180dead 100644 --- a/agents/shared.py +++ b/agents/shared.py @@ -33,19 +33,17 @@ def api_request(url, data=None, headers=None, method="GET"): return json.loads(resp.read().decode()) -def log_run(agent_id, status, output="", err="", metadata=None): - """Log a run to the dashboard API.""" +def log_run(agent_id, status, output="", err="", metadata=None, instance_id=None): + """Log a run to the dashboard API. Uses instance_id if available (v2), falls back to agent_id.""" try: - api_request( - f"{DASHBOARD_API}/api/agents/{agent_id}/runs", - data={ - "status": status, - "output": output, - "error": err, - "metadata": metadata or {}, - }, - method="POST", - ) + if instance_id: + api_request( + f"{DASHBOARD_API}/api/instances/{instance_id}/runs", + data={"status": status, "output": output, "error": err, "metadata": metadata or {}}, + method="POST", + ) + else: + print(f"Warning: no instance_id, run not logged for {agent_id}", file=sys.stderr) except Exception as e: print(f"Warning: failed to log run to dashboard: {e}", file=sys.stderr) diff --git a/dashboard/app.py b/dashboard/app.py index 3af3e74..c78e197 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -1,63 +1,125 @@ -from fastapi import FastAPI, Depends, HTTPException, Request, Response, Cookie +from fastapi import FastAPI, Depends, HTTPException, Response, Cookie from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse, RedirectResponse, JSONResponse +from fastapi.responses import FileResponse, RedirectResponse from sqlalchemy.orm import Session from pydantic import BaseModel from datetime import datetime, timezone from typing import Optional import hashlib -import hmac import json import os import secrets from database import get_db, init_db -from models import Agent, Run - -app = FastAPI(title="Agent Command Center", version="1.0.0") +from models import User, AgentCatalog, AgentInstance, Run, LLMProvider +app = FastAPI(title="Agent Command Center", version="2.0.0") # --- Auth --- -AUTH_USER = os.environ.get("AUTH_USER", "eric") -AUTH_PASS = os.environ.get("AUTH_PASS", "Kj8#mPx2vQ!nR4wL") SESSION_SECRET = os.environ.get("SESSION_SECRET", secrets.token_hex(32)) - -# In-memory session store -_sessions: dict[str, str] = {} +_sessions: dict[str, dict] = {} # token -> {user_id, username, role} -def create_session(username: str) -> str: +def hash_password(password: str) -> str: + salt = secrets.token_hex(16) + h = hashlib.sha256((salt + password).encode()).hexdigest() + return f"{salt}:{h}" + + +def verify_password(password: str, password_hash: str) -> bool: + salt, h = password_hash.split(":", 1) + return hashlib.sha256((salt + password).encode()).hexdigest() == h + + +def create_session(user: User) -> str: token = secrets.token_urlsafe(32) - _sessions[token] = username + _sessions[token] = {"user_id": user.id, "username": user.username, "role": user.role} return token -def get_current_user(session: Optional[str] = Cookie(None)) -> Optional[str]: +def get_current_user(session: Optional[str] = Cookie(None)) -> Optional[dict]: if session and session in _sessions: return _sessions[session] return None -def require_auth(session: Optional[str] = Cookie(None)): +def require_auth(session: Optional[str] = Cookie(None)) -> dict: user = get_current_user(session) if not user: raise HTTPException(status_code=401, detail="Not authenticated") return user +def require_admin(session: Optional[str] = Cookie(None)) -> dict: + user = require_auth(session) + if user["role"] != "admin": + raise HTTPException(status_code=403, detail="Admin access required") + return user + + +# --- Schemas --- + class LoginRequest(BaseModel): username: str password: str +class InstanceCreate(BaseModel): + catalog_id: str + name: Optional[str] = None + config: dict = {} + schedule: Optional[str] = None + +class InstanceUpdate(BaseModel): + name: Optional[str] = None + config: Optional[dict] = None + schedule: Optional[str] = None + status: Optional[str] = None + +class RunCreate(BaseModel): + status: str = "running" + output: str = "" + error: str = "" + metadata: dict = {} + +class UserCreate(BaseModel): + username: str + password: str + display_name: str = "" + role: str = "user" + +class UserUpdate(BaseModel): + display_name: Optional[str] = None + role: Optional[str] = None + password: Optional[str] = None + +class LLMProviderCreate(BaseModel): + name: str + provider_type: str = "anthropic" + api_url: str = "" + api_key: str = "" + default_model: str = "" + is_default: bool = False + +class LLMProviderUpdate(BaseModel): + name: Optional[str] = None + provider_type: Optional[str] = None + api_url: Optional[str] = None + api_key: Optional[str] = None + default_model: Optional[str] = None + is_default: Optional[bool] = None + + +# --- Auth Routes --- @app.post("/api/login") -def login(creds: LoginRequest, response: Response): - if creds.username == AUTH_USER and creds.password == AUTH_PASS: - token = create_session(creds.username) - response.set_cookie("session", token, httponly=True, samesite="lax", max_age=86400 * 7) - return {"status": "ok", "user": creds.username} - raise HTTPException(status_code=401, detail="Invalid credentials") +def login(creds: LoginRequest, response: Response, db: Session = Depends(get_db)): + user = db.query(User).filter(User.username == creds.username).first() + if not user or not verify_password(creds.password, user.password_hash): + raise HTTPException(status_code=401, detail="Invalid credentials") + token = create_session(user) + response.set_cookie("session", token, httponly=True, samesite="lax", max_age=86400 * 7) + return {"status": "ok", "user": user.username, "role": user.role, "display_name": user.display_name} @app.post("/api/logout") @@ -68,155 +130,174 @@ def logout(response: Response, session: Optional[str] = Cookie(None)): return {"status": "ok"} -@app.get("/login") -def login_page(): - return FileResponse("static/login.html") - - -# --- Pydantic schemas --- - -class AgentCreate(BaseModel): - id: str - name: str - description: str = "" - schedule: str = "manual" - config: dict = {} - -class AgentUpdate(BaseModel): - name: Optional[str] = None - description: Optional[str] = None - schedule: Optional[str] = None - status: Optional[str] = None - config: Optional[dict] = None - -class RunCreate(BaseModel): - status: str = "running" - output: str = "" - error: str = "" - metadata: dict = {} - -class RunUpdate(BaseModel): - status: Optional[str] = None - output: Optional[str] = None - error: Optional[str] = None - finished_at: Optional[str] = None - metadata: Optional[dict] = None - - -# --- API routes --- - -@app.get("/api/health") -def health(): - return {"status": "ok", "service": "agent-command-center"} - - -@app.get("/api/agents/{agent_id}/config") -def get_agent_config(agent_id: str, db: Session = Depends(get_db)): - """Internal endpoint for agents to fetch their config. No auth required.""" - agent = db.query(Agent).filter(Agent.id == agent_id).first() - if not agent: - raise HTTPException(status_code=404, detail="Agent not found") - return agent.config or {} - - -@app.get("/api/agents") -def list_agents(user: str = Depends(require_auth), db: Session = Depends(get_db)): - agents = db.query(Agent).all() - result = [] - for a in agents: - last_run = db.query(Run).filter(Run.agent_id == a.id).order_by(Run.started_at.desc()).first() - recent_runs = db.query(Run).filter(Run.agent_id == a.id).order_by(Run.started_at.desc()).limit(10).all() - success_streak = 0 - for r in recent_runs: - if r.status == "success": - success_streak += 1 - else: - break - result.append({ - "id": a.id, - "name": a.name, - "description": a.description, - "schedule": a.schedule, - "status": a.status, - "config": a.config or {}, - "created_at": a.created_at.isoformat() if a.created_at else None, - "last_run": { - "status": last_run.status, - "started_at": last_run.started_at.isoformat() if last_run.started_at else None, - "finished_at": last_run.finished_at.isoformat() if last_run.finished_at else None, - } if last_run else None, - "success_streak": success_streak, - "total_runs": db.query(Run).filter(Run.agent_id == a.id).count(), - }) - return result - - -@app.post("/api/agents") -def create_agent(agent: AgentCreate, db: Session = Depends(get_db)): - existing = db.query(Agent).filter(Agent.id == agent.id).first() - if existing: - raise HTTPException(status_code=409, detail="Agent already exists") - new_agent = Agent( - id=agent.id, - name=agent.name, - description=agent.description, - schedule=agent.schedule, - config=agent.config, - ) - db.add(new_agent) - db.commit() - return {"id": new_agent.id, "status": "created"} - - -@app.get("/api/agents/{agent_id}") -def get_agent(agent_id: str, user: str = Depends(require_auth), db: Session = Depends(get_db)): - agent = db.query(Agent).filter(Agent.id == agent_id).first() - if not agent: - raise HTTPException(status_code=404, detail="Agent not found") - runs = db.query(Run).filter(Run.agent_id == agent_id).order_by(Run.started_at.desc()).limit(50).all() +@app.get("/api/me") +def me(user: dict = Depends(require_auth), db: Session = Depends(get_db)): + u = db.query(User).filter(User.id == user["user_id"]).first() return { - "id": agent.id, - "name": agent.name, - "description": agent.description, - "schedule": agent.schedule, - "status": agent.status, - "config": agent.config or {}, - "created_at": agent.created_at.isoformat() if agent.created_at else None, - "runs": [{ - "id": r.id, - "started_at": r.started_at.isoformat() if r.started_at else None, - "finished_at": r.finished_at.isoformat() if r.finished_at else None, - "status": r.status, - "output": r.output, - "error": r.error, - "metadata": r.metadata_, - } for r in runs], + "id": u.id, "username": u.username, "display_name": u.display_name, + "role": u.role, "created_at": u.created_at.isoformat() if u.created_at else None, } -@app.put("/api/agents/{agent_id}") -def update_agent(agent_id: str, update: AgentUpdate, db: Session = Depends(get_db)): - agent = db.query(Agent).filter(Agent.id == agent_id).first() - if not agent: - raise HTTPException(status_code=404, detail="Agent not found") - updates = update.model_dump(exclude_none=True) - if "config" in updates: - current_config = agent.config or {} - current_config.update(updates.pop("config")) - agent.config = current_config - for field, value in updates.items(): - setattr(agent, field, value) +# --- Health --- + +@app.get("/api/health") +def health(): + return {"status": "ok", "service": "agent-command-center", "version": "2.0.0"} + + +# --- Agent Catalog --- + +@app.get("/api/catalog") +def list_catalog(user: dict = Depends(require_auth), db: Session = Depends(get_db)): + entries = db.query(AgentCatalog).all() + # Check which ones this user already has instances of + user_instance_ids = { + i.catalog_id for i in db.query(AgentInstance).filter(AgentInstance.user_id == user["user_id"]).all() + } + return [{ + "id": e.id, "name": e.name, "description": e.description, + "category": e.category, "config_schema": e.config_schema or {}, + "default_config": e.default_config or {}, + "supports_schedule": e.supports_schedule, "is_sub_agent": e.is_sub_agent, + "enabled": e.id in user_instance_ids, + } for e in entries] + + +@app.get("/api/catalog/{catalog_id}") +def get_catalog_entry(catalog_id: str, user: dict = Depends(require_auth), db: Session = Depends(get_db)): + entry = db.query(AgentCatalog).filter(AgentCatalog.id == catalog_id).first() + if not entry: + raise HTTPException(status_code=404) + return { + "id": entry.id, "name": entry.name, "description": entry.description, + "category": entry.category, "config_schema": entry.config_schema or {}, + "default_config": entry.default_config or {}, + "supports_schedule": entry.supports_schedule, "is_sub_agent": entry.is_sub_agent, + } + + +# --- Agent Instances (user-scoped) --- + +def serialize_instance(inst, db): + last_run = db.query(Run).filter(Run.instance_id == inst.id).order_by(Run.started_at.desc()).first() + recent = db.query(Run).filter(Run.instance_id == inst.id).order_by(Run.started_at.desc()).limit(10).all() + streak = 0 + for r in recent: + if r.status == "success": + streak += 1 + else: + break + return { + "id": inst.id, "catalog_id": inst.catalog_id, "name": inst.name, + "config": inst.config or {}, "schedule": inst.schedule, "status": inst.status, + "created_at": inst.created_at.isoformat() if inst.created_at else None, + "last_run": { + "status": last_run.status, + "started_at": last_run.started_at.isoformat() if last_run.started_at else None, + "finished_at": last_run.finished_at.isoformat() if last_run.finished_at else None, + } if last_run else None, + "success_streak": streak, + "total_runs": db.query(Run).filter(Run.instance_id == inst.id).count(), + } + + +@app.get("/api/instances") +def list_instances(user: dict = Depends(require_auth), db: Session = Depends(get_db)): + instances = db.query(AgentInstance).filter(AgentInstance.user_id == user["user_id"]).all() + return [serialize_instance(i, db) for i in instances] + + +@app.post("/api/instances") +def create_instance(data: InstanceCreate, user: dict = Depends(require_auth), db: Session = Depends(get_db)): + catalog = db.query(AgentCatalog).filter(AgentCatalog.id == data.catalog_id).first() + if not catalog: + raise HTTPException(status_code=404, detail="Agent type not found in catalog") + config = {**(catalog.default_config or {}), **data.config} + inst = AgentInstance( + user_id=user["user_id"], + catalog_id=data.catalog_id, + name=data.name or catalog.name, + config=config, + schedule=data.schedule or ("sub-agent" if catalog.is_sub_agent else "manual"), + ) + db.add(inst) db.commit() - return {"id": agent.id, "status": "updated"} + return {"id": inst.id, "status": "created"} -@app.post("/api/agents/{agent_id}/runs") -def create_run(agent_id: str, run: RunCreate, db: Session = Depends(get_db)): - agent = db.query(Agent).filter(Agent.id == agent_id).first() - if not agent: - raise HTTPException(status_code=404, detail="Agent not found") +@app.get("/api/instances/{instance_id}") +def get_instance(instance_id: int, user: dict = Depends(require_auth), db: Session = Depends(get_db)): + inst = db.query(AgentInstance).filter( + AgentInstance.id == instance_id, AgentInstance.user_id == user["user_id"] + ).first() + if not inst: + raise HTTPException(status_code=404) + runs = db.query(Run).filter(Run.instance_id == instance_id).order_by(Run.started_at.desc()).limit(50).all() + catalog = db.query(AgentCatalog).filter(AgentCatalog.id == inst.catalog_id).first() + result = serialize_instance(inst, db) + result["config_schema"] = catalog.config_schema if catalog else {} + result["runs"] = [{ + "id": r.id, + "started_at": r.started_at.isoformat() if r.started_at else None, + "finished_at": r.finished_at.isoformat() if r.finished_at else None, + "status": r.status, "output": r.output, "error": r.error, "metadata": r.metadata_, + } for r in runs] + return result + + +@app.put("/api/instances/{instance_id}") +def update_instance(instance_id: int, update: InstanceUpdate, user: dict = Depends(require_auth), db: Session = Depends(get_db)): + inst = db.query(AgentInstance).filter( + AgentInstance.id == instance_id, AgentInstance.user_id == user["user_id"] + ).first() + if not inst: + raise HTTPException(status_code=404) + if update.name is not None: + inst.name = update.name + if update.schedule is not None: + inst.schedule = update.schedule + if update.status is not None: + inst.status = update.status + if update.config is not None: + current = inst.config or {} + current.update(update.config) + inst.config = current + db.commit() + return {"id": inst.id, "status": "updated"} + + +@app.delete("/api/instances/{instance_id}") +def delete_instance(instance_id: int, user: dict = Depends(require_auth), db: Session = Depends(get_db)): + inst = db.query(AgentInstance).filter( + AgentInstance.id == instance_id, AgentInstance.user_id == user["user_id"] + ).first() + if not inst: + raise HTTPException(status_code=404) + db.query(Run).filter(Run.instance_id == instance_id).delete() + db.delete(inst) + db.commit() + return {"status": "deleted"} + + +# --- Internal endpoints (no auth, for agent scripts) --- + +@app.get("/api/instances/{instance_id}/config") +def get_instance_config(instance_id: int, db: Session = Depends(get_db)): + inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first() + if not inst: + raise HTTPException(status_code=404) + return inst.config or {} + + +@app.post("/api/instances/{instance_id}/runs") +def create_run(instance_id: int, run: RunCreate, db: Session = Depends(get_db)): + inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first() + if not inst: + raise HTTPException(status_code=404) new_run = Run( - agent_id=agent_id, + instance_id=instance_id, + user_id=inst.user_id, status=run.status, output=run.output, error=run.error, @@ -229,46 +310,195 @@ def create_run(agent_id: str, run: RunCreate, db: Session = Depends(get_db)): return {"id": new_run.id, "status": new_run.status} -@app.put("/api/runs/{run_id}") -def update_run(run_id: int, update: RunUpdate, db: Session = Depends(get_db)): - run = db.query(Run).filter(Run.id == run_id).first() - if not run: - raise HTTPException(status_code=404, detail="Run not found") - if update.status is not None: - run.status = update.status - if update.output is not None: - run.output = update.output - if update.error is not None: - run.error = update.error - if update.metadata is not None: - run.metadata_ = update.metadata - if update.finished_at is not None: - run.finished_at = datetime.fromisoformat(update.finished_at) - elif update.status in ("success", "failed"): - run.finished_at = datetime.now(timezone.utc) - db.commit() - return {"id": run.id, "status": run.status} - +# --- Runs (user-scoped) --- @app.get("/api/runs") -def list_runs(limit: int = 50, user: str = Depends(require_auth), db: Session = Depends(get_db)): - runs = db.query(Run).order_by(Run.started_at.desc()).limit(limit).all() +def list_runs(limit: int = 50, user: dict = Depends(require_auth), db: Session = Depends(get_db)): + runs = db.query(Run).filter(Run.user_id == user["user_id"]).order_by(Run.started_at.desc()).limit(limit).all() return [{ - "id": r.id, - "agent_id": r.agent_id, + "id": r.id, "instance_id": r.instance_id, "started_at": r.started_at.isoformat() if r.started_at else None, "finished_at": r.finished_at.isoformat() if r.finished_at else None, - "status": r.status, - "output": r.output, - "error": r.error, - "metadata": r.metadata_, + "status": r.status, "output": r.output, "error": r.error, "metadata": r.metadata_, } for r in runs] -# --- Static files (frontend) --- +# --- Admin: Users --- + +@app.get("/api/admin/users") +def admin_list_users(admin: dict = Depends(require_admin), db: Session = Depends(get_db)): + users = db.query(User).all() + return [{ + "id": u.id, "username": u.username, "display_name": u.display_name, + "role": u.role, "created_at": u.created_at.isoformat() if u.created_at else None, + "instance_count": db.query(AgentInstance).filter(AgentInstance.user_id == u.id).count(), + } for u in users] + + +@app.post("/api/admin/users") +def admin_create_user(data: UserCreate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)): + if db.query(User).filter(User.username == data.username).first(): + raise HTTPException(status_code=409, detail="Username exists") + user = User( + username=data.username, + password_hash=hash_password(data.password), + display_name=data.display_name or data.username, + role=data.role, + ) + db.add(user) + db.commit() + return {"id": user.id, "status": "created"} + + +@app.put("/api/admin/users/{user_id}") +def admin_update_user(user_id: int, update: UserUpdate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404) + if update.display_name is not None: + user.display_name = update.display_name + if update.role is not None: + user.role = update.role + if update.password is not None: + user.password_hash = hash_password(update.password) + db.commit() + return {"id": user.id, "status": "updated"} + + +@app.delete("/api/admin/users/{user_id}") +def admin_delete_user(user_id: int, admin: dict = Depends(require_admin), db: Session = Depends(get_db)): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404) + for inst in db.query(AgentInstance).filter(AgentInstance.user_id == user_id).all(): + db.query(Run).filter(Run.instance_id == inst.id).delete() + db.delete(inst) + db.delete(user) + db.commit() + return {"status": "deleted"} + + +# --- Admin: LLM Providers --- + +@app.get("/api/admin/llm-providers") +def admin_list_providers(admin: dict = Depends(require_admin), db: Session = Depends(get_db)): + providers = db.query(LLMProvider).all() + return [{ + "id": p.id, "name": p.name, "provider_type": p.provider_type, + "api_url": p.api_url, "api_key": "***" if p.api_key else "", + "default_model": p.default_model, "is_default": p.is_default, + } for p in providers] + + +@app.post("/api/admin/llm-providers") +def admin_create_provider(data: LLMProviderCreate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)): + if data.is_default: + db.query(LLMProvider).update({"is_default": False}) + provider = LLMProvider( + name=data.name, provider_type=data.provider_type, api_url=data.api_url, + api_key=data.api_key, default_model=data.default_model, is_default=data.is_default, + ) + db.add(provider) + db.commit() + return {"id": provider.id, "status": "created"} + + +@app.put("/api/admin/llm-providers/{provider_id}") +def admin_update_provider(provider_id: int, update: LLMProviderUpdate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)): + provider = db.query(LLMProvider).filter(LLMProvider.id == provider_id).first() + if not provider: + raise HTTPException(status_code=404) + if update.is_default: + db.query(LLMProvider).update({"is_default": False}) + for field, value in update.model_dump(exclude_none=True).items(): + setattr(provider, field, value) + db.commit() + return {"id": provider.id, "status": "updated"} + + +@app.delete("/api/admin/llm-providers/{provider_id}") +def admin_delete_provider(provider_id: int, admin: dict = Depends(require_admin), db: Session = Depends(get_db)): + provider = db.query(LLMProvider).filter(LLMProvider.id == provider_id).first() + if not provider: + raise HTTPException(status_code=404) + db.delete(provider) + db.commit() + return {"status": "deleted"} + + +# --- Admin: Catalog Management --- + +class CatalogCreate(BaseModel): + id: str + name: str + description: str = "" + category: str = "utility" + config_schema: dict = {} + default_config: dict = {} + supports_schedule: bool = True + is_sub_agent: bool = False + +class CatalogUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + category: Optional[str] = None + config_schema: Optional[dict] = None + default_config: Optional[dict] = None + supports_schedule: Optional[bool] = None + is_sub_agent: Optional[bool] = None + + +@app.post("/api/admin/catalog") +def admin_create_catalog(data: CatalogCreate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)): + if db.query(AgentCatalog).filter(AgentCatalog.id == data.id).first(): + raise HTTPException(status_code=409, detail="Catalog entry exists") + entry = AgentCatalog(**data.model_dump()) + db.add(entry) + db.commit() + return {"id": entry.id, "status": "created"} + + +@app.put("/api/admin/catalog/{catalog_id}") +def admin_update_catalog(catalog_id: str, update: CatalogUpdate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)): + entry = db.query(AgentCatalog).filter(AgentCatalog.id == catalog_id).first() + if not entry: + raise HTTPException(status_code=404) + for field, value in update.model_dump(exclude_none=True).items(): + setattr(entry, field, value) + db.commit() + return {"id": entry.id, "status": "updated"} + + +@app.delete("/api/admin/catalog/{catalog_id}") +def admin_delete_catalog(catalog_id: str, admin: dict = Depends(require_admin), db: Session = Depends(get_db)): + entry = db.query(AgentCatalog).filter(AgentCatalog.id == catalog_id).first() + if not entry: + raise HTTPException(status_code=404) + db.delete(entry) + db.commit() + return {"status": "deleted"} + + +# --- Static files --- app.mount("/static", StaticFiles(directory="static"), name="static") + +@app.get("/login") +def login_page(): + return FileResponse("static/login.html") + + +@app.get("/admin") +def admin_page(session: Optional[str] = Cookie(None)): + user = get_current_user(session) + if not user: + return RedirectResponse("/login", status_code=302) + if user["role"] != "admin": + return RedirectResponse("/", status_code=302) + return FileResponse("static/admin.html") + + @app.get("/") def root(session: Optional[str] = Cookie(None)): user = get_current_user(session) diff --git a/dashboard/models.py b/dashboard/models.py index 1ca9c0f..621e7a1 100644 --- a/dashboard/models.py +++ b/dashboard/models.py @@ -1,28 +1,60 @@ -from sqlalchemy import Column, String, Text, DateTime, Integer, ForeignKey, JSON +from sqlalchemy import Column, String, Text, DateTime, Integer, Boolean, ForeignKey, JSON from sqlalchemy.orm import relationship from datetime import datetime, timezone from database import Base -class Agent(Base): - __tablename__ = "agents" +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, autoincrement=True) + username = Column(String, unique=True, nullable=False) + password_hash = Column(String, nullable=False) + display_name = Column(String, default="") + role = Column(String, default="user") # admin or user + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + instances = relationship("AgentInstance", back_populates="user") + + +class AgentCatalog(Base): + __tablename__ = "agent_catalog" id = Column(String, primary_key=True) name = Column(String, nullable=False) description = Column(Text, default="") + category = Column(String, default="utility") # data, briefing, utility + config_schema = Column(JSON, default=dict) + default_config = Column(JSON, default=dict) + supports_schedule = Column(Boolean, default=True) + is_sub_agent = Column(Boolean, default=False) + + instances = relationship("AgentInstance", back_populates="catalog_entry") + + +class AgentInstance(Base): + __tablename__ = "agent_instances" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + catalog_id = Column(String, ForeignKey("agent_catalog.id"), nullable=False) + name = Column(String, nullable=False) + config = Column(JSON, default=dict) schedule = Column(String, default="manual") status = Column(String, default="active") - config = Column(JSON, default=dict) created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) - runs = relationship("Run", back_populates="agent", order_by="Run.started_at.desc()") + user = relationship("User", back_populates="instances") + catalog_entry = relationship("AgentCatalog", back_populates="instances") + runs = relationship("Run", back_populates="instance", order_by="Run.started_at.desc()") class Run(Base): __tablename__ = "runs" id = Column(Integer, primary_key=True, autoincrement=True) - agent_id = Column(String, ForeignKey("agents.id"), nullable=False) + instance_id = Column(Integer, ForeignKey("agent_instances.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) started_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) finished_at = Column(DateTime, nullable=True) status = Column(String, default="running") @@ -30,4 +62,16 @@ class Run(Base): error = Column(Text, default="") metadata_ = Column("metadata", JSON, default=dict) - agent = relationship("Agent", back_populates="runs") + instance = relationship("AgentInstance", back_populates="runs") + + +class LLMProvider(Base): + __tablename__ = "llm_providers" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String, nullable=False) + provider_type = Column(String, default="anthropic") # anthropic, openai, litellm, ollama + api_url = Column(String, default="") + api_key = Column(String, default="") + default_model = Column(String, default="") + is_default = Column(Boolean, default=False) diff --git a/dashboard/static/admin.html b/dashboard/static/admin.html new file mode 100644 index 0000000..909749e --- /dev/null +++ b/dashboard/static/admin.html @@ -0,0 +1,221 @@ + + + + + +Admin — Agent Command Center + + + +
+

Admin Panel

+
+ + +
+
+
+
+
Users
+
Agent Catalog
+
LLM Providers
+
System
+
+ + +
+
+

Create User

+
+
+
+
+
+
+
+
+ + +
+
UsernameDisplay NameRoleAgentsActions
+
+ + +
+
+

Add Catalog Entry

+
+
+
+
+
+
+
+
+
+ + +
+
IDNameCategoryTypeActions
+
+ + +
+
+

Add LLM Provider

+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
NameTypeURLModelDefaultActions
+
+ + +
+
+
+
+ + + + diff --git a/dashboard/static/index.html b/dashboard/static/index.html index ed2e82c..bf6145c 100644 --- a/dashboard/static/index.html +++ b/dashboard/static/index.html @@ -5,175 +5,110 @@ Agent Command Center -

Agent Command Center

-
+
0 agents - + + +
-

Agents

+
+

My Agents

+ +
@@ -186,247 +121,230 @@