Initial commit: Agent Command Center dashboard + weather briefing agent

This commit is contained in:
2026-04-13 01:06:42 +00:00
commit c86eb9ccc5
8 changed files with 857 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir fastapi uvicorn sqlalchemy
COPY . .
EXPOSE 8550
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8550"]
+202
View File
@@ -0,0 +1,202 @@
from fastapi import FastAPI, Depends, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from pydantic import BaseModel
from datetime import datetime, timezone
from typing import Optional
import json
from database import get_db, init_db
from models import Agent, Run
app = FastAPI(title="Agent Command Center", version="1.0.0")
# --- Pydantic schemas ---
class AgentCreate(BaseModel):
id: str
name: str
description: str = ""
schedule: str = "manual"
class AgentUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
schedule: Optional[str] = None
status: Optional[str] = None
class RunCreate(BaseModel):
status: str = "running"
output: str = ""
error: str = ""
metadata: dict = {}
class RunUpdate(BaseModel):
status: Optional[str] = None
output: Optional[str] = None
error: Optional[str] = None
finished_at: Optional[str] = None
metadata: Optional[dict] = None
# --- API routes ---
@app.get("/api/health")
def health():
return {"status": "ok", "service": "agent-command-center"}
@app.get("/api/agents")
def list_agents(db: Session = Depends(get_db)):
agents = db.query(Agent).all()
result = []
for a in agents:
last_run = db.query(Run).filter(Run.agent_id == a.id).order_by(Run.started_at.desc()).first()
recent_runs = db.query(Run).filter(Run.agent_id == a.id).order_by(Run.started_at.desc()).limit(10).all()
success_streak = 0
for r in recent_runs:
if r.status == "success":
success_streak += 1
else:
break
result.append({
"id": a.id,
"name": a.name,
"description": a.description,
"schedule": a.schedule,
"status": a.status,
"created_at": a.created_at.isoformat() if a.created_at else None,
"last_run": {
"status": last_run.status,
"started_at": last_run.started_at.isoformat() if last_run.started_at else None,
"finished_at": last_run.finished_at.isoformat() if last_run.finished_at else None,
} if last_run else None,
"success_streak": success_streak,
"total_runs": db.query(Run).filter(Run.agent_id == a.id).count(),
})
return result
@app.post("/api/agents")
def create_agent(agent: AgentCreate, db: Session = Depends(get_db)):
existing = db.query(Agent).filter(Agent.id == agent.id).first()
if existing:
raise HTTPException(status_code=409, detail="Agent already exists")
new_agent = Agent(
id=agent.id,
name=agent.name,
description=agent.description,
schedule=agent.schedule,
)
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, 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,
"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}")
def update_agent(agent_id: str, update: AgentUpdate, db: Session = Depends(get_db)):
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent not found")
for field, value in update.model_dump(exclude_none=True).items():
setattr(agent, field, value)
db.commit()
return {"id": agent.id, "status": "updated"}
@app.post("/api/agents/{agent_id}/runs")
def create_run(agent_id: str, run: RunCreate, db: Session = Depends(get_db)):
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent not found")
new_run = Run(
agent_id=agent_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}
@app.put("/api/runs/{run_id}")
def update_run(run_id: int, update: RunUpdate, db: Session = Depends(get_db)):
run = db.query(Run).filter(Run.id == run_id).first()
if not run:
raise HTTPException(status_code=404, detail="Run not found")
if update.status is not None:
run.status = update.status
if update.output is not None:
run.output = update.output
if update.error is not None:
run.error = update.error
if update.metadata is not None:
run.metadata_ = update.metadata
if update.finished_at is not None:
run.finished_at = datetime.fromisoformat(update.finished_at)
elif update.status in ("success", "failed"):
run.finished_at = datetime.now(timezone.utc)
db.commit()
return {"id": run.id, "status": run.status}
@app.get("/api/runs")
def list_runs(limit: int = 50, db: Session = Depends(get_db)):
runs = db.query(Run).order_by(Run.started_at.desc()).limit(limit).all()
return [{
"id": r.id,
"agent_id": r.agent_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]
# --- Static files (frontend) ---
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/")
def root():
return FileResponse("static/index.html")
# --- Startup ---
@app.on_event("startup")
def startup():
init_db()
+23
View File
@@ -0,0 +1,23 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
import os
DB_PATH = os.environ.get("DB_PATH", "/app/data/agents.db")
engine = create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
Base.metadata.create_all(bind=engine)
+32
View File
@@ -0,0 +1,32 @@
from sqlalchemy import Column, String, Text, DateTime, Integer, ForeignKey, JSON
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
class Agent(Base):
__tablename__ = "agents"
id = Column(String, primary_key=True)
name = Column(String, nullable=False)
description = Column(Text, default="")
schedule = Column(String, default="manual")
status = Column(String, default="active")
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
runs = relationship("Run", back_populates="agent", order_by="Run.started_at.desc()")
class Run(Base):
__tablename__ = "runs"
id = Column(Integer, primary_key=True, autoincrement=True)
agent_id = Column(String, ForeignKey("agents.id"), nullable=False)
started_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
finished_at = Column(DateTime, nullable=True)
status = Column(String, default="running")
output = Column(Text, default="")
error = Column(Text, default="")
metadata_ = Column("metadata", JSON, default=dict)
agent = relationship("Agent", back_populates="runs")
+366
View File
@@ -0,0 +1,366 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Agent Command Center</title>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #232733;
--border: #2e3345;
--text: #e4e6ed;
--text-dim: #8b8fa3;
--accent: #6c5ce7;
--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;
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);
}
.container { max-width: 1200px; margin: 0 auto; padding: 1.5rem 2rem; }
/* Agent Cards */
.agents-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.agent-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem;
cursor: pointer;
transition: border-color 0.2s, transform 0.1s;
}
.agent-card:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.agent-card .card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.agent-card h3 { font-size: 1.05rem; font-weight: 600; }
.agent-card .desc { color: var(--text-dim); font-size: 0.85rem; margin-bottom: 1rem; }
.agent-card .card-stats {
display: flex;
gap: 1.25rem;
font-size: 0.8rem;
color: var(--text-dim);
}
.agent-card .card-stats span { display: flex; align-items: center; gap: 0.3rem; }
.badge {
display: inline-block;
padding: 0.15rem 0.6rem;
border-radius: 12px;
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); }
/* Section headers */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h2 { font-size: 1.1rem; font-weight: 600; }
/* Run History Table */
.runs-table {
width: 100%;
border-collapse: collapse;
background: var(--surface);
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--border);
}
.runs-table th {
text-align: left;
padding: 0.75rem 1rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim);
background: var(--surface2);
border-bottom: 1px solid var(--border);
}
.runs-table td {
padding: 0.65rem 1rem;
font-size: 0.85rem;
border-bottom: 1px solid var(--border);
}
.runs-table tr:last-child td { border-bottom: none; }
.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);
}
/* Detail Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 100;
justify-content: center;
align-items: flex-start;
padding-top: 5vh;
}
.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: 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);
}
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-dim);
}
.empty-state h3 { margin-bottom: 0.5rem; color: var(--text); }
.time-ago { color: var(--text-dim); }
</style>
</head>
<body>
<div class="header">
<h1>Agent Command Center</h1>
<div class="status">
<div class="dot"></div>
<span id="agent-count">0 agents</span>
</div>
</div>
<div class="container">
<div class="section-header">
<h2>Agents</h2>
</div>
<div class="agents-grid" id="agents-grid"></div>
<div class="section-header">
<h2>Recent Runs</h2>
</div>
<div id="runs-container"></div>
</div>
<div class="modal-overlay" id="modal-overlay">
<div class="modal" id="modal-content"></div>
</div>
<script>
const API = '';
function timeAgo(dateStr) {
if (!dateStr) return 'never';
const d = new Date(dateStr + (dateStr.endsWith('Z') ? '' : 'Z'));
const now = new Date();
const sec = Math.floor((now - 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';
}
function formatTime(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr + (dateStr.endsWith('Z') ? '' : 'Z'));
return d.toLocaleString();
}
function renderAgents(agents) {
const grid = document.getElementById('agents-grid');
document.getElementById('agent-count', agents.length + ' agent' + (agents.length !== 1 ? 's' : ''));
if (agents.length === 0) {
grid.innerHTML = '<div class="empty-state"><h3>No agents registered</h3><p>Register your first agent via the API</p></div>';
return;
}
grid.innerHTML = agents.map(a => `
<div class="agent-card" 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">
<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>
<span>${a.total_runs} runs</span>
<span>${a.success_streak > 0 ? a.success_streak + ' streak' : ''}</span>
</div>
</div>
`).join('');
}
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 s = new Date(r.started_at + (r.started_at.endsWith('Z') ? '' : 'Z'));
const e = new Date(r.finished_at + (r.finished_at.endsWith('Z') ? '' : 'Z'));
const ms = e - s;
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>
`;
}
async function showAgent(id) {
const res = await fetch(API + '/api/agents/' + id);
const agent = await res.json();
const modal = document.getElementById('modal-content');
modal.innerHTML = `
<button class="close-btn" onclick="closeModal()">&times;</button>
<h2>${agent.name}</h2>
<div class="meta">
<span class="badge ${agent.status}">${agent.status}</span>
&nbsp; Schedule: ${agent.schedule} &nbsp; Created: ${formatTime(agent.created_at)}
</div>
<p style="margin-bottom:1.5rem;color:var(--text-dim)">${agent.description}</p>
<h3 style="margin-bottom:0.75rem">Run History</h3>
${agent.runs.length === 0 ? '<p style="color:var(--text-dim)">No runs yet</p>' :
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');
}
function closeModal() {
document.getElementById('modal-overlay').classList.remove('open');
}
document.getElementById('modal-overlay').addEventListener('click', e => {
if (e.target === e.currentTarget) closeModal();
});
async function refresh() {
try {
const [agentsRes, runsRes] = await Promise.all([
fetch(API + '/api/agents'),
fetch(API + '/api/runs?limit=25'),
]);
renderAgents(await agentsRes.json());
renderRuns(await runsRes.json());
} catch (err) {
console.error('Failed to fetch:', err);
}
}
refresh();
setInterval(refresh, 30000);
</script>
</body>
</html>