Agent chat, config editor, vault reader, network status - initial implementation

This commit is contained in:
2026-06-01 23:47:45 -04:00
parent 049edc0238
commit 0dc5a2db2f
6 changed files with 1319 additions and 5 deletions
+360 -3
View File
@@ -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")
+290
View File
@@ -6,6 +6,10 @@ function navigate(hash) {
const target = hash.replace('#', '') || 'agents';
sections.forEach(s => s.hidden = s.id !== target);
links.forEach(l => l.classList.toggle('active', l.getAttribute('href') === '#' + target));
// Init config panel when navigated to
if (target === 'config') {
initConfigPanel();
}
}
links.forEach(l => l.addEventListener('click', e => {
@@ -15,3 +19,289 @@ links.forEach(l => l.addEventListener('click', e => {
window.addEventListener('hashchange', () => navigate(window.location.hash));
navigate(window.location.hash || '#agents');
// ── Config File Editor ────────────────────────────────────────────────
let configState = {
files: [],
currentFile: null,
currentContent: '',
currentPath: '',
dirty: false,
lastSaved: null,
};
function initConfigPanel() {
if (configState.files.length === 0) {
loadFileList();
}
}
async function loadFileList() {
const container = document.getElementById('config-files-container');
container.innerHTML = '<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);
});
+19 -2
View File
@@ -27,8 +27,24 @@
<p>Coming soon...</p>
</section>
<section id="vault" class="panel" hidden>
<h2>Obsidian Vault</h2>
<p>Coming soon...</p>
<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>
<p>Select a file from the tree or search to get started.</p>
</div>
</div>
</div>
</div>
</section>
<section id="network" class="panel" hidden>
<h2>Network Status</h2>
@@ -37,5 +53,6 @@
</main>
</div>
<script src="/static/app.js"></script>
<script src="/static/vault.js"></script>
</body>
</html>
+129
View File
@@ -31,3 +31,132 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b
.nav-links a.active { border-right: none; border-bottom-color: var(--accent); }
.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); }
}
/* 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; }
}
+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 Files' in r.text)
p.terminate()
p.join()
print('\n=== All tests complete ===')