v2.0: Multi-user platform with agent catalog, admin panel, LLM providers

This commit is contained in:
2026-04-13 02:21:45 +00:00
parent b299ea701a
commit 26156543f6
8 changed files with 1002 additions and 580 deletions
+3
View File
@@ -3,9 +3,12 @@
from daily_briefing import run from daily_briefing import run
INSTANCE_ID = int(__import__('os').environ.get("ANGELA_INSTANCE_ID", "0"))
CONFIG = { CONFIG = {
"person": "Angela", "person": "Angela",
"agent_id": "angela-daily-briefing", "agent_id": "angela-daily-briefing",
"instance_id": INSTANCE_ID,
"wiki_parent_doc_id": "65966bd6-4ef8-4b79-9b79-e4aa62b94e96", "wiki_parent_doc_id": "65966bd6-4ef8-4b79-9b79-e4aa62b94e96",
"location": { "location": {
"name": "Providence", "name": "Providence",
+7 -3
View File
@@ -121,13 +121,17 @@ def run(config):
location (dict): {name, state, country, lat, lon} location (dict): {name, state, country, lat, lon}
""" """
agent_id = config["agent_id"] agent_id = config["agent_id"]
instance_id = config.get("instance_id", 0)
# Fetch live config from dashboard API, merge over defaults # Fetch live config from dashboard API, merge over defaults
if instance_id:
try: try:
live_config = api_request(f"{DASHBOARD_API}/api/agents/{agent_id}/config") live_config = api_request(f"{DASHBOARD_API}/api/instances/{instance_id}/config")
if live_config.get("location"): if live_config.get("location"):
config["location"] = live_config["location"] config["location"] = live_config["location"]
print(f"Using live config location: {config['location'].get('name', '?')}") print(f"Using live config location: {config['location'].get('name', '?')}")
if live_config.get("calendars"):
config["calendars"] = live_config["calendars"]
except Exception as e: except Exception as e:
print(f"Could not fetch live config, using defaults: {e}") print(f"Could not fetch live config, using defaults: {e}")
@@ -145,7 +149,7 @@ def run(config):
summaries = "; ".join(f"{name}: {s}" for name, _, s in sections) summaries = "; ".join(f"{name}: {s}" for name, _, s in sections)
output = f"Briefing {action}. {summaries}" 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, "wiki_doc_id": doc_id,
"action": action, "action": action,
"sub_agents": [name for name, _, _ in sections], "sub_agents": [name for name, _, _ in sections],
@@ -155,5 +159,5 @@ def run(config):
except Exception as e: except Exception as e:
err_msg = f"{type(e).__name__}: {e}" err_msg = f"{type(e).__name__}: {e}"
print(f"Error: {err_msg}", file=sys.stderr) 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) sys.exit(1)
+4
View File
@@ -3,9 +3,13 @@
from daily_briefing import run 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 = { CONFIG = {
"person": "Eric", "person": "Eric",
"agent_id": "eric-daily-briefing", "agent_id": "eric-daily-briefing",
"instance_id": INSTANCE_ID,
"wiki_parent_doc_id": "2a891fe8-579b-450b-a663-de93915896b7", "wiki_parent_doc_id": "2a891fe8-579b-450b-a663-de93915896b7",
"location": { "location": {
"name": "Providence", "name": "Providence",
+7 -9
View File
@@ -33,19 +33,17 @@ def api_request(url, data=None, headers=None, method="GET"):
return json.loads(resp.read().decode()) return json.loads(resp.read().decode())
def log_run(agent_id, status, output="", err="", metadata=None): def log_run(agent_id, status, output="", err="", metadata=None, instance_id=None):
"""Log a run to the dashboard API.""" """Log a run to the dashboard API. Uses instance_id if available (v2), falls back to agent_id."""
try: try:
if instance_id:
api_request( api_request(
f"{DASHBOARD_API}/api/agents/{agent_id}/runs", f"{DASHBOARD_API}/api/instances/{instance_id}/runs",
data={ data={"status": status, "output": output, "error": err, "metadata": metadata or {}},
"status": status,
"output": output,
"error": err,
"metadata": metadata or {},
},
method="POST", method="POST",
) )
else:
print(f"Warning: no instance_id, run not logged for {agent_id}", file=sys.stderr)
except Exception as e: except Exception as e:
print(f"Warning: failed to log run to dashboard: {e}", file=sys.stderr) print(f"Warning: failed to log run to dashboard: {e}", file=sys.stderr)
+404 -174
View File
@@ -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.staticfiles import StaticFiles
from fastapi.responses import FileResponse, RedirectResponse, JSONResponse from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
import hashlib import hashlib
import hmac
import json import json
import os import os
import secrets import secrets
from database import get_db, init_db from database import get_db, init_db
from models import Agent, Run from models import User, AgentCatalog, AgentInstance, Run, LLMProvider
app = FastAPI(title="Agent Command Center", version="1.0.0")
app = FastAPI(title="Agent Command Center", version="2.0.0")
# --- Auth --- # --- 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)) SESSION_SECRET = os.environ.get("SESSION_SECRET", secrets.token_hex(32))
_sessions: dict[str, dict] = {} # token -> {user_id, username, role}
# In-memory session store
_sessions: dict[str, str] = {}
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) token = secrets.token_urlsafe(32)
_sessions[token] = username _sessions[token] = {"user_id": user.id, "username": user.username, "role": user.role}
return token 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: if session and session in _sessions:
return _sessions[session] return _sessions[session]
return None return None
def require_auth(session: Optional[str] = Cookie(None)): def require_auth(session: Optional[str] = Cookie(None)) -> dict:
user = get_current_user(session) user = get_current_user(session)
if not user: if not user:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
return user 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): class LoginRequest(BaseModel):
username: str username: str
password: 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") @app.post("/api/login")
def login(creds: LoginRequest, response: Response): def login(creds: LoginRequest, response: Response, db: Session = Depends(get_db)):
if creds.username == AUTH_USER and creds.password == AUTH_PASS: user = db.query(User).filter(User.username == creds.username).first()
token = create_session(creds.username) if not user or not verify_password(creds.password, user.password_hash):
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") 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") @app.post("/api/logout")
@@ -68,155 +130,174 @@ def logout(response: Response, session: Optional[str] = Cookie(None)):
return {"status": "ok"} return {"status": "ok"}
@app.get("/login") @app.get("/api/me")
def login_page(): def me(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
return FileResponse("static/login.html") u = db.query(User).filter(User.id == user["user_id"]).first()
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,
}
# --- Pydantic schemas --- # --- Health ---
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") @app.get("/api/health")
def health(): def health():
return {"status": "ok", "service": "agent-command-center"} return {"status": "ok", "service": "agent-command-center", "version": "2.0.0"}
@app.get("/api/agents/{agent_id}/config") # --- Agent Catalog ---
def get_agent_config(agent_id: str, db: Session = Depends(get_db)):
"""Internal endpoint for agents to fetch their config. No auth required.""" @app.get("/api/catalog")
agent = db.query(Agent).filter(Agent.id == agent_id).first() def list_catalog(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
if not agent: entries = db.query(AgentCatalog).all()
raise HTTPException(status_code=404, detail="Agent not found") # Check which ones this user already has instances of
return agent.config or {} 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/agents") @app.get("/api/catalog/{catalog_id}")
def list_agents(user: str = Depends(require_auth), db: Session = Depends(get_db)): def get_catalog_entry(catalog_id: str, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
agents = db.query(Agent).all() entry = db.query(AgentCatalog).filter(AgentCatalog.id == catalog_id).first()
result = [] if not entry:
for a in agents: raise HTTPException(status_code=404)
last_run = db.query(Run).filter(Run.agent_id == a.id).order_by(Run.started_at.desc()).first() return {
recent_runs = db.query(Run).filter(Run.agent_id == a.id).order_by(Run.started_at.desc()).limit(10).all() "id": entry.id, "name": entry.name, "description": entry.description,
success_streak = 0 "category": entry.category, "config_schema": entry.config_schema or {},
for r in recent_runs: "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": if r.status == "success":
success_streak += 1 streak += 1
else: else:
break break
result.append({ return {
"id": a.id, "id": inst.id, "catalog_id": inst.catalog_id, "name": inst.name,
"name": a.name, "config": inst.config or {}, "schedule": inst.schedule, "status": inst.status,
"description": a.description, "created_at": inst.created_at.isoformat() if inst.created_at else None,
"schedule": a.schedule,
"status": a.status,
"config": a.config or {},
"created_at": a.created_at.isoformat() if a.created_at else None,
"last_run": { "last_run": {
"status": last_run.status, "status": last_run.status,
"started_at": last_run.started_at.isoformat() if last_run.started_at else None, "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, "finished_at": last_run.finished_at.isoformat() if last_run.finished_at else None,
} if last_run else None, } if last_run else None,
"success_streak": success_streak, "success_streak": streak,
"total_runs": db.query(Run).filter(Run.agent_id == a.id).count(), "total_runs": db.query(Run).filter(Run.instance_id == inst.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()
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],
} }
@app.put("/api/agents/{agent_id}") @app.get("/api/instances")
def update_agent(agent_id: str, update: AgentUpdate, db: Session = Depends(get_db)): def list_instances(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
agent = db.query(Agent).filter(Agent.id == agent_id).first() instances = db.query(AgentInstance).filter(AgentInstance.user_id == user["user_id"]).all()
if not agent: return [serialize_instance(i, db) for i in instances]
raise HTTPException(status_code=404, detail="Agent not found")
updates = update.model_dump(exclude_none=True)
if "config" in updates: @app.post("/api/instances")
current_config = agent.config or {} def create_instance(data: InstanceCreate, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
current_config.update(updates.pop("config")) catalog = db.query(AgentCatalog).filter(AgentCatalog.id == data.catalog_id).first()
agent.config = current_config if not catalog:
for field, value in updates.items(): raise HTTPException(status_code=404, detail="Agent type not found in catalog")
setattr(agent, field, value) 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() db.commit()
return {"id": agent.id, "status": "updated"} return {"id": inst.id, "status": "created"}
@app.post("/api/agents/{agent_id}/runs") @app.get("/api/instances/{instance_id}")
def create_run(agent_id: str, run: RunCreate, db: Session = Depends(get_db)): def get_instance(instance_id: int, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
agent = db.query(Agent).filter(Agent.id == agent_id).first() inst = db.query(AgentInstance).filter(
if not agent: AgentInstance.id == instance_id, AgentInstance.user_id == user["user_id"]
raise HTTPException(status_code=404, detail="Agent not found") ).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( new_run = Run(
agent_id=agent_id, instance_id=instance_id,
user_id=inst.user_id,
status=run.status, status=run.status,
output=run.output, output=run.output,
error=run.error, 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} return {"id": new_run.id, "status": new_run.status}
@app.put("/api/runs/{run_id}") # --- Runs (user-scoped) ---
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}
@app.get("/api/runs") @app.get("/api/runs")
def list_runs(limit: int = 50, user: str = Depends(require_auth), db: Session = Depends(get_db)): def list_runs(limit: int = 50, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
runs = db.query(Run).order_by(Run.started_at.desc()).limit(limit).all() runs = db.query(Run).filter(Run.user_id == user["user_id"]).order_by(Run.started_at.desc()).limit(limit).all()
return [{ return [{
"id": r.id, "id": r.id, "instance_id": r.instance_id,
"agent_id": r.agent_id,
"started_at": r.started_at.isoformat() if r.started_at else None, "started_at": r.started_at.isoformat() if r.started_at else None,
"finished_at": r.finished_at.isoformat() if r.finished_at else None, "finished_at": r.finished_at.isoformat() if r.finished_at else None,
"status": r.status, "status": r.status, "output": r.output, "error": r.error, "metadata": r.metadata_,
"output": r.output,
"error": r.error,
"metadata": r.metadata_,
} for r in runs] } 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.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("/") @app.get("/")
def root(session: Optional[str] = Cookie(None)): def root(session: Optional[str] = Cookie(None)):
user = get_current_user(session) user = get_current_user(session)
+51 -7
View File
@@ -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 sqlalchemy.orm import relationship
from datetime import datetime, timezone from datetime import datetime, timezone
from database import Base from database import Base
class Agent(Base): class User(Base):
__tablename__ = "agents" __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) id = Column(String, primary_key=True)
name = Column(String, nullable=False) name = Column(String, nullable=False)
description = Column(Text, default="") 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") schedule = Column(String, default="manual")
status = Column(String, default="active") status = Column(String, default="active")
config = Column(JSON, default=dict)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) 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): class Run(Base):
__tablename__ = "runs" __tablename__ = "runs"
id = Column(Integer, primary_key=True, autoincrement=True) 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)) started_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
finished_at = Column(DateTime, nullable=True) finished_at = Column(DateTime, nullable=True)
status = Column(String, default="running") status = Column(String, default="running")
@@ -30,4 +62,16 @@ class Run(Base):
error = Column(Text, default="") error = Column(Text, default="")
metadata_ = Column("metadata", JSON, default=dict) 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)
+221
View File
@@ -0,0 +1,221 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin — Agent Command Center</title>
<style>
:root{--bg:#0f1117;--surface:#1a1d27;--surface2:#232733;--border:#2e3345;--text:#e4e6ed;--text-dim:#8b8fa3;--accent:#6c5ce7;--accent-hover:#7c6ef0;--green:#00b894;--red:#e17055;--yellow:#fdcb6e;--blue:#74b9ff}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
.header{background:var(--surface);border-bottom:1px solid var(--border);padding:1rem 2rem;display:flex;align-items:center;justify-content:space-between}
.header h1{font-size:1.4rem;font-weight:600}
.header-right{display:flex;gap:.75rem;align-items:center}
.small-btn{background:none;border:1px solid var(--border);color:var(--text-dim);padding:.35rem .75rem;border-radius:6px;font-size:.8rem;cursor:pointer}
.small-btn:hover{border-color:var(--text-dim);color:var(--text)}
.container{max-width:1000px;margin:0 auto;padding:1.5rem 2rem}
.tabs{display:flex;gap:0;margin-bottom:1.5rem;border-bottom:1px solid var(--border)}
.tab{padding:.6rem 1.25rem;font-size:.9rem;cursor:pointer;color:var(--text-dim);border-bottom:2px solid transparent;transition:all .2s}
.tab:hover{color:var(--text)}.tab.active{color:var(--accent);border-bottom-color:var(--accent)}
.panel{display:none}.panel.active{display:block}
table{width:100%;border-collapse:collapse;background:var(--surface);border-radius:10px;overflow:hidden;border:1px solid var(--border)}
th{text-align:left;padding:.75rem 1rem;font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text-dim);background:var(--surface2);border-bottom:1px solid var(--border)}
td{padding:.65rem 1rem;font-size:.85rem;border-bottom:1px solid var(--border)}
tr:last-child td{border-bottom:none}
tr:hover td{background:var(--surface2)}
.badge{display:inline-block;padding:.15rem .6rem;border-radius:12px;font-size:.75rem;font-weight:500;text-transform:uppercase}
.badge.admin{background:rgba(225,112,85,.15);color:var(--red)}
.badge.user{background:rgba(0,184,148,.15);color:var(--green)}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin-bottom:.75rem}
.form-row.full{grid-template-columns:1fr}
.field label{display:block;font-size:.75rem;font-weight:500;color:var(--text-dim);text-transform:uppercase;margin-bottom:.3rem}
.field input,.field select{width:100%;padding:.5rem .65rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:.85rem;outline:none}
.field input:focus,.field select:focus{border-color:var(--accent)}
.form-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1.25rem;margin-bottom:1rem}
.form-card h3{margin-bottom:1rem;font-size:.95rem}
.btn{padding:.5rem 1.25rem;border:none;border-radius:6px;font-size:.85rem;cursor:pointer}
.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:var(--accent-hover)}
.btn-danger{background:var(--red);color:#fff;opacity:.8}.btn-danger:hover{opacity:1}
.btn-sm{padding:.3rem .75rem;font-size:.8rem}
.action-btns{display:flex;gap:.5rem}
.msg{font-size:.8rem;margin-top:.5rem}
.msg.ok{color:var(--green)}.msg.err{color:var(--red)}
.stat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:1rem;margin-bottom:1.5rem}
.stat-card{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem;text-align:center}
.stat-card .val{font-size:1.8rem;font-weight:700;color:var(--accent)}
.stat-card .lbl{font-size:.8rem;color:var(--text-dim);margin-top:.25rem}
</style>
</head>
<body>
<div class="header">
<h1>Admin Panel</h1>
<div class="header-right">
<button class="small-btn" onclick="location.href='/'">Dashboard</button>
<button class="small-btn" onclick="logout()">Logout</button>
</div>
</div>
<div class="container">
<div class="tabs">
<div class="tab active" onclick="switchTab('users')">Users</div>
<div class="tab" onclick="switchTab('catalog')">Agent Catalog</div>
<div class="tab" onclick="switchTab('llm')">LLM Providers</div>
<div class="tab" onclick="switchTab('system')">System</div>
</div>
<!-- Users -->
<div class="panel active" id="panel-users">
<div class="form-card">
<h3>Create User</h3>
<div class="form-row">
<div class="field"><label>Username</label><input id="nu-user" placeholder="username"></div>
<div class="field"><label>Password</label><input type="password" id="nu-pass"></div>
</div>
<div class="form-row">
<div class="field"><label>Display Name</label><input id="nu-name" placeholder="Full Name"></div>
<div class="field"><label>Role</label><select id="nu-role"><option value="user">User</option><option value="admin">Admin</option></select></div>
</div>
<button class="btn btn-primary" onclick="createUser()">Create</button>
<span class="msg" id="nu-msg"></span>
</div>
<table id="users-table"><thead><tr><th>Username</th><th>Display Name</th><th>Role</th><th>Agents</th><th>Actions</th></tr></thead><tbody></tbody></table>
</div>
<!-- Catalog -->
<div class="panel" id="panel-catalog">
<div class="form-card">
<h3>Add Catalog Entry</h3>
<div class="form-row">
<div class="field"><label>ID (slug)</label><input id="nc-id" placeholder="my-agent"></div>
<div class="field"><label>Name</label><input id="nc-name" placeholder="My Agent"></div>
</div>
<div class="form-row">
<div class="field"><label>Category</label><select id="nc-cat"><option value="data">Data</option><option value="briefing">Briefing</option><option value="utility">Utility</option></select></div>
<div class="field"><label>Sub-agent?</label><select id="nc-sub"><option value="false">No</option><option value="true">Yes</option></select></div>
</div>
<div class="form-row full"><div class="field"><label>Description</label><input id="nc-desc" placeholder="What this agent does"></div></div>
<button class="btn btn-primary" onclick="createCatalog()">Add</button>
<span class="msg" id="nc-msg"></span>
</div>
<table id="catalog-table"><thead><tr><th>ID</th><th>Name</th><th>Category</th><th>Type</th><th>Actions</th></tr></thead><tbody></tbody></table>
</div>
<!-- LLM Providers -->
<div class="panel" id="panel-llm">
<div class="form-card">
<h3>Add LLM Provider</h3>
<div class="form-row">
<div class="field"><label>Name</label><input id="nl-name" placeholder="Anthropic"></div>
<div class="field"><label>Type</label><select id="nl-type"><option value="anthropic">Anthropic</option><option value="openai">OpenAI</option><option value="litellm">LiteLLM</option><option value="ollama">Ollama</option></select></div>
</div>
<div class="form-row">
<div class="field"><label>API URL</label><input id="nl-url" placeholder="https://api.anthropic.com"></div>
<div class="field"><label>Default Model</label><input id="nl-model" placeholder="claude-sonnet-4-5-20250514"></div>
</div>
<div class="form-row">
<div class="field"><label>API Key</label><input type="password" id="nl-key"></div>
<div class="field"><label>Default?</label><select id="nl-default"><option value="false">No</option><option value="true">Yes</option></select></div>
</div>
<button class="btn btn-primary" onclick="createProvider()">Add</button>
<span class="msg" id="nl-msg"></span>
</div>
<table id="llm-table"><thead><tr><th>Name</th><th>Type</th><th>URL</th><th>Model</th><th>Default</th><th>Actions</th></tr></thead><tbody></tbody></table>
</div>
<!-- System -->
<div class="panel" id="panel-system">
<div class="stat-grid" id="sys-stats"></div>
</div>
</div>
<script>
const API='';
function switchTab(name){
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
document.querySelector(`.tab[onclick*="${name}"]`).classList.add('active');
document.getElementById('panel-'+name).classList.add('active');
}
async function logout(){await fetch(API+'/api/logout',{method:'POST'});location.href='/login'}
function showMsg(id,text,ok){const el=document.getElementById(id);el.textContent=text;el.className='msg '+(ok?'ok':'err');setTimeout(()=>el.textContent='',3000)}
// --- Users ---
async function loadUsers(){
const res=await fetch(API+'/api/admin/users');if(res.status===403||res.status===401){location.href='/';return}
const users=await res.json();
document.querySelector('#users-table tbody').innerHTML=users.map(u=>`<tr>
<td>${u.username}</td><td>${u.display_name}</td><td><span class="badge ${u.role}">${u.role}</span></td>
<td>${u.instance_count}</td>
<td><div class="action-btns"><button class="btn btn-danger btn-sm" onclick="deleteUser(${u.id},'${u.username}')">Delete</button></div></td>
</tr>`).join('');
}
async function createUser(){
const res=await fetch(API+'/api/admin/users',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
username:document.getElementById('nu-user').value,password:document.getElementById('nu-pass').value,
display_name:document.getElementById('nu-name').value,role:document.getElementById('nu-role').value})});
if(res.ok){showMsg('nu-msg','Created',true);loadUsers();document.getElementById('nu-user').value='';document.getElementById('nu-pass').value='';document.getElementById('nu-name').value=''}
else{const e=await res.json();showMsg('nu-msg',e.detail||'Error',false)}
}
async function deleteUser(id,name){if(!confirm('Delete user '+name+'?'))return;await fetch(API+'/api/admin/users/'+id,{method:'DELETE'});loadUsers()}
// --- Catalog ---
async function loadCatalog(){
const res=await fetch(API+'/api/admin/users');// check auth
const cres=await fetch(API+'/api/catalog');
const catalog=await cres.json();
document.querySelector('#catalog-table tbody').innerHTML=catalog.map(c=>`<tr>
<td><code>${c.id}</code></td><td>${c.name}</td><td><span class="badge ${c.category}">${c.category}</span></td>
<td>${c.is_sub_agent?'Sub-agent':'Standalone'}</td>
<td><button class="btn btn-danger btn-sm" onclick="deleteCatalog('${c.id}')">Delete</button></td>
</tr>`).join('');
}
async function createCatalog(){
const res=await fetch(API+'/api/admin/catalog',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
id:document.getElementById('nc-id').value,name:document.getElementById('nc-name').value,
description:document.getElementById('nc-desc').value,category:document.getElementById('nc-cat').value,
is_sub_agent:document.getElementById('nc-sub').value==='true'})});
if(res.ok){showMsg('nc-msg','Added',true);loadCatalog()}
else{const e=await res.json();showMsg('nc-msg',e.detail||'Error',false)}
}
async function deleteCatalog(id){if(!confirm('Delete catalog entry '+id+'?'))return;await fetch(API+'/api/admin/catalog/'+id,{method:'DELETE'});loadCatalog()}
// --- LLM Providers ---
async function loadProviders(){
const res=await fetch(API+'/api/admin/llm-providers');
const providers=await res.json();
document.querySelector('#llm-table tbody').innerHTML=providers.map(p=>`<tr>
<td>${p.name}</td><td>${p.provider_type}</td><td style="font-size:.8rem">${p.api_url||'-'}</td>
<td>${p.default_model||'-'}</td><td>${p.is_default?'Yes':'-'}</td>
<td><button class="btn btn-danger btn-sm" onclick="deleteProvider(${p.id})">Delete</button></td>
</tr>`).join('');
}
async function createProvider(){
const res=await fetch(API+'/api/admin/llm-providers',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
name:document.getElementById('nl-name').value,provider_type:document.getElementById('nl-type').value,
api_url:document.getElementById('nl-url').value,api_key:document.getElementById('nl-key').value,
default_model:document.getElementById('nl-model').value,is_default:document.getElementById('nl-default').value==='true'})});
if(res.ok){showMsg('nl-msg','Added',true);loadProviders()}
else{const e=await res.json();showMsg('nl-msg',e.detail||'Error',false)}
}
async function deleteProvider(id){if(!confirm('Delete this provider?'))return;await fetch(API+'/api/admin/llm-providers/'+id,{method:'DELETE'});loadProviders()}
// --- System ---
async function loadSystem(){
const[usersRes,instRes]=await Promise.all([fetch(API+'/api/admin/users'),fetch(API+'/api/health')]);
const users=await usersRes.json();const health=await instRes.json();
document.getElementById('sys-stats').innerHTML=`
<div class="stat-card"><div class="val">${users.length}</div><div class="lbl">Users</div></div>
<div class="stat-card"><div class="val">${users.reduce((s,u)=>s+u.instance_count,0)}</div><div class="lbl">Agent Instances</div></div>
<div class="stat-card"><div class="val">${health.version||'?'}</div><div class="lbl">Version</div></div>
<div class="stat-card"><div class="val" style="color:var(--green)">OK</div><div class="lbl">Status</div></div>`;
}
// Init
loadUsers();loadCatalog();loadProviders();loadSystem();
</script>
</body>
</html>
+279 -361
View File
@@ -5,175 +5,110 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Agent Command Center</title> <title>Agent Command Center</title>
<style> <style>
:root { :root{--bg:#0f1117;--surface:#1a1d27;--surface2:#232733;--border:#2e3345;--text:#e4e6ed;--text-dim:#8b8fa3;--accent:#6c5ce7;--accent-hover:#7c6ef0;--green:#00b894;--red:#e17055;--yellow:#fdcb6e;--blue:#74b9ff}
--bg: #0f1117; *{margin:0;padding:0;box-sizing:border-box}
--surface: #1a1d27; body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
--surface2: #232733; .header{background:var(--surface);border-bottom:1px solid var(--border);padding:1rem 2rem;display:flex;align-items:center;justify-content:space-between}
--border: #2e3345; .header h1{font-size:1.4rem;font-weight:600;letter-spacing:-.02em}
--text: #e4e6ed; .header-right{display:flex;align-items:center;gap:0.75rem;font-size:.85rem;color:var(--text-dim)}
--text-dim: #8b8fa3; .header-right .dot{width:8px;height:8px;border-radius:50%;background:var(--green)}
--accent: #6c5ce7; .header-right .user-name{color:var(--text)}
--accent-hover: #7c6ef0; .small-btn{background:none;border:1px solid var(--border);color:var(--text-dim);padding:.35rem .75rem;border-radius:6px;font-size:.8rem;cursor:pointer;transition:all .2s}
--green: #00b894; .small-btn:hover{border-color:var(--text-dim);color:var(--text)}
--red: #e17055; .container{max-width:1200px;margin:0 auto;padding:1.5rem 2rem}
--yellow: #fdcb6e; .section-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem}
--blue: #74b9ff; .section-header h2{font-size:1.1rem;font-weight:600}
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
.header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 1rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.header h1 { font-size: 1.4rem; font-weight: 600; letter-spacing: -0.02em; }
.header .status {
display: flex; align-items: center; gap: 0.5rem;
font-size: 0.85rem; color: var(--text-dim);
}
.header .status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); }
.logout-btn {
background: none; border: 1px solid var(--border); color: var(--text-dim);
padding: 0.35rem 0.75rem; border-radius: 6px; font-size: 0.8rem;
cursor: pointer; margin-left: 1rem; transition: border-color 0.2s, color 0.2s;
}
.logout-btn:hover { border-color: var(--text-dim); color: var(--text); }
.container { max-width: 1200px; margin: 0 auto; padding: 1.5rem 2rem; }
.agents-grid { .agents-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:1rem;margin-bottom:.75rem}
display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); .agent-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1.25rem;cursor:pointer;transition:border-color .2s,transform .1s}
gap: 1rem; margin-bottom: 0.75rem; .agent-card:hover{border-color:var(--accent);transform:translateY(-1px)}
} .agent-card.dimmed{opacity:.45}.agent-card.dimmed:hover{opacity:.7}
.agent-card { .agent-card .card-top{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:.75rem}
background: var(--surface); border: 1px solid var(--border); .agent-card h3{font-size:1.05rem;font-weight:600}
border-radius: 10px; padding: 1.25rem; cursor: pointer; .agent-card .desc{color:var(--text-dim);font-size:.85rem;margin-bottom:1rem}
transition: border-color 0.2s, transform 0.1s; .agent-card .card-stats{display:flex;gap:1.25rem;font-size:.8rem;color:var(--text-dim)}
} .agent-card .card-stats span{display:flex;align-items:center;gap:.3rem}
.agent-card:hover { border-color: var(--accent); transform: translateY(-1px); } .badge{display:inline-block;padding:.15rem .6rem;border-radius:12px;font-size:.75rem;font-weight:500;text-transform:uppercase;letter-spacing:.03em}
.agent-card.dimmed { opacity: 0.45; } .badge.active{background:rgba(0,184,148,.15);color:var(--green)}
.agent-card.dimmed:hover { opacity: 0.7; } .badge.paused{background:rgba(253,203,110,.15);color:var(--yellow)}
.agent-card .card-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem; } .badge.error,.badge.failed{background:rgba(225,112,85,.15);color:var(--red)}
.agent-card h3 { font-size: 1.05rem; font-weight: 600; } .badge.success{background:rgba(0,184,148,.15);color:var(--green)}
.agent-card .desc { color: var(--text-dim); font-size: 0.85rem; margin-bottom: 1rem; } .badge.running{background:rgba(116,185,255,.15);color:var(--blue)}
.agent-card .card-stats { display: flex; gap: 1.25rem; font-size: 0.8rem; color: var(--text-dim); } .badge.data{background:rgba(116,185,255,.1);color:var(--blue)}
.agent-card .card-stats span { display: flex; align-items: center; gap: 0.3rem; } .badge.briefing{background:rgba(108,92,231,.15);color:var(--accent)}
.badge.utility{background:rgba(139,143,163,.15);color:var(--text-dim)}
.badge { .paused-toggle{background:none;border:none;color:var(--text-dim);font-size:.8rem;cursor:pointer;padding:.4rem 0;margin-bottom:1.5rem;display:none}
display: inline-block; padding: 0.15rem 0.6rem; border-radius: 12px; .paused-toggle:hover{color:var(--text)}
font-size: 0.75rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em;
}
.badge.active { background: rgba(0,184,148,0.15); color: var(--green); }
.badge.paused { background: rgba(253,203,110,0.15); color: var(--yellow); }
.badge.error { background: rgba(225,112,85,0.15); color: var(--red); }
.badge.success { background: rgba(0,184,148,0.15); color: var(--green); }
.badge.failed { background: rgba(225,112,85,0.15); color: var(--red); }
.badge.running { background: rgba(116,185,255,0.15); color: var(--blue); }
.paused-toggle { .runs-table{width:100%;border-collapse:collapse;background:var(--surface);border-radius:10px;overflow:hidden;border:1px solid var(--border)}
background: none; border: none; color: var(--text-dim); font-size: 0.8rem; .runs-table th{text-align:left;padding:.75rem 1rem;font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text-dim);background:var(--surface2);border-bottom:1px solid var(--border)}
cursor: pointer; padding: 0.4rem 0; margin-bottom: 1.5rem; display: none; .runs-table td{padding:.65rem 1rem;font-size:.85rem;border-bottom:1px solid var(--border)}
} .runs-table tr:last-child td{border-bottom:none}
.paused-toggle:hover { color: var(--text); } .runs-table tr:hover td{background:var(--surface2)}
.output-preview{max-width:350px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text-dim)}
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } .modal-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);z-index:100;justify-content:center;align-items:flex-start;padding-top:5vh}
.section-header h2 { font-size: 1.1rem; font-weight: 600; } .modal-overlay.open{display:flex}
.modal{background:var(--surface);border:1px solid var(--border);border-radius:12px;width:90%;max-width:800px;max-height:85vh;overflow-y:auto;padding:1.5rem}
.modal h2{margin-bottom:.5rem}
.modal .meta{color:var(--text-dim);font-size:.85rem;margin-bottom:1.5rem}
.modal .close-btn{float:right;background:none;border:none;color:var(--text-dim);font-size:1.5rem;cursor:pointer}
.modal .close-btn:hover{color:var(--text)}
.run-output{background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:1rem;font-family:'SF Mono',Monaco,Consolas,monospace;font-size:.8rem;white-space:pre-wrap;max-height:200px;overflow-y:auto;margin-top:.5rem;color:var(--text-dim)}
.runs-table { .config-section{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:1rem;margin-bottom:1.5rem}
width: 100%; border-collapse: collapse; background: var(--surface); .config-section h3{font-size:.95rem;margin-bottom:1rem}
border-radius: 10px; overflow: hidden; border: 1px solid var(--border); .config-grid{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}
} .config-field label{display:block;font-size:.75rem;font-weight:500;color:var(--text-dim);text-transform:uppercase;letter-spacing:.04em;margin-bottom:.3rem}
.runs-table th { .config-field input,.config-field select{width:100%;padding:.5rem .65rem;background:var(--surface);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:.85rem;outline:none}
text-align: left; padding: 0.75rem 1rem; font-size: 0.75rem; font-weight: 600; .config-field input:focus,.config-field select:focus{border-color:var(--accent)}
text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-dim); .config-actions{display:flex;gap:.5rem;margin-top:1rem;align-items:center}
background: var(--surface2); border-bottom: 1px solid var(--border); .btn-save{padding:.5rem 1.25rem;background:var(--accent);color:#fff;border:none;border-radius:6px;font-size:.85rem;cursor:pointer}
} .btn-save:hover{background:var(--accent-hover)}
.runs-table td { padding: 0.65rem 1rem; font-size: 0.85rem; border-bottom: 1px solid var(--border); } .btn-danger{padding:.5rem 1.25rem;background:var(--red);color:#fff;border:none;border-radius:6px;font-size:.85rem;cursor:pointer;opacity:.8}
.runs-table tr:last-child td { border-bottom: none; } .btn-danger:hover{opacity:1}
.runs-table tr:hover td { background: var(--surface2); } .btn-secondary{padding:.5rem 1.25rem;background:none;color:var(--text-dim);border:1px solid var(--border);border-radius:6px;font-size:.85rem;cursor:pointer}
.output-preview { max-width: 350px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-dim); } .btn-secondary:hover{border-color:var(--text-dim);color:var(--text)}
.save-msg{font-size:.8rem;margin-left:.75rem;line-height:2}
.modal-overlay { /* Catalog grid */
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; .catalog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem;margin-top:1rem}
background: rgba(0,0,0,0.6); z-index: 100; justify-content: center; .catalog-card{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:1rem}
align-items: flex-start; padding-top: 5vh; .catalog-card h4{font-size:.95rem;margin-bottom:.3rem}
} .catalog-card .cat-desc{color:var(--text-dim);font-size:.8rem;margin-bottom:.75rem}
.modal-overlay.open { display: flex; } .catalog-card .cat-footer{display:flex;justify-content:space-between;align-items:center}
.modal { .catalog-card .btn-enable{padding:.35rem .85rem;background:var(--accent);color:#fff;border:none;border-radius:5px;font-size:.8rem;cursor:pointer}
background: var(--surface); border: 1px solid var(--border); border-radius: 12px; .catalog-card .btn-enable:hover{background:var(--accent-hover)}
width: 90%; max-width: 800px; max-height: 85vh; overflow-y: auto; padding: 1.5rem; .catalog-card .btn-enable.enabled{background:var(--surface);color:var(--text-dim);border:1px solid var(--border);cursor:default}
}
.modal h2 { margin-bottom: 0.5rem; }
.modal .meta { color: var(--text-dim); font-size: 0.85rem; margin-bottom: 1.5rem; }
.modal .close-btn {
float: right; background: none; border: none; color: var(--text-dim);
font-size: 1.5rem; cursor: pointer;
}
.modal .close-btn:hover { color: var(--text); }
.run-output {
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
padding: 1rem; font-family: 'SF Mono', Monaco, Consolas, monospace;
font-size: 0.8rem; white-space: pre-wrap; max-height: 200px; overflow-y: auto;
margin-top: 0.5rem; color: var(--text-dim);
}
/* Config form */ /* Calendar items */
.config-section { .cal-item{background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:.75rem;margin-bottom:.5rem}
background: var(--bg); border: 1px solid var(--border); border-radius: 8px; .cal-item .cal-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.5rem}
padding: 1rem; margin-bottom: 1.5rem; .cal-item .cal-remove{background:none;border:none;color:var(--red);cursor:pointer;font-size:.85rem}
}
.config-section h3 { font-size: 0.95rem; margin-bottom: 1rem; }
.config-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem;
}
.config-field label {
display: block; font-size: 0.75rem; font-weight: 500; color: var(--text-dim);
text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 0.3rem;
}
.config-field input, .config-field select {
width: 100%; padding: 0.5rem 0.65rem; background: var(--surface);
border: 1px solid var(--border); border-radius: 6px; color: var(--text);
font-size: 0.85rem; outline: none;
}
.config-field input:focus, .config-field select:focus { border-color: var(--accent); }
.config-actions { display: flex; gap: 0.5rem; margin-top: 1rem; }
.btn-save {
padding: 0.5rem 1.25rem; background: var(--accent); color: #fff;
border: none; border-radius: 6px; font-size: 0.85rem; cursor: pointer;
}
.btn-save:hover { background: var(--accent-hover); }
.btn-secondary {
padding: 0.5rem 1.25rem; background: none; color: var(--text-dim);
border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem; cursor: pointer;
}
.btn-secondary:hover { border-color: var(--text-dim); color: var(--text); }
.save-msg { font-size: 0.8rem; color: var(--green); margin-left: 0.75rem; line-height: 2; }
.empty-state { text-align: center; padding: 3rem; color: var(--text-dim); } .empty-state{text-align:center;padding:3rem;color:var(--text-dim)}
.empty-state h3 { margin-bottom: 0.5rem; color: var(--text); } .empty-state h3{margin-bottom:.5rem;color:var(--text)}
.time-ago { color: var(--text-dim); } .time-ago{color:var(--text-dim)}
</style> </style>
</head> </head>
<body> <body>
<div class="header"> <div class="header">
<h1>Agent Command Center</h1> <h1>Agent Command Center</h1>
<div class="status"> <div class="header-right">
<div class="dot"></div> <div class="dot"></div>
<span id="agent-count">0 agents</span> <span id="agent-count">0 agents</span>
<button class="logout-btn" onclick="logout()">Logout</button> <span class="user-name" id="user-display"></span>
<button class="small-btn" id="admin-btn" style="display:none" onclick="location.href='/admin'">Admin</button>
<button class="small-btn" onclick="logout()">Logout</button>
</div> </div>
</div> </div>
<div class="container"> <div class="container">
<div class="section-header"><h2>Agents</h2></div> <div class="section-header">
<h2>My Agents</h2>
<button class="btn-secondary" onclick="showCatalog()">+ Add Agent</button>
</div>
<div class="agents-grid" id="agents-grid"></div> <div class="agents-grid" id="agents-grid"></div>
<button class="paused-toggle" id="paused-toggle" onclick="togglePaused()"></button> <button class="paused-toggle" id="paused-toggle" onclick="togglePaused()"></button>
@@ -186,247 +121,230 @@
</div> </div>
<script> <script>
const API = ''; const API='';
let showPaused = false; let showPausedState=false, allInstances=[], currentUser=null;
let allAgents = [];
function timeAgo(dateStr) { function timeAgo(s){if(!s)return'never';const d=new Date(s+(s.endsWith('Z')?'':'Z')),sec=Math.floor((new Date()-d)/1000);if(sec<60)return'just now';if(sec<3600)return Math.floor(sec/60)+'m ago';if(sec<86400)return Math.floor(sec/3600)+'h ago';return Math.floor(sec/86400)+'d ago'}
if (!dateStr) return 'never'; function formatTime(s){if(!s)return'-';return new Date(s+(s.endsWith('Z')?'':'Z')).toLocaleString()}
const d = new Date(dateStr + (dateStr.endsWith('Z') ? '' : 'Z')); function closeModal(){document.getElementById('modal-overlay').classList.remove('open')}
const sec = Math.floor((new Date() - d) / 1000); document.getElementById('modal-overlay').addEventListener('click',e=>{if(e.target===e.currentTarget)closeModal()});
if (sec < 60) return 'just now';
if (sec < 3600) return Math.floor(sec / 60) + 'm ago';
if (sec < 86400) return Math.floor(sec / 3600) + 'h ago';
return Math.floor(sec / 86400) + 'd ago';
}
function formatTime(dateStr) { async function logout(){await fetch(API+'/api/logout',{method:'POST'});location.href='/login'}
if (!dateStr) return '-';
return new Date(dateStr + (dateStr.endsWith('Z') ? '' : 'Z')).toLocaleString();
}
function togglePaused() { function togglePaused(){showPausedState=!showPausedState;renderInstances(allInstances)}
showPaused = !showPaused;
renderAgents(allAgents);
}
function renderAgents(agents) { function renderInstances(instances){
allAgents = agents; allInstances=instances;
const grid = document.getElementById('agents-grid'); const grid=document.getElementById('agents-grid'),toggle=document.getElementById('paused-toggle');
const toggle = document.getElementById('paused-toggle'); const active=instances.filter(a=>a.status!=='paused'),paused=instances.filter(a=>a.status==='paused');
const active = agents.filter(a => a.status !== 'paused'); document.getElementById('agent-count').textContent=active.length+' active';
const paused = agents.filter(a => a.status === 'paused');
document.getElementById('agent-count').textContent = active.length + ' active agent' + (active.length !== 1 ? 's' : ''); if(!instances.length){grid.innerHTML='<div class="empty-state"><h3>No agents enabled</h3><p>Click "+ Add Agent" to browse the catalog</p></div>';toggle.style.display='none';return}
if (agents.length === 0) { const visible=showPausedState?instances:active;
grid.innerHTML = '<div class="empty-state"><h3>No agents registered</h3></div>'; grid.innerHTML=visible.map(a=>`
toggle.style.display = 'none'; <div class="agent-card ${a.status==='paused'?'dimmed':''}" onclick="showInstance(${a.id})">
return; <div class="card-top"><h3>${a.name}</h3><span class="badge ${a.status}">${a.status}</span></div>
} <div class="desc">${a.catalog_id}</div>
const visible = showPaused ? agents : active;
grid.innerHTML = visible.map(a => `
<div class="agent-card ${a.status === 'paused' ? 'dimmed' : ''}" onclick="showAgent('${a.id}')">
<div class="card-top">
<h3>${a.name}</h3>
<span class="badge ${a.status}">${a.status}</span>
</div>
<div class="desc">${a.description}</div>
<div class="card-stats"> <div class="card-stats">
<span>Last: ${a.last_run ? timeAgo(a.last_run.started_at) : 'never'}</span> <span>Last: ${a.last_run?timeAgo(a.last_run.started_at):'never'}</span>
<span>${a.last_run ? '<span class="badge ' + a.last_run.status + '">' + a.last_run.status + '</span>' : ''}</span> ${a.last_run?`<span class="badge ${a.last_run.status}">${a.last_run.status}</span>`:''}
<span>${a.total_runs} runs</span> <span>${a.total_runs} runs</span>
<span>${a.success_streak > 0 ? a.success_streak + ' streak' : ''}</span> ${a.success_streak>0?`<span>${a.success_streak} streak</span>`:''}
</div> </div>
</div> </div>`).join('');
`).join('');
if (paused.length > 0) { if(paused.length){toggle.style.display='block';toggle.textContent=(showPausedState?'Hide':'Show')+' paused ('+paused.length+')'}else{toggle.style.display='none'}
toggle.style.display = 'block'; }
toggle.textContent = showPaused
? 'Hide paused agents (' + paused.length + ')' function renderRuns(runs){
: 'Show paused agents (' + paused.length + ')'; const c=document.getElementById('runs-container');
if(!runs.length){c.innerHTML='<div class="empty-state"><p>No runs yet</p></div>';return}
c.innerHTML=`<table class="runs-table"><thead><tr><th>Instance</th><th>Status</th><th>Started</th><th>Duration</th><th>Output</th></tr></thead><tbody>
${runs.map(r=>{let dur='-';if(r.started_at&&r.finished_at){const ms=new Date(r.finished_at+(r.finished_at.endsWith('Z')?'':'Z'))-new Date(r.started_at+(r.started_at.endsWith('Z')?'':'Z'));dur=ms<1000?ms+'ms':(ms/1000).toFixed(1)+'s'}
return`<tr><td>#${r.instance_id}</td><td><span class="badge ${r.status}">${r.status}</span></td><td class="time-ago">${timeAgo(r.started_at)}</td><td>${dur}</td><td class="output-preview">${r.output||r.error||'-'}</td></tr>`}).join('')}
</tbody></table>`}
// --- Dynamic config form from schema ---
function buildConfigForm(inst){
const cfg=inst.config||{}, schema=inst.config_schema||{}, fields=schema.fields||[];
let html=`<div class="config-section"><h3>Settings</h3><div class="config-grid">
<div class="config-field"><label>Schedule</label><input type="text" id="cfg-schedule" value="${inst.schedule}" placeholder="0 5 * * *"></div>
<div class="config-field"><label>Status</label><select id="cfg-status"><option value="active" ${inst.status==='active'?'selected':''}>Active</option><option value="paused" ${inst.status==='paused'?'selected':''}>Paused</option></select></div>`;
for(const f of fields){
if(f.type==='array')continue; // handled separately below
const val=getNestedVal(cfg,f.key)||'';
if(f.type==='select'){
html+=`<div class="config-field"><label>${f.label}</label><select id="cfg-${f.key.replace(/\./g,'-')}">${(f.options||[]).map(o=>`<option value="${o}" ${val===o?'selected':''}>${o}</option>`).join('')}</select></div>`;
} else { } else {
toggle.style.display = 'none'; const inputType=f.type==='number'?'number':f.type==='password'?'password':'text';
const step=f.step?`step="${f.step}"`:'';
html+=`<div class="config-field"><label>${f.label}</label><input type="${inputType}" ${step} id="cfg-${f.key.replace(/\./g,'-')}" value="${val}" placeholder="${f.placeholder||''}"></div>`;
} }
}
function renderRuns(runs) {
const container = document.getElementById('runs-container');
if (runs.length === 0) {
container.innerHTML = '<div class="empty-state"><p>No runs yet</p></div>';
return;
}
container.innerHTML = `
<table class="runs-table">
<thead><tr><th>Agent</th><th>Status</th><th>Started</th><th>Duration</th><th>Output</th></tr></thead>
<tbody>
${runs.map(r => {
let dur = '-';
if (r.started_at && r.finished_at) {
const ms = new Date(r.finished_at + (r.finished_at.endsWith('Z') ? '' : 'Z'))
- new Date(r.started_at + (r.started_at.endsWith('Z') ? '' : 'Z'));
dur = ms < 1000 ? ms + 'ms' : (ms / 1000).toFixed(1) + 's';
}
return `<tr>
<td>${r.agent_id}</td>
<td><span class="badge ${r.status}">${r.status}</span></td>
<td class="time-ago">${timeAgo(r.started_at)}</td>
<td>${dur}</td>
<td class="output-preview">${r.output || r.error || '-'}</td>
</tr>`;
}).join('')}
</tbody>
</table>`;
}
function buildConfigForm(agent) {
const cfg = agent.config || {};
const loc = cfg.location || {};
const hasLocation = !!cfg.location || agent.id.includes('briefing');
let html = `<div class="config-section">
<h3>Settings</h3>
<div class="config-grid">
<div class="config-field">
<label>Schedule</label>
<input type="text" id="cfg-schedule" value="${agent.schedule}" placeholder="0 5 * * * or manual">
</div>
<div class="config-field">
<label>Status</label>
<select id="cfg-status">
<option value="active" ${agent.status === 'active' ? 'selected' : ''}>Active</option>
<option value="paused" ${agent.status === 'paused' ? 'selected' : ''}>Paused</option>
</select>
</div>`;
if (hasLocation) {
html += `
<div class="config-field">
<label>City</label>
<input type="text" id="cfg-loc-name" value="${loc.name || ''}" placeholder="Providence">
</div>
<div class="config-field">
<label>State</label>
<input type="text" id="cfg-loc-state" value="${loc.state || ''}" placeholder="Utah">
</div>
<div class="config-field">
<label>Country</label>
<input type="text" id="cfg-loc-country" value="${loc.country || 'US'}" placeholder="US">
</div>
<div class="config-field">
<label>Latitude</label>
<input type="number" step="0.0001" id="cfg-loc-lat" value="${loc.lat || ''}" placeholder="41.7064">
</div>
<div class="config-field">
<label>Longitude</label>
<input type="number" step="0.0001" id="cfg-loc-lon" value="${loc.lon || ''}" placeholder="-111.8133">
</div>`;
} }
html += `</div> html+=`</div>`;
<div class="config-actions">
<button class="btn-save" onclick="saveConfig('${agent.id}', ${hasLocation})">Save</button> // Array fields (calendars)
for(const f of fields){
if(f.type!=='array')continue;
const items=getNestedVal(cfg,f.key)||[];
html+=`<h4 style="margin-top:1rem;margin-bottom:.5rem">${f.label}</h4><div id="array-${f.key}">`;
items.forEach((item,i)=>{html+=renderArrayItem(f,item,i)});
html+=`</div><button class="btn-secondary" style="margin-top:.5rem" onclick="addArrayItem('${f.key}')">+ Add ${f.label.replace(/s$/,'')}</button>`;
}
html+=`<div class="config-actions">
<button class="btn-save" onclick="saveInstanceConfig(${inst.id})">Save</button>
<button class="btn-danger" onclick="deleteInstance(${inst.id})">Delete</button>
<span class="save-msg" id="save-msg"></span> <span class="save-msg" id="save-msg"></span>
</div> </div></div>`;
</div>`;
return html; return html;
} }
async function saveConfig(agentId, hasLocation) { function renderArrayItem(field,item,idx){
const body = { let html=`<div class="cal-item" id="arr-item-${idx}"><div class="cal-header"><strong>${item.name||'New'}</strong><button class="cal-remove" onclick="removeArrayItem(${idx})">Remove</button></div><div class="config-grid">`;
schedule: document.getElementById('cfg-schedule').value, for(const sf of field.item_fields||[]){
status: document.getElementById('cfg-status').value, if(sf.show_when){const[k,v]=[Object.keys(sf.show_when)[0],Object.values(sf.show_when)[0]];if(item[k]!==v)continue}
}; const val=item[sf.key]||'';
if(sf.type==='select'){
if (hasLocation) { html+=`<div class="config-field"><label>${sf.label}</label><select class="arr-${sf.key}" data-idx="${idx}" onchange="arrayFieldChanged(${idx})">${(sf.options||[]).map(o=>`<option value="${o}" ${val===o?'selected':''}>${o}</option>`).join('')}</select></div>`;
body.config = {
location: {
name: document.getElementById('cfg-loc-name').value,
state: document.getElementById('cfg-loc-state').value,
country: document.getElementById('cfg-loc-country').value || 'US',
lat: parseFloat(document.getElementById('cfg-loc-lat').value) || 0,
lon: parseFloat(document.getElementById('cfg-loc-lon').value) || 0,
}
};
}
const res = await fetch(API + '/api/agents/' + agentId, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const msg = document.getElementById('save-msg');
if (res.ok) {
msg.textContent = 'Saved';
msg.style.color = 'var(--green)';
setTimeout(() => { msg.textContent = ''; }, 2000);
refresh();
} else { } else {
msg.textContent = 'Error saving'; const t=sf.type==='password'?'password':'text';
msg.style.color = 'var(--red)'; html+=`<div class="config-field"><label>${sf.label}</label><input type="${t}" class="arr-${sf.key}" data-idx="${idx}" value="${val}"></div>`;
} }
}
html+=`</div></div>`;return html;
} }
async function showAgent(id) { let _currentSchema=null,_currentArrayField=null,_currentArrayItems=[];
const res = await fetch(API + '/api/agents/' + id);
if (res.status === 401) { window.location.href = '/login'; return; } function addArrayItem(key){
const agent = await res.json(); const field=_currentSchema.fields.find(f=>f.key===key);if(!field)return;
const modal = document.getElementById('modal-content'); _currentArrayItems.push({name:'',type:'caldav',url:'',username:'',password:''});
modal.innerHTML = ` const container=document.getElementById('array-'+key);
container.innerHTML=_currentArrayItems.map((item,i)=>renderArrayItem(field,item,i)).join('');
}
function removeArrayItem(idx){
_currentArrayItems.splice(idx,1);
const field=_currentSchema.fields.find(f=>f.type==='array');if(!field)return;
document.getElementById('array-'+field.key).innerHTML=_currentArrayItems.map((item,i)=>renderArrayItem(field,item,i)).join('');
}
function arrayFieldChanged(idx){/* re-render item when type changes */
collectArrayItems();
const field=_currentSchema.fields.find(f=>f.type==='array');if(!field)return;
document.getElementById('array-'+field.key).innerHTML=_currentArrayItems.map((item,i)=>renderArrayItem(field,item,i)).join('');
}
function collectArrayItems(){
const field=_currentSchema?.fields?.find(f=>f.type==='array');if(!field)return[];
_currentArrayItems=_currentArrayItems.map((item,i)=>{
const updated={...item};
for(const sf of field.item_fields||[]){
const el=document.querySelector(`.arr-${sf.key}[data-idx="${i}"]`);
if(el)updated[sf.key]=el.value;
}
return updated;
});
return _currentArrayItems;
}
function getNestedVal(obj,path){return path.split('.').reduce((o,k)=>o&&o[k],obj)}
function setNestedVal(obj,path,val){const keys=path.split('.');let o=obj;for(let i=0;i<keys.length-1;i++){if(!o[keys[i]])o[keys[i]]={};o=o[keys[i]]}o[keys[keys.length-1]]=val}
async function saveInstanceConfig(id){
const schema=_currentSchema||{};const fields=schema.fields||[];
const config={};
for(const f of fields){
if(f.type==='array'){
collectArrayItems();
setNestedVal(config,f.key,_currentArrayItems);
continue;
}
const el=document.getElementById('cfg-'+f.key.replace(/\./g,'-'));
if(!el)continue;
let val=el.value;
if(f.type==='number')val=parseFloat(val)||0;
setNestedVal(config,f.key,val);
}
const body={schedule:document.getElementById('cfg-schedule').value,status:document.getElementById('cfg-status').value,config};
const res=await fetch(API+'/api/instances/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const msg=document.getElementById('save-msg');
if(res.ok){msg.textContent='Saved';msg.style.color='var(--green)';setTimeout(()=>msg.textContent='',2000);refresh()}
else{msg.textContent='Error';msg.style.color='var(--red)'}
}
async function deleteInstance(id){
if(!confirm('Delete this agent instance and all its runs?'))return;
await fetch(API+'/api/instances/'+id,{method:'DELETE'});
closeModal();refresh();
}
async function showInstance(id){
const res=await fetch(API+'/api/instances/'+id);
if(res.status===401){location.href='/login';return}
const inst=await res.json();
_currentSchema=inst.config_schema||{};
const arrayField=_currentSchema.fields?.find(f=>f.type==='array');
_currentArrayItems=arrayField?getNestedVal(inst.config||{},arrayField.key)||[]:[];
document.getElementById('modal-content').innerHTML=`
<button class="close-btn" onclick="closeModal()">&times;</button> <button class="close-btn" onclick="closeModal()">&times;</button>
<h2>${agent.name}</h2> <h2>${inst.name}</h2>
<div class="meta"> <div class="meta"><span class="badge ${inst.status}">${inst.status}</span> &nbsp; ${inst.catalog_id} &nbsp; Schedule: ${inst.schedule} &nbsp; Created: ${formatTime(inst.created_at)}</div>
<span class="badge ${agent.status}">${agent.status}</span> ${buildConfigForm(inst)}
&nbsp; Schedule: ${agent.schedule} &nbsp; Created: ${formatTime(agent.created_at)} <h3 style="margin-bottom:.75rem">Run History</h3>
</div> ${!inst.runs?.length?'<p style="color:var(--text-dim)">No runs yet</p>':
<p style="margin-bottom:1.5rem;color:var(--text-dim)">${agent.description}</p> inst.runs.map(r=>`<div style="border:1px solid var(--border);border-radius:8px;padding:.75rem;margin-bottom:.75rem">
${buildConfigForm(agent)} <div style="display:flex;justify-content:space-between;margin-bottom:.5rem"><span class="badge ${r.status}">${r.status}</span><span class="time-ago">${formatTime(r.started_at)}</span></div>
<h3 style="margin-bottom:0.75rem">Run History</h3> ${r.output?'<div class="run-output">'+r.output+'</div>':''}${r.error?'<div class="run-output" style="border-color:var(--red)">'+r.error+'</div>':''}
${agent.runs.length === 0 ? '<p style="color:var(--text-dim)">No runs yet</p>' : </div>`).join('')}`;
agent.runs.map(r => `
<div style="border:1px solid var(--border);border-radius:8px;padding:0.75rem;margin-bottom:0.75rem">
<div style="display:flex;justify-content:space-between;margin-bottom:0.5rem">
<span class="badge ${r.status}">${r.status}</span>
<span class="time-ago">${formatTime(r.started_at)}</span>
</div>
${r.output ? '<div class="run-output">' + r.output + '</div>' : ''}
${r.error ? '<div class="run-output" style="border-color:var(--red)">' + r.error + '</div>' : ''}
</div>
`).join('')
}`;
document.getElementById('modal-overlay').classList.add('open'); document.getElementById('modal-overlay').classList.add('open');
} }
function closeModal() { document.getElementById('modal-overlay').classList.remove('open'); } // --- Catalog ---
document.getElementById('modal-overlay').addEventListener('click', e => { async function showCatalog(){
if (e.target === e.currentTarget) closeModal(); const res=await fetch(API+'/api/catalog');
}); if(res.status===401){location.href='/login';return}
const catalog=await res.json();
async function logout() { document.getElementById('modal-content').innerHTML=`
await fetch(API + '/api/logout', {method: 'POST'}); <button class="close-btn" onclick="closeModal()">&times;</button>
window.location.href = '/login'; <h2>Agent Catalog</h2>
<p style="color:var(--text-dim);margin-bottom:1rem">Browse available agents and enable the ones you want.</p>
<div class="catalog-grid">
${catalog.map(c=>`<div class="catalog-card">
<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:.3rem"><h4>${c.name}</h4><span class="badge ${c.category}">${c.category}</span></div>
<div class="cat-desc">${c.description}${c.is_sub_agent?' (sub-agent)':''}</div>
<div class="cat-footer">
<span style="font-size:.75rem;color:var(--text-dim)">${c.supports_schedule?'Schedulable':'Manual/sub-agent'}</span>
${c.enabled?'<button class="btn-enable enabled">Enabled</button>':`<button class="btn-enable" onclick="enableAgent('${c.id}','${c.name}')">Enable</button>`}
</div>
</div>`).join('')}
</div>`;
document.getElementById('modal-overlay').classList.add('open');
} }
async function refresh() { async function enableAgent(catalogId,name){
try { const res=await fetch(API+'/api/instances',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({catalog_id:catalogId,name:name})});
const [agentsRes, runsRes] = await Promise.all([ if(res.ok){closeModal();refresh()}
fetch(API + '/api/agents'), }
fetch(API + '/api/runs?limit=25'),
]); async function refresh(){
if (agentsRes.status === 401 || runsRes.status === 401) { try{
window.location.href = '/login'; const[instRes,runsRes,meRes]=await Promise.all([fetch(API+'/api/instances'),fetch(API+'/api/runs?limit=25'),fetch(API+'/api/me')]);
return; if(instRes.status===401||runsRes.status===401){location.href='/login';return}
renderInstances(await instRes.json());renderRuns(await runsRes.json());
if(meRes.ok){
currentUser=await meRes.json();
document.getElementById('user-display').textContent=currentUser.display_name||currentUser.username;
document.getElementById('admin-btn').style.display=currentUser.role==='admin'?'inline-block':'none';
} }
renderAgents(await agentsRes.json()); }catch(e){console.error(e)}
renderRuns(await runsRes.json());
} catch (err) { console.error('Failed to fetch:', err); }
} }
refresh();setInterval(refresh,30000);
refresh();
setInterval(refresh, 30000);
</script> </script>
</body> </body>
</html> </html>