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
This commit is contained in:
2026-06-01 23:49:30 -04:00
parent 0dc5a2db2f
commit 54af25d2da
5 changed files with 1331 additions and 9 deletions
+345
View File
@@ -2,8 +2,10 @@
import asyncio import asyncio
import base64 import base64
import json
import os import os
import secrets import secrets
import sqlite3
import ssl import ssl
import subprocess import subprocess
import time import time
@@ -36,6 +38,113 @@ _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"
@@ -225,6 +334,167 @@ async def _run_health_checks() -> dict:
return result 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 ───────────────────────────────────────────────────── # ── Path security ─────────────────────────────────────────────────────
def _resolve_vault_path(user_path: str) -> Path | None: def _resolve_vault_path(user_path: str) -> Path | None:
"""Resolve a user-supplied path relative to the vault root. """Resolve a user-supplied path relative to the vault root.
@@ -407,6 +677,81 @@ async def vault_search(q: str = Query(..., min_length=1, description="Search que
}) })
# ── Agent Chat API ──────────────────────────────────────────────────
@app.get("/api/agents")
async def agents_list():
"""List available Hermes agent profiles with online/offline status."""
profiles = _get_all_profiles_status()
return JSONResponse({"agents": profiles})
@app.post("/api/agents/{profile}/send")
async def agent_send(profile: str, request: Request):
"""Send a message to a Hermes agent profile and return the response."""
if profile not in KNOWN_PROFILES:
return JSONResponse({"error": f"Unknown profile: {profile}"}, status_code=404)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
message = (body or {}).get("message", "").strip()
if not message:
return JSONResponse({"error": "Message is required"}, status_code=400)
# Save user message
_save_message(profile, "user", message)
try:
proc = await asyncio.create_subprocess_exec(
HERMES_BIN, "-p", profile, "chat", "-q", message,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, "PYTHONUNBUFFERED": "1"},
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
except asyncio.TimeoutError:
proc.kill()
return JSONResponse({"error": "Agent response timed out"}, status_code=504)
if proc.returncode != 0:
err = stderr.decode().strip() if stderr else ""
return JSONResponse(
{"error": f"Agent exited with code {proc.returncode}: {err[:200]}"},
status_code=502,
)
response_text = stdout.decode().strip() if stdout else ""
if not response_text:
response_text = "(no response)"
# Save agent response
_save_message(profile, "agent", response_text)
return JSONResponse({
"profile": profile,
"response": response_text,
})
except FileNotFoundError:
return JSONResponse({"error": "Hermes binary not found"}, status_code=500)
except Exception as e:
return JSONResponse({"error": str(e)[:200]}, status_code=500)
@app.get("/api/agents/{profile}/history")
async def agent_history(profile: str, limit: int = Query(50, ge=1, le=200)):
"""Get recent conversation history for a profile."""
if profile not in KNOWN_PROFILES:
return JSONResponse({"error": f"Unknown profile: {profile}"}, status_code=404)
messages = _get_conversation_history(profile, limit=limit)
return JSONResponse({"profile": profile, "messages": messages})
# ── Serve frontend ──────────────────────────────────────────────────── # ── 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")
+318
View File
@@ -304,4 +304,322 @@ function showToast(message, type) {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.getElementById('config-save-btn').addEventListener('click', saveFile); document.getElementById('config-save-btn').addEventListener('click', saveFile);
document.getElementById('config-back-btn').addEventListener('click', goBackToList); 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;
}
+134 -8
View File
@@ -19,12 +19,63 @@
</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>
<h2>Config Editor</h2> <div class="config-file-list" id="config-file-list">
<p>Coming soon...</p> <div class="config-header">
<h2>Config Editor</h2>
<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 id="vault-app">
@@ -46,9 +97,84 @@
</div> </div>
</div> </div>
</section> </section>
<section id="network" class="panel" hidden> <section id="network" class="panel network-panel" hidden>
<h2>Network Status</h2> <div class="network-header">
<p>Coming soon...</p> <h2>Network Status</h2>
<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>
+533
View File
@@ -148,6 +148,340 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b
to { opacity: 1; transform: translateX(-50%) translateY(0); } 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 */ /* Mobile */
@media (max-width: 768px) { @media (max-width: 768px) {
.config-panel { padding: 0; } .config-panel { padding: 0; }
@@ -160,3 +494,202 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b
.editor-filename { width: 100%; font-size: 14px; } .editor-filename { width: 100%; font-size: 14px; }
.editor-status-bar { flex-wrap: wrap; gap: 8px; } .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; }
}
+1 -1
View File
@@ -86,7 +86,7 @@ t('Manual backup endpoint', r.status_code == 200 and 'backup_path' in r.json())
# 10. HTML serving # 10. HTML serving
r = requests.get(f'{BASE}/', auth=AUTH) r = requests.get(f'{BASE}/', auth=AUTH)
t('Serves index.html', r.status_code == 200 and 'Config Files' in r.text) t('Serves index.html', r.status_code == 200 and 'Config Editor' in r.text)
p.terminate() p.terminate()
p.join() p.join()