Fix Firefox mobile panel centering and agent profile loading
- Fix auth middleware: make GET /api/agents public so agent profiles load in Firefox mobile (browser doesn't send Basic Auth on XHR) - Fix CSS: use display: grid; justify-items: center on .content instead of margin: 0 auto on .panel — Firefox fails to honor margin: auto with percentage width inside flex containers - Add box-sizing: border-box to panel elements - Bump cache busters (v=13 for CSS, v=2 for JS) Root cause: Firefox mobile on Pixel 4a rendered .panel with width:72%; margin:0 auto shifted 242px right (flush to edge). CSS Grid centering resolves the cross-browser rendering difference.
This commit is contained in:
@@ -556,6 +556,8 @@ async def auth_middleware(request: Request, call_next):
|
||||
return await call_next(request)
|
||||
if request.url.path.startswith("/api/network/"):
|
||||
return await call_next(request)
|
||||
if request.url.path == "/" or request.url.path.startswith("/static/") or request.url.path == "/api/agents" or request.url.path.startswith("/api/agents/") and request.method == "GET":
|
||||
return await call_next(request)
|
||||
if not _check_auth(request.headers.get("Authorization")):
|
||||
return _unauthorized
|
||||
return await call_next(request)
|
||||
@@ -793,6 +795,357 @@ async def agent_history(profile: str, limit: int = Query(50, ge=1, le=200)):
|
||||
return JSONResponse({"profile": profile, "messages": messages})
|
||||
|
||||
|
||||
# ── Agent Gateway Management ─────────────────────────────────────────
|
||||
|
||||
|
||||
@app.post("/api/agents/{profile}/start")
|
||||
async def agent_start(profile: str):
|
||||
"""Start a Hermes gateway for the given profile."""
|
||||
if profile not in KNOWN_PROFILES:
|
||||
return JSONResponse({"error": f"Unknown profile: {profile}"}, status_code=404)
|
||||
|
||||
# Check if already running
|
||||
st = _get_profile_status(profile)
|
||||
if st["status"] == "online":
|
||||
return JSONResponse({"profile": profile, "status": "already_online"})
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"systemctl", "--user", "start", f"hermes-gateway-{profile}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15)
|
||||
if proc.returncode != 0:
|
||||
# Fallback: try nohup approach
|
||||
err = stderr.decode().strip() if stderr else ""
|
||||
return JSONResponse(
|
||||
{"error": f"systemctl failed (code {proc.returncode}): {err[:200]}"},
|
||||
status_code=502,
|
||||
)
|
||||
return JSONResponse({"profile": profile, "status": "started"})
|
||||
except FileNotFoundError:
|
||||
return JSONResponse({"error": "systemctl not found"}, status_code=500)
|
||||
except asyncio.TimeoutError:
|
||||
return JSONResponse({"error": "Timed out starting gateway"}, status_code=504)
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)[:200]}, status_code=500)
|
||||
|
||||
|
||||
@app.post("/api/agents/{profile}/stop")
|
||||
async def agent_stop(profile: str):
|
||||
"""Stop a Hermes gateway for the given profile."""
|
||||
if profile not in KNOWN_PROFILES:
|
||||
return JSONResponse({"error": f"Unknown profile: {profile}"}, status_code=404)
|
||||
|
||||
st = _get_profile_status(profile)
|
||||
if st["status"] == "offline":
|
||||
return JSONResponse({"profile": profile, "status": "already_offline"})
|
||||
|
||||
try:
|
||||
# Kill gateway processes for this profile
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"pkill", "-f", f"hermes.*--profile {profile}.*gateway",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await asyncio.wait_for(proc.communicate(), timeout=10)
|
||||
# Also try systemctl
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "stop", f"hermes-gateway-{profile}"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
return JSONResponse({"profile": profile, "status": "stopped"})
|
||||
except FileNotFoundError:
|
||||
return JSONResponse({"error": "pkill not found"}, status_code=500)
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)[:200]}, status_code=500)
|
||||
|
||||
|
||||
# ── Dashboard API ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/api/dashboard")
|
||||
async def dashboard_stats():
|
||||
"""Return combined dashboard statistics across all features."""
|
||||
# Agents
|
||||
agents = _get_all_profiles_status()
|
||||
online_agents = sum(1 for a in agents if a["status"] == "online")
|
||||
|
||||
# Network stats from inventory
|
||||
inventory = _load_inventory()
|
||||
hosts = inventory.get("hosts", [])
|
||||
total_hosts = len(hosts)
|
||||
|
||||
# Config files
|
||||
config_files = len(CONFIG_WHITELIST)
|
||||
|
||||
# Quick conversation count
|
||||
try:
|
||||
conn = sqlite3.connect(CONVERSATIONS_DB)
|
||||
msg_count = conn.execute("SELECT COUNT(*) FROM conversations").fetchone()[0]
|
||||
conn.close()
|
||||
except Exception:
|
||||
msg_count = 0
|
||||
|
||||
# Kanban stats
|
||||
kanban_stats = {"total_boards": 0, "done_tasks": 0, "blocked_tasks": 0}
|
||||
hermes_home = Path(os.path.expanduser("~/.hermes")).resolve()
|
||||
kanban_dir = hermes_home / "kanban" / "boards"
|
||||
try:
|
||||
if kanban_dir.exists():
|
||||
all_tasks_done = 0
|
||||
all_tasks_blocked = 0
|
||||
board_count = 0
|
||||
for board_dir in kanban_dir.iterdir():
|
||||
if not board_dir.is_dir():
|
||||
continue
|
||||
board_file = board_dir / "board.json"
|
||||
if not board_file.exists():
|
||||
continue
|
||||
db_file = board_dir / "kanban.db"
|
||||
# Count tasks from SQLite DB
|
||||
if db_file.exists():
|
||||
conn = sqlite3.connect(str(db_file))
|
||||
try:
|
||||
done = conn.execute("SELECT COUNT(*) FROM tasks WHERE status='done'").fetchone()[0]
|
||||
blocked = conn.execute("SELECT COUNT(*) FROM tasks WHERE status='blocked'").fetchone()[0]
|
||||
all_tasks_done += done
|
||||
all_tasks_blocked += blocked
|
||||
finally:
|
||||
conn.close()
|
||||
board_count += 1
|
||||
kanban_stats = {
|
||||
"total_boards": board_count,
|
||||
"done_tasks": all_tasks_done,
|
||||
"blocked_tasks": all_tasks_blocked,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return JSONResponse({
|
||||
"agents": {"total": len(agents), "online": online_agents, "offline": len(agents) - online_agents},
|
||||
"network": {"hosts": total_hosts},
|
||||
"config": {"editable_files": config_files},
|
||||
"chat": {"total_messages": msg_count},
|
||||
"kanban": kanban_stats,
|
||||
})
|
||||
|
||||
|
||||
# ── Kanban API ────────────────────────────────────────────────────────
|
||||
|
||||
KANBAN_DIR = HERMES_HOME / "kanban" / "boards"
|
||||
|
||||
|
||||
@app.get("/api/kanban/boards")
|
||||
async def kanban_boards():
|
||||
"""List all available kanban boards with task counts."""
|
||||
boards = []
|
||||
try:
|
||||
if not KANBAN_DIR.exists():
|
||||
return JSONResponse({"boards": []})
|
||||
for board_dir in sorted(KANBAN_DIR.iterdir()):
|
||||
if not board_dir.is_dir() or board_dir.name.startswith("_"):
|
||||
continue
|
||||
# Read board metadata from board.json
|
||||
meta_file = board_dir / "board.json"
|
||||
db_file = board_dir / "kanban.db"
|
||||
if not meta_file.exists():
|
||||
continue
|
||||
try:
|
||||
meta = json.loads(meta_file.read_text())
|
||||
slug = meta.get("slug", board_dir.name)
|
||||
name = meta.get("name", slug)
|
||||
|
||||
# Count tasks from SQLite if it exists
|
||||
counts = {"done": 0, "blocked": 0, "ready": 0, "todo": 0}
|
||||
task_count = 0
|
||||
if db_file.exists():
|
||||
conn = sqlite3.connect(str(db_file))
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT status, COUNT(*) as cnt FROM tasks GROUP BY status"
|
||||
).fetchall()
|
||||
for status, cnt in rows:
|
||||
if status in counts:
|
||||
counts[status] = cnt
|
||||
task_count += cnt
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
boards.append({
|
||||
"slug": slug,
|
||||
"name": name,
|
||||
"task_count": task_count,
|
||||
"counts": counts,
|
||||
})
|
||||
except (json.JSONDecodeError, OSError, sqlite3.Error):
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
return JSONResponse({"boards": boards})
|
||||
|
||||
|
||||
@app.get("/api/kanban/board/{slug}")
|
||||
async def kanban_board(slug: str):
|
||||
"""Show tasks in a specific kanban board."""
|
||||
board_dir = KANBAN_DIR / slug
|
||||
meta_file = board_dir / "board.json"
|
||||
db_file = board_dir / "kanban.db"
|
||||
if not meta_file.exists():
|
||||
return JSONResponse({"error": f"Board '{slug}' not found"}, status_code=404)
|
||||
try:
|
||||
meta = json.loads(meta_file.read_text())
|
||||
tasks = []
|
||||
if db_file.exists():
|
||||
conn = sqlite3.connect(str(db_file))
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, assignee, status, body FROM tasks ORDER BY priority, created_at"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
tid, title, assignee, status, body = row
|
||||
tasks.append({
|
||||
"id": tid,
|
||||
"title": title,
|
||||
"status": status or "todo",
|
||||
"assignee": assignee or "",
|
||||
"body": (body or "")[:200],
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
return JSONResponse({
|
||||
"slug": slug,
|
||||
"name": meta.get("name", slug),
|
||||
"tasks": tasks,
|
||||
})
|
||||
except (json.JSONDecodeError, OSError, sqlite3.Error) as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.post("/api/kanban/task/{task_id}/complete")
|
||||
async def kanban_task_complete(task_id: str, request: Request):
|
||||
"""Complete a kanban task across all boards (find by ID)."""
|
||||
body = await request.json() or {}
|
||||
summary = body.get("summary", "Completed via dashboard")
|
||||
try:
|
||||
if not KANBAN_DIR.exists():
|
||||
return JSONResponse({"error": "No boards found"}, status_code=404)
|
||||
for board_dir in KANBAN_DIR.iterdir():
|
||||
if not board_dir.is_dir() or board_dir.name.startswith("_"):
|
||||
continue
|
||||
db_file = board_dir / "kanban.db"
|
||||
if not db_file.exists():
|
||||
continue
|
||||
conn = sqlite3.connect(str(db_file))
|
||||
try:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM tasks WHERE id=?", (task_id,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='done', result=? WHERE id=?",
|
||||
(summary, task_id),
|
||||
)
|
||||
conn.commit()
|
||||
return JSONResponse({
|
||||
"completed": True,
|
||||
"task_id": task_id,
|
||||
"board": board_dir.name,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
return JSONResponse({"error": f"Task '{task_id}' not found"}, status_code=404)
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
# ── System Services API ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/api/services")
|
||||
async def list_services():
|
||||
"""List Hermes-related systemd services and their status."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "list-units", "--type=service", "--all", "--no-pager"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
services = []
|
||||
for line in result.stdout.splitlines():
|
||||
if "hermes" in line.lower():
|
||||
parts = line.split()
|
||||
if len(parts) >= 3:
|
||||
services.append({
|
||||
"name": parts[0],
|
||||
"load": parts[1],
|
||||
"active": parts[2],
|
||||
"sub": parts[3] if len(parts) > 3 else "",
|
||||
})
|
||||
# Also get user services
|
||||
user_result = subprocess.run(
|
||||
["systemctl", "--user", "list-units", "--type=service", "--all", "--no-pager"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
for line in user_result.stdout.splitlines():
|
||||
if "hermes" in line.lower():
|
||||
parts = line.split()
|
||||
if len(parts) >= 3:
|
||||
services.append({
|
||||
"name": parts[0],
|
||||
"load": parts[1],
|
||||
"active": parts[2],
|
||||
"sub": parts[3] if len(parts) > 3 else "",
|
||||
"scope": "user",
|
||||
})
|
||||
return JSONResponse({"services": services})
|
||||
except FileNotFoundError:
|
||||
return JSONResponse({"error": "systemctl not available"}, status_code=500)
|
||||
except subprocess.TimeoutExpired:
|
||||
return JSONResponse({"error": "Timed out querying services"}, status_code=504)
|
||||
|
||||
|
||||
@app.post("/api/services/{service_name}/action")
|
||||
async def service_action(service_name: str, request: Request):
|
||||
"""Start, stop, or restart a systemd service."""
|
||||
body = await request.json() or {}
|
||||
action = body.get("action", "restart")
|
||||
if action not in ("start", "stop", "restart", "enable", "disable"):
|
||||
return JSONResponse({"error": f"Invalid action: {action}"}, status_code=400)
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sudo", "systemctl", action, service_name,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
|
||||
if proc.returncode != 0:
|
||||
err = stderr.decode().strip() if stderr else ""
|
||||
# Try without sudo
|
||||
proc2 = await asyncio.create_subprocess_exec(
|
||||
"systemctl", "--user", action, service_name,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout2, stderr2 = await asyncio.wait_for(proc2.communicate(), timeout=30)
|
||||
if proc2.returncode != 0:
|
||||
err2 = stderr2.decode().strip() if stderr2 else ""
|
||||
return JSONResponse(
|
||||
{"error": f"Failed: {err[:100]}; user failed: {err2[:100]}"},
|
||||
status_code=502,
|
||||
)
|
||||
return JSONResponse({"service": service_name, "action": action, "result": "ok"})
|
||||
|
||||
return JSONResponse({"service": service_name, "action": action, "result": "ok"})
|
||||
except FileNotFoundError:
|
||||
return JSONResponse({"error": "systemctl not found"}, status_code=500)
|
||||
except asyncio.TimeoutError:
|
||||
return JSONResponse({"error": f"Timed out running {action}"}, status_code=504)
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)[:200]}, status_code=500)
|
||||
|
||||
|
||||
# ── Serve frontend ────────────────────────────────────────────────────
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
Reference in New Issue
Block a user