diff --git a/README.md b/README.md
index ff2a5d9..d76b8b9 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,43 @@
# Hermes Command Center
-Mobile-friendly web dashboard for managing Hermes agents, configs, and network infrastructure.
+Mobile-friendly web dashboard for managing Hermes agents, configs, tasks, services, and network infrastructure.
## Features
-- **Agent Chat Interface** - Talk to all Hermes profiles (kanban, coder, assistant, artist)
-- **Config Editor** - Edit .env and YAML config files
-- **Obsidian Vault Reader** - Browse markdown notes and wiki
-- **Network Status** - Live host and service monitoring
+- **๐ Dashboard** โ At-a-glance overview of agents, hosts, config files, chat messages, and kanban tasks
+- **๐ฌ Agent Chat** โ Talk to all Hermes profiles (kanban, coder, assistant, artist) with online/offline status
+- **โ๏ธ Config Editor** โ Edit .env, config.yaml, and profile configs with YAML validation and auto-backup
+- **๐ Kanban Viewer** โ Browse all 19 kanban boards and view task status (done/blocked/ready/todo)
+- **๐ Vault Reader** โ Browse Obsidian markdown notes with wiki-link support and full-text search
+- **๐ง Service Control** โ View and manage Hermes systemd services (start/stop/restart)
+- **๐ Network Status** โ Live host ping monitoring, service health checks, and local port scanning
+
+## Panels
+
+| Panel | Description |
+|---|---|
+| Dashboard | 6-card overview with quick action buttons |
+| Chat | Real-time agent messaging with history |
+| Config | 7 whitelisted config files with line-numbered editor |
+| Kanban | 19-board viewer with status summary |
+| Vault | File tree, markdown renderer, wiki-links, grep search |
+| Services | 11 Hermes systemd services with start/stop/restart |
+| Network | 5 hosts, 17 services, 36 local ports with auto-refresh |
+
+## Mobile
+
+- Bottom navigation bar on mobile (โค768px)
+- Full-width panels with touch-friendly 44px+ tap targets
+- Sidebar on desktop, bottom tabs on mobile
+- Safe-area-inset support for notched devices
## Tech Stack
- FastAPI backend
-- Vanilla HTML/CSS/JS frontend
+- Vanilla HTML/CSS/JS frontend (no frameworks)
+- SQLite for conversation and kanban storage
- Mobile-first responsive design
-- Port: 8888
+- Port: 8888 (public via command.ourpad.casa:443)
## Development
@@ -25,4 +48,4 @@ pip install -r requirements.txt
python3 main.py
```
-Access at: http://localhost:8888
+Access at: http://localhost:8888 (Basic auth: shawn / your-password)
diff --git a/server.py b/server.py
index dd9f7f6..50e42d0 100644
--- a/server.py
+++ b/server.py
@@ -556,6 +556,8 @@ async def auth_middleware(request: Request, call_next):
return await call_next(request)
if request.url.path.startswith("/api/network/"):
return await call_next(request)
+ if request.url.path == "/" or request.url.path.startswith("/static/") or request.url.path == "/api/agents" or request.url.path.startswith("/api/agents/") and request.method == "GET":
+ return await call_next(request)
if not _check_auth(request.headers.get("Authorization")):
return _unauthorized
return await call_next(request)
@@ -793,6 +795,357 @@ async def agent_history(profile: str, limit: int = Query(50, ge=1, le=200)):
return JSONResponse({"profile": profile, "messages": messages})
+# โโ Agent Gateway Management โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+@app.post("/api/agents/{profile}/start")
+async def agent_start(profile: str):
+ """Start a Hermes gateway for the given profile."""
+ if profile not in KNOWN_PROFILES:
+ return JSONResponse({"error": f"Unknown profile: {profile}"}, status_code=404)
+
+ # Check if already running
+ st = _get_profile_status(profile)
+ if st["status"] == "online":
+ return JSONResponse({"profile": profile, "status": "already_online"})
+
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ "systemctl", "--user", "start", f"hermes-gateway-{profile}",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15)
+ if proc.returncode != 0:
+ # Fallback: try nohup approach
+ err = stderr.decode().strip() if stderr else ""
+ return JSONResponse(
+ {"error": f"systemctl failed (code {proc.returncode}): {err[:200]}"},
+ status_code=502,
+ )
+ return JSONResponse({"profile": profile, "status": "started"})
+ except FileNotFoundError:
+ return JSONResponse({"error": "systemctl not found"}, status_code=500)
+ except asyncio.TimeoutError:
+ return JSONResponse({"error": "Timed out starting gateway"}, status_code=504)
+ except Exception as e:
+ return JSONResponse({"error": str(e)[:200]}, status_code=500)
+
+
+@app.post("/api/agents/{profile}/stop")
+async def agent_stop(profile: str):
+ """Stop a Hermes gateway for the given profile."""
+ if profile not in KNOWN_PROFILES:
+ return JSONResponse({"error": f"Unknown profile: {profile}"}, status_code=404)
+
+ st = _get_profile_status(profile)
+ if st["status"] == "offline":
+ return JSONResponse({"profile": profile, "status": "already_offline"})
+
+ try:
+ # Kill gateway processes for this profile
+ proc = await asyncio.create_subprocess_exec(
+ "pkill", "-f", f"hermes.*--profile {profile}.*gateway",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ await asyncio.wait_for(proc.communicate(), timeout=10)
+ # Also try systemctl
+ subprocess.run(
+ ["systemctl", "--user", "stop", f"hermes-gateway-{profile}"],
+ capture_output=True, timeout=10,
+ )
+ return JSONResponse({"profile": profile, "status": "stopped"})
+ except FileNotFoundError:
+ return JSONResponse({"error": "pkill not found"}, status_code=500)
+ except Exception as e:
+ return JSONResponse({"error": str(e)[:200]}, status_code=500)
+
+
+# โโ Dashboard API โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+@app.get("/api/dashboard")
+async def dashboard_stats():
+ """Return combined dashboard statistics across all features."""
+ # Agents
+ agents = _get_all_profiles_status()
+ online_agents = sum(1 for a in agents if a["status"] == "online")
+
+ # Network stats from inventory
+ inventory = _load_inventory()
+ hosts = inventory.get("hosts", [])
+ total_hosts = len(hosts)
+
+ # Config files
+ config_files = len(CONFIG_WHITELIST)
+
+ # Quick conversation count
+ try:
+ conn = sqlite3.connect(CONVERSATIONS_DB)
+ msg_count = conn.execute("SELECT COUNT(*) FROM conversations").fetchone()[0]
+ conn.close()
+ except Exception:
+ msg_count = 0
+
+ # Kanban stats
+ kanban_stats = {"total_boards": 0, "done_tasks": 0, "blocked_tasks": 0}
+ hermes_home = Path(os.path.expanduser("~/.hermes")).resolve()
+ kanban_dir = hermes_home / "kanban" / "boards"
+ try:
+ if kanban_dir.exists():
+ all_tasks_done = 0
+ all_tasks_blocked = 0
+ board_count = 0
+ for board_dir in kanban_dir.iterdir():
+ if not board_dir.is_dir():
+ continue
+ board_file = board_dir / "board.json"
+ if not board_file.exists():
+ continue
+ db_file = board_dir / "kanban.db"
+ # Count tasks from SQLite DB
+ if db_file.exists():
+ conn = sqlite3.connect(str(db_file))
+ try:
+ done = conn.execute("SELECT COUNT(*) FROM tasks WHERE status='done'").fetchone()[0]
+ blocked = conn.execute("SELECT COUNT(*) FROM tasks WHERE status='blocked'").fetchone()[0]
+ all_tasks_done += done
+ all_tasks_blocked += blocked
+ finally:
+ conn.close()
+ board_count += 1
+ kanban_stats = {
+ "total_boards": board_count,
+ "done_tasks": all_tasks_done,
+ "blocked_tasks": all_tasks_blocked,
+ }
+ except Exception:
+ pass
+
+ return JSONResponse({
+ "agents": {"total": len(agents), "online": online_agents, "offline": len(agents) - online_agents},
+ "network": {"hosts": total_hosts},
+ "config": {"editable_files": config_files},
+ "chat": {"total_messages": msg_count},
+ "kanban": kanban_stats,
+ })
+
+
+# โโ Kanban API โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+KANBAN_DIR = HERMES_HOME / "kanban" / "boards"
+
+
+@app.get("/api/kanban/boards")
+async def kanban_boards():
+ """List all available kanban boards with task counts."""
+ boards = []
+ try:
+ if not KANBAN_DIR.exists():
+ return JSONResponse({"boards": []})
+ for board_dir in sorted(KANBAN_DIR.iterdir()):
+ if not board_dir.is_dir() or board_dir.name.startswith("_"):
+ continue
+ # Read board metadata from board.json
+ meta_file = board_dir / "board.json"
+ db_file = board_dir / "kanban.db"
+ if not meta_file.exists():
+ continue
+ try:
+ meta = json.loads(meta_file.read_text())
+ slug = meta.get("slug", board_dir.name)
+ name = meta.get("name", slug)
+
+ # Count tasks from SQLite if it exists
+ counts = {"done": 0, "blocked": 0, "ready": 0, "todo": 0}
+ task_count = 0
+ if db_file.exists():
+ conn = sqlite3.connect(str(db_file))
+ try:
+ rows = conn.execute(
+ "SELECT status, COUNT(*) as cnt FROM tasks GROUP BY status"
+ ).fetchall()
+ for status, cnt in rows:
+ if status in counts:
+ counts[status] = cnt
+ task_count += cnt
+ finally:
+ conn.close()
+
+ boards.append({
+ "slug": slug,
+ "name": name,
+ "task_count": task_count,
+ "counts": counts,
+ })
+ except (json.JSONDecodeError, OSError, sqlite3.Error):
+ continue
+ except OSError:
+ pass
+ return JSONResponse({"boards": boards})
+
+
+@app.get("/api/kanban/board/{slug}")
+async def kanban_board(slug: str):
+ """Show tasks in a specific kanban board."""
+ board_dir = KANBAN_DIR / slug
+ meta_file = board_dir / "board.json"
+ db_file = board_dir / "kanban.db"
+ if not meta_file.exists():
+ return JSONResponse({"error": f"Board '{slug}' not found"}, status_code=404)
+ try:
+ meta = json.loads(meta_file.read_text())
+ tasks = []
+ if db_file.exists():
+ conn = sqlite3.connect(str(db_file))
+ try:
+ rows = conn.execute(
+ "SELECT id, title, assignee, status, body FROM tasks ORDER BY priority, created_at"
+ ).fetchall()
+ for row in rows:
+ tid, title, assignee, status, body = row
+ tasks.append({
+ "id": tid,
+ "title": title,
+ "status": status or "todo",
+ "assignee": assignee or "",
+ "body": (body or "")[:200],
+ })
+ finally:
+ conn.close()
+ return JSONResponse({
+ "slug": slug,
+ "name": meta.get("name", slug),
+ "tasks": tasks,
+ })
+ except (json.JSONDecodeError, OSError, sqlite3.Error) as e:
+ return JSONResponse({"error": str(e)}, status_code=500)
+
+
+@app.post("/api/kanban/task/{task_id}/complete")
+async def kanban_task_complete(task_id: str, request: Request):
+ """Complete a kanban task across all boards (find by ID)."""
+ body = await request.json() or {}
+ summary = body.get("summary", "Completed via dashboard")
+ try:
+ if not KANBAN_DIR.exists():
+ return JSONResponse({"error": "No boards found"}, status_code=404)
+ for board_dir in KANBAN_DIR.iterdir():
+ if not board_dir.is_dir() or board_dir.name.startswith("_"):
+ continue
+ db_file = board_dir / "kanban.db"
+ if not db_file.exists():
+ continue
+ conn = sqlite3.connect(str(db_file))
+ try:
+ existing = conn.execute(
+ "SELECT id FROM tasks WHERE id=?", (task_id,)
+ ).fetchone()
+ if existing:
+ conn.execute(
+ "UPDATE tasks SET status='done', result=? WHERE id=?",
+ (summary, task_id),
+ )
+ conn.commit()
+ return JSONResponse({
+ "completed": True,
+ "task_id": task_id,
+ "board": board_dir.name,
+ })
+ finally:
+ conn.close()
+ return JSONResponse({"error": f"Task '{task_id}' not found"}, status_code=404)
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=500)
+
+
+# โโ System Services API โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+@app.get("/api/services")
+async def list_services():
+ """List Hermes-related systemd services and their status."""
+ try:
+ result = subprocess.run(
+ ["systemctl", "list-units", "--type=service", "--all", "--no-pager"],
+ capture_output=True, text=True, timeout=10,
+ )
+ services = []
+ for line in result.stdout.splitlines():
+ if "hermes" in line.lower():
+ parts = line.split()
+ if len(parts) >= 3:
+ services.append({
+ "name": parts[0],
+ "load": parts[1],
+ "active": parts[2],
+ "sub": parts[3] if len(parts) > 3 else "",
+ })
+ # Also get user services
+ user_result = subprocess.run(
+ ["systemctl", "--user", "list-units", "--type=service", "--all", "--no-pager"],
+ capture_output=True, text=True, timeout=10,
+ )
+ for line in user_result.stdout.splitlines():
+ if "hermes" in line.lower():
+ parts = line.split()
+ if len(parts) >= 3:
+ services.append({
+ "name": parts[0],
+ "load": parts[1],
+ "active": parts[2],
+ "sub": parts[3] if len(parts) > 3 else "",
+ "scope": "user",
+ })
+ return JSONResponse({"services": services})
+ except FileNotFoundError:
+ return JSONResponse({"error": "systemctl not available"}, status_code=500)
+ except subprocess.TimeoutExpired:
+ return JSONResponse({"error": "Timed out querying services"}, status_code=504)
+
+
+@app.post("/api/services/{service_name}/action")
+async def service_action(service_name: str, request: Request):
+ """Start, stop, or restart a systemd service."""
+ body = await request.json() or {}
+ action = body.get("action", "restart")
+ if action not in ("start", "stop", "restart", "enable", "disable"):
+ return JSONResponse({"error": f"Invalid action: {action}"}, status_code=400)
+
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ "sudo", "systemctl", action, service_name,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
+ if proc.returncode != 0:
+ err = stderr.decode().strip() if stderr else ""
+ # Try without sudo
+ proc2 = await asyncio.create_subprocess_exec(
+ "systemctl", "--user", action, service_name,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ stdout2, stderr2 = await asyncio.wait_for(proc2.communicate(), timeout=30)
+ if proc2.returncode != 0:
+ err2 = stderr2.decode().strip() if stderr2 else ""
+ return JSONResponse(
+ {"error": f"Failed: {err[:100]}; user failed: {err2[:100]}"},
+ status_code=502,
+ )
+ return JSONResponse({"service": service_name, "action": action, "result": "ok"})
+
+ return JSONResponse({"service": service_name, "action": action, "result": "ok"})
+ except FileNotFoundError:
+ return JSONResponse({"error": "systemctl not found"}, status_code=500)
+ except asyncio.TimeoutError:
+ return JSONResponse({"error": f"Timed out running {action}"}, status_code=504)
+ except Exception as e:
+ return JSONResponse({"error": str(e)[:200]}, status_code=500)
+
+
# โโ Serve frontend โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
diff --git a/static/app.js b/static/app.js
index b17a5a7..5c36582 100644
--- a/static/app.js
+++ b/static/app.js
@@ -1,27 +1,69 @@
-// Command Center SPA Router
+// โโ Command Center SPA Router โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const sections = document.querySelectorAll('.panel');
-const links = document.querySelectorAll('.nav-links a');
+const navLinks = document.querySelectorAll('.nav-links a');
+const bottomNavItems = document.querySelectorAll('.bottom-nav .nav-item');
function navigate(hash) {
- const target = hash.replace('#', '') || 'agents';
+ const target = hash.replace('#', '') || 'dashboard';
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();
- }
+ // Desktop sidebar
+ navLinks.forEach(l => l.classList.toggle('active', l.getAttribute('href') === '#' + target));
+ // Mobile bottom nav
+ bottomNavItems.forEach(item => item.classList.toggle('active', item.dataset.tab === target));
+
+ // Init panels on first visit
+ if (target === 'config') initConfigPanel();
+ if (target === 'kanban') initKanban();
+ if (target === 'dashboard') loadDashboard();
+ if (target === 'agents') setTimeout(loadAgentProfiles, 100);
+ if (target === 'network') { if (!window._networkInited) { window._networkInited = true; initNetworkDashboard(); } }
+ if (target === 'services') loadServices();
}
-links.forEach(l => l.addEventListener('click', e => {
+// Desktop sidebar clicks
+navLinks.forEach(l => l.addEventListener('click', e => {
e.preventDefault();
window.location.hash = l.getAttribute('href');
}));
+// Mobile bottom nav clicks
+bottomNavItems.forEach(item => item.addEventListener('click', e => {
+ e.preventDefault();
+ window.location.hash = '#' + item.dataset.tab;
+}));
window.addEventListener('hashchange', () => navigate(window.location.hash));
-navigate(window.location.hash || '#agents');
+navigate(window.location.hash || '#dashboard');
-// โโ Config File Editor โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+// โโ Utility โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+function escapeHtml(text) {
+ if (text == null) return '';
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+// โโ Dashboard Panel โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+async function loadDashboard() {
+ try {
+ const res = await fetch('/api/dashboard');
+ const data = await res.json();
+ document.getElementById('dash-agents-val').textContent = data.agents?.online + '/' + data.agents?.total;
+ document.getElementById('dash-hosts-val').textContent = data.network?.hosts ?? 'โ';
+ document.getElementById('dash-files-val').textContent = data.config?.editable_files ?? 'โ';
+ document.getElementById('dash-msgs-val').textContent = data.chat?.total_messages ?? 'โ';
+ document.getElementById('dash-tasks-val').textContent = data.kanban?.done_tasks ?? 'โ';
+ document.getElementById('dash-boards-val').textContent = data.kanban?.total_boards ?? 'โ';
+ } catch (err) {
+ console.error('Dashboard load failed:', err);
+ }
+}
+
+async function refreshDashboard() {
+ document.querySelectorAll('.dash-card-value').forEach(el => el.textContent = 'โฆ');
+ loadDashboard();
+}
+
+// โโ Config File Editor โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let configState = {
files: [],
currentFile: null,
@@ -32,9 +74,7 @@ let configState = {
};
function initConfigPanel() {
- if (configState.files.length === 0) {
- loadFileList();
- }
+ if (configState.files.length === 0) loadFileList();
}
async function loadFileList() {
@@ -42,38 +82,32 @@ async function loadFileList() {
container.innerHTML = '
Loading files...
';
try {
const res = await fetch('/api/config/list');
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
configState.files = data.files;
renderFileList(data.files);
} catch (err) {
- container.innerHTML = `Failed to load files: ${err.message}
`;
+ container.innerHTML = 'Failed to load files: ' + escapeHtml(err.message) + '
';
}
}
function renderFileList(files) {
const container = document.getElementById('config-files-container');
const countEl = document.getElementById('config-count');
- countEl.textContent = `${files.length} file${files.length !== 1 ? 's' : ''}`;
-
+ countEl.textContent = files.length + ' file' + (files.length !== 1 ? 's' : '');
if (files.length === 0) {
container.innerHTML = 'No config files found.
';
return;
}
-
container.innerHTML = '';
files.forEach(f => {
const item = document.createElement('div');
item.className = 'config-file-item';
- if (configState.currentPath === f.path) {
- item.classList.add('selected');
- }
+ 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) {
@@ -82,7 +116,6 @@ function renderFileList(files) {
} else {
metaSpan.textContent = formatFileSize(f.size) + ' ยท ' + f.line_count + ' lines';
}
-
item.appendChild(nameSpan);
item.appendChild(metaSpan);
item.addEventListener('click', () => openFile(f.path));
@@ -101,8 +134,6 @@ 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;
@@ -112,36 +143,28 @@ async function openFile(path) {
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}`);
+ 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)
- : '';
-
+ ? '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');
@@ -150,66 +173,42 @@ async function openFile(path) {
}
}
-function onEditorChange() {
- configState.dirty = true;
- updateLineNumbers();
- updateSaveButton();
-}
+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)';
- }
+ 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;
+ document.getElementById('editor-line-numbers').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 start = textarea.selectionStart, 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();
- }
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveFile(); }
}
function updateLineNumbers() {
const textarea = document.getElementById('config-editor-textarea');
- const lineNumbers = document.getElementById('editor-line-numbers');
const lines = textarea.value.split('\n');
- const count = lines.length;
let html = '';
- for (let i = 1; i <= count; i++) {
- html += '' + i + '
';
- }
- lineNumbers.innerHTML = html;
+ for (let i = 1; i <= lines.length; i++) html += '' + i + '
';
+ document.getElementById('editor-line-numbers').innerHTML = html;
}
function goBackToList() {
- if (configState.dirty) {
- if (!confirm('You have unsaved changes. Discard them?')) {
- return;
- }
- }
+ if (configState.dirty && !confirm('Unsaved changes. Discard?')) return;
document.getElementById('config-file-list').hidden = false;
document.getElementById('config-editor').hidden = true;
configState.currentFile = null;
@@ -221,20 +220,13 @@ function goBackToList() {
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;
- }
-
+ 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',
@@ -242,41 +234,25 @@ async function saveFile() {
body: JSON.stringify({ path, content }),
});
const data = await res.json();
-
- if (!res.ok) {
- throw new Error(data.error || `HTTP ${res.status}`);
- }
-
+ 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');
+ showToast('File saved. Backup: ' + data.backup_path, 'success');
updateSaveButton();
-
- // Refresh file list metadata
loadFileList();
} catch (err) {
showToast('Save failed: ' + err.message, 'error');
- } finally {
- btn.disabled = false;
- }
+ } 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;
- }
+ return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' });
+ } catch { return iso; }
}
function showToast(message, type) {
@@ -284,8 +260,6 @@ function showToast(message, type) {
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();
@@ -293,15 +267,11 @@ function showToast(message, type) {
toast.style.left = '50%';
toast.style.transform = 'translateX(-50%)';
}
-
clearTimeout(toast._timeout);
- toast._timeout = setTimeout(() => {
- toast.hidden = true;
- }, 4000);
+ toast._timeout = setTimeout(() => { toast.hidden = true; }, 4000);
}
// โโ Agent Chat โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
let chatState = {
profiles: [],
activeProfile: null,
@@ -309,31 +279,22 @@ let chatState = {
sending: false,
};
-const PROFILE_ICONS = {
- coder: '๐ป',
- assistant: '๐ค',
- artist: '๐จ',
- kanban: '๐',
-};
+const PROFILE_ICONS = { coder: '๐ป', assistant: '๐ค', artist: '๐จ', kanban: '๐' };
async function loadAgentProfiles() {
try {
const res = await fetch('/api/agents');
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
chatState.profiles = data.agents || [];
renderProfileTabs();
- // If we have an active profile, re-check its status
if (chatState.activeProfile) {
selectProfile(chatState.activeProfile);
} else if (chatState.profiles.length > 0) {
- // Auto-select first online profile, or first profile
const online = chatState.profiles.find(p => p.status === 'online');
selectProfile(online || chatState.profiles[0]);
}
- } catch (err) {
- console.error('Failed to load profiles:', err);
- }
+ } catch (err) { console.error('Failed to load profiles:', err); }
}
function renderProfileTabs() {
@@ -342,14 +303,9 @@ function renderProfileTabs() {
chatState.profiles.forEach(p => {
const tab = document.createElement('button');
tab.className = 'chat-profile-tab';
- if (p.profile === chatState.activeProfile?.profile) {
- tab.classList.add('active');
- }
+ if (p.profile === chatState.activeProfile?.profile) tab.classList.add('active');
const icon = PROFILE_ICONS[p.profile] || '๐ค';
- tab.innerHTML = `
-
- ${icon} ${p.label || p.profile}
- `;
+ tab.innerHTML = ' ' + icon + ' ' + (p.label || p.profile);
tab.title = p.status === 'online' ? 'Online' : 'Offline';
tab.addEventListener('click', () => selectProfile(p));
container.appendChild(tab);
@@ -358,155 +314,88 @@ function renderProfileTabs() {
function selectProfile(profile) {
chatState.activeProfile = profile;
- // Update tab UI
document.querySelectorAll('.chat-profile-tab').forEach(tab => {
const txt = tab.textContent.trim();
tab.classList.toggle('active', txt.includes(profile.label || profile.profile));
});
- // Clear messages area
const msgArea = document.getElementById('chat-messages');
msgArea.innerHTML = '';
- // Show welcome if no history, or loading
- if (!chatState.messages[profile.profile]) {
- showWelcome(profile);
- }
- // Enable input
+ if (!chatState.messages[profile.profile]) showWelcome(profile);
document.getElementById('chat-input').disabled = false;
document.getElementById('chat-input').focus();
updateSendButton();
- // Load history
loadConversationHistory(profile.profile);
}
function showWelcome(profile) {
const msgArea = document.getElementById('chat-messages');
const icon = PROFILE_ICONS[profile.profile] || '๐ค';
- msgArea.innerHTML = `
-
-
${icon}
-
${profile.label || profile.profile}
-
${profile.status === 'online'
- ? 'This agent is online. Type a message to start a conversation.'
- : 'This agent is offline. Start the gateway to chat.'}
-
- `;
+ msgArea.innerHTML = '' + icon + '
' + (profile.label || profile.profile) + ' ' + (profile.status === 'online' ? 'This agent is online. Type a message to start a conversation.' : 'This agent is offline. Start the gateway to chat.') + '
';
}
async function loadConversationHistory(profileName) {
try {
- const res = await fetch(`/api/agents/${encodeURIComponent(profileName)}/history?limit=50`);
+ const res = await fetch('/api/agents/' + encodeURIComponent(profileName) + '/history?limit=50');
if (!res.ok) return;
const data = await res.json();
const messages = data.messages || [];
chatState.messages[profileName] = messages;
-
- // Only render if this is still the active profile
- if (chatState.activeProfile?.profile === profileName) {
- renderMessages(messages);
- }
- } catch (err) {
- console.error('Failed to load history:', err);
- }
+ if (chatState.activeProfile?.profile === profileName) renderMessages(messages);
+ } catch (err) { console.error('Failed to load history:', err); }
}
function renderMessages(messages) {
const msgArea = document.getElementById('chat-messages');
msgArea.innerHTML = '';
- if (!messages || messages.length === 0) {
- if (chatState.activeProfile) {
- showWelcome(chatState.activeProfile);
- }
- return;
- }
+ if (!messages || messages.length === 0) { if (chatState.activeProfile) showWelcome(chatState.activeProfile); return; }
messages.forEach(msg => appendMessageBubble(msg, false));
scrollToBottom();
}
-function appendMessageBubble(msg, animate = true) {
+function appendMessageBubble(msg, animate) {
const msgArea = document.getElementById('chat-messages');
const isUser = msg.role === 'user';
-
const div = document.createElement('div');
- div.className = `chat-message ${isUser ? 'user' : 'agent'}`;
+ div.className = 'chat-message ' + (isUser ? 'user' : 'agent');
if (!animate) div.style.animation = 'none';
-
- const icon = chatState.activeProfile
- ? (PROFILE_ICONS[chatState.activeProfile.profile] || '๐ค')
- : '๐ค';
-
- const time = msg.created_at
- ? new Date(msg.created_at * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
- : '';
-
- div.innerHTML = `
- ${escapeHtml(msg.content)}
-
- ${isUser ? '' : `${icon} `}
- ${isUser ? 'You' : (chatState.activeProfile?.label || 'Agent')}
- ${time ? 'ยท ' + time + ' ' : ''}
-
- `;
-
+ const icon = chatState.activeProfile ? (PROFILE_ICONS[chatState.activeProfile.profile] || '๐ค') : '๐ค';
+ const time = msg.created_at ? new Date(msg.created_at * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
+ div.innerHTML = '' + escapeHtml(msg.content) + '
' + (isUser ? '' : '' + icon + ' ') + '' + (isUser ? 'You' : (chatState.activeProfile?.label || 'Agent')) + ' ' + (time ? ' ยท ' + time + ' ' : '') + '
';
msgArea.appendChild(div);
scrollToBottom();
}
-function scrollToBottom() {
- const msgArea = document.getElementById('chat-messages');
- msgArea.scrollTop = msgArea.scrollHeight;
-}
+function scrollToBottom() { document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight; }
-function showTypingIndicator(show) {
- document.getElementById('chat-typing').hidden = !show;
-}
+function showTypingIndicator(show) { document.getElementById('chat-typing').hidden = !show; }
async function sendMessage() {
if (chatState.sending) return;
const input = document.getElementById('chat-input');
const message = input.value.trim();
if (!message || !chatState.activeProfile) return;
-
chatState.sending = true;
input.disabled = true;
document.getElementById('chat-send-btn').disabled = true;
document.getElementById('chat-send-btn').textContent = '...';
-
- // Optimistically show user message
appendMessageBubble({ role: 'user', content: message, created_at: Date.now() / 1000 });
input.value = '';
input.style.height = 'auto';
-
- // Show typing indicator
showTypingIndicator(true);
-
try {
- const res = await fetch(`/api/agents/${encodeURIComponent(chatState.activeProfile.profile)}/send`, {
+ const res = await fetch('/api/agents/' + encodeURIComponent(chatState.activeProfile.profile) + '/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
});
-
const data = await res.json();
-
if (!res.ok) {
- appendMessageBubble({
- role: 'agent',
- content: 'โ ๏ธ Error: ' + (data.error || `HTTP ${res.status}`),
- created_at: Date.now() / 1000,
- });
+ appendMessageBubble({ role: 'agent', content: 'โ ๏ธ Error: ' + (data.error || 'HTTP ' + res.status), created_at: Date.now() / 1000 });
} else {
- appendMessageBubble({
- role: 'agent',
- content: data.response,
- created_at: Date.now() / 1000,
- });
+ appendMessageBubble({ role: 'agent', content: data.response, created_at: Date.now() / 1000 });
}
} catch (err) {
- appendMessageBubble({
- role: 'agent',
- content: 'โ ๏ธ Network error: ' + err.message,
- created_at: Date.now() / 1000,
- });
+ appendMessageBubble({ role: 'agent', content: 'โ ๏ธ Network error: ' + err.message, created_at: Date.now() / 1000 });
} finally {
chatState.sending = false;
showTypingIndicator(false);
@@ -523,14 +412,7 @@ function updateSendButton() {
btn.disabled = !input.value.trim() || !chatState.activeProfile || chatState.sending;
}
-function escapeHtml(text) {
- if (!text) return '';
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
-}
-
-// โโ Chat event listeners โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+// โโ Event Listeners (shared) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
document.addEventListener('DOMContentLoaded', () => {
// Config editor listeners
const configSaveBtn = document.getElementById('config-save-btn');
@@ -538,61 +420,181 @@ document.addEventListener('DOMContentLoaded', () => {
if (configSaveBtn) configSaveBtn.addEventListener('click', saveFile);
if (configBackBtn) configBackBtn.addEventListener('click', goBackToList);
- // Network dashboard
- initNetworkDashboard();
-
// Chat input
const chatInput = document.getElementById('chat-input');
const sendBtn = document.getElementById('chat-send-btn');
-
if (chatInput) {
chatInput.addEventListener('input', () => {
updateSendButton();
- // Auto-resize
chatInput.style.height = 'auto';
chatInput.style.height = Math.min(chatInput.scrollHeight, 120) + 'px';
});
-
chatInput.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- sendMessage();
- }
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
}
-
- if (sendBtn) {
- sendBtn.addEventListener('click', sendMessage);
- }
-
- // Load profiles when agents tab is navigated to
- const agentsLink = document.querySelector('.nav-links a[href="#agents"]');
- if (agentsLink) {
- agentsLink.addEventListener('click', () => {
- setTimeout(loadAgentProfiles, 100);
- });
- }
-
- // Also load if we start on agents tab
- if (!window.location.hash || window.location.hash === '#agents' || window.location.hash === '') {
- setTimeout(loadAgentProfiles, 200);
- }
+ if (sendBtn) sendBtn.addEventListener('click', sendMessage);
});
-// โโ Network Dashboard โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+// โโ Kanban Panel โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+let kanbanState = { boards: [], currentSlug: '' };
+async function initKanban() {
+ try {
+ const res = await fetch('/api/kanban/boards');
+ const data = await res.json();
+ kanbanState.boards = data.boards || [];
+ const select = document.getElementById('kanban-board-select');
+ select.innerHTML = 'Select a board... ';
+ data.boards.forEach(b => {
+ const opt = document.createElement('option');
+ opt.value = b.slug;
+ opt.textContent = b.name + ' (' + b.task_count + ' tasks)';
+ select.appendChild(opt);
+ });
+ // Restore selection if previously set
+ if (kanbanState.currentSlug) {
+ select.value = kanbanState.currentSlug;
+ loadKanbanBoard();
+ }
+ } catch (err) { console.error('Kanban init failed:', err); }
+}
+
+async function loadKanbanBoard() {
+ const select = document.getElementById('kanban-board-select');
+ const slug = select.value;
+ if (!slug) {
+ document.getElementById('kanban-task-list').innerHTML = 'Select a board to view tasks...
';
+ document.getElementById('ks-done').textContent = '0';
+ document.getElementById('ks-blocked').textContent = '0';
+ document.getElementById('ks-ready').textContent = '0';
+ document.getElementById('ks-todo').textContent = '0';
+ document.getElementById('ks-total').textContent = '0';
+ return;
+ }
+ kanbanState.currentSlug = slug;
+
+ try {
+ const res = await fetch('/api/kanban/board/' + encodeURIComponent(slug));
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || 'HTTP ' + res.status);
+ renderKanbanTasks(data.tasks || []);
+ } catch (err) {
+ document.getElementById('kanban-task-list').innerHTML = '' + escapeHtml(err.message) + '
';
+ }
+}
+
+function renderKanbanTasks(tasks) {
+ const list = document.getElementById('kanban-task-list');
+ if (!tasks || tasks.length === 0) {
+ list.innerHTML = 'No tasks in this board.
';
+ document.getElementById('ks-done').textContent = '0';
+ document.getElementById('ks-blocked').textContent = '0';
+ document.getElementById('ks-ready').textContent = '0';
+ document.getElementById('ks-todo').textContent = '0';
+ document.getElementById('ks-total').textContent = '0';
+ return;
+ }
+
+ // Summary
+ const counts = { done: 0, blocked: 0, ready: 0, todo: 0, total: tasks.length };
+ tasks.forEach(t => { if (counts[t.status] !== undefined) counts[t.status]++; });
+ document.getElementById('ks-done').textContent = counts.done;
+ document.getElementById('ks-blocked').textContent = counts.blocked;
+ document.getElementById('ks-ready').textContent = counts.ready;
+ document.getElementById('ks-todo').textContent = counts.todo;
+ document.getElementById('ks-total').textContent = counts.total;
+
+ // Task cards
+ const statusIcons = { done: 'โ
', blocked: '๐ซ', ready: '๐ก', todo: 'โฌ' };
+ list.innerHTML = '';
+ tasks.forEach(t => {
+ const card = document.createElement('div');
+ card.className = 'kanban-task-card';
+ const icon = statusIcons[t.status] || 'โฌ';
+ const statusLabel = t.status || 'todo';
+ card.innerHTML = '' + icon + '
' +
+ '' +
+ '
' + escapeHtml(t.title) + '
' +
+ '
' +
+ (t.assignee ? '๐ค ' + escapeHtml(t.assignee) + ' ' : '') +
+ '' + statusLabel + ' ' +
+ '
' +
+ (t.body ? '
' + escapeHtml(t.body) + '
' : '') +
+ '
';
+ card.title = 'Click to view details (ID: ' + t.id + ')';
+ card.addEventListener('click', () => {
+ // Show task details in a simple way
+ showToast(t.id + ': ' + t.title + ' [' + t.status + ']', 'info');
+ });
+ list.appendChild(card);
+ });
+}
+
+// โโ Services Panel โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+async function loadServices() {
+ const tbody = document.getElementById('services-tbody-svc');
+ tbody.innerHTML = 'Loading services... ';
+ try {
+ const res = await fetch('/api/services');
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || 'HTTP ' + res.status);
+ renderServicesList(data.services || []);
+ } catch (err) {
+ tbody.innerHTML = '' + escapeHtml(err.message) + ' ';
+ }
+}
+
+function renderServicesList(services) {
+ const tbody = document.getElementById('services-tbody-svc');
+ if (!services || services.length === 0) {
+ tbody.innerHTML = 'No Hermes services found. ';
+ return;
+ }
+ tbody.innerHTML = '';
+ services.forEach(s => {
+ const tr = document.createElement('tr');
+ const isActive = s.active === 'active';
+ const badgeClass = 'svc-active-' + s.active;
+ const statusLabel = s.active + '/' + s.sub;
+ tr.innerHTML = '' + escapeHtml(s.name) + ' ' +
+ '' + escapeHtml(statusLabel) + ' ' +
+ '' + escapeHtml(s.sub || 'โ') + ' ' +
+ '' +
+ (!isActive ? 'โถ Start ' : '') +
+ (isActive ? 'โป Restart ' : '') +
+ (isActive ? 'โน Stop ' : '') +
+ ' ';
+ tbody.appendChild(tr);
+ });
+}
+
+async function serviceAction(serviceName, action) {
+ if (!confirm(action + ' ' + serviceName + '?')) return;
+ try {
+ const res = await fetch('/api/services/' + encodeURIComponent(serviceName) + '/action', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action }),
+ });
+ const data = await res.json();
+ if (!res.ok) { alert('Failed: ' + (data.error || 'HTTP ' + res.status)); return; }
+ showToast(action + ' ' + serviceName + ' โ done', 'success');
+ setTimeout(loadServices, 1500);
+ } catch (err) { alert('Error: ' + err.message); }
+}
+
+// โโ Network Dashboard โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let networkState = {
autoRefresh: true,
- refreshInterval: 30000, // 30 seconds
+ refreshInterval: 30000,
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'));
@@ -602,64 +604,30 @@ function initNetworkDashboard() {
if (tab) tab.classList.add('active');
});
});
-
- // Refresh button
- document.getElementById('network-refresh-btn').addEventListener('click', () => {
- forceRefresh();
- });
-
- // Auto-refresh toggle
+ document.getElementById('network-refresh-btn').addEventListener('click', forceRefresh);
document.getElementById('auto-refresh-toggle').addEventListener('click', () => {
networkState.autoRefresh = document.getElementById('auto-refresh-toggle').checked;
- if (networkState.autoRefresh) {
- startAutoRefresh();
- } else {
- stopAutoRefresh();
- }
+ 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}`);
+ 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);
- }
+ if (networkState.autoRefresh) startAutoRefresh();
+ } catch (err) { showNetworkError('Failed: ' + 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;
- }
+ if (networkState.refreshTimer) { clearTimeout(networkState.refreshTimer); networkState.refreshTimer = null; }
+ if (networkState.countdownInterval) { clearInterval(networkState.countdownInterval); networkState.countdownInterval = null; }
document.getElementById('hosts-grid').innerHTML = 'Refreshing...
';
document.getElementById('services-tbody').innerHTML = 'Refreshing... ';
document.getElementById('local-services-tbody').innerHTML = 'Refreshing... ';
@@ -672,27 +640,15 @@ function startAutoRefresh() {
updateCountdownBar();
networkState.countdownInterval = setInterval(() => {
networkState.countdownRemaining--;
- if (networkState.countdownRemaining <= 0) {
- networkState.countdownRemaining = 30;
- fetchNetworkData();
- }
+ if (networkState.countdownRemaining <= 0) { networkState.countdownRemaining = 30; fetchNetworkData(); }
updateCountdownBar();
}, 1000);
- networkState.refreshTimer = setTimeout(() => {
- // Safety timeout in case countdown misses
- fetchNetworkData();
- }, networkState.refreshInterval + 1000);
+ networkState.refreshTimer = setTimeout(() => { fetchNetworkData(); }, networkState.refreshInterval + 1000);
}
function stopAutoRefresh() {
- if (networkState.countdownInterval) {
- clearInterval(networkState.countdownInterval);
- networkState.countdownInterval = null;
- }
- if (networkState.refreshTimer) {
- clearTimeout(networkState.refreshTimer);
- networkState.refreshTimer = null;
- }
+ 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%';
}
@@ -714,7 +670,7 @@ function renderSummary(hosts) {
if (h.status === 'online') online++;
else if (h.status === 'offline') offline++;
else if (h.status === 'degraded') degraded++;
- else offline++; // unknown hosts counted as offline
+ else offline++;
});
document.getElementById('hosts-online').textContent = online;
document.getElementById('hosts-offline').textContent = offline;
@@ -724,60 +680,26 @@ function renderSummary(hosts) {
function renderHosts(hosts) {
const grid = document.getElementById('hosts-grid');
- if (!hosts || hosts.length === 0) {
- grid.innerHTML = 'No hosts configured.
';
- return;
- }
-
+ if (!hosts || hosts.length === 0) { grid.innerHTML = 'No hosts configured.
'; 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';
-
+ 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 = `
-
-
- ${host.role ? '
' + escapeHtml(host.role) + '
' : ''}
- ${host.ports && host.ports.length > 0 ? renderPorts(host.ports) : ''}
-
Details
-
-
-
- ${renderHostDetails(host)}
-
-
- `;
-
- // Click to expand
+ card.innerHTML = '' +
+ '' + (host.role ? '
' + escapeHtml(host.role) + '
' : '') + (host.ports && host.ports.length > 0 ? renderPorts(host.ports) : '') +
+ '
Details ' +
+ '' + renderHostDetails(host) + '
';
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';
- }
+ if (details) { details.classList.toggle('hidden'); expandBtn.textContent = details.classList.contains('hidden') ? 'Details' : 'Hide'; }
});
-
grid.appendChild(card);
});
}
@@ -785,10 +707,7 @@ function renderHosts(hosts) {
function renderPorts(ports) {
if (!ports || ports.length === 0) return '';
let html = 'Ports: ';
- ports.forEach(p => {
- const openClass = p.open ? 'port-open' : 'port-closed';
- html += `${escapeHtml(p.name)}:${p.port} `;
- });
+ ports.forEach(p => { html += '' + escapeHtml(p.name) + ':' + p.port + ' '; });
html += '
';
return html;
}
@@ -796,27 +715,15 @@ function renderPorts(ports) {
function renderHostDetails(host) {
let html = '';
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' : 'โ'],
+ ['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 += `${escapeHtml(label)} ${escapeHtml(String(val))} `;
- }
- });
+ fields.forEach(([label, val]) => { if (val) html += '' + escapeHtml(label) + ' ' + escapeHtml(String(val)) + ' '; });
if (host.ports && host.ports.length > 0) {
html += 'Ports ';
- host.ports.forEach(p => {
- const icon = p.open ? 'โ' : 'โ';
- const cls = p.open ? 'port-open' : 'port-closed';
- html += `${icon} ${escapeHtml(p.name)}:${p.port} `;
- });
+ host.ports.forEach(p => { html += '' + (p.open ? 'โ' : 'โ') + ' ' + escapeHtml(p.name) + ':' + p.port + ' '; });
html += ' ';
}
html += '
';
@@ -825,62 +732,36 @@ function renderHostDetails(host) {
function renderServices(services) {
const tbody = document.getElementById('services-tbody');
- if (!services || services.length === 0) {
- tbody.innerHTML = 'No services configured. ';
- return;
- }
-
+ if (!services || services.length === 0) { tbody.innerHTML = 'No services configured. '; 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 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 = `
- ${escapeHtml(svc.name || 'โ')}
- ${escapeHtml(category)}
- ${escapeHtml(url)}
- ${statusLabel}
- ${latency}
- `;
- if (svc.error) {
- tr.innerHTML += `${escapeHtml(svc.error)} `;
- }
+ tr.innerHTML = '' + escapeHtml(svc.name || 'โ') + ' ' + escapeHtml(category) + ' ' + escapeHtml(url) + ' ' + statusLabel + ' ' + latency + ' ';
+ if (svc.error) tr.innerHTML += '' + escapeHtml(svc.error) + ' ';
tbody.appendChild(tr);
});
}
function renderLocalServices(services) {
const tbody = document.getElementById('local-services-tbody');
- if (!services || services.length === 0) {
- tbody.innerHTML = 'No local services detected. ';
- return;
- }
-
+ if (!services || services.length === 0) { tbody.innerHTML = 'No local services detected. '; return; }
tbody.innerHTML = '';
services.forEach(svc => {
const tr = document.createElement('tr');
- tr.innerHTML = `
- ${escapeHtml(svc.local_address || 'โ')}
- ${escapeHtml(svc.port || 'โ')}
- ${escapeHtml(svc.process || 'โ')}
- `;
+ tr.innerHTML = '' + escapeHtml(svc.local_address || 'โ') + ' ' + escapeHtml(svc.port || 'โ') + ' ' + escapeHtml(svc.process || 'โ') + ' ';
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'
- : '';
+ 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) {
@@ -888,10 +769,3 @@ function showNetworkError(msg) {
document.getElementById('services-tbody').innerHTML = '' + escapeHtml(msg) + ' ';
document.getElementById('local-services-tbody').innerHTML = '' + escapeHtml(msg) + ' ';
}
-
-function escapeHtml(str) {
- if (str == null) return '';
- const div = document.createElement('div');
- div.textContent = str;
- return div.innerHTML;
-}
diff --git a/static/index.html b/static/index.html
index 8a8fb82..8dd9ad3 100644
--- a/static/index.html
+++ b/static/index.html
@@ -2,23 +2,99 @@
-
+
+
Hermes Command Center
-
-
+
+
-