Compare commits

...

7 Commits

Author SHA1 Message Date
shawn 2840d8b58e Fix agent tab wrapping: horizontal scroll instead of flex-shrink
- Tabs now scroll horizontally (overflow-x: auto) with hidden scrollbar
- Each tab maintains its natural width (flex-shrink: 0) — no wrapping
- Label stays pinned (flex-shrink: 0) so it doesn't get lost when scrolling
2026-06-02 18:02:10 -04:00
shawn 1a3740cdad Widen mobile panels 88% → 94% 2026-06-02 18:00:36 -04:00
shawn 147c2eb30d Widen mobile panels 72% → 88%, stretch profile tabs horizontally
- Mobile panel max-width: 72% → 88% (6% margins each side)
- Profile tabs: flex:1 with justify-content:center, fill available width
- Cache buster style.css v=13 → v=14
2026-06-02 17:53:12 -04:00
shawn 5839bc47ce Add watchdog agent profile to command center
- Add 'watchdog' to KNOWN_PROFILES (5th agent)
- Custom status check via watchdog state file + kanban gateway fallback
- New /api/watchdog/status endpoint with scripts, plugins, heartbeat info
- Watchdog endpoint bypasses auth (public, non-sensitive)
- Update frontend PROFILE_ICONS + cache buster
2026-06-02 17:45:03 -04:00
shawn 3f086b7849 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.
2026-06-02 17:18:17 -04:00
shawn 9788abb8ce feat: Agent Chat API backend - profiles, send, history
- GET /api/agents - list known profiles with online/offline status
- POST /api/agents/{profile}/send - send message via Hermes CLI subprocess
- GET /api/agents/{profile}/history - retrieve conversation history
- SQLite conversation store at ~/.hermes/kanban/boards/hermes-command-center/conversations.db
- Response cleanup to strip Hermes CLI wrapper formatting
- Auth-gated via existing Basic Auth middleware
2026-06-01 23:53:39 -04:00
shawn ca1cb63f65 feat: Network Status Dashboard frontend
- Host cards grid with live status (green/red/yellow badges), latency, expandable details
- Service table with category, URL/port, status, latency
- Local services tab (from ss -tlnp)
- Auto-refresh every 30s with visual countdown bar
- Manual refresh button with rotate animation
- Summary bar (online/degraded/offline/total counts)
- Mobile-responsive: single-column cards, scrollable tables
- Tab-based navigation between Hosts / Services / Local Services
2026-06-01 23:50:38 -04:00
5 changed files with 1924 additions and 320 deletions
+31 -8
View File
@@ -1,20 +1,43 @@
# Hermes Command Center
Mobile-friendly web dashboard for managing Hermes agents, configs, and network infrastructure.
Mobile-friendly web dashboard for managing Hermes agents, configs, tasks, services, and network infrastructure.
## Features
- **Agent Chat Interface** - Talk to all Hermes profiles (kanban, coder, assistant, artist)
- **Config Editor** - Edit .env and YAML config files
- **Obsidian Vault Reader** - Browse markdown notes and wiki
- **Network Status** - Live host and service monitoring
- **📊 Dashboard** — At-a-glance overview of agents, hosts, config files, chat messages, and kanban tasks
- **💬 Agent Chat** — Talk to all Hermes profiles (kanban, coder, assistant, artist) with online/offline status
- **⚙️ Config Editor** — Edit .env, config.yaml, and profile configs with YAML validation and auto-backup
- **📋 Kanban Viewer** — Browse all 19 kanban boards and view task status (done/blocked/ready/todo)
- **📚 Vault Reader** — Browse Obsidian markdown notes with wiki-link support and full-text search
- **🔧 Service Control** — View and manage Hermes systemd services (start/stop/restart)
- **🌐 Network Status** — Live host ping monitoring, service health checks, and local port scanning
## Panels
| Panel | Description |
|---|---|
| Dashboard | 6-card overview with quick action buttons |
| Chat | Real-time agent messaging with history |
| Config | 7 whitelisted config files with line-numbered editor |
| Kanban | 19-board viewer with status summary |
| Vault | File tree, markdown renderer, wiki-links, grep search |
| Services | 11 Hermes systemd services with start/stop/restart |
| Network | 5 hosts, 17 services, 36 local ports with auto-refresh |
## Mobile
- Bottom navigation bar on mobile (≤768px)
- Full-width panels with touch-friendly 44px+ tap targets
- Sidebar on desktop, bottom tabs on mobile
- Safe-area-inset support for notched devices
## Tech Stack
- FastAPI backend
- Vanilla HTML/CSS/JS frontend
- Vanilla HTML/CSS/JS frontend (no frameworks)
- SQLite for conversation and kanban storage
- Mobile-first responsive design
- Port: 8888
- Port: 8888 (public via command.ourpad.casa:443)
## Development
@@ -25,4 +48,4 @@ pip install -r requirements.txt
python3 main.py
```
Access at: http://localhost:8888
Access at: http://localhost:8888 (Basic auth: shawn / your-password)
+499 -1
View File
@@ -40,13 +40,14 @@ _unauthorized = Response(
# ── Agent Profiles ──────────────────────────────────────────────────
HERMES_BIN = "/home/oplabs/.hermes/hermes-agent/venv/bin/hermes"
KNOWN_PROFILES = ["coder", "assistant", "artist", "kanban"]
KNOWN_PROFILES = ["coder", "assistant", "artist", "kanban", "watchdog"]
PROFILE_LABELS = {
"coder": "Cody (Coder)",
"assistant": "Ashley (Assistant)",
"artist": "Artie (Artist)",
"kanban": "Kanban Manager",
"watchdog": "Watchdog (Infra)",
}
PROFILE_ICONS = {
@@ -54,12 +55,37 @@ PROFILE_ICONS = {
"assistant": "🤖",
"artist": "🎨",
"kanban": "📋",
"watchdog": "🛡️",
}
def _get_profile_status(profile: str) -> dict:
"""Check if a Hermes profile gateway process is running."""
try:
# Watchdog runs as a plugin inside the kanban gateway; check its state file
if profile == "watchdog":
watchdog_state = Path(os.path.expanduser("~/.hermes/workspace/watchdog-state.json"))
if watchdog_state.exists():
try:
state = json.loads(watchdog_state.read_text())
last_beat = state.get("last_heartbeat", 0)
now = time.time()
# Consider alive if heartbeat within the last 3 hours
if last_beat and (now - last_beat) < 10800:
return {"status": "online", "profile": profile, "last_heartbeat": last_beat}
except (json.JSONDecodeError, OSError):
pass
# Fallback: check if kanban gateway is running (watchdog rides on it)
result = subprocess.run(
["ps", "aux"],
capture_output=True, text=True, timeout=10,
)
for line in result.stdout.splitlines():
if "--profile kanban" in line and "gateway run" in line:
return {"status": "online", "profile": profile,
"note": "running via kanban gateway (plugin)"}
return {"status": "offline", "profile": profile}
result = subprocess.run(
["ps", "aux"],
capture_output=True, text=True, timeout=10,
@@ -143,6 +169,42 @@ def _get_conversation_history(profile: str, limit: int = 50) -> list[dict]:
conn.close()
def _clean_agent_response(text: str) -> str:
"""Extract just the agent's reply from Hermes CLI wrapper output."""
lines = text.splitlines()
result_lines = []
in_box = False
for line in lines:
stripped = line.strip()
if stripped.startswith("Query:") or stripped.startswith("Initializing"):
continue
if stripped.startswith("╭─"):
in_box = True
continue
if stripped.startswith("╰─"):
in_box = False
continue
if in_box:
result_lines.append(stripped)
if stripped.startswith("Resume this session") \
or stripped.startswith("Session:") \
or stripped.startswith("Duration:") \
or stripped.startswith("Messages:"):
break
cleaned = "\n".join(result_lines).strip()
if cleaned:
return cleaned
if "╰─" in text:
parts = text.split("╰─")
before = parts[0]
if "╭─" in before:
for line in before.split("╭─", 1)[1].splitlines():
s = line.strip()
if s:
return s
return ""
_init_conversations_db()
app = FastAPI(title="Hermes Command Center")
@@ -520,6 +582,10 @@ 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.startswith("/api/watchdog/"):
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)
@@ -677,6 +743,82 @@ async def vault_search(q: str = Query(..., min_length=1, description="Search que
})
# ── Watchdog API ─────────────────────────────────────────────────────
WATCHDOG_STATE_PATH = Path(os.path.expanduser("~/.hermes/workspace/watchdog-state.json"))
WATCHDOG_SCRIPTS_DIR = Path(os.path.expanduser("~/.hermes/scripts"))
def _get_watchdog_scripts() -> list[dict]:
"""List watchdog scripts and their basic info."""
scripts = []
try:
for f in sorted(WATCHDOG_SCRIPTS_DIR.glob("*watchdog*")):
if f.suffix in (".sh", ".py"):
scripts.append({
"name": f.name,
"path": str(f),
"size": f.stat().st_size,
"modified": int(f.stat().st_mtime),
})
except OSError:
pass
return scripts
def _get_watchdog_plugins() -> list[dict]:
"""List watchdog-related plugins."""
plugins = []
plugin_dir = Path(os.path.expanduser("~/.hermes/plugins"))
try:
for p in sorted(plugin_dir.glob("*watchdog*")):
plugin_yaml = p / "plugin.yaml"
if plugin_yaml.exists():
try:
meta = yaml.safe_load(plugin_yaml.read_text()) or {}
except Exception:
meta = {}
plugins.append({
"name": p.name,
"description": meta.get("description", ""),
"version": meta.get("version", ""),
})
except OSError:
pass
return plugins
@app.get("/api/watchdog/status")
async def watchdog_status():
"""Return detailed watchdog agent status (heartbeat, scripts, plugins)."""
state = {}
state_file = WATCHDOG_STATE_PATH
if state_file.exists():
try:
state = json.loads(state_file.read_text())
except (json.JSONDecodeError, OSError):
state = {}
profile_status = _get_profile_status("watchdog")
# Age of last heartbeat
last_heartbeat = state.get("last_heartbeat", 0)
if last_heartbeat:
age = time.time() - last_heartbeat
age_str = f"{age/60:.0f}m ago" if age < 3600 else f"{age/3600:.1f}h ago"
state["heartbeat_age_seconds"] = int(age)
state["heartbeat_age_str"] = age_str
return JSONResponse({
"status": profile_status["status"],
"note": profile_status.get("note", ""),
"state": state,
"scripts": _get_watchdog_scripts(),
"plugins": _get_watchdog_plugins(),
"cached_at": time.time(),
})
# ── Agent Chat API ──────────────────────────────────────────────────
@@ -729,6 +871,11 @@ async def agent_send(profile: str, request: Request):
if not response_text:
response_text = "(no response)"
# Clean up CLI wrapper text — extract the actual agent response
cleaned = _clean_agent_response(response_text)
if cleaned:
response_text = cleaned
# Save agent response
_save_message(profile, "agent", response_text)
@@ -752,6 +899,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")
+421 -275
View File
@@ -1,27 +1,69 @@
// Command Center SPA Router
// ── Command Center SPA Router ───────────────────────────────────────
const sections = document.querySelectorAll('.panel');
const links = document.querySelectorAll('.nav-links a');
const navLinks = document.querySelectorAll('.nav-links a');
const bottomNavItems = document.querySelectorAll('.bottom-nav .nav-item');
function navigate(hash) {
const target = hash.replace('#', '') || 'agents';
const target = hash.replace('#', '') || 'dashboard';
sections.forEach(s => s.hidden = s.id !== target);
links.forEach(l => l.classList.toggle('active', l.getAttribute('href') === '#' + target));
// Init config panel when navigated to
if (target === 'config') {
initConfigPanel();
}
// Desktop sidebar
navLinks.forEach(l => l.classList.toggle('active', l.getAttribute('href') === '#' + target));
// Mobile bottom nav
bottomNavItems.forEach(item => item.classList.toggle('active', item.dataset.tab === target));
// Init panels on first visit
if (target === 'config') initConfigPanel();
if (target === 'kanban') initKanban();
if (target === 'dashboard') loadDashboard();
if (target === 'agents') setTimeout(loadAgentProfiles, 100);
if (target === 'network') { if (!window._networkInited) { window._networkInited = true; initNetworkDashboard(); } }
if (target === 'services') loadServices();
}
links.forEach(l => l.addEventListener('click', e => {
// Desktop sidebar clicks
navLinks.forEach(l => l.addEventListener('click', e => {
e.preventDefault();
window.location.hash = l.getAttribute('href');
}));
// Mobile bottom nav clicks
bottomNavItems.forEach(item => item.addEventListener('click', e => {
e.preventDefault();
window.location.hash = '#' + item.dataset.tab;
}));
window.addEventListener('hashchange', () => navigate(window.location.hash));
navigate(window.location.hash || '#agents');
navigate(window.location.hash || '#dashboard');
// ── Config File Editor ────────────────────────────────────────────────
// ── Utility ─────────────────────────────────────────────────────────
function escapeHtml(text) {
if (text == null) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ── Dashboard Panel ─────────────────────────────────────────────────
async function loadDashboard() {
try {
const res = await fetch('/api/dashboard');
const data = await res.json();
document.getElementById('dash-agents-val').textContent = data.agents?.online + '/' + data.agents?.total;
document.getElementById('dash-hosts-val').textContent = data.network?.hosts ?? '—';
document.getElementById('dash-files-val').textContent = data.config?.editable_files ?? '—';
document.getElementById('dash-msgs-val').textContent = data.chat?.total_messages ?? '—';
document.getElementById('dash-tasks-val').textContent = data.kanban?.done_tasks ?? '—';
document.getElementById('dash-boards-val').textContent = data.kanban?.total_boards ?? '—';
} catch (err) {
console.error('Dashboard load failed:', err);
}
}
async function refreshDashboard() {
document.querySelectorAll('.dash-card-value').forEach(el => el.textContent = '…');
loadDashboard();
}
// ── Config File Editor ──────────────────────────────────────────────
let configState = {
files: [],
currentFile: null,
@@ -32,9 +74,7 @@ let configState = {
};
function initConfigPanel() {
if (configState.files.length === 0) {
loadFileList();
}
if (configState.files.length === 0) loadFileList();
}
async function loadFileList() {
@@ -42,38 +82,32 @@ async function loadFileList() {
container.innerHTML = '<p class="loading-text">Loading files...</p>';
try {
const res = await fetch('/api/config/list');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
configState.files = data.files;
renderFileList(data.files);
} catch (err) {
container.innerHTML = `<p class="error-text">Failed to load files: ${err.message}</p>`;
container.innerHTML = '<p class="error-text">Failed to load files: ' + escapeHtml(err.message) + '</p>';
}
}
function renderFileList(files) {
const container = document.getElementById('config-files-container');
const countEl = document.getElementById('config-count');
countEl.textContent = `${files.length} file${files.length !== 1 ? 's' : ''}`;
countEl.textContent = files.length + ' file' + (files.length !== 1 ? 's' : '');
if (files.length === 0) {
container.innerHTML = '<p class="empty-text">No config files found.</p>';
return;
}
container.innerHTML = '';
files.forEach(f => {
const item = document.createElement('div');
item.className = 'config-file-item';
if (configState.currentPath === f.path) {
item.classList.add('selected');
}
if (configState.currentPath === f.path) item.classList.add('selected');
item.dataset.path = f.path;
const nameSpan = document.createElement('span');
nameSpan.className = 'file-item-name';
nameSpan.textContent = f.label || f.filename;
const metaSpan = document.createElement('span');
metaSpan.className = 'file-item-meta';
if (f.missing) {
@@ -82,7 +116,6 @@ function renderFileList(files) {
} else {
metaSpan.textContent = formatFileSize(f.size) + ' · ' + f.line_count + ' lines';
}
item.appendChild(nameSpan);
item.appendChild(metaSpan);
item.addEventListener('click', () => openFile(f.path));
@@ -101,8 +134,6 @@ async function openFile(path) {
configState.dirty = false;
configState.currentPath = path;
configState.lastSaved = null;
// Show editor view
document.getElementById('config-file-list').hidden = true;
document.getElementById('config-editor').hidden = false;
document.getElementById('config-editor-textarea').disabled = true;
@@ -112,36 +143,28 @@ async function openFile(path) {
document.getElementById('status-path').textContent = 'Loading...';
document.getElementById('status-meta').textContent = '';
document.getElementById('status-saved').textContent = '';
try {
const res = await fetch('/api/config/read?path=' + encodeURIComponent(path));
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || `HTTP ${res.status}`);
throw new Error(err.error || 'HTTP ' + res.status);
}
const data = await res.json();
configState.currentFile = data;
configState.currentContent = data.content;
const filename = data.label || data.filename;
document.getElementById('editor-filename').textContent = filename;
document.getElementById('config-editor-textarea').value = data.content;
document.getElementById('config-editor-textarea').disabled = false;
document.getElementById('config-editor-textarea').focus();
// Status bar
document.getElementById('status-path').textContent = data.path;
document.getElementById('status-meta').textContent = formatFileSize(data.size) + ' · ' + data.line_count + ' lines';
document.getElementById('status-saved').textContent = data.last_modified
? 'Last modified: ' + formatTimestamp(data.last_modified)
: '';
? 'Last modified: ' + formatTimestamp(data.last_modified) : '';
updateLineNumbers();
document.getElementById('config-editor-textarea').addEventListener('input', onEditorChange);
document.getElementById('config-editor-textarea').addEventListener('scroll', syncScroll);
document.getElementById('config-editor-textarea').addEventListener('keydown', handleEditorKeydown);
// Highlight selected in file list
renderFileList(configState.files);
} catch (err) {
showToast('Error loading file: ' + err.message, 'error');
@@ -150,66 +173,42 @@ async function openFile(path) {
}
}
function onEditorChange() {
configState.dirty = true;
updateLineNumbers();
updateSaveButton();
}
function onEditorChange() { configState.dirty = true; updateLineNumbers(); updateSaveButton(); }
function updateSaveButton() {
const btn = document.getElementById('config-save-btn');
if (configState.dirty) {
btn.textContent = 'Save';
btn.style.background = 'var(--accent)';
} else {
btn.textContent = 'Saved ✓';
btn.style.background = 'var(--success)';
}
if (configState.dirty) { btn.textContent = 'Save'; btn.style.background = 'var(--accent)'; }
else { btn.textContent = 'Saved ✓'; btn.style.background = 'var(--success)'; }
}
function syncScroll() {
const textarea = document.getElementById('config-editor-textarea');
const lineNumbers = document.getElementById('editor-line-numbers');
lineNumbers.scrollTop = textarea.scrollTop;
document.getElementById('editor-line-numbers').scrollTop = textarea.scrollTop;
}
function handleEditorKeydown(e) {
// Tab key support
if (e.key === 'Tab') {
e.preventDefault();
const textarea = document.getElementById('config-editor-textarea');
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const start = textarea.selectionStart, end = textarea.selectionEnd;
const val = textarea.value;
textarea.value = val.substring(0, start) + ' ' + val.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + 4;
onEditorChange();
}
// Ctrl+S / Cmd+S
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveFile();
}
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveFile(); }
}
function updateLineNumbers() {
const textarea = document.getElementById('config-editor-textarea');
const lineNumbers = document.getElementById('editor-line-numbers');
const lines = textarea.value.split('\n');
const count = lines.length;
let html = '';
for (let i = 1; i <= count; i++) {
html += '<div class="ln">' + i + '</div>';
}
lineNumbers.innerHTML = html;
for (let i = 1; i <= lines.length; i++) html += '<div class="ln">' + i + '</div>';
document.getElementById('editor-line-numbers').innerHTML = html;
}
function goBackToList() {
if (configState.dirty) {
if (!confirm('You have unsaved changes. Discard them?')) {
return;
}
}
if (configState.dirty && !confirm('Unsaved changes. Discard?')) return;
document.getElementById('config-file-list').hidden = false;
document.getElementById('config-editor').hidden = true;
configState.currentFile = null;
@@ -221,20 +220,13 @@ function goBackToList() {
async function saveFile() {
if (!configState.dirty) return;
const textarea = document.getElementById('config-editor-textarea');
const content = textarea.value;
const path = configState.currentPath;
// Confirmation dialog
if (!confirm('Save changes to ' + (configState.currentFile?.label || path) + '?')) {
return;
}
if (!confirm('Save changes to ' + (configState.currentFile?.label || path) + '?')) return;
const btn = document.getElementById('config-save-btn');
btn.disabled = true;
btn.textContent = 'Saving...';
try {
const res = await fetch('/api/config/save', {
method: 'POST',
@@ -242,41 +234,25 @@ async function saveFile() {
body: JSON.stringify({ path, content }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || `HTTP ${res.status}`);
}
if (!res.ok) throw new Error(data.error || 'HTTP ' + res.status);
configState.dirty = false;
configState.lastSaved = new Date();
configState.currentContent = content;
// Update status
document.getElementById('status-meta').textContent = formatFileSize(data.size) + ' · ' + data.line_count + ' lines';
document.getElementById('status-saved').textContent = 'Saved: ' + formatTimestamp(new Date().toISOString());
showToast('File saved successfully. Backup: ' + data.backup_path, 'success');
showToast('File saved. Backup: ' + data.backup_path, 'success');
updateSaveButton();
// Refresh file list metadata
loadFileList();
} catch (err) {
showToast('Save failed: ' + err.message, 'error');
} finally {
btn.disabled = false;
}
} finally { btn.disabled = false; }
}
function formatTimestamp(iso) {
try {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
} catch {
return iso;
}
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch { return iso; }
}
function showToast(message, type) {
@@ -284,8 +260,6 @@ function showToast(message, type) {
toast.textContent = message;
toast.className = 'config-toast toast-' + (type || 'info');
toast.hidden = false;
// Position toast
const editor = document.querySelector('.config-editor');
if (!editor.hidden) {
const rect = editor.getBoundingClientRect();
@@ -293,34 +267,334 @@ function showToast(message, type) {
toast.style.left = '50%';
toast.style.transform = 'translateX(-50%)';
}
clearTimeout(toast._timeout);
toast._timeout = setTimeout(() => {
toast.hidden = true;
}, 4000);
toast._timeout = setTimeout(() => { toast.hidden = true; }, 4000);
}
// ── Event listeners ──────────────────────────────────────────────────
// ── Agent Chat ────────────────────────────────────────────────────
let chatState = {
profiles: [],
activeProfile: null,
messages: {},
sending: false,
};
const PROFILE_ICONS = { coder: '💻', assistant: '🤖', artist: '🎨', kanban: '📋', watchdog: '🛡️' };
async function loadAgentProfiles() {
try {
const res = await fetch('/api/agents');
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
chatState.profiles = data.agents || [];
renderProfileTabs();
if (chatState.activeProfile) {
selectProfile(chatState.activeProfile);
} else if (chatState.profiles.length > 0) {
const online = chatState.profiles.find(p => p.status === 'online');
selectProfile(online || chatState.profiles[0]);
}
} catch (err) { console.error('Failed to load profiles:', err); }
}
function renderProfileTabs() {
const container = document.getElementById('chat-profile-tabs');
container.innerHTML = '';
chatState.profiles.forEach(p => {
const tab = document.createElement('button');
tab.className = 'chat-profile-tab';
if (p.profile === chatState.activeProfile?.profile) tab.classList.add('active');
const icon = PROFILE_ICONS[p.profile] || '🤖';
tab.innerHTML = '<span class="status-dot ' + p.status + '"></span> ' + icon + ' ' + (p.label || p.profile);
tab.title = p.status === 'online' ? 'Online' : 'Offline';
tab.addEventListener('click', () => selectProfile(p));
container.appendChild(tab);
});
}
function selectProfile(profile) {
chatState.activeProfile = profile;
document.querySelectorAll('.chat-profile-tab').forEach(tab => {
const txt = tab.textContent.trim();
tab.classList.toggle('active', txt.includes(profile.label || profile.profile));
});
const msgArea = document.getElementById('chat-messages');
msgArea.innerHTML = '';
if (!chatState.messages[profile.profile]) showWelcome(profile);
document.getElementById('chat-input').disabled = false;
document.getElementById('chat-input').focus();
updateSendButton();
loadConversationHistory(profile.profile);
}
function showWelcome(profile) {
const msgArea = document.getElementById('chat-messages');
const icon = PROFILE_ICONS[profile.profile] || '🤖';
msgArea.innerHTML = '<div class="chat-welcome"><div class="chat-welcome-icon">' + icon + '</div><h3>' + (profile.label || profile.profile) + '</h3><p>' + (profile.status === 'online' ? 'This agent is online. Type a message to start a conversation.' : 'This agent is offline. Start the gateway to chat.') + '</p></div>';
}
async function loadConversationHistory(profileName) {
try {
const res = await fetch('/api/agents/' + encodeURIComponent(profileName) + '/history?limit=50');
if (!res.ok) return;
const data = await res.json();
const messages = data.messages || [];
chatState.messages[profileName] = messages;
if (chatState.activeProfile?.profile === profileName) renderMessages(messages);
} catch (err) { console.error('Failed to load history:', err); }
}
function renderMessages(messages) {
const msgArea = document.getElementById('chat-messages');
msgArea.innerHTML = '';
if (!messages || messages.length === 0) { if (chatState.activeProfile) showWelcome(chatState.activeProfile); return; }
messages.forEach(msg => appendMessageBubble(msg, false));
scrollToBottom();
}
function appendMessageBubble(msg, animate) {
const msgArea = document.getElementById('chat-messages');
const isUser = msg.role === 'user';
const div = document.createElement('div');
div.className = 'chat-message ' + (isUser ? 'user' : 'agent');
if (!animate) div.style.animation = 'none';
const icon = chatState.activeProfile ? (PROFILE_ICONS[chatState.activeProfile.profile] || '🤖') : '🤖';
const time = msg.created_at ? new Date(msg.created_at * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
div.innerHTML = '<div class="bubble">' + escapeHtml(msg.content) + '</div><div class="msg-meta">' + (isUser ? '' : '<span class="msg-profile-icon">' + icon + '</span>') + '<span>' + (isUser ? 'You' : (chatState.activeProfile?.label || 'Agent')) + '</span>' + (time ? ' <span>· ' + time + '</span>' : '') + '</div>';
msgArea.appendChild(div);
scrollToBottom();
}
function scrollToBottom() { document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight; }
function showTypingIndicator(show) { document.getElementById('chat-typing').hidden = !show; }
async function sendMessage() {
if (chatState.sending) return;
const input = document.getElementById('chat-input');
const message = input.value.trim();
if (!message || !chatState.activeProfile) return;
chatState.sending = true;
input.disabled = true;
document.getElementById('chat-send-btn').disabled = true;
document.getElementById('chat-send-btn').textContent = '...';
appendMessageBubble({ role: 'user', content: message, created_at: Date.now() / 1000 });
input.value = '';
input.style.height = 'auto';
showTypingIndicator(true);
try {
const res = await fetch('/api/agents/' + encodeURIComponent(chatState.activeProfile.profile) + '/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
});
const data = await res.json();
if (!res.ok) {
appendMessageBubble({ role: 'agent', content: '⚠️ Error: ' + (data.error || 'HTTP ' + res.status), created_at: Date.now() / 1000 });
} else {
appendMessageBubble({ role: 'agent', content: data.response, created_at: Date.now() / 1000 });
}
} catch (err) {
appendMessageBubble({ role: 'agent', content: '⚠️ Network error: ' + err.message, created_at: Date.now() / 1000 });
} finally {
chatState.sending = false;
showTypingIndicator(false);
input.disabled = false;
document.getElementById('chat-send-btn').disabled = false;
document.getElementById('chat-send-btn').textContent = 'Send';
input.focus();
}
}
function updateSendButton() {
const input = document.getElementById('chat-input');
const btn = document.getElementById('chat-send-btn');
btn.disabled = !input.value.trim() || !chatState.activeProfile || chatState.sending;
}
// ── Event Listeners (shared) ───────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('config-save-btn').addEventListener('click', saveFile);
document.getElementById('config-back-btn').addEventListener('click', goBackToList);
initNetworkDashboard();
// Config editor listeners
const configSaveBtn = document.getElementById('config-save-btn');
const configBackBtn = document.getElementById('config-back-btn');
if (configSaveBtn) configSaveBtn.addEventListener('click', saveFile);
if (configBackBtn) configBackBtn.addEventListener('click', goBackToList);
// Chat input
const chatInput = document.getElementById('chat-input');
const sendBtn = document.getElementById('chat-send-btn');
if (chatInput) {
chatInput.addEventListener('input', () => {
updateSendButton();
chatInput.style.height = 'auto';
chatInput.style.height = Math.min(chatInput.scrollHeight, 120) + 'px';
});
chatInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
}
if (sendBtn) sendBtn.addEventListener('click', sendMessage);
});
// ── Network Dashboard ────────────────────────────────────────────────
// ── Kanban Panel ──────────────────────────────────────────────────
let kanbanState = { boards: [], currentSlug: '' };
async function initKanban() {
try {
const res = await fetch('/api/kanban/boards');
const data = await res.json();
kanbanState.boards = data.boards || [];
const select = document.getElementById('kanban-board-select');
select.innerHTML = '<option value="">Select a board...</option>';
data.boards.forEach(b => {
const opt = document.createElement('option');
opt.value = b.slug;
opt.textContent = b.name + ' (' + b.task_count + ' tasks)';
select.appendChild(opt);
});
// Restore selection if previously set
if (kanbanState.currentSlug) {
select.value = kanbanState.currentSlug;
loadKanbanBoard();
}
} catch (err) { console.error('Kanban init failed:', err); }
}
async function loadKanbanBoard() {
const select = document.getElementById('kanban-board-select');
const slug = select.value;
if (!slug) {
document.getElementById('kanban-task-list').innerHTML = '<p class="loading-text">Select a board to view tasks...</p>';
document.getElementById('ks-done').textContent = '0';
document.getElementById('ks-blocked').textContent = '0';
document.getElementById('ks-ready').textContent = '0';
document.getElementById('ks-todo').textContent = '0';
document.getElementById('ks-total').textContent = '0';
return;
}
kanbanState.currentSlug = slug;
try {
const res = await fetch('/api/kanban/board/' + encodeURIComponent(slug));
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'HTTP ' + res.status);
renderKanbanTasks(data.tasks || []);
} catch (err) {
document.getElementById('kanban-task-list').innerHTML = '<p class="error-text">' + escapeHtml(err.message) + '</p>';
}
}
function renderKanbanTasks(tasks) {
const list = document.getElementById('kanban-task-list');
if (!tasks || tasks.length === 0) {
list.innerHTML = '<p class="empty-text">No tasks in this board.</p>';
document.getElementById('ks-done').textContent = '0';
document.getElementById('ks-blocked').textContent = '0';
document.getElementById('ks-ready').textContent = '0';
document.getElementById('ks-todo').textContent = '0';
document.getElementById('ks-total').textContent = '0';
return;
}
// Summary
const counts = { done: 0, blocked: 0, ready: 0, todo: 0, total: tasks.length };
tasks.forEach(t => { if (counts[t.status] !== undefined) counts[t.status]++; });
document.getElementById('ks-done').textContent = counts.done;
document.getElementById('ks-blocked').textContent = counts.blocked;
document.getElementById('ks-ready').textContent = counts.ready;
document.getElementById('ks-todo').textContent = counts.todo;
document.getElementById('ks-total').textContent = counts.total;
// Task cards
const statusIcons = { done: '✅', blocked: '🚫', ready: '🟡', todo: '⬜' };
list.innerHTML = '';
tasks.forEach(t => {
const card = document.createElement('div');
card.className = 'kanban-task-card';
const icon = statusIcons[t.status] || '⬜';
const statusLabel = t.status || 'todo';
card.innerHTML = '<div class="kanban-task-status status-' + statusLabel + '">' + icon + '</div>' +
'<div class="kanban-task-info">' +
'<div class="kanban-task-title">' + escapeHtml(t.title) + '</div>' +
'<div class="kanban-task-meta">' +
(t.assignee ? '<span>👤 ' + escapeHtml(t.assignee) + '</span>' : '') +
'<span class="status-badge status-' + statusLabel + '">' + statusLabel + '</span>' +
'</div>' +
(t.body ? '<div class="kanban-task-body">' + escapeHtml(t.body) + '</div>' : '') +
'</div>';
card.title = 'Click to view details (ID: ' + t.id + ')';
card.addEventListener('click', () => {
// Show task details in a simple way
showToast(t.id + ': ' + t.title + ' [' + t.status + ']', 'info');
});
list.appendChild(card);
});
}
// ── Services Panel ─────────────────────────────────────────────────
async function loadServices() {
const tbody = document.getElementById('services-tbody-svc');
tbody.innerHTML = '<tr><td colspan="4" class="loading-text">Loading services...</td></tr>';
try {
const res = await fetch('/api/services');
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'HTTP ' + res.status);
renderServicesList(data.services || []);
} catch (err) {
tbody.innerHTML = '<tr><td colspan="4" class="error-text">' + escapeHtml(err.message) + '</td></tr>';
}
}
function renderServicesList(services) {
const tbody = document.getElementById('services-tbody-svc');
if (!services || services.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-text">No Hermes services found.</td></tr>';
return;
}
tbody.innerHTML = '';
services.forEach(s => {
const tr = document.createElement('tr');
const isActive = s.active === 'active';
const badgeClass = 'svc-active-' + s.active;
const statusLabel = s.active + '/' + s.sub;
tr.innerHTML = '<td class="svc-name">' + escapeHtml(s.name) + '</td>' +
'<td><span class="svc-status-badge ' + badgeClass + '">' + escapeHtml(statusLabel) + '</span></td>' +
'<td>' + escapeHtml(s.sub || '—') + '</td>' +
'<td class="service-actions">' +
(!isActive ? '<button class="service-action-btn start" onclick="serviceAction(\'' + escapeHtml(s.name) + "','start'" + ')">▶ Start</button>' : '') +
(isActive ? '<button class="service-action-btn restart" onclick="serviceAction(\'' + escapeHtml(s.name) + "','restart'" + ')">↻ Restart</button>' : '') +
(isActive ? '<button class="service-action-btn stop" onclick="serviceAction(\'' + escapeHtml(s.name) + "','stop'" + ')">⏹ Stop</button>' : '') +
'</td>';
tbody.appendChild(tr);
});
}
async function serviceAction(serviceName, action) {
if (!confirm(action + ' ' + serviceName + '?')) return;
try {
const res = await fetch('/api/services/' + encodeURIComponent(serviceName) + '/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
});
const data = await res.json();
if (!res.ok) { alert('Failed: ' + (data.error || 'HTTP ' + res.status)); return; }
showToast(action + ' ' + serviceName + ' — done', 'success');
setTimeout(loadServices, 1500);
} catch (err) { alert('Error: ' + err.message); }
}
// ── Network Dashboard ────────────────────────────────────────────────
let networkState = {
autoRefresh: true,
refreshInterval: 30000, // 30 seconds
refreshInterval: 30000,
countdownInterval: null,
refreshTimer: null,
countdownRemaining: 0,
data: null,
expandedHost: null,
};
function initNetworkDashboard() {
// Tab switching
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
@@ -330,64 +604,30 @@ function initNetworkDashboard() {
if (tab) tab.classList.add('active');
});
});
// Refresh button
document.getElementById('network-refresh-btn').addEventListener('click', () => {
forceRefresh();
});
// Auto-refresh toggle
document.getElementById('network-refresh-btn').addEventListener('click', forceRefresh);
document.getElementById('auto-refresh-toggle').addEventListener('click', () => {
networkState.autoRefresh = document.getElementById('auto-refresh-toggle').checked;
if (networkState.autoRefresh) {
startAutoRefresh();
} else {
stopAutoRefresh();
}
if (networkState.autoRefresh) startAutoRefresh(); else stopAutoRefresh();
});
// Listen for navigation to network panel
const networkLink = document.querySelector('.nav-links a[href="#network"]');
if (networkLink) {
networkLink.addEventListener('click', () => {
setTimeout(() => {
if (!networkState.data) {
fetchNetworkData();
}
}, 100);
});
}
// Initial load (in case we start on network tab)
fetchNetworkData();
}
async function fetchNetworkData() {
try {
const res = await fetch('/api/network/refresh');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
networkState.data = data;
renderNetwork(data);
updateFooter(data);
if (networkState.autoRefresh) {
startAutoRefresh();
}
} catch (err) {
showNetworkError('Failed to fetch network data: ' + err.message);
}
if (networkState.autoRefresh) startAutoRefresh();
} catch (err) { showNetworkError('Failed: ' + err.message); }
}
function forceRefresh() {
networkState.data = null;
if (networkState.refreshTimer) {
clearTimeout(networkState.refreshTimer);
networkState.refreshTimer = null;
}
if (networkState.countdownInterval) {
clearInterval(networkState.countdownInterval);
networkState.countdownInterval = null;
}
if (networkState.refreshTimer) { clearTimeout(networkState.refreshTimer); networkState.refreshTimer = null; }
if (networkState.countdownInterval) { clearInterval(networkState.countdownInterval); networkState.countdownInterval = null; }
document.getElementById('hosts-grid').innerHTML = '<div class="loading-text">Refreshing...</div>';
document.getElementById('services-tbody').innerHTML = '<tr><td colspan="5" class="loading-text">Refreshing...</td></tr>';
document.getElementById('local-services-tbody').innerHTML = '<tr><td colspan="3" class="loading-text">Refreshing...</td></tr>';
@@ -400,27 +640,15 @@ function startAutoRefresh() {
updateCountdownBar();
networkState.countdownInterval = setInterval(() => {
networkState.countdownRemaining--;
if (networkState.countdownRemaining <= 0) {
networkState.countdownRemaining = 30;
fetchNetworkData();
}
if (networkState.countdownRemaining <= 0) { networkState.countdownRemaining = 30; fetchNetworkData(); }
updateCountdownBar();
}, 1000);
networkState.refreshTimer = setTimeout(() => {
// Safety timeout in case countdown misses
fetchNetworkData();
}, networkState.refreshInterval + 1000);
networkState.refreshTimer = setTimeout(() => { fetchNetworkData(); }, networkState.refreshInterval + 1000);
}
function stopAutoRefresh() {
if (networkState.countdownInterval) {
clearInterval(networkState.countdownInterval);
networkState.countdownInterval = null;
}
if (networkState.refreshTimer) {
clearTimeout(networkState.refreshTimer);
networkState.refreshTimer = null;
}
if (networkState.countdownInterval) { clearInterval(networkState.countdownInterval); networkState.countdownInterval = null; }
if (networkState.refreshTimer) { clearTimeout(networkState.refreshTimer); networkState.refreshTimer = null; }
document.getElementById('countdown-fill').style.width = '0%';
}
@@ -442,7 +670,7 @@ function renderSummary(hosts) {
if (h.status === 'online') online++;
else if (h.status === 'offline') offline++;
else if (h.status === 'degraded') degraded++;
else offline++; // unknown hosts counted as offline
else offline++;
});
document.getElementById('hosts-online').textContent = online;
document.getElementById('hosts-offline').textContent = offline;
@@ -452,60 +680,26 @@ function renderSummary(hosts) {
function renderHosts(hosts) {
const grid = document.getElementById('hosts-grid');
if (!hosts || hosts.length === 0) {
grid.innerHTML = '<div class="empty-text">No hosts configured.</div>';
return;
}
if (!hosts || hosts.length === 0) { grid.innerHTML = '<div class="empty-text">No hosts configured.</div>'; return; }
grid.innerHTML = '';
hosts.forEach(host => {
const card = document.createElement('div');
card.className = 'host-card status-' + (host.status || 'unknown');
card.dataset.host = host.name;
const statusLabel = host.status === 'online' ? 'Online' :
host.status === 'offline' ? 'Offline' :
host.status === 'degraded' ? 'Degraded' : 'Unknown';
const statusLabel = host.status === 'online' ? 'Online' : host.status === 'offline' ? 'Offline' : host.status === 'degraded' ? 'Degraded' : 'Unknown';
let latencyDisplay = host.latency_ms != null ? host.latency_ms.toFixed(1) + 'ms' : '—';
let osDisplay = host.os || '';
card.innerHTML = `
<div class="host-card-header">
<div class="host-name-row">
<span class="host-name">${escapeHtml(host.name)}</span>
<span class="status-badge status-${host.status || 'unknown'}">${statusLabel}</span>
</div>
<div class="host-meta">
<span class="host-ip">${escapeHtml(host.ip || '—')}</span>
${osDisplay ? '<span class="host-os">' + escapeHtml(osDisplay) + '</span>' : ''}
<span class="host-latency">${latencyDisplay}</span>
</div>
</div>
<div class="host-card-body">
${host.role ? '<div class="host-role">' + escapeHtml(host.role) + '</div>' : ''}
${host.ports && host.ports.length > 0 ? renderPorts(host.ports) : ''}
<button class="btn btn-sm host-expand-btn" data-host="${escapeHtml(host.name)}">Details</button>
</div>
<div class="host-details hidden" id="host-details-${escapeHtml(host.name).replace(/\s/g, '_')}">
<div class="host-details-content">
${renderHostDetails(host)}
</div>
</div>
`;
// Click to expand
card.innerHTML = '<div class="host-card-header"><div class="host-name-row"><span class="host-name">' + escapeHtml(host.name) + '</span><span class="status-badge status-' + (host.status || 'unknown') + '">' + statusLabel + '</span></div><div class="host-meta"><span class="host-ip">' + escapeHtml(host.ip || '—') + '</span>' + (osDisplay ? '<span class="host-os">' + escapeHtml(osDisplay) + '</span>' : '') + '<span class="host-latency">' + latencyDisplay + '</span></div></div>' +
'<div class="host-card-body">' + (host.role ? '<div class="host-role">' + escapeHtml(host.role) + '</div>' : '') + (host.ports && host.ports.length > 0 ? renderPorts(host.ports) : '') +
'<button class="btn btn-sm host-expand-btn" data-host="' + escapeHtml(host.name) + '">Details</button></div>' +
'<div class="host-details hidden" id="host-details-' + escapeHtml(host.name).replace(/\s/g, '_') + '"><div class="host-details-content">' + renderHostDetails(host) + '</div></div>';
const expandBtn = card.querySelector('.host-expand-btn');
expandBtn.addEventListener('click', (e) => {
e.stopPropagation();
const detailsId = 'host-details-' + escapeHtml(host.name).replace(/\s/g, '_');
const details = document.getElementById(detailsId);
if (details) {
details.classList.toggle('hidden');
expandBtn.textContent = details.classList.contains('hidden') ? 'Details' : 'Hide';
}
if (details) { details.classList.toggle('hidden'); expandBtn.textContent = details.classList.contains('hidden') ? 'Details' : 'Hide'; }
});
grid.appendChild(card);
});
}
@@ -513,10 +707,7 @@ function renderHosts(hosts) {
function renderPorts(ports) {
if (!ports || ports.length === 0) return '';
let html = '<div class="host-ports"><span class="ports-label">Ports:</span>';
ports.forEach(p => {
const openClass = p.open ? 'port-open' : 'port-closed';
html += `<span class="port-badge ${openClass}" title="${escapeHtml(p.name)}:${p.port}">${escapeHtml(p.name)}:${p.port}</span>`;
});
ports.forEach(p => { html += '<span class="port-badge ' + (p.open ? 'port-open' : 'port-closed') + '" title="' + escapeHtml(p.name) + ':' + p.port + '">' + escapeHtml(p.name) + ':' + p.port + '</span>'; });
html += '</div>';
return html;
}
@@ -524,27 +715,15 @@ function renderPorts(ports) {
function renderHostDetails(host) {
let html = '<table class="details-table">';
const fields = [
['Hostname', host.hostname],
['OS', host.os],
['Role', host.role],
['Local IP', host.local_ip],
['Tailscale IP', host.tailscale_ip],
['Public IP', host.public_ip],
['Status', host.status],
['Latency', host.latency_ms != null ? host.latency_ms.toFixed(1) + 'ms' : '—'],
['Hostname', host.hostname], ['OS', host.os], ['Role', host.role],
['Local IP', host.local_ip], ['Tailscale IP', host.tailscale_ip],
['Public IP', host.public_ip], ['Status', host.status],
['Latency', host.latency_ms != null ? host.latency_ms.toFixed(1) + 'ms' : '—']
];
fields.forEach(([label, val]) => {
if (val) {
html += `<tr><td class="dt-label">${escapeHtml(label)}</td><td class="dt-value">${escapeHtml(String(val))}</td></tr>`;
}
});
fields.forEach(([label, val]) => { if (val) html += '<tr><td class="dt-label">' + escapeHtml(label) + '</td><td class="dt-value">' + escapeHtml(String(val)) + '</td></tr>'; });
if (host.ports && host.ports.length > 0) {
html += '<tr><td class="dt-label">Ports</td><td class="dt-value">';
host.ports.forEach(p => {
const icon = p.open ? '✓' : '✗';
const cls = p.open ? 'port-open' : 'port-closed';
html += `<span class="port-badge ${cls}">${icon} ${escapeHtml(p.name)}:${p.port}</span> `;
});
host.ports.forEach(p => { html += '<span class="port-badge ' + (p.open ? 'port-open' : 'port-closed') + '">' + (p.open ? '✓' : '✗') + ' ' + escapeHtml(p.name) + ':' + p.port + '</span> '; });
html += '</td></tr>';
}
html += '</table>';
@@ -553,62 +732,36 @@ function renderHostDetails(host) {
function renderServices(services) {
const tbody = document.getElementById('services-tbody');
if (!services || services.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-text">No services configured.</td></tr>';
return;
}
if (!services || services.length === 0) { tbody.innerHTML = '<tr><td colspan="5" class="empty-text">No services configured.</td></tr>'; return; }
tbody.innerHTML = '';
services.forEach(svc => {
const tr = document.createElement('tr');
const status = svc.status || 'unknown';
const statusLabel = status === 'online' ? 'Online' :
status === 'offline' ? 'Offline' : 'Unknown';
const statusLabel = status === 'online' ? 'Online' : status === 'offline' ? 'Offline' : 'Unknown';
const latency = svc.latency_ms != null ? svc.latency_ms.toFixed(1) + 'ms' : '—';
const url = svc.url || (svc.port ? ':' + svc.port : '—');
const category = svc.category || '—';
tr.innerHTML = `
<td class="svc-name">${escapeHtml(svc.name || '—')}</td>
<td><span class="svc-category">${escapeHtml(category)}</span></td>
<td class="svc-url">${escapeHtml(url)}</td>
<td><span class="status-badge status-${status}">${statusLabel}</span></td>
<td class="svc-latency">${latency}</td>
`;
if (svc.error) {
tr.innerHTML += `<td class="svc-error" colspan="5"><small>${escapeHtml(svc.error)}</small></td>`;
}
tr.innerHTML = '<td class="svc-name">' + escapeHtml(svc.name || '—') + '</td><td><span class="svc-category">' + escapeHtml(category) + '</span></td><td class="svc-url">' + escapeHtml(url) + '</td><td><span class="status-badge status-' + status + '">' + statusLabel + '</span></td><td class="svc-latency">' + latency + '</td>';
if (svc.error) tr.innerHTML += '<td class="svc-error" colspan="5"><small>' + escapeHtml(svc.error) + '</small></td>';
tbody.appendChild(tr);
});
}
function renderLocalServices(services) {
const tbody = document.getElementById('local-services-tbody');
if (!services || services.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" class="empty-text">No local services detected.</td></tr>';
return;
}
if (!services || services.length === 0) { tbody.innerHTML = '<tr><td colspan="3" class="empty-text">No local services detected.</td></tr>'; return; }
tbody.innerHTML = '';
services.forEach(svc => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${escapeHtml(svc.local_address || '—')}</td>
<td>${escapeHtml(svc.port || '—')}</td>
<td>${escapeHtml(svc.process || '—')}</td>
`;
tr.innerHTML = '<td>' + escapeHtml(svc.local_address || '—') + '</td><td>' + escapeHtml(svc.port || '—') + '</td><td>' + escapeHtml(svc.process || '—') + '</td>';
tbody.appendChild(tr);
});
}
function updateFooter(data) {
const cached = data.cached_at ? new Date(data.cached_at * 1000) : null;
document.getElementById('last-updated').textContent = cached
? 'Last updated: ' + cached.toLocaleTimeString()
: 'Last updated: —';
document.getElementById('cache-info').textContent = data.cache_ttl
? 'Cache: ' + data.cache_ttl + 's'
: '';
document.getElementById('last-updated').textContent = cached ? 'Last updated: ' + cached.toLocaleTimeString() : 'Last updated: —';
document.getElementById('cache-info').textContent = data.cache_ttl ? 'Cache: ' + data.cache_ttl + 's' : '';
}
function showNetworkError(msg) {
@@ -616,10 +769,3 @@ function showNetworkError(msg) {
document.getElementById('services-tbody').innerHTML = '<tr><td colspan="5" class="error-text">' + escapeHtml(msg) + '</td></tr>';
document.getElementById('local-services-tbody').innerHTML = '<tr><td colspan="3" class="error-text">' + escapeHtml(msg) + '</td></tr>';
}
function escapeHtml(str) {
if (str == null) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
+143 -35
View File
@@ -2,23 +2,99 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0d1117">
<title>Hermes Command Center</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<link rel="stylesheet" href="/static/style.css?v=16">
</head>
<body>
<div id="app">
<nav class="sidebar">
<!-- Desktop Sidebar -->
<nav class="sidebar" id="sidebar">
<div class="logo">🎛️ Command Center</div>
<ul class="nav-links">
<li><a href="#agents" class="active">💬 Agents</a></li>
<li><a href="#dashboard" class="active">📊 Dashboard</a></li>
<li><a href="#agents">💬 Chat</a></li>
<li><a href="#config">⚙️ Config</a></li>
<li><a href="#kanban">📋 Kanban</a></li>
<li><a href="#vault">📚 Vault</a></li>
<li><a href="#services">🔧 Services</a></li>
<li><a href="#network">🌐 Network</a></li>
</ul>
</nav>
<main class="content">
<section id="agents" class="panel">
<!-- Mobile Bottom Nav -->
<nav class="bottom-nav" id="bottom-nav">
<a href="#dashboard" class="nav-item active" data-tab="dashboard">📊<span>Home</span></a>
<a href="#agents" class="nav-item" data-tab="agents">💬<span>Chat</span></a>
<a href="#config" class="nav-item" data-tab="config">⚙️<span>Config</span></a>
<a href="#kanban" class="nav-item" data-tab="kanban">📋<span>Tasks</span></a>
<a href="#vault" class="nav-item" data-tab="vault">📚<span>Vault</span></a>
<a href="#network" class="nav-item" data-tab="network">🌐<span>Net</span></a>
</nav>
<main class="content" id="content">
<!-- ════════════ Dashboard Panel ════════════ -->
<section id="dashboard" class="panel">
<h2 class="panel-title">📊 Dashboard</h2>
<div class="dash-grid" id="dash-grid">
<div class="dash-card" id="dash-agents">
<div class="dash-card-icon">💬</div>
<div class="dash-card-body">
<div class="dash-card-value" id="dash-agents-val"></div>
<div class="dash-card-label">Agents Online</div>
</div>
</div>
<div class="dash-card" id="dash-hosts">
<div class="dash-card-icon">🖥️</div>
<div class="dash-card-body">
<div class="dash-card-value" id="dash-hosts-val"></div>
<div class="dash-card-label">Hosts</div>
</div>
</div>
<div class="dash-card" id="dash-files">
<div class="dash-card-icon">⚙️</div>
<div class="dash-card-body">
<div class="dash-card-value" id="dash-files-val"></div>
<div class="dash-card-label">Config Files</div>
</div>
</div>
<div class="dash-card" id="dash-msgs">
<div class="dash-card-icon">💭</div>
<div class="dash-card-body">
<div class="dash-card-value" id="dash-msgs-val"></div>
<div class="dash-card-label">Chat Messages</div>
</div>
</div>
<div class="dash-card" id="dash-tasks">
<div class="dash-card-icon"></div>
<div class="dash-card-body">
<div class="dash-card-value" id="dash-tasks-val"></div>
<div class="dash-card-label">Tasks Done</div>
</div>
</div>
<div class="dash-card" id="dash-boards">
<div class="dash-card-icon">📋</div>
<div class="dash-card-body">
<div class="dash-card-value" id="dash-boards-val"></div>
<div class="dash-card-label">Kanban Boards</div>
</div>
</div>
</div>
<div class="dash-section">
<h3>Quick Actions</h3>
<div class="dash-actions">
<button class="btn btn-sm" onclick="window.location.hash='#agents'">💬 Open Chat</button>
<button class="btn btn-sm" onclick="window.location.hash='#kanban'">📋 View Tasks</button>
<button class="btn btn-sm" onclick="window.location.hash='#network'">🌐 Check Network</button>
<button class="btn btn-sm" onclick="refreshDashboard()">🔄 Refresh</button>
</div>
</div>
</section>
<!-- ════════════ Agent Chat Panel ════════════ -->
<section id="agents" class="panel" hidden>
<div id="chat-app">
<div class="chat-header">
<div class="chat-profile-strip">
@@ -45,10 +121,12 @@
</div>
</div>
</section>
<!-- ════════════ Config Editor Panel ════════════ -->
<section id="config" class="panel config-panel" hidden>
<div class="config-file-list" id="config-file-list">
<div class="config-header">
<h2>Config Editor</h2>
<h2 class="panel-title">⚙️ Config Editor</h2>
<span class="config-count" id="config-count">0 files</span>
</div>
<div class="config-files-container" id="config-files-container">
@@ -60,7 +138,7 @@
<button id="config-back-btn" class="btn btn-sm" title="Back to file list">&larr; Back</button>
<span class="editor-filename" id="editor-filename"></span>
<div class="editor-actions">
<button id="config-save-btn" class="btn btn-primary btn-sm" disabled>Saved &#10003;</button>
<button id="config-save-btn" class="btn btn-primary btn-sm" disabled>Saved </button>
</div>
</div>
<div class="editor-body">
@@ -77,6 +155,30 @@
</div>
<div id="config-toast" class="config-toast" hidden></div>
</section>
<!-- ════════════ Kanban Panel ════════════ -->
<section id="kanban" class="panel" hidden>
<div class="kanban-header">
<h2 class="panel-title">📋 Kanban Boards</h2>
<div class="kanban-controls">
<select id="kanban-board-select" class="kanban-select" onchange="loadKanbanBoard()">
<option value="">Select a board...</option>
</select>
</div>
</div>
<div id="kanban-summary" class="kanban-summary">
<div class="kanban-summary-item"><span class="ks-num" id="ks-done">0</span> Done</div>
<div class="kanban-summary-item"><span class="ks-num" id="ks-blocked">0</span> Blocked</div>
<div class="kanban-summary-item"><span class="ks-num" id="ks-ready">0</span> Ready</div>
<div class="kanban-summary-item"><span class="ks-num" id="ks-todo">0</span> Todo</div>
<div class="kanban-summary-item"><span class="ks-num" id="ks-total">0</span> Total</div>
</div>
<div id="kanban-task-list" class="kanban-task-list">
<p class="loading-text">Select a board to view tasks...</p>
</div>
</section>
<!-- ════════════ Vault Reader Panel ════════════ -->
<section id="vault" class="panel" hidden>
<div id="vault-app">
<div class="vault-toolbar">
@@ -97,9 +199,34 @@
</div>
</div>
</section>
<!-- ════════════ Services Panel ════════════ -->
<section id="services" class="panel" hidden>
<div class="services-panel-header">
<h2 class="panel-title">🔧 System Services</h2>
<button class="btn btn-sm" onclick="loadServices()">🔄 Refresh</button>
</div>
<div class="services-table-wrap">
<table class="services-table" id="services-table-svc">
<thead>
<tr>
<th>Service</th>
<th>Status</th>
<th>Sub</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="services-tbody-svc">
<tr><td colspan="4" class="loading-text">Loading services...</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ════════════ Network Dashboard Panel ════════════ -->
<section id="network" class="panel network-panel" hidden>
<div class="network-header">
<h2>Network Status</h2>
<h2>🌐 Network Status</h2>
<div class="network-controls">
<div class="countdown-bar" id="countdown-bar">
<div class="countdown-fill" id="countdown-fill"></div>
@@ -111,39 +238,28 @@
</label>
</div>
</div>
<!-- Summary Bar -->
<div class="network-summary" id="network-summary">
<div class="summary-item online"><span class="summary-count" id="hosts-online">0</span> Online</div>
<div class="summary-item degraded"><span class="summary-count" id="hosts-degraded">0</span> Degraded</div>
<div class="summary-item offline"><span class="summary-count" id="hosts-offline">0</span> Offline</div>
<div class="summary-item total"><span class="summary-count" id="hosts-total">0</span> Total</div>
</div>
<div class="network-tabs">
<button class="tab-btn active" data-tab="hosts">Hosts</button>
<button class="tab-btn" data-tab="services">Services</button>
<button class="tab-btn" data-tab="local">Local Services</button>
<button class="tab-btn" data-tab="local">Local</button>
</div>
<!-- Hosts Tab -->
<div class="tab-content active" id="tab-hosts">
<div class="hosts-grid" id="hosts-grid">
<div class="loading-text">Loading hosts...</div>
</div>
</div>
<!-- Services Tab -->
<div class="tab-content" id="tab-services">
<div class="services-table-wrap">
<table class="services-table" id="services-table">
<thead>
<tr>
<th>Service</th>
<th>Category</th>
<th>URL / Port</th>
<th>Status</th>
<th>Latency</th>
<th>Service</th><th>Category</th><th>URL / Port</th><th>Status</th><th>Latency</th>
</tr>
</thead>
<tbody id="services-tbody">
@@ -152,33 +268,25 @@
</table>
</div>
</div>
<!-- Local Services Tab -->
<div class="tab-content" id="tab-local">
<div class="services-table-wrap">
<table class="services-table" id="local-services-table">
<thead>
<tr>
<th>Address</th>
<th>Port</th>
<th>Process</th>
</tr>
</thead>
<thead><tr><th>Address</th><th>Port</th><th>Process</th></tr></thead>
<tbody id="local-services-tbody">
<tr><td colspan="3" class="loading-text">Loading local services...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="network-footer">
<span class="last-updated" id="last-updated">Last updated: —</span>
<span class="cache-info" id="cache-info"></span>
</div>
</section>
</main>
</div>
<script src="/static/app.js"></script>
<script src="/static/vault.js"></script>
<script src="/static/app.js?v=3"></script>
<script src="/static/vault.js?v=2"></script>
</body>
</html>
+830 -1
View File
@@ -32,8 +32,288 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b
.content { margin-left: 0; padding: 12px; }
}
/* ── Agent Chat ──────────────────────────────────────────────────── */
#chat-app {
display: flex;
flex-direction: column;
height: calc(100vh - 100px);
min-height: 400px;
}
.chat-header {
flex-shrink: 0;
padding: 0 0 12px;
border-bottom: 1px solid var(--border);
margin-bottom: 0;
}
.chat-profile-strip {
display: flex;
align-items: center;
gap: 8px;
}
.chat-profiles-label {
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.chat-profile-tabs {
display: flex;
gap: 4px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
flex: 1;
padding-bottom: 2px;
}
.chat-profile-tabs::-webkit-scrollbar { display: none; }
.chat-profile-tab {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border: 1px solid var(--border);
border-radius: 16px;
background: transparent;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
flex-shrink: 0;
}
.chat-profile-tab:hover {
border-color: var(--accent);
color: var(--text);
}
.chat-profile-tab.active {
background: rgba(0, 212, 255, 0.1);
border-color: var(--accent);
color: var(--accent);
}
.chat-profile-tab .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.chat-profile-tab .status-dot.online {
background: var(--success);
box-shadow: 0 0 6px rgba(63, 185, 80, 0.5);
}
.chat-profile-tab .status-dot.offline {
background: var(--text-muted);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px 0;
display: flex;
flex-direction: column;
gap: 8px;
scroll-behavior: smooth;
}
.chat-welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
flex: 1;
color: var(--text-muted);
gap: 8px;
}
.chat-welcome-icon {
font-size: 48px;
opacity: 0.5;
}
.chat-welcome h3 {
font-size: 18px;
color: var(--text);
margin: 0;
}
.chat-welcome p {
font-size: 13px;
max-width: 360px;
line-height: 1.5;
margin: 0;
}
.chat-message {
display: flex;
flex-direction: column;
max-width: 80%;
animation: msg-in 0.2s ease;
}
@keyframes msg-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.chat-message.user {
align-self: flex-end;
}
.chat-message.agent {
align-self: flex-start;
}
.chat-message .bubble {
padding: 10px 14px;
border-radius: 14px;
line-height: 1.5;
font-size: 13px;
word-wrap: break-word;
white-space: pre-wrap;
}
.chat-message.user .bubble {
background: var(--accent);
color: #000;
border-bottom-right-radius: 4px;
}
.chat-message.agent .bubble {
background: var(--surface);
border: 1px solid var(--border);
border-bottom-left-radius: 4px;
color: var(--text);
}
.chat-message .msg-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
color: var(--text-muted);
padding: 2px 4px 0;
}
.chat-message.user .msg-meta {
justify-content: flex-end;
}
.chat-message .msg-profile-icon {
font-size: 11px;
}
#chat-typing {
flex-shrink: 0;
display: none;
align-items: center;
gap: 4px;
padding: 8px 4px;
font-size: 12px;
color: var(--text-muted);
}
#chat-typing[hidden] { display: none; }
#chat-typing:not([hidden]) { display: flex; }
.typing-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
animation: typing-bounce 1.4s infinite ease-in-out;
}
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing-bounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-6px); }
}
.typing-label {
margin-left: 6px;
font-style: italic;
}
.chat-input-area {
flex-shrink: 0;
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.chat-input-area textarea {
flex: 1;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 20px;
background: var(--surface);
color: var(--text);
font-size: 13px;
font-family: inherit;
outline: none;
resize: none;
line-height: 1.4;
min-height: 40px;
max-height: 120px;
transition: border-color 0.15s;
}
.chat-input-area textarea:focus {
border-color: var(--accent);
}
.chat-input-area textarea::placeholder {
color: var(--text-muted);
}
.chat-input-area .btn {
border-radius: 20px;
padding: 8px 20px;
flex-shrink: 0;
align-self: flex-end;
}
.chat-error {
color: var(--danger);
font-size: 12px;
padding: 4px 0;
text-align: center;
}
/* Mobile */
@media (max-width: 768px) {
#chat-app {
height: calc(100vh - 180px);
}
.chat-message {
max-width: 90%;
}
.chat-message .bubble {
font-size: 14px;
}
.chat-profile-tab {
font-size: 11px;
padding: 4px 10px;
}
}
/* ── Config Panel ───────────────────────────────────────────────── */
.config-panel { padding: 0; overflow: hidden; display: flex; flex-direction: column; }
.config-panel { padding: 0; overflow: hidden; display: none; flex-direction: column; }
.config-panel[hidden] { display: none; }
.config-panel:not([hidden]) { display: flex; }
.config-panel h2 { margin: 0; font-size: 16px; }
.config-file-list,
@@ -693,3 +973,552 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b
.network-footer { padding: 6px 14px; flex-direction: column; gap: 4px; }
.countdown-bar { width: 40px; }
}
/* ── Panel Title ────────────────────────────────────────────────── */
.panel-title {
margin: 0 0 16px;
font-size: 20px;
display: flex;
align-items: center;
gap: 8px;
}
/* ── Dashboard ──────────────────────────────────────────────────── */
.dash-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.dash-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(0,0,0,0.15);
transition: border-color 0.15s;
cursor: default;
min-height: 80px;
}
.dash-card:hover {
border-color: var(--accent);
}
.dash-card-icon {
font-size: 28px;
flex-shrink: 0;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: rgba(0,212,255,0.08);
}
.dash-card-body {
flex: 1;
min-width: 0;
}
.dash-card-value {
font-size: 26px;
font-weight: 700;
line-height: 1.2;
color: var(--accent);
}
.dash-card-label {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
.dash-section {
margin-top: 8px;
}
.dash-section h3 {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 10px;
font-weight: 500;
}
.dash-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* ── Kanban Board ───────────────────────────────────────────────── */
.kanban-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.kanban-controls {
display: flex;
align-items: center;
gap: 8px;
}
.kanban-select {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: #0a0e14;
color: var(--text);
font-size: 13px;
outline: none;
cursor: pointer;
min-width: 200px;
min-height: 38px;
}
.kanban-select:focus {
border-color: var(--accent);
}
.kanban-summary {
display: flex;
gap: 0;
margin-bottom: 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.kanban-summary-item {
flex: 1;
text-align: center;
padding: 10px 4px;
font-size: 11px;
color: var(--text-muted);
border-right: 1px solid var(--border);
}
.kanban-summary-item:last-child {
border-right: none;
}
.ks-num {
display: block;
font-size: 20px;
font-weight: 700;
margin-bottom: 2px;
}
#ks-done { color: var(--success); }
#ks-blocked { color: var(--danger); }
#ks-ready { color: var(--warning); }
#ks-todo { color: var(--text-muted); }
#ks-total { color: var(--accent); }
.kanban-task-list {
max-height: 500px;
overflow-y: auto;
}
.kanban-task-card {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 6px;
background: rgba(0,0,0,0.08);
transition: border-color 0.12s;
cursor: pointer;
}
.kanban-task-card:hover {
border-color: var(--accent);
}
.kanban-task-card:last-child {
margin-bottom: 0;
}
.kanban-task-status {
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
margin-top: 2px;
}
.kanban-task-status.status-done {
background: rgba(63,185,80,0.15);
color: var(--success);
}
.kanban-task-status.status-blocked {
background: rgba(248,81,73,0.15);
color: var(--danger);
}
.kanban-task-status.status-ready {
background: rgba(210,153,34,0.15);
color: var(--warning);
}
.kanban-task-status.status-todo {
background: rgba(139,148,158,0.15);
color: var(--text-muted);
}
.kanban-task-info {
flex: 1;
min-width: 0;
}
.kanban-task-title {
font-size: 13px;
font-weight: 500;
color: var(--text);
line-height: 1.4;
margin-bottom: 2px;
}
.kanban-task-meta {
font-size: 11px;
color: var(--text-muted);
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.kanban-task-body {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* ── Services Panel ─────────────────────────────────────────────── */
.services-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 8px;
}
#services-table-svc .svc-status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
#services-table-svc .svc-active-active {
background: rgba(63,185,80,0.15);
color: var(--success);
}
#services-table-svc .svc-active-inactive,
#services-table-svc .svc-active-dead {
background: rgba(248,81,73,0.15);
color: var(--danger);
}
.service-action-btn {
padding: 3px 8px;
font-size: 11px;
border-radius: 4px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: all 0.12s;
min-height: 28px;
}
.service-action-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.service-action-btn.restart:hover { border-color: var(--warning); color: var(--warning); }
.service-action-btn.stop:hover { border-color: var(--danger); color: var(--danger); }
.service-action-btn.start:hover { border-color: var(--success); color: var(--success); }
.service-actions {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
/* ── Bottom Navigation (Mobile) ─────────────────────────────────── */
.bottom-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
background: var(--surface);
border-top: 1px solid var(--border);
padding: 4px 0;
justify-content: space-around;
align-items: center;
padding-bottom: max(4px, env(safe-area-inset-bottom, 4px));
}
.bottom-nav .nav-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 6px 8px;
color: var(--text-muted);
text-decoration: none;
font-size: 20px;
border-radius: 8px;
transition: all 0.12s;
min-width: 48px;
-webkit-tap-highlight-color: transparent;
}
.bottom-nav .nav-item span {
font-size: 9px;
line-height: 1;
font-weight: 500;
}
.bottom-nav .nav-item.active {
color: var(--accent);
background: rgba(0,212,255,0.08);
}
.bottom-nav .nav-item:active {
transform: scale(0.92);
}
/* ── Mobile Overhaul ────────────────────────────────────────────── */
@media (max-width: 768px) {
/* Hide desktop sidebar on mobile */
.sidebar {
display: none;
}
/* Show bottom nav on mobile */
.bottom-nav {
display: flex;
}
/* Content fills full width on mobile - use grid centering */
.content {
margin-left: 0;
padding: 12px 12px 80px;
display: grid;
justify-items: center;
}
/* All panels on mobile: centered via grid parent */
.panel {
width: 100%;
max-width: 94%;
margin: 0 0 12px;
padding: 10px;
border-radius: 10px;
overflow-x: hidden;
box-sizing: border-box;
}
/* Chat app — prevent any horizontal overflow */
#chat-app {
overflow-x: hidden;
}
/* Dashboard cards - 2 columns */
.dash-grid {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.dash-card {
padding: 12px;
min-height: 70px;
}
.dash-card-icon {
width: 36px;
height: 36px;
font-size: 20px;
}
.dash-card-value {
font-size: 22px;
}
.dash-actions {
flex-direction: column;
}
.dash-actions .btn {
width: 100%;
text-align: center;
}
/* Kanban mobile */
.kanban-select {
width: 100%;
min-width: unset;
}
.kanban-summary {
flex-wrap: wrap;
}
.kanban-summary-item {
min-width: 33%;
border-bottom: 1px solid var(--border);
}
.kanban-header {
flex-direction: column;
align-items: stretch;
}
.kanban-controls {
flex-direction: column;
}
.kanban-task-card {
padding: 10px;
}
/* Services mobile */
.services-table-wrap {
overflow-x: auto;
}
#services-table-svc th,
#services-table-svc td {
padding: 6px 8px;
font-size: 12px;
}
.service-actions {
flex-direction: column;
}
/* Existing mobile improvements */
.panel-title {
font-size: 17px;
margin-bottom: 12px;
}
.services-panel-header {
flex-direction: column;
align-items: flex-start;
}
/* Existing vault mobile at 900px */
@media (max-width: 900px) {
.vault-body { flex-direction: column; }
.vault-tree-panel { width: 100%; max-height: 50vh; display: none; }
.vault-tree-panel.vault-tree-visible { display: block; }
.vault-content-panel { max-height: none; }
.vault-search-box { max-width: none; }
}
/* Existing config mobile */
.config-panel { padding: 0; }
.config-file-list, .config-editor { padding: 12px; }
.config-file-item { padding: 8px 10px; }
.editor-body { flex-direction: column; }
.editor-line-numbers { display: none; }
.editor-textarea { min-height: 250px; }
.editor-header { flex-wrap: wrap; }
.editor-filename { width: 100%; font-size: 14px; }
.editor-status-bar { flex-wrap: wrap; gap: 8px; }
/* Chat mobile improvements */
#chat-app {
height: calc(100vh - 180px);
}
.chat-message { max-width: 65%; }
.chat-message .bubble { font-size: 14px; }
.chat-message.user { margin-right: 12px; }
.chat-message.agent { margin-left: 12px; }
.chat-welcome p { max-width: 260px; margin: 0 auto; }
.chat-welcome-icon { font-size: 40px; }
.chat-profile-tab { font-size: 11px; padding: 4px 8px; white-space: normal; word-break: break-word; }
.chat-profile-strip { gap: 4px; }
.chat-profile-tabs { gap: 2px; }
/* Ensure input textarea doesn't overflow */
.chat-input-area textarea { min-width: 0; max-width: 70%; }
/* Network mobile improvements */
.network-header { padding: 12px 14px 8px; }
.network-summary { padding: 8px 14px; gap: 4px; }
.summary-count { font-size: 18px; }
.summary-item { font-size: 11px; }
.hosts-grid { grid-template-columns: 1fr; gap: 8px; }
.host-card { padding: 10px; }
.tab-content { padding: 10px 14px; }
.tab-btn { padding: 8px 12px; font-size: 12px; }
.network-footer { padding: 6px 14px; flex-direction: column; gap: 4px; }
.countdown-bar { width: 40px; }
}
/* ── Touch-friendly sizing ─────────────────────────────────────── */
button, a, select, input, textarea {
touch-action: manipulation;
}
.btn, .nav-item, .kanban-task-card, .config-file-item,
.service-action-btn, .chat-profile-tab {
cursor: pointer;
}
/* Min tap targets for mobile */
@media (max-width: 768px) {
.btn, .service-action-btn {
min-height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.chat-profile-tab {
min-height: 36px;
padding: 6px 12px;
}
.config-file-item {
min-height: 44px;
padding: 10px 12px;
}
.kanban-task-card {
min-height: 48px;
}
textarea, input, select {
font-size: 16px !important; /* prevents iOS zoom */
}
}