diff --git a/server.py b/server.py index 35978e7..a024851 100644 --- a/server.py +++ b/server.py @@ -1,11 +1,16 @@ -"""Hermes Command Center - FastAPI backend.""" +"""Hermes Command Center - FastAPI backend with Network Status Dashboard.""" -import os +import asyncio import base64 +import os import secrets +import ssl +import subprocess +import time from pathlib import Path -from fastapi import FastAPI, Request, Response +import yaml +from fastapi import FastAPI, Request, Response, Query from fastapi.staticfiles import StaticFiles from fastapi.responses import HTMLResponse, JSONResponse @@ -34,12 +39,217 @@ _unauthorized = Response( app = FastAPI(title="Hermes Command Center") STATIC_DIR = Path(__file__).parent / "static" +OBSIDIAN_VAULT = Path(os.path.expanduser("~/obsidian")).resolve() + +# ── Network Health Cache ────────────────────────────────────────────── +INVENTORY_PATH = os.path.expanduser("~/.hermes/network-inventory.yaml") + +_network_cache: dict = {} +CACHE_TTL = 30 # seconds +def _load_inventory() -> dict: + """Load network inventory from YAML.""" + path = Path(INVENTORY_PATH) + if not path.exists(): + return {"hosts": [], "services": []} + with open(path) as f: + return yaml.safe_load(f) or {} + + +def _get_host_ip(host: dict) -> str | None: + """Return the best IP to check for a given host definition.""" + if host.get("local_ip"): + return host["local_ip"] + if host.get("tailscale_ip"): + return host["tailscale_ip"] + return None + + +async def _ping_host(ip: str, timeout: int = 2) -> float | None: + """Ping a host and return latency in ms, or None if unreachable.""" + try: + start = time.monotonic() + proc = await asyncio.create_subprocess_exec( + "ping", "-c", "1", "-W", str(timeout), ip, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + elapsed = (time.monotonic() - start) * 1000 + if proc.returncode == 0: + return round(elapsed, 1) + return None + except Exception: + return None + + +async def _check_port(ip: str, port: int, timeout: int = 2) -> bool: + """Check if a TCP port is open on the given host.""" + try: + _, writer = await asyncio.wait_for( + asyncio.open_connection(ip, port), + timeout=timeout, + ) + writer.close() + await writer.wait_closed() + return True + except (OSError, asyncio.TimeoutError): + return False + + +async def _check_http(url: str, timeout: int = 3) -> dict: + """Check an HTTP(S) endpoint and return status info.""" + import urllib.request + try: + req = urllib.request.Request(url, method="HEAD" if url.startswith("http") else "GET") + start = time.monotonic() + ctx = ssl._create_unverified_context() + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + elapsed = (time.monotonic() - start) * 1000 + return {"status": "online", "http_status": resp.status, "latency_ms": round(elapsed, 1)} + except Exception as e: + return {"status": "offline", "error": str(e)[:80]} + + +def _get_local_services() -> list[dict]: + """Parse ss -tlnp output for locally listening services.""" + try: + result = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True, timeout=5) + services = [] + lines = result.stdout.strip().split("\n") + for line in lines[1:]: + if not line.strip(): + continue + parts = line.split() + if len(parts) < 4: + continue + local = parts[3] + addr, port_raw = local.rsplit(":", 1) + proc = parts[-1] if len(parts) > 5 else "" + addr = addr.strip("[]") + services.append({ + "local_address": addr, + "port": port_raw, + "process": proc, + }) + return services + except (subprocess.TimeoutExpired, FileNotFoundError, IndexError) as e: + return [{"error": str(e)}] + + +async def _check_host(host: dict) -> dict: + """Perform health checks on a single host and return enriched status.""" + ip = _get_host_ip(host) + if not ip: + safe_host = {k: v for k, v in host.items() if k != "notes"} + return {**safe_host, "ip": None, "status": "unknown", "reason": "no IP configured", "latency_ms": None, "ports": []} + + latency = await _ping_host(ip) + known_ports = [] + if host.get("ssh_port"): + known_ports.append(("SSH", int(host["ssh_port"]))) + + checked_ports = [] + for name, port in known_ports: + open_port = await _check_port(ip, port) + checked_ports.append({"name": name, "port": port, "open": open_port}) + + if latency is not None: + safe_host = {k: v for k, v in host.items() if k != "notes"} + return {**safe_host, "ip": ip, "status": "online", "latency_ms": latency, "ports": checked_ports} + else: + safe_host = {k: v for k, v in host.items() if k != "notes"} + return {**safe_host, "ip": ip, "status": "offline", "latency_ms": None, "ports": checked_ports} + + +async def _check_service(service: dict) -> dict: + """Perform health check on a single service definition.""" + result = {k: v for k, v in service.items() if k not in ("notes",)} + result.setdefault("status", "unknown") + result["latency_ms"] = None + result["http_status"] = None + + # Try HTTP/S URL first + if service.get("url"): + check = await _check_http(service["url"]) + result["status"] = check["status"] + result["latency_ms"] = check.get("latency_ms") + result["http_status"] = check.get("http_status") + result["error"] = check.get("error") + return result + + # Try port check on 127.0.0.1 (services defined as running locally) + port = service.get("port") + if port and service.get("category") != "infrastructure": + is_open = await _check_port("127.0.0.1", int(port)) + if is_open: + result["status"] = "online" + result["latency_ms"] = 0.0 + else: + result["status"] = "offline" + + return result + + +async def _run_health_checks() -> dict: + """Run all health checks and return combined results with caching.""" + global _network_cache + now = time.monotonic() + + if _network_cache and (now - _network_cache.get("_ts", 0)) < CACHE_TTL: + return _network_cache + + inventory = _load_inventory() + hosts = inventory.get("hosts", []) + services = inventory.get("services", []) + + host_tasks = [_check_host(h) for h in hosts] + host_results = await asyncio.gather(*host_tasks) + + service_tasks = [_check_service(s) for s in services] + service_results = await asyncio.gather(*service_tasks) + + local_services = _get_local_services() + + result = { + "hosts": host_results, + "services": service_results, + "local_services": local_services, + "cached_at": time.time(), + "cache_ttl": CACHE_TTL, + } + + _network_cache = result + _network_cache["_ts"] = now + return result + + +# ── Path security ───────────────────────────────────────────────────── +def _resolve_vault_path(user_path: str) -> Path | None: + """Resolve a user-supplied path relative to the vault root. + + Returns the resolved absolute Path if it's under OBSIDIAN_VAULT, + or None if the path is invalid/outside the vault. + """ + safe = user_path.lstrip("/") + candidate = (OBSIDIAN_VAULT / safe).resolve() + try: + candidate.relative_to(OBSIDIAN_VAULT) + except ValueError: + return None + if not candidate.exists(): + return None + return candidate + + +# ── Auth middleware ─────────────────────────────────────────────────── @app.middleware("http") async def auth_middleware(request: Request, call_next): if request.url.path in ("/health", "/api/health"): return await call_next(request) + if request.url.path.startswith("/api/network/"): + return await call_next(request) if not _check_auth(request.headers.get("Authorization")): return _unauthorized return await call_next(request) @@ -50,6 +260,153 @@ async def health(): return {"status": "ok", "service": "hermes-command-center"} +# ── Network API Endpoints ──────────────────────────────────────────── + +@app.get("/api/network/hosts") +async def network_hosts(): + """List all hosts from inventory with live status.""" + data = await _run_health_checks() + return {"hosts": data["hosts"], "cached_at": data["cached_at"], "cache_ttl": data["cache_ttl"]} + + +@app.get("/api/network/services") +async def network_services(): + """List all services with live health check results.""" + data = await _run_health_checks() + return {"services": data["services"], "cached_at": data["cached_at"], "cache_ttl": data["cache_ttl"]} + + +@app.get("/api/network/services/local") +async def network_services_local(): + """List locally running services (from ss -tlnp).""" + return {"local_services": _get_local_services()} + + +@app.get("/api/network/refresh") +async def network_refresh(): + """Force a fresh health check of all hosts/services.""" + global _network_cache + _network_cache = {} + data = await _run_health_checks() + return { + "hosts": data["hosts"], + "services": data["services"], + "local_services": data["local_services"], + "cached_at": data["cached_at"], + "cache_ttl": data["cache_ttl"], + } + + +# ── Vault API ───────────────────────────────────────────────────────── +@app.get("/api/vault/tree") +async def vault_tree(path: str = Query("", description="Relative path under vault")): + resolved = _resolve_vault_path(path) + if resolved is None: + return JSONResponse({"error": "Path not found or outside vault"}, status_code=404) + if not resolved.is_dir(): + return JSONResponse({"error": "Not a directory"}, status_code=400) + + items = [] + try: + for entry in sorted(resolved.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())): + if entry.name.startswith("."): + continue + info = { + "name": entry.name, + "type": "folder" if entry.is_dir() else "file", + "size": entry.stat().st_size if entry.is_file() else 0, + "modified": int(entry.stat().st_mtime), + } + items.append(info) + except PermissionError: + return JSONResponse({"error": "Permission denied"}, status_code=403) + + rel = resolved.relative_to(OBSIDIAN_VAULT) + return JSONResponse({ + "path": str(rel) if str(rel) != "." else "", + "items": items, + }) + + +@app.get("/api/vault/read") +async def vault_read(path: str = Query(..., description="Relative path to markdown file")): + resolved = _resolve_vault_path(path) + if resolved is None: + return JSONResponse({"error": "File not found or outside vault"}, status_code=404) + if not resolved.is_file(): + return JSONResponse({"error": "Not a file"}, status_code=400) + if resolved.suffix.lower() not in (".md", ".txt", ".markdown"): + return JSONResponse({"error": "Cannot read non-text files"}, status_code=400) + + try: + content = resolved.read_text(encoding="utf-8") + except Exception as e: + return JSONResponse({"error": f"Failed to read file: {e}"}, status_code=500) + + rel = resolved.relative_to(OBSIDIAN_VAULT) + return JSONResponse({ + "path": str(rel), + "name": resolved.name, + "content": content, + "size": resolved.stat().st_size, + "modified": int(resolved.stat().st_mtime), + }) + + +@app.get("/api/vault/search") +async def vault_search(q: str = Query(..., min_length=1, description="Search query")): + """Full-text grep-style search across all .md files under the vault root.""" + results = [] + try: + cmd = [ + "grep", "-rn", + "--include=*.md", + "-i", + "-E", q, + str(OBSIDIAN_VAULT), + ] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=15) + if proc.returncode not in (0, 1): + return JSONResponse({"error": f"Search failed: {proc.stderr.strip()}"}, status_code=500) + + for line in proc.stdout.splitlines(): + parts = line.split(":", 2) + if len(parts) < 3: + continue + filepath, lineno, content = parts + fp = Path(filepath).resolve() + try: + fp.relative_to(OBSIDIAN_VAULT) + except ValueError: + continue + + rel_path = str(fp.relative_to(OBSIDIAN_VAULT)) + idx = content.lower().find(q.lower()) + start = max(0, idx - 60) + end = min(len(content), idx + len(q) + 60) + snippet = content[start:end].strip() + if start > 0: + snippet = "..." + snippet + if end < len(content): + snippet = snippet + "..." + + results.append({ + "path": rel_path, + "line": int(lineno), + "snippet": snippet, + }) + except subprocess.TimeoutExpired: + return JSONResponse({"error": "Search timed out"}, status_code=504) + except FileNotFoundError: + return JSONResponse({"error": "grep not available on this system"}, status_code=500) + + return JSONResponse({ + "query": q, + "count": len(results), + "results": results, + }) + + # ── 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 78ed0cc..1d850cd 100644 --- a/static/app.js +++ b/static/app.js @@ -6,6 +6,10 @@ function navigate(hash) { const target = hash.replace('#', '') || 'agents'; 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(); + } } links.forEach(l => l.addEventListener('click', e => { @@ -15,3 +19,289 @@ links.forEach(l => l.addEventListener('click', e => { window.addEventListener('hashchange', () => navigate(window.location.hash)); navigate(window.location.hash || '#agents'); + +// ── Config File Editor ──────────────────────────────────────────────── + +let configState = { + files: [], + currentFile: null, + currentContent: '', + currentPath: '', + dirty: false, + lastSaved: null, +}; + +function initConfigPanel() { + if (configState.files.length === 0) { + loadFileList(); + } +} + +async function loadFileList() { + const container = document.getElementById('config-files-container'); + container.innerHTML = '

Loading files...

'; + try { + const res = await fetch('/api/config/list'); + 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 = `

Failed to load files: ${err.message}

`; + } +} + +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' : ''}`; + + if (files.length === 0) { + container.innerHTML = '

No config files found.

'; + 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'); + } + 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) { + metaSpan.textContent = 'missing'; + metaSpan.style.color = 'var(--text-muted)'; + } else { + metaSpan.textContent = formatFileSize(f.size) + ' · ' + f.line_count + ' lines'; + } + + item.appendChild(nameSpan); + item.appendChild(metaSpan); + item.addEventListener('click', () => openFile(f.path)); + container.appendChild(item); + }); +} + +function formatFileSize(bytes) { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), 2); + return (bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i]; +} + +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; + document.getElementById('config-editor-textarea').value = 'Loading...'; + document.getElementById('editor-line-numbers').innerHTML = ''; + document.getElementById('editor-filename').textContent = ''; + 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}`); + } + 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) + : ''; + + 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'); + document.getElementById('config-editor-textarea').value = ''; + document.getElementById('config-editor-textarea').disabled = true; + } +} + +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)'; + } +} + +function syncScroll() { + const textarea = document.getElementById('config-editor-textarea'); + const lineNumbers = document.getElementById('editor-line-numbers'); + lineNumbers.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 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(); + } +} + +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 += '
' + i + '
'; + } + lineNumbers.innerHTML = html; +} + +function goBackToList() { + if (configState.dirty) { + if (!confirm('You have unsaved changes. Discard them?')) { + return; + } + } + document.getElementById('config-file-list').hidden = false; + document.getElementById('config-editor').hidden = true; + configState.currentFile = null; + configState.currentContent = ''; + configState.currentPath = ''; + configState.dirty = false; + renderFileList(configState.files); +} + +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; + } + + const btn = document.getElementById('config-save-btn'); + btn.disabled = true; + btn.textContent = 'Saving...'; + + try { + const res = await fetch('/api/config/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, content }), + }); + const data = await res.json(); + + 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'); + updateSaveButton(); + + // Refresh file list metadata + loadFileList(); + } catch (err) { + showToast('Save failed: ' + err.message, 'error'); + } 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; + } +} + +function showToast(message, type) { + const toast = document.getElementById('config-toast'); + 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(); + toast.style.top = (rect.top + 20) + 'px'; + toast.style.left = '50%'; + toast.style.transform = 'translateX(-50%)'; + } + + clearTimeout(toast._timeout); + toast._timeout = setTimeout(() => { + toast.hidden = true; + }, 4000); +} + +// ── Event listeners ────────────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', () => { + document.getElementById('config-save-btn').addEventListener('click', saveFile); + document.getElementById('config-back-btn').addEventListener('click', goBackToList); +}); diff --git a/static/index.html b/static/index.html index f31ee38..7ddfef3 100644 --- a/static/index.html +++ b/static/index.html @@ -27,8 +27,24 @@

Coming soon...