Agent chat, config editor, vault reader, network status - initial implementation
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user