diff --git a/server.py b/server.py
index a024851..e1d273c 100644
--- a/server.py
+++ b/server.py
@@ -2,8 +2,10 @@
import asyncio
import base64
+import json
import os
import secrets
+import sqlite3
import ssl
import subprocess
import time
@@ -36,6 +38,113 @@ _unauthorized = Response(
headers={"WWW-Authenticate": 'Basic realm="Command Center"'},
)
+# ── Agent Profiles ──────────────────────────────────────────────────
+HERMES_BIN = "/home/oplabs/.hermes/hermes-agent/venv/bin/hermes"
+KNOWN_PROFILES = ["coder", "assistant", "artist", "kanban"]
+
+PROFILE_LABELS = {
+ "coder": "Cody (Coder)",
+ "assistant": "Ashley (Assistant)",
+ "artist": "Artie (Artist)",
+ "kanban": "Kanban Manager",
+}
+
+PROFILE_ICONS = {
+ "coder": "💻",
+ "assistant": "🤖",
+ "artist": "🎨",
+ "kanban": "📋",
+}
+
+
+def _get_profile_status(profile: str) -> dict:
+ """Check if a Hermes profile gateway process is running."""
+ try:
+ result = subprocess.run(
+ ["ps", "aux"],
+ capture_output=True, text=True, timeout=10,
+ )
+ for line in result.stdout.splitlines():
+ if f"--profile {profile}" in line and "gateway run" in line:
+ return {"status": "online", "profile": profile}
+ return {"status": "offline", "profile": profile}
+ except Exception:
+ return {"status": "unknown", "profile": profile}
+
+
+def _get_all_profiles_status() -> list[dict]:
+ """Return all known profiles with online/offline status."""
+ results = []
+ for p in KNOWN_PROFILES:
+ st = _get_profile_status(p)
+ st["label"] = PROFILE_LABELS.get(p, p.title())
+ st["icon"] = PROFILE_ICONS.get(p, "🤖")
+ results.append(st)
+ return results
+
+
+# ── Conversations Database ──────────────────────────────────────────
+CONVERSATIONS_DB = os.path.expanduser(
+ "~/.hermes/kanban/boards/hermes-command-center/conversations.db"
+)
+os.makedirs(os.path.dirname(CONVERSATIONS_DB), exist_ok=True)
+
+
+def _init_conversations_db():
+ """Create the conversations table if it doesn't exist."""
+ conn = sqlite3.connect(CONVERSATIONS_DB)
+ try:
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS conversations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ profile TEXT NOT NULL,
+ role TEXT NOT NULL,
+ content TEXT NOT NULL,
+ created_at REAL NOT NULL
+ )
+ """)
+ conn.execute("""
+ CREATE INDEX IF NOT EXISTS idx_conversations_profile
+ ON conversations(profile, created_at)
+ """)
+ conn.commit()
+ finally:
+ conn.close()
+
+
+def _save_message(profile: str, role: str, content: str):
+ """Save a chat message to the conversation DB."""
+ conn = sqlite3.connect(CONVERSATIONS_DB)
+ try:
+ conn.execute(
+ "INSERT INTO conversations (profile, role, content, created_at) VALUES (?, ?, ?, ?)",
+ (profile, role, content, time.time()),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+
+def _get_conversation_history(profile: str, limit: int = 50) -> list[dict]:
+ """Get recent conversation history for a profile."""
+ conn = sqlite3.connect(CONVERSATIONS_DB)
+ try:
+ rows = conn.execute(
+ "SELECT role, content, created_at FROM conversations "
+ "WHERE profile = ? ORDER BY created_at DESC LIMIT ?",
+ (profile, limit),
+ ).fetchall()
+ messages = [
+ {"role": r[0], "content": r[1], "created_at": r[2]}
+ for r in reversed(rows)
+ ]
+ return messages
+ finally:
+ conn.close()
+
+
+_init_conversations_db()
+
app = FastAPI(title="Hermes Command Center")
STATIC_DIR = Path(__file__).parent / "static"
@@ -225,6 +334,167 @@ async def _run_health_checks() -> dict:
return result
+# ── Config Editor ──────────────────────────────────────────────────────
+HERMES_HOME = Path(os.path.expanduser("~/.hermes")).resolve()
+
+CONFIG_WHITELIST = [
+ {"path": "~/.hermes/.env", "filename": ".env", "label": ".env (Environment Variables)"},
+ {"path": "~/.hermes/config.yaml", "filename": "config.yaml", "label": "config.yaml (Main Config)"},
+ {"path": "~/.hermes/profiles/kanban/config.yaml", "filename": "config.yaml", "label": "kanban/config.yaml"},
+ {"path": "~/.hermes/profiles/coder/config.yaml", "filename": "config.yaml", "label": "coder/config.yaml"},
+ {"path": "~/.hermes/profiles/assistant/config.yaml", "filename": "config.yaml", "label": "assistant/config.yaml"},
+ {"path": "~/.hermes/profiles/kanban/SOUL.md", "filename": "SOUL.md", "label": "kanban/SOUL.md"},
+ {"path": "~/.hermes/profiles/kanban/AGENTS.md", "filename": "AGENTS.md", "label": "kanban/AGENTS.md"},
+]
+
+# Resolve whitelist once at startup
+CONFIG_PATHS: dict[str, dict] = {}
+for entry in CONFIG_WHITELIST:
+ resolved = Path(entry["path"]).expanduser().resolve()
+ CONFIG_PATHS[str(resolved)] = entry
+
+
+def _resolve_config_path(user_path: str) -> Path | None:
+ """Resolve and validate a config path against the whitelist.
+
+ Returns the resolved Path if whitelisted, None otherwise.
+ Accepts both absolute paths and ~/ prefixed paths.
+ """
+ candidate = Path(user_path).expanduser().resolve()
+ if str(candidate) in CONFIG_PATHS:
+ return candidate
+ return None
+
+
+def _create_backup(filepath: Path) -> str:
+ """Create a timestamped backup of the given file. Returns the backup path."""
+ bak = filepath.parent / f"{filepath.name}.bak.{int(time.time())}"
+ bak.write_text(filepath.read_text(encoding="utf-8"), encoding="utf-8")
+ return str(bak)
+
+
+def _validate_yaml(content: str) -> str | None:
+ """Validate YAML content. Returns None if valid, or an error message."""
+ try:
+ yaml.safe_load(content)
+ return None
+ except yaml.YAMLError as e:
+ return f"YAML validation error: {e}"
+
+
+@ app.get("/api/config/list")
+async def config_list():
+ """List all editable config files with metadata."""
+ files = []
+ for entry in CONFIG_WHITELIST:
+ fp = Path(entry["path"]).expanduser().resolve()
+ info = {
+ "path": str(fp),
+ "filename": entry["filename"],
+ "label": entry["label"],
+ "missing": not fp.exists(),
+ }
+ if fp.exists():
+ stat = fp.stat()
+ content = fp.read_text(encoding="utf-8")
+ info["size"] = stat.st_size
+ info["line_count"] = len(content.split("\n"))
+ info["last_modified"] = int(stat.st_mtime)
+ else:
+ info["size"] = 0
+ info["line_count"] = 0
+ info["last_modified"] = None
+ files.append(info)
+ return {"files": files}
+
+
+@ app.get("/api/config/read")
+async def config_read(path: str = Query(..., description="Path to config file")):
+ """Read a config file's content."""
+ fp = _resolve_config_path(path)
+ if fp is None:
+ return JSONResponse({"error": "Path not allowed or not found in whitelist"}, status_code=403)
+ if not fp.exists():
+ return JSONResponse({"error": "File not found"}, status_code=404)
+
+ try:
+ content = fp.read_text(encoding="utf-8")
+ except Exception as e:
+ return JSONResponse({"error": f"Failed to read file: {e}"}, status_code=500)
+
+ entry = CONFIG_PATHS.get(str(fp), {})
+ stat = fp.stat()
+ return JSONResponse({
+ "path": str(fp),
+ "filename": entry.get("filename", fp.name),
+ "label": entry.get("label", fp.name),
+ "content": content,
+ "size": stat.st_size,
+ "line_count": len(content.split("\n")),
+ "last_modified": int(stat.st_mtime),
+ })
+
+
+@ app.post("/api/config/save")
+async def config_save(request: Request):
+ """Save a config file with automatic backup and YAML validation."""
+ body = await request.json()
+ path = body.get("path", "")
+ content = body.get("content", "")
+
+ if not path or content is None:
+ return JSONResponse({"error": "Missing 'path' or 'content' in request body"}, status_code=400)
+
+ fp = _resolve_config_path(path)
+ if fp is None:
+ return JSONResponse({"error": "Path not allowed or not found in whitelist"}, status_code=403)
+
+ # YAML validation for .yaml/.yml files
+ if fp.suffix.lower() in (".yaml", ".yml"):
+ err = _validate_yaml(content)
+ if err:
+ return JSONResponse({"error": err}, status_code=400)
+
+ # Auto-backup
+ try:
+ backup_path = _create_backup(fp) if fp.exists() else None
+ except Exception as e:
+ return JSONResponse({"error": f"Failed to create backup: {e}"}, status_code=500)
+
+ # Save
+ try:
+ fp.write_text(content, encoding="utf-8")
+ except Exception as e:
+ return JSONResponse({"error": f"Failed to save file: {e}"}, status_code=500)
+
+ stat = fp.stat()
+ return JSONResponse({
+ "saved": True,
+ "path": str(fp),
+ "backup_path": backup_path,
+ "size": stat.st_size,
+ "line_count": len(content.split("\n")),
+ "last_modified": int(stat.st_mtime),
+ })
+
+
+@ app.post("/api/config/backup")
+async def config_backup(path: str = Query(..., description="Path to config file to backup")):
+ """Create a manual backup of a config file."""
+ fp = _resolve_config_path(path)
+ if fp is None:
+ return JSONResponse({"error": "Path not allowed or not found in whitelist"}, status_code=403)
+ if not fp.exists():
+ return JSONResponse({"error": "File not found"}, status_code=404)
+
+ try:
+ backup_path = _create_backup(fp)
+ except Exception as e:
+ return JSONResponse({"error": f"Failed to create backup: {e}"}, status_code=500)
+
+ return JSONResponse({"backup_path": backup_path, "path": str(fp)})
+
+
# ── Path security ─────────────────────────────────────────────────────
def _resolve_vault_path(user_path: str) -> Path | None:
"""Resolve a user-supplied path relative to the vault root.
@@ -407,6 +677,81 @@ async def vault_search(q: str = Query(..., min_length=1, description="Search que
})
+# ── Agent Chat API ──────────────────────────────────────────────────
+
+
+@app.get("/api/agents")
+async def agents_list():
+ """List available Hermes agent profiles with online/offline status."""
+ profiles = _get_all_profiles_status()
+ return JSONResponse({"agents": profiles})
+
+
+@app.post("/api/agents/{profile}/send")
+async def agent_send(profile: str, request: Request):
+ """Send a message to a Hermes agent profile and return the response."""
+ if profile not in KNOWN_PROFILES:
+ return JSONResponse({"error": f"Unknown profile: {profile}"}, status_code=404)
+
+ try:
+ body = await request.json()
+ except Exception:
+ return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
+
+ message = (body or {}).get("message", "").strip()
+ if not message:
+ return JSONResponse({"error": "Message is required"}, status_code=400)
+
+ # Save user message
+ _save_message(profile, "user", message)
+
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ HERMES_BIN, "-p", profile, "chat", "-q", message,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ env={**os.environ, "PYTHONUNBUFFERED": "1"},
+ )
+ try:
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
+ except asyncio.TimeoutError:
+ proc.kill()
+ return JSONResponse({"error": "Agent response timed out"}, status_code=504)
+
+ if proc.returncode != 0:
+ err = stderr.decode().strip() if stderr else ""
+ return JSONResponse(
+ {"error": f"Agent exited with code {proc.returncode}: {err[:200]}"},
+ status_code=502,
+ )
+
+ response_text = stdout.decode().strip() if stdout else ""
+ if not response_text:
+ response_text = "(no response)"
+
+ # Save agent response
+ _save_message(profile, "agent", response_text)
+
+ return JSONResponse({
+ "profile": profile,
+ "response": response_text,
+ })
+
+ except FileNotFoundError:
+ return JSONResponse({"error": "Hermes binary not found"}, status_code=500)
+ except Exception as e:
+ return JSONResponse({"error": str(e)[:200]}, status_code=500)
+
+
+@app.get("/api/agents/{profile}/history")
+async def agent_history(profile: str, limit: int = Query(50, ge=1, le=200)):
+ """Get recent conversation history for a profile."""
+ if profile not in KNOWN_PROFILES:
+ return JSONResponse({"error": f"Unknown profile: {profile}"}, status_code=404)
+ messages = _get_conversation_history(profile, limit=limit)
+ return JSONResponse({"profile": profile, "messages": messages})
+
+
# ── Serve frontend ────────────────────────────────────────────────────
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
diff --git a/static/app.js b/static/app.js
index 1d850cd..5c941f0 100644
--- a/static/app.js
+++ b/static/app.js
@@ -304,4 +304,322 @@ function showToast(message, type) {
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('config-save-btn').addEventListener('click', saveFile);
document.getElementById('config-back-btn').addEventListener('click', goBackToList);
+ initNetworkDashboard();
});
+
+// ── Network Dashboard ────────────────────────────────────────────────
+
+let networkState = {
+ autoRefresh: true,
+ refreshInterval: 30000, // 30 seconds
+ 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'));
+ btn.classList.add('active');
+ document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
+ const tab = document.getElementById('tab-' + btn.dataset.tab);
+ if (tab) tab.classList.add('active');
+ });
+ });
+
+ // Refresh button
+ document.getElementById('network-refresh-btn').addEventListener('click', () => {
+ forceRefresh();
+ });
+
+ // Auto-refresh toggle
+ document.getElementById('auto-refresh-toggle').addEventListener('click', () => {
+ networkState.autoRefresh = document.getElementById('auto-refresh-toggle').checked;
+ 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}`);
+ 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);
+ }
+}
+
+function forceRefresh() {
+ networkState.data = null;
+ if (networkState.refreshTimer) {
+ clearTimeout(networkState.refreshTimer);
+ networkState.refreshTimer = null;
+ }
+ if (networkState.countdownInterval) {
+ clearInterval(networkState.countdownInterval);
+ networkState.countdownInterval = null;
+ }
+ document.getElementById('hosts-grid').innerHTML = '
Refreshing...
';
+ document.getElementById('services-tbody').innerHTML = '| Refreshing... |
';
+ document.getElementById('local-services-tbody').innerHTML = '| Refreshing... |
';
+ fetchNetworkData();
+}
+
+function startAutoRefresh() {
+ stopAutoRefresh();
+ networkState.countdownRemaining = 30;
+ updateCountdownBar();
+ networkState.countdownInterval = setInterval(() => {
+ networkState.countdownRemaining--;
+ if (networkState.countdownRemaining <= 0) {
+ networkState.countdownRemaining = 30;
+ fetchNetworkData();
+ }
+ updateCountdownBar();
+ }, 1000);
+ networkState.refreshTimer = setTimeout(() => {
+ // Safety timeout in case countdown misses
+ fetchNetworkData();
+ }, networkState.refreshInterval + 1000);
+}
+
+function stopAutoRefresh() {
+ 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%';
+}
+
+function updateCountdownBar() {
+ const pct = (networkState.countdownRemaining / 30) * 100;
+ document.getElementById('countdown-fill').style.width = pct + '%';
+}
+
+function renderNetwork(data) {
+ renderHosts(data.hosts || []);
+ renderServices(data.services || []);
+ renderLocalServices(data.local_services || []);
+ renderSummary(data.hosts || []);
+}
+
+function renderSummary(hosts) {
+ let online = 0, offline = 0, degraded = 0;
+ hosts.forEach(h => {
+ if (h.status === 'online') online++;
+ else if (h.status === 'offline') offline++;
+ else if (h.status === 'degraded') degraded++;
+ else offline++; // unknown hosts counted as offline
+ });
+ document.getElementById('hosts-online').textContent = online;
+ document.getElementById('hosts-offline').textContent = offline;
+ document.getElementById('hosts-degraded').textContent = degraded;
+ document.getElementById('hosts-total').textContent = hosts.length;
+}
+
+function renderHosts(hosts) {
+ const grid = document.getElementById('hosts-grid');
+ if (!hosts || hosts.length === 0) {
+ grid.innerHTML = 'No hosts configured.
';
+ 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';
+
+ let latencyDisplay = host.latency_ms != null ? host.latency_ms.toFixed(1) + 'ms' : '—';
+ let osDisplay = host.os || '';
+
+ card.innerHTML = `
+
+
+ ${host.role ? '
' + escapeHtml(host.role) + '
' : ''}
+ ${host.ports && host.ports.length > 0 ? renderPorts(host.ports) : ''}
+
+
+
+
+ ${renderHostDetails(host)}
+
+
+ `;
+
+ // Click to expand
+ 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';
+ }
+ });
+
+ grid.appendChild(card);
+ });
+}
+
+function renderPorts(ports) {
+ if (!ports || ports.length === 0) return '';
+ let html = 'Ports:';
+ ports.forEach(p => {
+ const openClass = p.open ? 'port-open' : 'port-closed';
+ html += `${escapeHtml(p.name)}:${p.port}`;
+ });
+ html += '
';
+ return html;
+}
+
+function renderHostDetails(host) {
+ let html = '';
+ 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' : '—'],
+ ];
+ fields.forEach(([label, val]) => {
+ if (val) {
+ html += `| ${escapeHtml(label)} | ${escapeHtml(String(val))} |
`;
+ }
+ });
+ if (host.ports && host.ports.length > 0) {
+ html += '| Ports | ';
+ host.ports.forEach(p => {
+ const icon = p.open ? '✓' : '✗';
+ const cls = p.open ? 'port-open' : 'port-closed';
+ html += `${icon} ${escapeHtml(p.name)}:${p.port} `;
+ });
+ html += ' |
';
+ }
+ html += '
';
+ return html;
+}
+
+function renderServices(services) {
+ const tbody = document.getElementById('services-tbody');
+ if (!services || services.length === 0) {
+ tbody.innerHTML = '| No services configured. |
';
+ 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 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 = `
+ ${escapeHtml(svc.name || '—')} |
+ ${escapeHtml(category)} |
+ ${escapeHtml(url)} |
+ ${statusLabel} |
+ ${latency} |
+ `;
+ if (svc.error) {
+ tr.innerHTML += `${escapeHtml(svc.error)} | `;
+ }
+ tbody.appendChild(tr);
+ });
+}
+
+function renderLocalServices(services) {
+ const tbody = document.getElementById('local-services-tbody');
+ if (!services || services.length === 0) {
+ tbody.innerHTML = '| No local services detected. |
';
+ return;
+ }
+
+ tbody.innerHTML = '';
+ services.forEach(svc => {
+ const tr = document.createElement('tr');
+ tr.innerHTML = `
+ ${escapeHtml(svc.local_address || '—')} |
+ ${escapeHtml(svc.port || '—')} |
+ ${escapeHtml(svc.process || '—')} |
+ `;
+ 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'
+ : '';
+}
+
+function showNetworkError(msg) {
+ document.getElementById('hosts-grid').innerHTML = '' + escapeHtml(msg) + '
';
+ document.getElementById('services-tbody').innerHTML = '| ' + escapeHtml(msg) + ' |
';
+ document.getElementById('local-services-tbody').innerHTML = '| ' + escapeHtml(msg) + ' |
';
+}
+
+function escapeHtml(str) {
+ if (str == null) return '';
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+}
diff --git a/static/index.html b/static/index.html
index 7ddfef3..8a8fb82 100644
--- a/static/index.html
+++ b/static/index.html
@@ -19,12 +19,63 @@
- Agent Chat
- Coming soon...
+
+
+
+
+
💬
+
Agent Chat
+
Select an agent profile above and type a message to start a conversation.
+
+
+
+
+
+
+ Agent is typing...
+
+
+
+
+
+
-
- Config Editor
- Coming soon...
+
+
+
+
+
Select a panel to open...
+
+
+
+
-
- Network Status
- Coming soon...
+
+
+
+
+
+
0 Online
+
0 Degraded
+
0 Offline
+
0 Total
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Service |
+ Category |
+ URL / Port |
+ Status |
+ Latency |
+
+
+
+ | Loading services... |
+
+
+
+
+
+
+
+
+
+
+
+ | Address |
+ Port |
+ Process |
+
+
+
+ | Loading local services... |
+
+
+
+
+
+
diff --git a/static/style.css b/static/style.css
index 3c5a314..c29c8d6 100644
--- a/static/style.css
+++ b/static/style.css
@@ -148,6 +148,340 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
+/* ── Vault Reader ──────────────────────────────────────────────────── */
+
+/* Layout */
+.vault-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--border);
+}
+.vault-body {
+ display: flex;
+ gap: 16px;
+ min-height: 400px;
+}
+
+/* Tree panel */
+.vault-tree-panel {
+ width: 280px;
+ flex-shrink: 0;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: #0a0e14;
+ overflow-y: auto;
+ max-height: 70vh;
+ padding: 8px 0;
+ font-size: 13px;
+}
+.vault-tree-node {
+ padding: 4px 8px;
+ cursor: default;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ user-select: none;
+}
+.vault-tree-folder {
+ cursor: pointer;
+ color: var(--text);
+ border-left: 2px solid transparent;
+}
+.vault-tree-folder:hover {
+ background: rgba(0,212,255,0.06);
+ border-left-color: var(--accent);
+}
+.vault-tree-file {
+ cursor: pointer;
+ color: var(--text-muted);
+ border-left: 2px solid transparent;
+ transition: background 0.1s;
+}
+.vault-tree-file:hover {
+ background: rgba(0,212,255,0.06);
+ color: var(--text);
+ border-left-color: var(--accent);
+}
+.vault-tree-file.active {
+ color: var(--accent);
+ background: rgba(0,212,255,0.1);
+ border-left-color: var(--accent);
+ font-weight: 500;
+}
+.vault-tree-other {
+ cursor: default;
+}
+.tree-folder-icon, .tree-file-icon {
+ flex-shrink: 0;
+ width: 18px;
+ text-align: center;
+ font-size: 11px;
+}
+.tree-folder-icon { font-size: 10px; color: var(--text-muted); }
+.tree-file-icon { font-size: 13px; }
+.tree-folder-name { font-weight: 500; }
+.tree-muted { opacity: 0.5; }
+.vault-tree-children { }
+.vault-tree-error {
+ padding: 8px 12px;
+ color: var(--danger);
+ font-size: 12px;
+}
+
+/* Content panel */
+.vault-content-panel {
+ flex: 1;
+ min-width: 0;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: #0a0e14;
+ padding: 20px;
+ overflow-y: auto;
+ max-height: 70vh;
+}
+.vault-welcome {
+ text-align: center;
+ padding: 60px 20px;
+ color: var(--text-muted);
+}
+.vault-welcome h2 { font-size: 22px; margin-bottom: 8px; color: var(--text); }
+.vault-welcome p { font-size: 14px; }
+.vault-loading {
+ text-align: center;
+ padding: 40px;
+ color: var(--text-muted);
+ font-size: 14px;
+}
+.vault-error {
+ padding: 20px;
+ color: var(--danger);
+ font-size: 14px;
+}
+
+/* File header */
+.vault-file-header {
+ margin-bottom: 16px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--border);
+}
+.vault-file-path {
+ font-size: 12px;
+ color: var(--text-muted);
+ font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
+}
+
+/* Markdown body */
+.vault-markdown-body {
+ line-height: 1.7;
+ font-size: 15px;
+ color: var(--text);
+ overflow-wrap: break-word;
+}
+.vault-markdown-body h1 { font-size: 26px; font-weight: 700; margin: 24px 0 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
+.vault-markdown-body h2 { font-size: 22px; font-weight: 600; margin: 20px 0 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
+.vault-markdown-body h3 { font-size: 18px; font-weight: 600; margin: 16px 0 8px; }
+.vault-markdown-body h4 { font-size: 16px; font-weight: 600; margin: 14px 0 6px; }
+.vault-markdown-body h5 { font-size: 14px; font-weight: 600; margin: 12px 0 6px; }
+.vault-markdown-body h6 { font-size: 13px; font-weight: 600; margin: 12px 0 6px; color: var(--text-muted); }
+.vault-markdown-body p { margin: 0 0 12px; }
+.vault-markdown-body a { color: var(--accent); text-decoration: none; }
+.vault-markdown-body a:hover { text-decoration: underline; }
+.vault-markdown-body img { max-width: 100%; border-radius: 6px; margin: 12px 0; }
+.vault-markdown-body blockquote {
+ margin: 12px 0;
+ padding: 8px 16px;
+ border-left: 3px solid var(--accent);
+ background: rgba(0,212,255,0.04);
+ color: var(--text-muted);
+ border-radius: 0 6px 6px 0;
+}
+.vault-markdown-body ul, .vault-markdown-body ol {
+ margin: 8px 0 12px;
+ padding-left: 24px;
+}
+.vault-markdown-body li { margin: 4px 0; }
+.vault-markdown-body li > ul, .vault-markdown-body li > ol { margin: 4px 0; }
+.vault-markdown-body hr { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
+.vault-markdown-body pre {
+ margin: 12px 0;
+ padding: 14px 16px;
+ background: #0d1117;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ overflow-x: auto;
+ font-size: 13px;
+ line-height: 1.5;
+}
+.vault-markdown-body code {
+ font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
+ font-size: 13px;
+ padding: 2px 5px;
+ background: rgba(0,0,0,0.3);
+ border-radius: 3px;
+}
+.vault-markdown-body pre code {
+ padding: 0;
+ background: none;
+ border: none;
+ font-size: 13px;
+}
+.vault-markdown-body table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 12px 0;
+ font-size: 14px;
+}
+.vault-markdown-body th, .vault-markdown-body td {
+ padding: 8px 12px;
+ border: 1px solid var(--border);
+ text-align: left;
+}
+.vault-markdown-body th {
+ background: rgba(0,212,255,0.06);
+ font-weight: 600;
+}
+.vault-markdown-body tr:nth-child(even) {
+ background: rgba(255,255,255,0.015);
+}
+.vault-markdown-body del { color: var(--text-muted); }
+.vault-markdown-body strong { font-weight: 600; }
+.vault-markdown-body em { font-style: italic; }
+
+/* Wiki-links */
+.wiki-link {
+ color: var(--accent) !important;
+ text-decoration: underline !important;
+ text-decoration-style: dotted !important;
+ cursor: pointer;
+ padding: 1px 2px;
+ border-radius: 3px;
+ transition: background 0.1s;
+}
+.wiki-link:hover {
+ background: rgba(0,212,255,0.12);
+ text-decoration-style: solid !important;
+}
+
+/* Search box */
+.vault-search-box {
+ position: relative;
+ flex: 1;
+ max-width: 400px;
+}
+.vault-search-box input {
+ width: 100%;
+ padding: 8px 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: #0a0e14;
+ color: var(--text);
+ font-size: 13px;
+ outline: none;
+ transition: border-color 0.15s;
+}
+.vault-search-box input:focus {
+ border-color: var(--accent);
+}
+.vault-search-box input::placeholder {
+ color: var(--text-muted);
+}
+.vault-btn {
+ padding: 6px 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--surface);
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 16px;
+ line-height: 1;
+ transition: all 0.12s;
+}
+.vault-btn:hover {
+ border-color: var(--accent);
+ color: var(--text);
+}
+
+/* Search dropdown */
+.vault-search-dropdown {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ margin-top: 4px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
+ max-height: 400px;
+ overflow-y: auto;
+ z-index: 100;
+}
+.vault-search-status {
+ padding: 12px 16px;
+ color: var(--text-muted);
+ font-size: 13px;
+ text-align: center;
+}
+.vault-search-count {
+ padding: 8px 16px;
+ font-size: 11px;
+ color: var(--text-muted);
+ border-bottom: 1px solid var(--border);
+}
+.vault-search-result-item {
+ padding: 8px 16px;
+ cursor: pointer;
+ border-bottom: 1px solid transparent;
+ transition: background 0.1s;
+}
+.vault-search-result-item:hover {
+ background: rgba(0,212,255,0.06);
+}
+.vault-search-result-item:not(:last-child) {
+ border-bottom-color: var(--border);
+}
+.vault-search-result-path {
+ font-size: 11px;
+ color: var(--text-muted);
+ font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
+ margin-bottom: 2px;
+}
+.vault-search-result-snippet {
+ font-size: 12px;
+ color: var(--text);
+ line-height: 1.4;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Mobile vault */
+@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;
+ }
+}
+
/* Mobile */
@media (max-width: 768px) {
.config-panel { padding: 0; }
@@ -160,3 +494,202 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b
.editor-filename { width: 100%; font-size: 14px; }
.editor-status-bar { flex-wrap: wrap; gap: 8px; }
}
+
+/* ── Network Dashboard ──────────────────────────────────────────── */
+.network-panel { padding: 0; overflow: hidden; }
+.network-header {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 16px 20px 12px; border-bottom: 1px solid var(--border);
+ flex-wrap: wrap; gap: 8px;
+}
+.network-header h2 { margin: 0; font-size: 18px; }
+.network-controls {
+ display: flex; align-items: center; gap: 10px;
+}
+
+/* Countdown bar */
+.countdown-bar {
+ width: 60px; height: 4px; background: var(--border);
+ border-radius: 2px; overflow: hidden;
+}
+.countdown-fill {
+ height: 100%; background: var(--accent);
+ transition: width 0.3s linear; border-radius: 2px;
+}
+
+/* Auto-refresh toggle */
+.auto-refresh-toggle {
+ display: flex; align-items: center; gap: 4px;
+ font-size: 11px; color: var(--text-muted); cursor: pointer;
+ user-select: none;
+}
+.auto-refresh-toggle input { accent-color: var(--accent); }
+
+/* Refresh button */
+.btn-refresh {
+ font-size: 16px; line-height: 1; padding: 4px 10px;
+ transition: transform 0.2s;
+}
+.btn-refresh:active { transform: rotate(180deg); }
+
+/* Summary Bar */
+.network-summary {
+ display: flex; gap: 0; padding: 12px 20px;
+ border-bottom: 1px solid var(--border);
+ background: rgba(0,0,0,0.1);
+}
+.summary-item {
+ flex: 1; text-align: center; font-size: 12px;
+ color: var(--text-muted); padding: 4px;
+}
+.summary-count {
+ display: block; font-size: 22px; font-weight: 700;
+ margin-bottom: 2px;
+}
+.summary-item.online .summary-count { color: var(--success); }
+.summary-item.offline .summary-count { color: var(--danger); }
+.summary-item.degraded .summary-count { color: var(--warning); }
+.summary-item.total .summary-count { color: var(--accent); }
+
+/* Tabs */
+.network-tabs {
+ display: flex; border-bottom: 1px solid var(--border);
+ padding: 0 16px; background: rgba(0,0,0,0.05);
+}
+.tab-btn {
+ padding: 10px 16px; font-size: 13px; font-weight: 500;
+ background: none; border: none; color: var(--text-muted);
+ cursor: pointer; border-bottom: 2px solid transparent;
+ transition: all 0.15s;
+}
+.tab-btn:hover { color: var(--text); }
+.tab-btn.active {
+ color: var(--accent); border-bottom-color: var(--accent);
+}
+.tab-content { display: none; padding: 16px 20px; }
+.tab-content.active { display: block; }
+
+/* Host Cards Grid */
+.hosts-grid {
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ gap: 12px;
+}
+.host-card {
+ border: 1px solid var(--border); border-radius: var(--radius);
+ padding: 14px; background: rgba(0,0,0,0.15);
+ transition: border-color 0.15s, box-shadow 0.15s;
+ cursor: default;
+}
+.host-card:hover { border-color: #555; }
+.host-card.status-online { border-left: 3px solid var(--success); }
+.host-card.status-offline { border-left: 3px solid var(--danger); }
+.host-card.status-degraded { border-left: 3px solid var(--warning); }
+.host-card.status-unknown { border-left: 3px solid var(--text-muted); }
+
+.host-card-header { margin-bottom: 8px; }
+.host-name-row {
+ display: flex; align-items: center; justify-content: space-between;
+ margin-bottom: 4px;
+}
+.host-name { font-size: 15px; font-weight: 600; }
+.host-meta {
+ display: flex; align-items: center; gap: 12px;
+ font-size: 12px; color: var(--text-muted); flex-wrap: wrap;
+}
+.host-ip { font-family: monospace; font-size: 12px; }
+.host-os { font-family: monospace; font-size: 11px; }
+.host-latency { font-family: monospace; font-size: 12px; }
+.host-card-body { font-size: 12px; color: var(--text-muted); }
+.host-role { margin-bottom: 6px; line-height: 1.4; }
+.host-expand-btn { margin-top: 8px; }
+
+/* Port badges */
+.host-ports {
+ display: flex; flex-wrap: wrap; align-items: center; gap: 4px;
+ margin-top: 6px;
+}
+.ports-label { font-size: 11px; color: var(--text-muted); margin-right: 4px; }
+.port-badge {
+ display: inline-block; padding: 2px 6px; border-radius: 4px;
+ font-size: 10px; font-family: monospace; font-weight: 500;
+}
+.port-open {
+ background: rgba(63, 185, 80, 0.15); color: var(--success);
+ border: 1px solid rgba(63, 185, 80, 0.3);
+}
+.port-closed {
+ background: rgba(248, 81, 73, 0.15); color: var(--danger);
+ border: 1px solid rgba(248, 81, 73, 0.3);
+}
+
+/* Status Badge */
+.status-badge {
+ display: inline-block; padding: 3px 8px; border-radius: 10px;
+ font-size: 11px; font-weight: 600; text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+.status-online { background: rgba(63, 185, 80, 0.15); color: var(--success); }
+.status-offline { background: rgba(248, 81, 73, 0.15); color: var(--danger); }
+.status-degraded { background: rgba(210, 153, 34, 0.15); color: var(--warning); }
+.status-unknown { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); }
+
+/* Host details (expandable) */
+.host-details {
+ margin-top: 10px; padding-top: 10px;
+ border-top: 1px solid var(--border);
+}
+.host-details.hidden { display: none; }
+.details-table { width: 100%; font-size: 12px; border-collapse: collapse; }
+.details-table td { padding: 4px 8px; border-bottom: 1px solid rgba(48,54,61,0.4); }
+.dt-label { color: var(--text-muted); width: 100px; font-weight: 500; }
+.dt-value { color: var(--text); font-family: monospace; }
+
+/* Services Table */
+.services-table-wrap { overflow-x: auto; }
+.services-table { width: 100%; border-collapse: collapse; font-size: 13px; }
+.services-table th {
+ text-align: left; padding: 8px 10px;
+ border-bottom: 2px solid var(--border);
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
+ color: var(--text-muted); font-weight: 600;
+ white-space: nowrap;
+}
+.services-table td {
+ padding: 8px 10px; border-bottom: 1px solid var(--border);
+ vertical-align: middle;
+}
+.services-table tr:hover td { background: rgba(0,212,255,0.03); }
+.svc-name { font-weight: 500; }
+.svc-category {
+ display: inline-block; padding: 2px 6px; border-radius: 4px;
+ font-size: 11px; background: rgba(139,148,158,0.1); color: var(--text-muted);
+}
+.svc-url { font-family: monospace; font-size: 12px; color: var(--text-muted); max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.svc-latency { font-family: monospace; font-size: 12px; }
+.svc-error { color: var(--danger); font-size: 11px; }
+
+/* Network Footer */
+.network-footer {
+ display: flex; justify-content: space-between; align-items: center;
+ padding: 8px 20px; border-top: 1px solid var(--border);
+ font-size: 11px; color: var(--text-muted);
+}
+
+/* Loading / Empty / Error states */
+.loading-text { color: var(--text-muted); font-style: italic; padding: 20px 0; text-align: center; }
+.empty-text { color: var(--text-muted); padding: 20px 0; text-align: center; }
+.error-text { color: var(--danger); padding: 20px 0; text-align: center; }
+
+/* Mobile network */
+@media (max-width: 768px) {
+ .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; }
+}
diff --git a/test_config_editor.py b/test_config_editor.py
index dfe2947..da947a7 100644
--- a/test_config_editor.py
+++ b/test_config_editor.py
@@ -86,7 +86,7 @@ t('Manual backup endpoint', r.status_code == 200 and 'backup_path' in r.json())
# 10. HTML serving
r = requests.get(f'{BASE}/', auth=AUTH)
-t('Serves index.html', r.status_code == 200 and 'Config Files' in r.text)
+t('Serves index.html', r.status_code == 200 and 'Config Editor' in r.text)
p.terminate()
p.join()