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 = ` +
+
+ ${escapeHtml(host.name)} + ${statusLabel} +
+
+ ${escapeHtml(host.ip || '—')} + ${osDisplay ? '' + escapeHtml(osDisplay) + '' : ''} + ${latencyDisplay} +
+
+
+ ${host.role ? '
' + escapeHtml(host.role) + '
' : ''} + ${host.ports && host.ports.length > 0 ? renderPorts(host.ports) : ''} + +
+ + `; + + // 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 += ``; + } + }); + if (host.ports && host.ports.length > 0) { + html += ''; + } + html += '
${escapeHtml(label)}${escapeHtml(String(val))}
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 += '
'; + 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: +
+
+
+
+
+
💬
+

Agent Chat

+

Select an agent profile above and type a message to start a conversation.

+
+
+ +
+ + +
+
-
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()