diff --git a/dashboard/app.py b/dashboard/app.py index d5ccde7..a5557d0 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -11,7 +11,7 @@ import os import secrets from database import get_db, init_db -from models import User, AgentCatalog, AgentInstance, Run, LLMProvider +from models import User, AgentCatalog, AgentInstance, Run, LLMProvider, Bridge app = FastAPI(title="Agent Command Center", version="2026.04.12.01") @@ -479,6 +479,230 @@ def admin_delete_catalog(catalog_id: str, admin: dict = Depends(require_admin), return {"status": "deleted"} +# --- 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: + age = (datetime.now(timezone.utc) - bridge.last_heartbeat).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 + + + + + Label + com.jfamily.apple-bridge + ProgramArguments + + $BRIDGE_DIR/start-bridge.sh + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/apple-bridge.log + StandardErrorPath + /tmp/apple-bridge.err + + +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") diff --git a/dashboard/models.py b/dashboard/models.py index 621e7a1..142072c 100644 --- a/dashboard/models.py +++ b/dashboard/models.py @@ -65,6 +65,23 @@ class Run(Base): instance = relationship("AgentInstance", back_populates="runs") +class Bridge(Base): + __tablename__ = "bridges" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + api_key = Column(String, nullable=False) # Auth token for bridge requests + bridge_url = Column(String, default="") # http://ip:port + hostname = Column(String, default="") # e.g. "Jungbauers-MBP" + platform = Column(String, default="macos") # macos, ios (future) + capabilities = Column(JSON, default=list) # ["notes", "reading-list"] + status = Column(String, default="offline") # online, offline + last_heartbeat = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + user = relationship("User") + + class LLMProvider(Base): __tablename__ = "llm_providers" diff --git a/dashboard/static/admin.html b/dashboard/static/admin.html index 909749e..2f2bbe2 100644 --- a/dashboard/static/admin.html +++ b/dashboard/static/admin.html @@ -64,6 +64,7 @@ tr:hover td{background:var(--surface2)}
Users
Agent Catalog
LLM Providers
+
Bridges
System
@@ -126,6 +127,11 @@ tr:hover td{background:var(--surface2)}
NameTypeURLModelDefaultActions
+ +
+
UserHostnameURLPlatformStatusLast HeartbeatCapabilities
+
+
@@ -203,6 +209,24 @@ async function createProvider(){ } async function deleteProvider(id){if(!confirm('Delete this provider?'))return;await fetch(API+'/api/admin/llm-providers/'+id,{method:'DELETE'});loadProviders()} +// --- Bridges --- +async function loadBridges(){ + const res=await fetch(API+'/api/admin/bridges'); + if(!res.ok)return; + const bridges=await res.json(); + const tbody=document.querySelector('#bridges-table tbody'); + if(!bridges.length){tbody.innerHTML='No bridges connected';return} + tbody.innerHTML=bridges.map(b=>` + ${b.username} + ${b.hostname||'-'} + ${b.bridge_url||'-'} + ${b.platform} + ${b.status} + ${b.last_heartbeat||'-'} + ${(b.capabilities||[]).join(', ')} + `).join(''); +} + // --- System --- async function loadSystem(){ const[usersRes,instRes]=await Promise.all([fetch(API+'/api/admin/users'),fetch(API+'/api/health')]); @@ -215,7 +239,7 @@ async function loadSystem(){ } // Init -loadUsers();loadCatalog();loadProviders();loadSystem(); +loadUsers();loadCatalog();loadProviders();loadBridges();loadSystem(); diff --git a/dashboard/static/index.html b/dashboard/static/index.html index bf6145c..da78910 100644 --- a/dashboard/static/index.html +++ b/dashboard/static/index.html @@ -87,6 +87,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b .cal-item .cal-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.5rem} .cal-item .cal-remove{background:none;border:none;color:var(--red);cursor:pointer;font-size:.85rem} +.bridge-bar{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1rem 1.25rem;margin-bottom:1.5rem;display:flex;justify-content:space-between;align-items:center} +.bridge-bar .bridge-info{display:flex;align-items:center;gap:.75rem;font-size:.85rem} +.bridge-bar .bridge-dot{width:8px;height:8px;border-radius:50%} +.bridge-bar .bridge-dot.online{background:var(--green)} +.bridge-bar .bridge-dot.offline{background:var(--red)} +.bridge-bar .bridge-actions{display:flex;gap:.5rem} + .empty-state{text-align:center;padding:3rem;color:var(--text-dim)} .empty-state h3{margin-bottom:.5rem;color:var(--text)} .time-ago{color:var(--text-dim)} @@ -105,6 +112,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
+

My Agents

@@ -332,11 +340,51 @@ async function enableAgent(catalogId,name){ if(res.ok){closeModal();refresh()} } +// --- Bridge --- +async function loadBridge(){ + try{ + const res=await fetch(API+'/api/bridge/me'); + if(!res.ok)return; + const b=await res.json(); + const el=document.getElementById('bridge-bar'); + if(!b.connected){ + el.innerHTML=`
+
Mac Bridge: Not installed
+
+
`; + } else { + const ago=timeAgo(b.last_heartbeat); + el.innerHTML=`
+
+
+ Mac Bridge: ${b.status} on ${b.hostname||'unknown'} + ${b.bridge_url} · heartbeat ${ago} + ${(b.capabilities||[]).join(', ')} +
+
+ + +
+
`; + } + }catch(e){console.error('Bridge check failed:',e)} +} + +async function installBridge(){ + window.open(API+'/api/bridge/install-script','_blank'); +} + +async function disconnectBridge(){ + if(!confirm('Disconnect your Mac Bridge? You can reinstall later.'))return; + await fetch(API+'/api/bridge/me',{method:'DELETE'}); + loadBridge(); +} + async function refresh(){ try{ const[instRes,runsRes,meRes]=await Promise.all([fetch(API+'/api/instances'),fetch(API+'/api/runs?limit=25'),fetch(API+'/api/me')]); if(instRes.status===401||runsRes.status===401){location.href='/login';return} - renderInstances(await instRes.json());renderRuns(await runsRes.json()); + renderInstances(await instRes.json());renderRuns(await runsRes.json());loadBridge(); if(meRes.ok){ currentUser=await meRes.json(); document.getElementById('user-display').textContent=currentUser.display_name||currentUser.username;