1150 lines
43 KiB
Python
1150 lines
43 KiB
Python
from fastapi import FastAPI, Depends, HTTPException, Response, Cookie
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.responses import FileResponse, RedirectResponse
|
|
from sqlalchemy.orm import Session
|
|
from pydantic import BaseModel
|
|
from datetime import datetime, timezone, timedelta
|
|
from typing import Optional
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import secrets
|
|
import smtplib
|
|
from email.mime.text import MIMEText
|
|
from email.mime.multipart import MIMEMultipart
|
|
|
|
from database import get_db, init_db
|
|
from models import User, AgentCatalog, AgentInstance, Run, LLMProvider, Bridge, RouteLog
|
|
|
|
app = FastAPI(title="Agent Command Center", version="2026.04.12.01")
|
|
|
|
# --- Auth ---
|
|
|
|
SESSION_SECRET = os.environ.get("SESSION_SECRET", secrets.token_hex(32))
|
|
_sessions: dict[str, dict] = {} # token -> {user_id, username, role}
|
|
_magic_links: dict[str, dict] = {} # token -> {user_id, email, expires}
|
|
|
|
# SMTP Config
|
|
SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.gmail.com")
|
|
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
|
|
SMTP_USER = os.environ.get("SMTP_USER", "eric.jungbauer@gmail.com")
|
|
SMTP_PASS = os.environ.get("SMTP_PASS", "jozj oags ifqy auey")
|
|
SMTP_FROM = os.environ.get("SMTP_FROM", "eric.jungbauer@gmail.com")
|
|
APP_URL = os.environ.get("APP_URL", "https://agents.jfamily.io")
|
|
|
|
|
|
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] = {"user_id": user.id, "username": user.username, "role": user.role}
|
|
return token
|
|
|
|
|
|
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)) -> 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 # accepts username or email
|
|
password: str
|
|
|
|
class MagicLinkRequest(BaseModel):
|
|
email: 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
|
|
email: str = ""
|
|
password: str
|
|
display_name: str = ""
|
|
role: str = "user"
|
|
|
|
class UserUpdate(BaseModel):
|
|
display_name: Optional[str] = None
|
|
email: 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, db: Session = Depends(get_db)):
|
|
user = db.query(User).filter(
|
|
(User.username == creds.username) | (User.email == 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, "email": user.email or "", "role": user.role, "display_name": user.display_name}
|
|
|
|
|
|
@app.post("/api/logout")
|
|
def logout(response: Response, session: Optional[str] = Cookie(None)):
|
|
if session and session in _sessions:
|
|
del _sessions[session]
|
|
response.delete_cookie("session")
|
|
return {"status": "ok"}
|
|
|
|
|
|
def send_magic_email(email: str, token: str):
|
|
"""Send a magic link email via SMTP."""
|
|
link = f"{APP_URL}/auth/verify?token={token}"
|
|
html = f"""
|
|
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:2rem">
|
|
<h2 style="color:#6c5ce7">Agent Command Center</h2>
|
|
<p>Click the button below to sign in:</p>
|
|
<a href="{link}" style="display:inline-block;background:#6c5ce7;color:#fff;padding:12px 32px;
|
|
border-radius:6px;text-decoration:none;font-weight:500;margin:1rem 0">Sign In</a>
|
|
<p style="color:#888;font-size:13px">This link expires in 15 minutes. If you didn't request this, ignore this email.</p>
|
|
<p style="color:#888;font-size:12px">{APP_URL}</p>
|
|
</div>
|
|
"""
|
|
msg = MIMEMultipart("alternative")
|
|
msg["Subject"] = "Sign in to Agent Command Center"
|
|
msg["From"] = SMTP_FROM
|
|
msg["To"] = email
|
|
msg.attach(MIMEText(f"Sign in: {link}\n\nExpires in 15 minutes.", "plain"))
|
|
msg.attach(MIMEText(html, "html"))
|
|
|
|
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
|
|
server.starttls()
|
|
server.login(SMTP_USER, SMTP_PASS)
|
|
server.send_message(msg)
|
|
|
|
|
|
@app.post("/api/auth/magic-link")
|
|
def request_magic_link(data: MagicLinkRequest, db: Session = Depends(get_db)):
|
|
"""Send a magic link to the user's email."""
|
|
user = db.query(User).filter(User.email == data.email).first()
|
|
if not user:
|
|
# Don't reveal whether email exists — return success either way
|
|
return {"status": "ok", "message": "If that email is registered, a sign-in link has been sent."}
|
|
|
|
# Generate token
|
|
token = secrets.token_urlsafe(32)
|
|
_magic_links[token] = {
|
|
"user_id": user.id,
|
|
"email": user.email,
|
|
"expires": datetime.now(timezone.utc) + timedelta(minutes=15),
|
|
}
|
|
|
|
# Send email
|
|
try:
|
|
send_magic_email(user.email, token)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to send email: {e}")
|
|
|
|
return {"status": "ok", "message": "If that email is registered, a sign-in link has been sent."}
|
|
|
|
|
|
@app.get("/auth/verify")
|
|
def verify_magic_link(token: str, response: Response, db: Session = Depends(get_db)):
|
|
"""Verify a magic link token and log the user in."""
|
|
link_data = _magic_links.pop(token, None)
|
|
if not link_data:
|
|
return RedirectResponse("/login?error=invalid", status_code=302)
|
|
|
|
if datetime.now(timezone.utc) > link_data["expires"]:
|
|
return RedirectResponse("/login?error=expired", status_code=302)
|
|
|
|
user = db.query(User).filter(User.id == link_data["user_id"]).first()
|
|
if not user:
|
|
return RedirectResponse("/login?error=invalid", status_code=302)
|
|
|
|
# Create session
|
|
session_token = create_session(user)
|
|
resp = RedirectResponse("/", status_code=302)
|
|
resp.set_cookie("session", session_token, httponly=True, samesite="lax", max_age=86400 * 7)
|
|
return resp
|
|
|
|
|
|
@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()
|
|
llm = u.llm_config or {}
|
|
return {
|
|
"id": u.id, "username": u.username, "email": u.email or "",
|
|
"display_name": u.display_name, "role": u.role,
|
|
"has_llm": bool(llm.get("api_key")),
|
|
"llm_provider": llm.get("provider_type", ""),
|
|
"llm_model": llm.get("default_model", ""),
|
|
"created_at": u.created_at.isoformat() if u.created_at else None,
|
|
}
|
|
|
|
|
|
class UserLLMConfig(BaseModel):
|
|
provider_type: str = "" # anthropic, openai, litellm, ollama
|
|
api_url: str = ""
|
|
api_key: str = ""
|
|
default_model: str = ""
|
|
|
|
|
|
@app.get("/api/me/llm")
|
|
def get_my_llm(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
|
|
"""Get current user's LLM config."""
|
|
u = db.query(User).filter(User.id == user["user_id"]).first()
|
|
llm = u.llm_config or {}
|
|
return {
|
|
"provider_type": llm.get("provider_type", ""),
|
|
"api_url": llm.get("api_url", ""),
|
|
"api_key": "***" if llm.get("api_key") else "",
|
|
"default_model": llm.get("default_model", ""),
|
|
"configured": bool(llm.get("api_key")),
|
|
}
|
|
|
|
|
|
@app.put("/api/me/llm")
|
|
def update_my_llm(data: UserLLMConfig, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
|
|
"""Update current user's LLM config (bring your own LLM)."""
|
|
u = db.query(User).filter(User.id == user["user_id"]).first()
|
|
current = u.llm_config or {}
|
|
update = data.model_dump()
|
|
# Only update api_key if a real value was provided (not "***")
|
|
if update.get("api_key") == "***" or not update.get("api_key"):
|
|
update["api_key"] = current.get("api_key", "")
|
|
u.llm_config = update
|
|
db.commit()
|
|
return {"status": "updated"}
|
|
|
|
|
|
@app.delete("/api/me/llm")
|
|
def delete_my_llm(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
|
|
"""Remove user's LLM config (fall back to system default)."""
|
|
u = db.query(User).filter(User.id == user["user_id"]).first()
|
|
u.llm_config = {}
|
|
db.commit()
|
|
return {"status": "removed"}
|
|
|
|
|
|
@app.get("/api/users/{user_id}/llm")
|
|
def get_user_llm(user_id: int, db: Session = Depends(get_db)):
|
|
"""Internal: resolve LLM config for a user. Returns user's own config if set, otherwise system default."""
|
|
u = db.query(User).filter(User.id == user_id).first()
|
|
if not u:
|
|
raise HTTPException(status_code=404)
|
|
user_llm = u.llm_config or {}
|
|
if user_llm.get("api_key"):
|
|
return {
|
|
"source": "user",
|
|
"provider_type": user_llm.get("provider_type", ""),
|
|
"api_url": user_llm.get("api_url", ""),
|
|
"api_key": user_llm["api_key"],
|
|
"default_model": user_llm.get("default_model", ""),
|
|
}
|
|
# Fall back to system default
|
|
default = db.query(LLMProvider).filter(LLMProvider.is_default == True).first()
|
|
if default:
|
|
return {
|
|
"source": "system",
|
|
"provider_type": default.provider_type,
|
|
"api_url": default.api_url,
|
|
"api_key": default.api_key,
|
|
"default_model": default.default_model,
|
|
}
|
|
return {"source": "none", "provider_type": "", "api_url": "", "api_key": "", "default_model": ""}
|
|
|
|
|
|
# --- Health ---
|
|
|
|
@app.get("/api/health")
|
|
def health():
|
|
return {"status": "ok", "service": "agent-command-center", "version": "2026.04.12.01"}
|
|
|
|
|
|
# --- 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,
|
|
"requires_llm": e.requires_llm,
|
|
"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")
|
|
# Enforce LLM requirement
|
|
if catalog.requires_llm:
|
|
u = db.query(User).filter(User.id == user["user_id"]).first()
|
|
user_llm = u.llm_config or {} if u else {}
|
|
has_user_llm = bool(user_llm.get("api_key"))
|
|
has_system_llm = db.query(LLMProvider).filter(LLMProvider.is_default == True).first() is not None
|
|
if not has_user_llm and not has_system_llm:
|
|
raise HTTPException(status_code=400, detail="This agent requires an LLM provider. Configure one via the LLM button in the header.")
|
|
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": inst.id, "status": "created"}
|
|
|
|
|
|
@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:
|
|
# Must assign a NEW dict — SQLAlchemy won't detect in-place mutations on JSON columns
|
|
new_config = {**(inst.config or {}), **update.config}
|
|
inst.config = new_config
|
|
# Auto-rename instance if project_name is set (e.g. "Project Monitor - WSIT")
|
|
if new_config.get("project_name") and inst.catalog_id == "project-monitor":
|
|
inst.name = f"Project Monitor - {new_config['project_name']}"
|
|
from sqlalchemy.orm.attributes import flag_modified
|
|
flag_modified(inst, "config")
|
|
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"}
|
|
|
|
|
|
@app.post("/api/instances/{instance_id}/trigger")
|
|
def trigger_instance(instance_id: int, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
|
|
"""Trigger a manual run of an agent instance. Runs async via subprocess."""
|
|
inst = db.query(AgentInstance).filter(
|
|
AgentInstance.id == instance_id, AgentInstance.user_id == user["user_id"]
|
|
).first()
|
|
if not inst:
|
|
raise HTTPException(status_code=404)
|
|
|
|
# Determine which script to run based on catalog type and user
|
|
catalog_id = inst.catalog_id
|
|
u = db.query(User).filter(User.id == user["user_id"]).first()
|
|
|
|
import subprocess
|
|
agent_dir = "/app/agents"
|
|
env = {**dict(os.environ), "PYTHONPATH": agent_dir}
|
|
|
|
if catalog_id == "daily-briefing":
|
|
script_map = {"eric": "eric_briefing.py", "angela": "angela_briefing.py"}
|
|
script = script_map.get(u.username, None)
|
|
env_key = f"{u.username.upper().replace('.', '_')}_INSTANCE_ID"
|
|
env[env_key] = str(instance_id)
|
|
if script:
|
|
cmd = ["python3", f"{agent_dir}/{script}"]
|
|
else:
|
|
# Generic: run the engine directly with instance config
|
|
cmd = ["python3", "-c",
|
|
f"import sys; sys.path.insert(0, '{agent_dir}'); "
|
|
f"from daily_briefing import run; "
|
|
f"run({{'person': '{u.display_name}', 'agent_id': '{catalog_id}', "
|
|
f"'instance_id': {instance_id}, 'wiki_parent_doc_id': '', 'location': {{}}}})"]
|
|
subprocess.Popen(cmd, env=env, cwd=agent_dir)
|
|
return {"status": "triggered", "message": f"Running {catalog_id} for {u.display_name}"}
|
|
|
|
if catalog_id == "project-monitor":
|
|
# Write config to a temp file to avoid shell escaping issues
|
|
config_path = f"/tmp/pm_config_{instance_id}.json"
|
|
with open(config_path, "w") as f:
|
|
json.dump({"config": inst.config or {}, "user_id": user["user_id"], "instance_id": instance_id}, f)
|
|
cmd = ["python3", "-c",
|
|
f"import sys, json; sys.path.insert(0, '{agent_dir}'); "
|
|
f"d = json.load(open('{config_path}')); "
|
|
f"from project_monitor import run; "
|
|
f"run(d['config'], user_id=d['user_id'], instance_id=d['instance_id'])"]
|
|
subprocess.Popen(cmd, env=env, cwd=agent_dir)
|
|
return {"status": "triggered", "message": f"Running project monitor: {inst.name}"}
|
|
|
|
return {"status": "error", "message": f"Manual trigger not yet supported for {catalog_id}"}
|
|
|
|
|
|
# --- Internal endpoints (no auth, for agent scripts) ---
|
|
|
|
@app.get("/api/instances/by-user/{user_id}")
|
|
def get_user_instances(user_id: int, catalog_id: str = None, db: Session = Depends(get_db)):
|
|
"""Internal: get a user's instances, optionally filtered by catalog type."""
|
|
query = db.query(AgentInstance).filter(AgentInstance.user_id == user_id, AgentInstance.status == "active")
|
|
if catalog_id:
|
|
query = query.filter(AgentInstance.catalog_id == catalog_id)
|
|
instances = query.all()
|
|
return [{"id": i.id, "catalog_id": i.catalog_id, "name": i.name, "config": i.config or {}} for i in instances]
|
|
|
|
@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(
|
|
instance_id=instance_id,
|
|
user_id=inst.user_id,
|
|
status=run.status,
|
|
output=run.output,
|
|
error=run.error,
|
|
metadata_=run.metadata,
|
|
)
|
|
if run.status in ("success", "failed"):
|
|
new_run.finished_at = datetime.now(timezone.utc)
|
|
db.add(new_run)
|
|
db.commit()
|
|
return {"id": new_run.id, "status": new_run.status}
|
|
|
|
|
|
# --- Runs (user-scoped) ---
|
|
|
|
@app.get("/api/runs")
|
|
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, "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_,
|
|
} for r in runs]
|
|
|
|
|
|
# --- 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, "email": u.email or "",
|
|
"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,
|
|
email=data.email or None,
|
|
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.email is not None:
|
|
user.email = update.email or None
|
|
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"}
|
|
|
|
|
|
# --- Agent Router ---
|
|
|
|
class RouterRequest(BaseModel):
|
|
request: str
|
|
|
|
|
|
@app.get("/api/catalog/all")
|
|
def list_catalog_all(db: Session = Depends(get_db)):
|
|
"""Internal: full catalog for the router (no auth)."""
|
|
entries = db.query(AgentCatalog).all()
|
|
return [{
|
|
"id": e.id, "name": e.name, "description": e.description,
|
|
"category": e.category, "supports_schedule": e.supports_schedule,
|
|
"is_sub_agent": e.is_sub_agent, "requires_llm": e.requires_llm,
|
|
} for e in entries]
|
|
|
|
|
|
@app.post("/api/router")
|
|
def ask_router(data: RouterRequest, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
|
|
"""Route a natural language request to the best agent."""
|
|
import subprocess
|
|
import sys
|
|
|
|
# Call the router agent
|
|
agent_dir = "/app/agents"
|
|
config_path = f"/tmp/router_{user['user_id']}_{secrets.token_hex(4)}.json"
|
|
with open(config_path, "w") as f:
|
|
json.dump({"user_id": user["user_id"], "request": data.request}, f)
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["python3", "-c",
|
|
f"import sys, json; sys.path.insert(0, '{agent_dir}'); "
|
|
f"d = json.load(open('{config_path}')); "
|
|
f"from agent_router import route; "
|
|
f"r = route(d['user_id'], d['request']); "
|
|
f"print(json.dumps(r))"],
|
|
capture_output=True, text=True, timeout=120, cwd=agent_dir,
|
|
env={**dict(os.environ), "PYTHONPATH": agent_dir},
|
|
)
|
|
os.remove(config_path)
|
|
|
|
if result.returncode != 0:
|
|
raise RuntimeError(result.stderr[:500])
|
|
|
|
recommendation = json.loads(result.stdout.strip())
|
|
except Exception as e:
|
|
return {"error": str(e), "action": "not_possible", "reasoning": f"Router error: {e}"}
|
|
|
|
# Log the route
|
|
log = RouteLog(
|
|
user_id=user["user_id"],
|
|
request_text=data.request,
|
|
recommended_agent=recommendation.get("agent_name", ""),
|
|
action=recommendation.get("action", ""),
|
|
reasoning=recommendation.get("reasoning", ""),
|
|
outcome="pending",
|
|
metadata_={
|
|
"instance_id": recommendation.get("instance_id"),
|
|
"catalog_id": recommendation.get("catalog_id"),
|
|
"config": recommendation.get("config"),
|
|
"model": recommendation.get("model", ""),
|
|
"tokens_in": recommendation.get("tokens_in", 0),
|
|
"tokens_out": recommendation.get("tokens_out", 0),
|
|
},
|
|
)
|
|
db.add(log)
|
|
db.commit()
|
|
|
|
recommendation["route_id"] = log.id
|
|
return recommendation
|
|
|
|
|
|
@app.post("/api/router/{route_id}/accept")
|
|
def accept_route(route_id: int, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
|
|
"""Accept a router suggestion and execute it."""
|
|
log = db.query(RouteLog).filter(RouteLog.id == route_id, RouteLog.user_id == user["user_id"]).first()
|
|
if not log:
|
|
raise HTTPException(status_code=404)
|
|
|
|
log.outcome = "accepted"
|
|
meta = log.metadata_ or {}
|
|
action = log.action
|
|
instance_id = meta.get("instance_id")
|
|
|
|
# Execute the recommended action
|
|
if action == "run_existing" and instance_id:
|
|
# Trigger the instance
|
|
import subprocess
|
|
inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first()
|
|
if inst:
|
|
agent_dir = "/app/agents"
|
|
catalog_id = inst.catalog_id
|
|
u = db.query(User).filter(User.id == user["user_id"]).first()
|
|
env = {**dict(os.environ), "PYTHONPATH": agent_dir}
|
|
|
|
if catalog_id == "daily-briefing":
|
|
script_map = {"eric": "eric_briefing.py", "angela": "angela_briefing.py"}
|
|
script = script_map.get(u.username)
|
|
if script:
|
|
env_key = f"{u.username.upper().replace('.', '_')}_INSTANCE_ID"
|
|
env[env_key] = str(instance_id)
|
|
subprocess.Popen(["python3", f"{agent_dir}/{script}"], env=env, cwd=agent_dir)
|
|
elif catalog_id == "project-monitor":
|
|
config_path = f"/tmp/pm_config_{instance_id}.json"
|
|
with open(config_path, "w") as f:
|
|
json.dump({"config": inst.config or {}, "user_id": user["user_id"], "instance_id": instance_id}, f)
|
|
subprocess.Popen(
|
|
["python3", "-c",
|
|
f"import sys, json; sys.path.insert(0, '{agent_dir}'); "
|
|
f"d = json.load(open('{config_path}')); "
|
|
f"from project_monitor import run; "
|
|
f"run(d['config'], user_id=d['user_id'], instance_id=d['instance_id'])"],
|
|
env=env, cwd=agent_dir,
|
|
)
|
|
db.commit()
|
|
return {"status": "executing", "message": f"Running {inst.name}"}
|
|
|
|
elif action == "configure" and instance_id:
|
|
inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first()
|
|
if inst and meta.get("config"):
|
|
new_config = {**(inst.config or {}), **meta["config"]}
|
|
inst.config = new_config
|
|
from sqlalchemy.orm.attributes import flag_modified
|
|
flag_modified(inst, "config")
|
|
if new_config.get("project_name") and inst.catalog_id == "project-monitor":
|
|
inst.name = f"Project Monitor - {new_config['project_name']}"
|
|
db.commit()
|
|
return {"status": "configured", "message": f"Updated {inst.name}"}
|
|
|
|
elif action == "create_and_run":
|
|
catalog_id = meta.get("catalog_id")
|
|
catalog = db.query(AgentCatalog).filter(AgentCatalog.id == catalog_id).first()
|
|
if catalog:
|
|
config = {**(catalog.default_config or {}), **(meta.get("config") or {})}
|
|
inst = AgentInstance(
|
|
user_id=user["user_id"],
|
|
catalog_id=catalog_id,
|
|
name=meta.get("instance_name") or catalog.name,
|
|
config=config,
|
|
schedule="manual",
|
|
)
|
|
db.add(inst)
|
|
db.commit()
|
|
return {"status": "created", "message": f"Created and ready: {inst.name}", "instance_id": inst.id}
|
|
|
|
elif action == "info":
|
|
db.commit()
|
|
return {"status": "info", "message": log.reasoning}
|
|
|
|
db.commit()
|
|
return {"status": "accepted"}
|
|
|
|
|
|
@app.post("/api/router/{route_id}/reject")
|
|
def reject_route(route_id: int, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
|
|
"""Reject a router suggestion."""
|
|
log = db.query(RouteLog).filter(RouteLog.id == route_id, RouteLog.user_id == user["user_id"]).first()
|
|
if not log:
|
|
raise HTTPException(status_code=404)
|
|
log.outcome = "rejected"
|
|
db.commit()
|
|
return {"status": "rejected"}
|
|
|
|
|
|
# --- Bridge Management ---
|
|
|
|
class BridgeRegister(BaseModel):
|
|
user_id: int
|
|
api_key: str
|
|
bridge_url: str
|
|
hostname: str = ""
|
|
platform: str = "macos"
|
|
capabilities: list = []
|
|
|
|
|
|
class BridgeHeartbeat(BaseModel):
|
|
bridge_url: str = ""
|
|
capabilities: list = []
|
|
|
|
|
|
@app.post("/api/bridge/register")
|
|
def register_bridge(data: BridgeRegister, db: Session = Depends(get_db)):
|
|
"""Called by bridge installer to register with the dashboard."""
|
|
user = db.query(User).filter(User.id == data.user_id).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
# Remove existing bridge for this user (one bridge per user)
|
|
db.query(Bridge).filter(Bridge.user_id == data.user_id).delete()
|
|
bridge = Bridge(
|
|
user_id=data.user_id,
|
|
api_key=data.api_key,
|
|
bridge_url=data.bridge_url,
|
|
hostname=data.hostname,
|
|
platform=data.platform,
|
|
capabilities=data.capabilities,
|
|
status="online",
|
|
last_heartbeat=datetime.now(timezone.utc),
|
|
)
|
|
db.add(bridge)
|
|
db.commit()
|
|
return {"status": "registered", "bridge_id": bridge.id}
|
|
|
|
|
|
@app.post("/api/bridge/heartbeat")
|
|
def bridge_heartbeat(data: BridgeHeartbeat, request_api_key: str = None,
|
|
db: Session = Depends(get_db)):
|
|
"""Called periodically by the bridge to update its status and IP."""
|
|
# Auth via X-Bridge-Token header
|
|
from fastapi import Request
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.post("/api/bridge/{api_key}/heartbeat")
|
|
def bridge_heartbeat_keyed(api_key: str, data: BridgeHeartbeat, db: Session = Depends(get_db)):
|
|
"""Heartbeat endpoint keyed by bridge API key."""
|
|
bridge = db.query(Bridge).filter(Bridge.api_key == api_key).first()
|
|
if not bridge:
|
|
raise HTTPException(status_code=404, detail="Bridge not found")
|
|
if data.bridge_url:
|
|
bridge.bridge_url = data.bridge_url
|
|
if data.capabilities:
|
|
bridge.capabilities = data.capabilities
|
|
bridge.status = "online"
|
|
bridge.last_heartbeat = datetime.now(timezone.utc)
|
|
db.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.get("/api/bridge/me")
|
|
def get_my_bridge(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
|
|
"""Get the current user's bridge info."""
|
|
bridge = db.query(Bridge).filter(Bridge.user_id == user["user_id"]).first()
|
|
if not bridge:
|
|
return {"connected": False}
|
|
# Mark offline if no heartbeat in 10 minutes
|
|
if bridge.last_heartbeat:
|
|
hb = bridge.last_heartbeat.replace(tzinfo=timezone.utc) if bridge.last_heartbeat.tzinfo is None else bridge.last_heartbeat
|
|
age = (datetime.now(timezone.utc) - hb).total_seconds()
|
|
if age > 600:
|
|
bridge.status = "offline"
|
|
db.commit()
|
|
return {
|
|
"connected": True,
|
|
"bridge_url": bridge.bridge_url,
|
|
"hostname": bridge.hostname,
|
|
"platform": bridge.platform,
|
|
"capabilities": bridge.capabilities or [],
|
|
"status": bridge.status,
|
|
"last_heartbeat": bridge.last_heartbeat.isoformat() if bridge.last_heartbeat else None,
|
|
}
|
|
|
|
|
|
@app.delete("/api/bridge/me")
|
|
def disconnect_bridge(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
|
|
"""Disconnect the current user's bridge."""
|
|
db.query(Bridge).filter(Bridge.user_id == user["user_id"]).delete()
|
|
db.commit()
|
|
return {"status": "disconnected"}
|
|
|
|
|
|
@app.get("/api/users/{user_id}/bridge")
|
|
def get_user_bridge(user_id: int, db: Session = Depends(get_db)):
|
|
"""Internal endpoint for agents to get a user's bridge URL + auth."""
|
|
bridge = db.query(Bridge).filter(Bridge.user_id == user_id).first()
|
|
if not bridge or bridge.status == "offline":
|
|
return {"available": False}
|
|
return {
|
|
"available": True,
|
|
"bridge_url": bridge.bridge_url,
|
|
"api_key": bridge.api_key,
|
|
}
|
|
|
|
|
|
@app.get("/api/bridge/install-script")
|
|
def get_install_script(user: dict = Depends(require_auth), db: Session = Depends(get_db)):
|
|
"""Generate a personalized bridge installer script for the current user."""
|
|
user_id = user["user_id"]
|
|
username = user["username"]
|
|
api_key = secrets.token_urlsafe(32)
|
|
dashboard_url = "https://agents.jfamily.io"
|
|
|
|
script = f'''#!/bin/bash
|
|
# Agent Command Center — Mac Bridge Installer
|
|
# Generated for: {username} (user {user_id})
|
|
# Run this on your Mac to install the Apple Ecosystem Bridge.
|
|
|
|
set -e
|
|
echo "Installing Agent Command Center Mac Bridge for {username}..."
|
|
|
|
BRIDGE_DIR="$HOME/.local/apple-bridge"
|
|
mkdir -p "$BRIDGE_DIR"
|
|
|
|
# Download bridge.py
|
|
cat > "$BRIDGE_DIR/bridge.py" << 'BRIDGE_EOF'
|
|
BRIDGE_PLACEHOLDER
|
|
BRIDGE_EOF
|
|
|
|
# Create config
|
|
cat > "$BRIDGE_DIR/config.json" << 'CONFIG_EOF'
|
|
{{
|
|
"user_id": {user_id},
|
|
"username": "{username}",
|
|
"api_key": "{api_key}",
|
|
"dashboard_url": "{dashboard_url}",
|
|
"port": {8551 + user_id}
|
|
}}
|
|
CONFIG_EOF
|
|
|
|
# Create launcher
|
|
cat > "$BRIDGE_DIR/start-bridge.sh" << 'LAUNCHER_EOF'
|
|
#!/bin/bash
|
|
exec /opt/homebrew/bin/python3 "$HOME/.local/apple-bridge/bridge.py"
|
|
LAUNCHER_EOF
|
|
chmod +x "$BRIDGE_DIR/start-bridge.sh"
|
|
|
|
# Create launchd plist
|
|
PLIST="$HOME/Library/LaunchAgents/com.jfamily.apple-bridge.plist"
|
|
cat > "$PLIST" << PLIST_EOF
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Label</key>
|
|
<string>com.jfamily.apple-bridge</string>
|
|
<key>ProgramArguments</key>
|
|
<array>
|
|
<string>$BRIDGE_DIR/start-bridge.sh</string>
|
|
</array>
|
|
<key>RunAtLoad</key>
|
|
<true/>
|
|
<key>KeepAlive</key>
|
|
<true/>
|
|
<key>StandardOutPath</key>
|
|
<string>/tmp/apple-bridge.log</string>
|
|
<key>StandardErrorPath</key>
|
|
<string>/tmp/apple-bridge.err</string>
|
|
</dict>
|
|
</plist>
|
|
PLIST_EOF
|
|
|
|
# Install Python dependencies
|
|
echo "Installing Python dependencies..."
|
|
/opt/homebrew/bin/pip3 install --break-system-packages -q fastapi uvicorn 2>/dev/null || pip3 install --break-system-packages -q fastapi uvicorn
|
|
|
|
# Start the bridge
|
|
echo "Starting bridge..."
|
|
launchctl unload "$PLIST" 2>/dev/null
|
|
launchctl load "$PLIST"
|
|
sleep 3
|
|
|
|
# Register with dashboard
|
|
BRIDGE_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "unknown")
|
|
BRIDGE_PORT={8551 + user_id}
|
|
HOSTNAME=$(hostname)
|
|
|
|
echo "Registering with dashboard..."
|
|
curl -s -X POST "{dashboard_url}/api/bridge/register" \\
|
|
-H "Content-Type: application/json" \\
|
|
-d '{{"user_id": {user_id}, "api_key": "{api_key}", "bridge_url": "http://'$BRIDGE_IP':'$BRIDGE_PORT'", "hostname": "'$HOSTNAME'", "platform": "macos", "capabilities": ["notes", "reading-list"]}}'
|
|
|
|
echo ""
|
|
echo "===================================="
|
|
echo " Mac Bridge installed for {username}!"
|
|
echo " Bridge URL: http://$BRIDGE_IP:$BRIDGE_PORT"
|
|
echo " Dashboard: {dashboard_url}"
|
|
echo "===================================="
|
|
'''
|
|
from fastapi.responses import PlainTextResponse
|
|
return PlainTextResponse(script, media_type="text/x-shellscript",
|
|
headers={"Content-Disposition": f"attachment; filename=install-bridge-{username}.sh"})
|
|
|
|
|
|
# Admin: Bridge overview
|
|
@app.get("/api/admin/bridges")
|
|
def admin_list_bridges(admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
|
|
bridges = db.query(Bridge).all()
|
|
return [{
|
|
"id": b.id,
|
|
"user_id": b.user_id,
|
|
"username": db.query(User).filter(User.id == b.user_id).first().username if db.query(User).filter(User.id == b.user_id).first() else "?",
|
|
"bridge_url": b.bridge_url,
|
|
"hostname": b.hostname,
|
|
"platform": b.platform,
|
|
"capabilities": b.capabilities or [],
|
|
"status": b.status,
|
|
"last_heartbeat": b.last_heartbeat.isoformat() if b.last_heartbeat else None,
|
|
} for b in bridges]
|
|
|
|
|
|
# --- 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)
|
|
if not user:
|
|
return RedirectResponse("/login", status_code=302)
|
|
return FileResponse("static/index.html")
|
|
|
|
|
|
# --- Startup ---
|
|
|
|
@app.on_event("startup")
|
|
def startup():
|
|
init_db()
|