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
+
+
+
+
+
+
+
Users
+
Agent Catalog
+
LLM Providers
+
System
+
+
+
+
+
+
| Username | Display Name | Role | Agents | Actions |
|---|
+
+
+
+
+
+
| ID | Name | Category | Type | Actions |
|---|
+
+
+
+
+
+
| Name | Type | URL | Model | Default | Actions |
|---|
+
+
+
+
+
+
+
+
+
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
-