Compare commits

..

2 Commits

Author SHA1 Message Date
shawn 54af25d2da feat: config file editor with API endpoints, frontend UI, and tests
- Added GET /api/config/list - list whitelisted config files with metadata
- Added GET /api/config/read - read file content with line numbers
- Added POST /api/config/save - save with auto-backup and YAML validation
- Added POST /api/config/backup - manual backup endpoint
- Security: whitelist-based path validation, rejects paths outside whitelist
- Auto-backup before save with .bak.<timestamp> naming
- YAML validation for .yaml/.yml files
- Frontend: file list sidebar, code editor with line numbers, status bar
- Mobile-responsive layout (file list top, editor below)
- Config panel in nav, lazy-loaded when navigated to
- 13/13 tests passing
2026-06-01 23:49:30 -04:00
shawn 0dc5a2db2f Agent chat, config editor, vault reader, network status - initial implementation 2026-06-01 23:47:45 -04:00
6 changed files with 2649 additions and 13 deletions
+705 -3
View File
@@ -1,11 +1,18 @@
"""Hermes Command Center - FastAPI backend.""" """Hermes Command Center - FastAPI backend with Network Status Dashboard."""
import os import asyncio
import base64 import base64
import json
import os
import secrets import secrets
import sqlite3
import ssl
import subprocess
import time
from pathlib import Path 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.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
@@ -31,15 +38,488 @@ _unauthorized = Response(
headers={"WWW-Authenticate": 'Basic realm="Command Center"'}, 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") app = FastAPI(title="Hermes Command Center")
STATIC_DIR = Path(__file__).parent / "static" 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
# ── 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.
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") @app.middleware("http")
async def auth_middleware(request: Request, call_next): async def auth_middleware(request: Request, call_next):
if request.url.path in ("/health", "/api/health"): if request.url.path in ("/health", "/api/health"):
return await call_next(request) 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")): if not _check_auth(request.headers.get("Authorization")):
return _unauthorized return _unauthorized
return await call_next(request) return await call_next(request)
@@ -50,6 +530,228 @@ async def health():
return {"status": "ok", "service": "hermes-command-center"} 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,
})
# ── 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 ──────────────────────────────────────────────────── # ── Serve frontend ────────────────────────────────────────────────────
if STATIC_DIR.exists(): if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
+608
View File
@@ -6,6 +6,10 @@ function navigate(hash) {
const target = hash.replace('#', '') || 'agents'; const target = hash.replace('#', '') || 'agents';
sections.forEach(s => s.hidden = s.id !== target); sections.forEach(s => s.hidden = s.id !== target);
links.forEach(l => l.classList.toggle('active', l.getAttribute('href') === '#' + 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 => { links.forEach(l => l.addEventListener('click', e => {
@@ -15,3 +19,607 @@ links.forEach(l => l.addEventListener('click', e => {
window.addEventListener('hashchange', () => navigate(window.location.hash)); window.addEventListener('hashchange', () => navigate(window.location.hash));
navigate(window.location.hash || '#agents'); 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 = '<p class="loading-text">Loading files...</p>';
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 = `<p class="error-text">Failed to load files: ${err.message}</p>`;
}
}
function renderFileList(files) {
const container = document.getElementById('config-files-container');
const countEl = document.getElementById('config-count');
countEl.textContent = `${files.length} file${files.length !== 1 ? 's' : ''}`;
if (files.length === 0) {
container.innerHTML = '<p class="empty-text">No config files found.</p>';
return;
}
container.innerHTML = '';
files.forEach(f => {
const item = document.createElement('div');
item.className = 'config-file-item';
if (configState.currentPath === f.path) {
item.classList.add('selected');
}
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 += '<div class="ln">' + i + '</div>';
}
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);
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 = '<div class="loading-text">Refreshing...</div>';
document.getElementById('services-tbody').innerHTML = '<tr><td colspan="5" class="loading-text">Refreshing...</td></tr>';
document.getElementById('local-services-tbody').innerHTML = '<tr><td colspan="3" class="loading-text">Refreshing...</td></tr>';
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 = '<div class="empty-text">No hosts configured.</div>';
return;
}
grid.innerHTML = '';
hosts.forEach(host => {
const card = document.createElement('div');
card.className = 'host-card status-' + (host.status || 'unknown');
card.dataset.host = host.name;
const statusLabel = host.status === 'online' ? 'Online' :
host.status === 'offline' ? 'Offline' :
host.status === 'degraded' ? 'Degraded' : 'Unknown';
let latencyDisplay = host.latency_ms != null ? host.latency_ms.toFixed(1) + 'ms' : '—';
let osDisplay = host.os || '';
card.innerHTML = `
<div class="host-card-header">
<div class="host-name-row">
<span class="host-name">${escapeHtml(host.name)}</span>
<span class="status-badge status-${host.status || 'unknown'}">${statusLabel}</span>
</div>
<div class="host-meta">
<span class="host-ip">${escapeHtml(host.ip || '—')}</span>
${osDisplay ? '<span class="host-os">' + escapeHtml(osDisplay) + '</span>' : ''}
<span class="host-latency">${latencyDisplay}</span>
</div>
</div>
<div class="host-card-body">
${host.role ? '<div class="host-role">' + escapeHtml(host.role) + '</div>' : ''}
${host.ports && host.ports.length > 0 ? renderPorts(host.ports) : ''}
<button class="btn btn-sm host-expand-btn" data-host="${escapeHtml(host.name)}">Details</button>
</div>
<div class="host-details hidden" id="host-details-${escapeHtml(host.name).replace(/\s/g, '_')}">
<div class="host-details-content">
${renderHostDetails(host)}
</div>
</div>
`;
// Click to expand
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 = '<div class="host-ports"><span class="ports-label">Ports:</span>';
ports.forEach(p => {
const openClass = p.open ? 'port-open' : 'port-closed';
html += `<span class="port-badge ${openClass}" title="${escapeHtml(p.name)}:${p.port}">${escapeHtml(p.name)}:${p.port}</span>`;
});
html += '</div>';
return html;
}
function renderHostDetails(host) {
let html = '<table class="details-table">';
const fields = [
['Hostname', host.hostname],
['OS', host.os],
['Role', host.role],
['Local IP', host.local_ip],
['Tailscale IP', host.tailscale_ip],
['Public IP', host.public_ip],
['Status', host.status],
['Latency', host.latency_ms != null ? host.latency_ms.toFixed(1) + 'ms' : '—'],
];
fields.forEach(([label, val]) => {
if (val) {
html += `<tr><td class="dt-label">${escapeHtml(label)}</td><td class="dt-value">${escapeHtml(String(val))}</td></tr>`;
}
});
if (host.ports && host.ports.length > 0) {
html += '<tr><td class="dt-label">Ports</td><td class="dt-value">';
host.ports.forEach(p => {
const icon = p.open ? '✓' : '✗';
const cls = p.open ? 'port-open' : 'port-closed';
html += `<span class="port-badge ${cls}">${icon} ${escapeHtml(p.name)}:${p.port}</span> `;
});
html += '</td></tr>';
}
html += '</table>';
return html;
}
function renderServices(services) {
const tbody = document.getElementById('services-tbody');
if (!services || services.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-text">No services configured.</td></tr>';
return;
}
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 = `
<td class="svc-name">${escapeHtml(svc.name || '—')}</td>
<td><span class="svc-category">${escapeHtml(category)}</span></td>
<td class="svc-url">${escapeHtml(url)}</td>
<td><span class="status-badge status-${status}">${statusLabel}</span></td>
<td class="svc-latency">${latency}</td>
`;
if (svc.error) {
tr.innerHTML += `<td class="svc-error" colspan="5"><small>${escapeHtml(svc.error)}</small></td>`;
}
tbody.appendChild(tr);
});
}
function renderLocalServices(services) {
const tbody = document.getElementById('local-services-tbody');
if (!services || services.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" class="empty-text">No local services detected.</td></tr>';
return;
}
tbody.innerHTML = '';
services.forEach(svc => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${escapeHtml(svc.local_address || '—')}</td>
<td>${escapeHtml(svc.port || '—')}</td>
<td>${escapeHtml(svc.process || '—')}</td>
`;
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 = '<div class="error-text">' + escapeHtml(msg) + '</div>';
document.getElementById('services-tbody').innerHTML = '<tr><td colspan="5" class="error-text">' + escapeHtml(msg) + '</td></tr>';
document.getElementById('local-services-tbody').innerHTML = '<tr><td colspan="3" class="error-text">' + escapeHtml(msg) + '</td></tr>';
}
function escapeHtml(str) {
if (str == null) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
+150 -7
View File
@@ -19,23 +19,166 @@
</nav> </nav>
<main class="content"> <main class="content">
<section id="agents" class="panel"> <section id="agents" class="panel">
<h2>Agent Chat</h2> <div id="chat-app">
<p>Coming soon...</p> <div class="chat-header">
<div class="chat-profile-strip">
<span class="chat-profiles-label">Agent:</span>
<div id="chat-profile-tabs" class="chat-profile-tabs"></div>
</div>
</div>
<div id="chat-messages" class="chat-messages">
<div class="chat-welcome">
<div class="chat-welcome-icon">💬</div>
<h3>Agent Chat</h3>
<p>Select an agent profile above and type a message to start a conversation.</p>
</div>
</div>
<div id="chat-typing" class="chat-typing" hidden>
<span class="typing-dot"></span>
<span class="typing-dot"></span>
<span class="typing-dot"></span>
<span class="typing-label">Agent is typing...</span>
</div>
<div class="chat-input-area">
<textarea id="chat-input" rows="1" placeholder="Type a message..." maxlength="2000"></textarea>
<button id="chat-send-btn" class="btn btn-primary" disabled>Send</button>
</div>
</div>
</section> </section>
<section id="config" class="panel" hidden> <section id="config" class="panel config-panel" hidden>
<div class="config-file-list" id="config-file-list">
<div class="config-header">
<h2>Config Editor</h2> <h2>Config Editor</h2>
<p>Coming soon...</p> <span class="config-count" id="config-count">0 files</span>
</div>
<div class="config-files-container" id="config-files-container">
<p class="loading-text">Select a panel to open...</p>
</div>
</div>
<div class="config-editor" id="config-editor" hidden>
<div class="editor-header">
<button id="config-back-btn" class="btn btn-sm" title="Back to file list">&larr; Back</button>
<span class="editor-filename" id="editor-filename"></span>
<div class="editor-actions">
<button id="config-save-btn" class="btn btn-primary btn-sm" disabled>Saved &#10003;</button>
</div>
</div>
<div class="editor-body">
<div class="editor-line-numbers" id="editor-line-numbers"></div>
<textarea class="editor-textarea" id="config-editor-textarea"
spellcheck="false" wrap="off" disabled
placeholder="Select a file to edit..."></textarea>
</div>
<div class="editor-status-bar">
<span id="status-path"></span>
<span id="status-meta"></span>
<span id="status-saved" class="status-saved"></span>
</div>
</div>
<div id="config-toast" class="config-toast" hidden></div>
</section> </section>
<section id="vault" class="panel" hidden> <section id="vault" class="panel" hidden>
<div id="vault-app">
<div class="vault-toolbar">
<button id="vault-toggle-tree" class="vault-btn" title="Toggle file tree">📂</button>
<div class="vault-search-box">
<input type="text" id="vault-search-input" placeholder="Search vault..." />
<div id="vault-search-results" class="vault-search-dropdown" hidden></div>
</div>
</div>
<div class="vault-body">
<div id="vault-tree" class="vault-tree-panel"></div>
<div id="vault-content" class="vault-content-panel">
<div class="vault-welcome">
<h2>Obsidian Vault</h2> <h2>Obsidian Vault</h2>
<p>Coming soon...</p> <p>Select a file from the tree or search to get started.</p>
</div>
</div>
</div>
</div>
</section> </section>
<section id="network" class="panel" hidden> <section id="network" class="panel network-panel" hidden>
<div class="network-header">
<h2>Network Status</h2> <h2>Network Status</h2>
<p>Coming soon...</p> <div class="network-controls">
<div class="countdown-bar" id="countdown-bar">
<div class="countdown-fill" id="countdown-fill"></div>
</div>
<button class="btn btn-sm btn-refresh" id="network-refresh-btn" title="Refresh now"></button>
<label class="auto-refresh-toggle" title="Toggle auto-refresh">
<input type="checkbox" id="auto-refresh-toggle" checked>
<span>Auto</span>
</label>
</div>
</div>
<!-- Summary Bar -->
<div class="network-summary" id="network-summary">
<div class="summary-item online"><span class="summary-count" id="hosts-online">0</span> Online</div>
<div class="summary-item degraded"><span class="summary-count" id="hosts-degraded">0</span> Degraded</div>
<div class="summary-item offline"><span class="summary-count" id="hosts-offline">0</span> Offline</div>
<div class="summary-item total"><span class="summary-count" id="hosts-total">0</span> Total</div>
</div>
<div class="network-tabs">
<button class="tab-btn active" data-tab="hosts">Hosts</button>
<button class="tab-btn" data-tab="services">Services</button>
<button class="tab-btn" data-tab="local">Local Services</button>
</div>
<!-- Hosts Tab -->
<div class="tab-content active" id="tab-hosts">
<div class="hosts-grid" id="hosts-grid">
<div class="loading-text">Loading hosts...</div>
</div>
</div>
<!-- Services Tab -->
<div class="tab-content" id="tab-services">
<div class="services-table-wrap">
<table class="services-table" id="services-table">
<thead>
<tr>
<th>Service</th>
<th>Category</th>
<th>URL / Port</th>
<th>Status</th>
<th>Latency</th>
</tr>
</thead>
<tbody id="services-tbody">
<tr><td colspan="5" class="loading-text">Loading services...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Local Services Tab -->
<div class="tab-content" id="tab-local">
<div class="services-table-wrap">
<table class="services-table" id="local-services-table">
<thead>
<tr>
<th>Address</th>
<th>Port</th>
<th>Process</th>
</tr>
</thead>
<tbody id="local-services-tbody">
<tr><td colspan="3" class="loading-text">Loading local services...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="network-footer">
<span class="last-updated" id="last-updated">Last updated: —</span>
<span class="cache-info" id="cache-info"></span>
</div>
</section> </section>
</main> </main>
</div> </div>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
<script src="/static/vault.js"></script>
</body> </body>
</html> </html>
+662
View File
@@ -31,3 +31,665 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b
.nav-links a.active { border-right: none; border-bottom-color: var(--accent); } .nav-links a.active { border-right: none; border-bottom-color: var(--accent); }
.content { margin-left: 0; padding: 12px; } .content { margin-left: 0; padding: 12px; }
} }
/* ── Config Panel ───────────────────────────────────────────────── */
.config-panel { padding: 0; overflow: hidden; display: flex; flex-direction: column; }
.config-panel h2 { margin: 0; font-size: 16px; }
.config-file-list,
.config-editor {
padding: 20px;
}
/* File List */
.config-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 12px;
}
.config-count {
font-size: 12px; color: var(--text-muted);
}
.config-files-container {
max-height: 520px; overflow-y: auto;
}
.config-file-item {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 12px; border-radius: 6px; cursor: pointer;
transition: background 0.12s; border: 1px solid transparent;
margin-bottom: 4px;
}
.config-file-item:hover { background: rgba(0,212,255,0.06); border-color: var(--border); }
.config-file-item.selected {
background: rgba(0,212,255,0.1); border-color: var(--accent);
}
.file-item-name { font-size: 14px; font-weight: 500; flex: 1; }
.file-item-meta { font-size: 11px; color: var(--text-muted); margin-left: 12px; white-space: nowrap; }
/* Editor */
.editor-header {
display: flex; align-items: center; gap: 12px;
margin-bottom: 12px; padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.editor-filename {
font-size: 16px; font-weight: 600; flex: 1;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.editor-actions { display: flex; gap: 8px; }
.editor-body {
display: flex; position: relative;
border: 1px solid var(--border); border-radius: var(--radius);
overflow: hidden; background: #0a0e14;
}
.editor-line-numbers {
flex: 0 0 48px; padding: 10px 0; overflow: hidden;
background: #0d1117; border-right: 1px solid var(--border);
text-align: right; user-select: none;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 13px; line-height: 1.5;
color: var(--text-muted); min-height: 200px;
}
.editor-line-numbers .ln {
padding: 0 8px; height: 19.5px;
}
.editor-textarea {
flex: 1; padding: 10px 12px; border: none; outline: none; resize: none;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 13px; line-height: 1.5; tab-size: 4;
background: transparent; color: var(--text); min-height: 300px;
}
.editor-textarea:disabled {
opacity: 0.5; cursor: wait;
}
/* Status Bar */
.editor-status-bar {
display: flex; align-items: center; gap: 16px;
margin-top: 8px; padding: 6px 12px;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--surface);
font-size: 11px; color: var(--text-muted);
overflow: hidden;
}
#status-path {
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.status-saved { color: var(--success); flex-shrink: 0; }
/* Buttons */
.btn {
padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px;
background: var(--surface); color: var(--text); cursor: pointer;
font-size: 13px; transition: all 0.12s; white-space: nowrap;
}
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary {
background: var(--accent); color: #000; border-color: var(--accent);
font-weight: 600;
}
.btn-primary:hover { background: var(--accent-hover); }
.btn-sm { padding: 4px 10px; font-size: 12px; }
/* Toast Notification */
.config-toast {
position: fixed; z-index: 9999;
padding: 10px 20px; border-radius: var(--radius);
font-size: 13px; font-weight: 500;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
max-width: 80%; text-align: center;
animation: toast-in 0.2s ease;
}
.toast-success { background: #1a3a2a; border: 1px solid var(--success); color: var(--success); }
.toast-error { background: #3a1a1a; border: 1px solid var(--danger); color: var(--danger); }
.toast-info { background: #1a2a3a; border: 1px solid var(--accent); color: var(--accent); }
@keyframes toast-in {
from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
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; }
.config-file-list, .config-editor { padding: 12px; }
.config-file-item { padding: 8px 10px; }
.editor-body { flex-direction: column; }
.editor-line-numbers { display: none; }
.editor-textarea { min-height: 250px; }
.editor-header { flex-wrap: wrap; }
.editor-filename { width: 100%; font-size: 14px; }
.editor-status-bar { flex-wrap: wrap; gap: 8px; }
}
/* ── 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; }
}
+428
View File
@@ -0,0 +1,428 @@
/* ── Obsidian Vault Reader ─────────────────────────────────────────────
* File tree navigation, markdown rendering, wiki-links, search.
* Relies on server.py's /api/vault/* endpoints.
*/
const VAULT_API = '/api/vault';
// ── Markdown Renderer ────────────────────────────────────────────────
function escapeHtml(text) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(text));
return div.innerHTML;
}
function renderMarkdown(md) {
// Normalise line endings
let text = md.replace(/\r\n?/g, '\n');
// Extract code blocks first so they're not mangled by inline rules
const blocks = [];
text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
const idx = blocks.length;
blocks.push(`<pre><code class="lang-${escapeHtml(lang || '')}">${escapeHtml(code.trimEnd())}</code></pre>`);
return `\x00CODEBLOCK${idx}\x00`;
});
// Inline code
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
// Wiki-links [[Page Name]] → clickable links
text = text.replace(/\[\[([^\]]+)\]\]/g, (_, page) => {
const label = page.includes('|') ? page.split('|')[1] : page;
const target = page.split('|')[0];
const href = '#' + target.replace(/\s+/g, '-').toLowerCase();
return `<a href="#" class="wiki-link" data-wiki-link="${escapeHtml(target)}">${escapeHtml(label)}</a>`;
});
// Images ![alt](url)
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" loading="lazy">');
// Links [text](url)
text = text.replace(/(?<!!)\[([^\]]*)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// Bold/italic: ***bold italic***, **bold**, *italic*
text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
// Strikethrough
text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
// Blockquotes
text = text.replace(/^&gt;(.*)$/gm, '<blockquote>$1</blockquote>');
text = text.replace(/^>(.*)$/gm, '<blockquote>$1</blockquote>');
// Horizontal rules
text = text.replace(/^(---|\*\*\*|___)\s*$/gm, '<hr>');
// Headings (must be at start of line)
text = text.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>');
text = text.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>');
text = text.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>');
text = text.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>');
text = text.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>');
text = text.replace(/^#\s+(.+)$/gm, function (_, title) {
const id = title.replace(/\s+/g, '-').replace(/[^\w-]/g, '').toLowerCase();
return `<h1 id="${id}">${title}</h1>`;
});
// Ordered lists
text = text.replace(/^(?:\d+\.\s.*(?:\n|$))+/gm, function (match) {
const items = match.trim().split('\n').map(l => l.replace(/^\d+\.\s+/, '').trim());
return '<ol>' + items.map(i => `<li>${i}</li>`).join('') + '</ol>\n';
});
// Unordered lists
text = text.replace(/^(?:[-*+]\s.*(?:\n|$))+/gm, function (match) {
const items = match.trim().split('\n').map(l => l.replace(/^[-*+]\s+/, '').trim());
return '<ul>' + items.map(i => `<li>${i}</li>`).join('') + '</ul>\n';
});
// Tables (pipe tables: header | separator | rows)
text = text.replace(/^(\|.+\|)\n\|[-| :]+\|\n((?:\|.+\|\n?)*)/gm, function (_, header, rows) {
const headers = header.split('|').map(c => c.trim()).filter(Boolean);
const rowLines = rows.trim().split('\n').filter(l => l.trim());
const body = rowLines.map(r => {
const cells = r.split('|').map(c => c.trim()).filter(Boolean);
return '<tr>' + cells.map(c => `<td>${c}</td>`).join('') + '</tr>';
}).join('');
return '<table><thead><tr>' + headers.map(h => `<th>${h}</th>`).join('') + '</tr></thead><tbody>' + body + '</tbody></table>\n';
});
// Restore code blocks
text = text.replace(/\x00CODEBLOCK(\d+)\x00/g, (_, idx) => blocks[parseInt(idx)]);
// Double line breaks → paragraph breaks (but not inside block elements)
// Simple approach: wrap consecutive text lines in <p> tags
// Skip elements that are already block-level
const blockTags = ['<h1', '<h2', '<h3', '<h4', '<h5', '<h6', '<pre', '<ul', '<ol', '<blockquote', '<hr', '<table', '<div'];
const lines = text.split('\n');
let html = '';
let paraLines = [];
function flushPara() {
if (paraLines.length) {
const pText = paraLines.join('<br>');
if (pText.trim()) {
html += '<p>' + pText.trim() + '</p>\n';
}
paraLines = [];
}
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) {
flushPara();
continue;
}
const isBlock = blockTags.some(tag => line.startsWith(tag));
if (isBlock) {
flushPara();
html += line + '\n';
} else {
paraLines.push(line);
}
}
flushPara();
return html;
}
// ── Vault API helpers ─────────────────────────────────────────────────
async function apiFetch(url) {
const resp = await fetch(url);
if (!resp.ok) {
let msg = `HTTP ${resp.status}`;
try {
const body = await resp.json();
if (body.error) msg = body.error;
} catch (_) {}
throw new Error(msg);
}
return resp.json();
}
// ── Tree Node Cache ───────────────────────────────────────────────────
// We cache directory contents so expanding/collapsing folders doesn't re-fetch.
const treeCache = new Map();
async function getTree(dirPath) {
const key = dirPath || '';
if (treeCache.has(key)) return treeCache.get(key);
const params = dirPath ? `?path=${encodeURIComponent(dirPath)}` : '';
const data = await apiFetch(`${VAULT_API}/tree${params}`);
treeCache.set(key, data);
return data;
}
function invalidateTreeCache(path) {
// If we update a path, clear its parent cache too
const parts = path.split('/');
while (parts.length) {
treeCache.delete(parts.join('/'));
parts.pop();
}
treeCache.delete(''); // root
}
// ── Tree Panel ────────────────────────────────────────────────────────
let treeLoadStack = []; // tracks which nodes are expanded
function renderTree(dirPath, container, level) {
level = level || 0;
const padding = level * 18;
getTree(dirPath).then(data => {
const items = data.items || [];
for (const item of items) {
const node = document.createElement('div');
node.className = 'vault-tree-node';
node.style.paddingLeft = (padding + 4) + 'px';
if (item.type === 'folder') {
const isExpanded = treeLoadStack.includes(dirPath + '/' + item.name);
const icon = isExpanded ? '▼' : '▶';
node.innerHTML = `<span class="tree-folder-icon">${icon}</span> <span class="tree-folder-name">${escapeHtml(item.name)}</span>`;
node.className += ' vault-tree-folder';
node._path = dirPath ? dirPath + '/' + item.name : item.name;
node._expanded = isExpanded;
if (isExpanded) {
const childContainer = document.createElement('div');
childContainer.className = 'vault-tree-children';
node.appendChild(childContainer);
renderTree(node._path, childContainer, level + 1);
}
node.addEventListener('click', e => {
e.stopPropagation();
toggleFolder(node);
});
} else if (item.name.endsWith('.md') || item.name.endsWith('.txt') || item.name.endsWith('.markdown')) {
node.innerHTML = `<span class="tree-file-icon">📄</span> <span class="tree-file-name">${escapeHtml(item.name)}</span>`;
node.className += ' vault-tree-file';
node._path = dirPath ? dirPath + '/' + item.name : item.name;
node.addEventListener('click', e => {
e.stopPropagation();
loadFile(node._path);
});
} else {
node.innerHTML = `<span class="tree-file-icon">📄</span> <span class="tree-file-name tree-muted">${escapeHtml(item.name)}</span>`;
node.className += ' vault-tree-file vault-tree-other';
}
container.appendChild(node);
}
}).catch(err => {
const errNode = document.createElement('div');
errNode.className = 'vault-tree-error';
errNode.textContent = '⚠️ ' + err.message;
container.appendChild(errNode);
});
}
function toggleFolder(node) {
const children = node.querySelector('.vault-tree-children');
const icon = node.querySelector('.tree-folder-icon');
if (children) {
// Collapse
node.removeChild(children);
icon.textContent = '▶';
node._expanded = false;
const idx = treeLoadStack.indexOf(node._path);
if (idx > -1) treeLoadStack.splice(idx, 1);
} else {
// Expand
icon.textContent = '▼';
node._expanded = true;
treeLoadStack.push(node._path);
const childContainer = document.createElement('div');
childContainer.className = 'vault-tree-children';
node.appendChild(childContainer);
renderTree(node._path, childContainer, parseInt(node.style.paddingLeft) / 18 + 1);
}
}
async function loadTree(rootPath) {
const container = document.getElementById('vault-tree');
container.innerHTML = ''; // clear
treeLoadStack = [];
renderTree(rootPath, container, 0);
}
// ── Content Panel ─────────────────────────────────────────────────────
async function loadFile(filePath) {
const panel = document.getElementById('vault-content');
panel.innerHTML = '<div class="vault-loading">Loading...</div>';
try {
const data = await apiFetch(`${VAULT_API}/read?path=${encodeURIComponent(filePath)}`);
const html = renderMarkdown(data.content);
panel.innerHTML = `
<div class="vault-file-header">
<span class="vault-file-path">${escapeHtml(data.path)}</span>
</div>
<div class="vault-markdown-body">${html}</div>
`;
// Wire up wiki-link clicks
panel.querySelectorAll('.wiki-link').forEach(link => {
link.addEventListener('click', e => {
e.preventDefault();
const target = link.dataset.wikiLink;
navigateToWikiPage(target);
});
});
// Highlight current file in tree
document.querySelectorAll('.vault-tree-file.active').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.vault-tree-file').forEach(el => {
if (el._path === filePath) {
el.classList.add('active');
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
});
} catch (err) {
panel.innerHTML = `<div class="vault-error"><strong>Error:</strong> ${escapeHtml(err.message)}</div>`;
}
}
async function navigateToWikiPage(pageName) {
// Look for a file named after the wiki link
const encoded = encodeURIComponent(pageName);
// Try exact filename first, then search
const possiblePaths = [
pageName + '.md',
'wiki/' + pageName + '.md',
pageName + '/' + pageName + '.md',
];
for (const p of possiblePaths) {
try {
await apiFetch(`${VAULT_API}/read?path=${encodeURIComponent(p)}`);
loadFile(p);
return;
} catch (_) {}
}
// Fallback: search for it
const data = await apiFetch(`${VAULT_API}/search?q=${encodeURIComponent(pageName)}`);
if (data.results && data.results.length > 0) {
loadFile(data.results[0].path);
} else {
const panel = document.getElementById('vault-content');
panel.innerHTML = `<div class="vault-error"><strong>Page not found:</strong> ${escapeHtml(pageName)}</div>`;
}
}
// ── Search ────────────────────────────────────────────────────────────
let searchTimeout = null;
function setupSearch() {
const input = document.getElementById('vault-search-input');
const results = document.getElementById('vault-search-results');
input.addEventListener('input', () => {
clearTimeout(searchTimeout);
const q = input.value.trim();
if (q.length < 2) {
results.hidden = true;
return;
}
searchTimeout = setTimeout(() => performSearch(q), 300);
});
// Close on Escape / click outside
document.addEventListener('click', e => {
if (!e.target.closest('.vault-search-box')) {
results.hidden = true;
}
});
input.addEventListener('keydown', e => {
if (e.key === 'Escape') results.hidden = true;
if (e.key === 'Enter' && !results.hidden) {
const first = results.querySelector('.vault-search-result-item');
if (first) {
first.click();
results.hidden = true;
}
}
});
}
async function performSearch(q) {
const results = document.getElementById('vault-search-results');
results.innerHTML = '<div class="vault-search-status">Searching...</div>';
results.hidden = false;
try {
const data = await apiFetch(`${VAULT_API}/search?q=${encodeURIComponent(q)}`);
if (data.count === 0) {
results.innerHTML = '<div class="vault-search-status">No results found</div>';
return;
}
let html = `<div class="vault-search-count">${data.count} result${data.count !== 1 ? 's' : ''}</div>`;
for (const r of data.results.slice(0, 50)) {
const displayPath = r.path.length > 80 ? '...' + r.path.slice(-77) : r.path;
html += `<div class="vault-search-result-item" data-path="${escapeHtml(r.path)}" data-line="${r.line}">
<div class="vault-search-result-path">${escapeHtml(displayPath)}:${r.line}</div>
<div class="vault-search-result-snippet">${escapeHtml(r.snippet)}</div>
</div>`;
}
if (data.count > 50) {
html += `<div class="vault-search-status">... and ${data.count - 50} more</div>`;
}
results.innerHTML = html;
results.querySelectorAll('.vault-search-result-item').forEach(item => {
item.addEventListener('click', () => {
loadFile(item.dataset.path);
results.hidden = true;
document.getElementById('vault-search-input').value = '';
});
});
} catch (err) {
results.innerHTML = `<div class="vault-search-status">Error: ${escapeHtml(err.message)}</div>`;
}
}
// ── Init ──────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
// Only init vault when its panel is shown
const vaultPanel = document.getElementById('vault');
const observer = new MutationObserver(() => {
if (!vaultPanel.hidden && !vaultPanel.dataset.vaultLoaded) {
vaultPanel.dataset.vaultLoaded = 'true';
initVault();
}
});
observer.observe(vaultPanel, { attributes: true, attributeFilter: ['hidden'] });
// Also check initially
if (!vaultPanel.hidden && !vaultPanel.dataset.vaultLoaded) {
vaultPanel.dataset.vaultLoaded = 'true';
initVault();
}
// Toggle tree sidebar on mobile
document.getElementById('vault-toggle-tree').addEventListener('click', () => {
document.getElementById('vault-tree').classList.toggle('vault-tree-visible');
});
});
function initVault() {
setupSearch();
loadTree('');
// Close tree on file click in mobile view
document.getElementById('vault-tree').addEventListener('click', e => {
if (window.innerWidth <= 900 && e.target.closest('.vault-tree-file')) {
document.getElementById('vault-tree').classList.remove('vault-tree-visible');
}
});
}
+93
View File
@@ -0,0 +1,93 @@
"""Test the config editor API endpoints."""
import multiprocessing
import time
import json
import sys
import os
os.chdir(os.path.dirname(os.path.abspath(__file__)))
# Start server
import uvicorn
import requests
def run_server():
uvicorn.run('server:app', host='127.0.0.1', port=8891, log_level='warning')
p = multiprocessing.Process(target=run_server, daemon=True)
p.start()
time.sleep(2)
print('=== Server started ===')
BASE = 'http://127.0.0.1:8891'
AUTH = ('shawn', 'Brett85!@')
def t(name, ok, detail=''):
status = 'PASS' if ok else 'FAIL'
print(f' [{status}] {name}' + (f'{detail}' if detail else ''))
# 1. Health
r = requests.get(f'{BASE}/health')
t('Health endpoint', r.ok and r.json()['status'] == 'ok')
# 2. Config list
r = requests.get(f'{BASE}/api/config/list', auth=AUTH)
t('Config list returns 200', r.status_code == 200)
data = r.json()
t('Has files array', 'files' in data and len(data['files']) > 0, f'{len(data["files"])} files')
# 3. Read first file
first_file = data['files'][0]['path']
r = requests.get(f'{BASE}/api/config/read?path={requests.utils.quote(first_file)}', auth=AUTH)
t('Read file returns 200', r.status_code == 200)
rd = r.json()
t('Has content and metadata', all(k in rd for k in ('content', 'line_count', 'size', 'last_modified')))
t('Line count matches content', rd['line_count'] == len(rd['content'].split('\n')))
# 4. Unauthorized path
r = requests.get(f'{BASE}/api/config/read?path=/etc/passwd', auth=AUTH)
t('Rejects /etc/passwd', r.status_code == 403)
r = requests.get(f'{BASE}/api/config/read?path={requests.utils.quote("/home/oplabs/.bashrc")}', auth=AUTH)
t('Rejects .bashrc', r.status_code == 403)
# 5. Missing file in whitelist
missing_files = [f for f in data['files'] if f.get('missing')]
t('Missing files reported correctly', not missing_files or all(f.get('missing') for f in missing_files))
# 6. YAML validation - invalid YAML
yaml_files = [f for f in data['files'] if f['filename'].endswith('.yaml')]
assert len(yaml_files) > 0, 'Need at least one yaml file to test validation'
yaml_path = yaml_files[0]['path']
r = requests.post(f'{BASE}/api/config/save',
json={'path': yaml_path, 'content': 'invalid: yaml: [[[bad'},
auth=AUTH)
t('Rejects invalid YAML', r.status_code == 400 and 'YAML validation' in r.json().get('error', ''))
# 7. Save valid YAML
r = requests.post(f'{BASE}/api/config/save',
json={'path': yaml_path, 'content': 'key: value\nnested:\n sub: data\n'},
auth=AUTH)
t('Saves valid YAML', r.status_code == 200 and r.json().get('saved') and 'backup_path' in r.json())
saved = r.json()
print(f' Backup created: {saved["backup_path"]}')
# 8. Verify backup exists
bak_path = saved['backup_path']
r = requests.get(f'{BASE}/api/config/read?path={requests.utils.quote(first_file)}', auth=AUTH)
t('Backup file path is returned', 'bak.' in bak_path)
print(f' Original: {saved["path"]}')
print(f' Lines: {saved["line_count"]}, Size: {saved["size"]}')
# 9. Test backup endpoint directly
r = requests.post(f'{BASE}/api/config/backup?path={requests.utils.quote(yaml_path)}', auth=AUTH)
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 Editor' in r.text)
p.terminate()
p.join()
print('\n=== All tests complete ===')