From 3f086b7849862d6a820ffbb3fd6855f8ee611120 Mon Sep 17 00:00:00 2001 From: Shawn Date: Tue, 2 Jun 2026 17:18:17 -0400 Subject: [PATCH] Fix Firefox mobile panel centering and agent profile loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix auth middleware: make GET /api/agents public so agent profiles load in Firefox mobile (browser doesn't send Basic Auth on XHR) - Fix CSS: use display: grid; justify-items: center on .content instead of margin: 0 auto on .panel โ€” Firefox fails to honor margin: auto with percentage width inside flex containers - Add box-sizing: border-box to panel elements - Bump cache busters (v=13 for CSS, v=2 for JS) Root cause: Firefox mobile on Pixel 4a rendered .panel with width:72%; margin:0 auto shifted 242px right (flush to edge). CSS Grid centering resolves the cross-browser rendering difference. --- README.md | 39 ++- server.py | 353 +++++++++++++++++++++++ static/app.js | 694 +++++++++++++++++++--------------------------- static/index.html | 178 +++++++++--- static/style.css | 559 ++++++++++++++++++++++++++++++++++++- 5 files changed, 1367 insertions(+), 456 deletions(-) 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 = ''; + 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 ? '' : '') + + (isActive ? '' : '') + + (isActive ? '' : '') + + ''; + 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 = ` -
-
- ${escapeHtml(host.name)} - ${statusLabel} -
-
- ${escapeHtml(host.ip || 'โ€”')} - ${osDisplay ? '' + escapeHtml(osDisplay) + '' : ''} - ${latencyDisplay} -
-
-
- ${host.role ? '
' + escapeHtml(host.role) + '
' : ''} - ${host.ports && host.ports.length > 0 ? renderPorts(host.ports) : ''} - -
- - `; - - // Click to expand + card.innerHTML = '
' + escapeHtml(host.name) + '' + statusLabel + '
' + escapeHtml(host.ip || 'โ€”') + '' + (osDisplay ? '' + escapeHtml(osDisplay) + '' : '') + '' + latencyDisplay + '
' + + '
' + (host.role ? '
' + escapeHtml(host.role) + '
' : '') + (host.ports && host.ports.length > 0 ? renderPorts(host.ports) : '') + + '
' + + ''; 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 += ``; - } - }); + fields.forEach(([label, val]) => { if (val) html += ''; }); if (host.ports && host.ports.length > 0) { html += ''; } html += '
${escapeHtml(label)}${escapeHtml(String(val))}
' + escapeHtml(label) + '' + escapeHtml(String(val)) + '
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 += '
'; @@ -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 - - + +
-
- - + + diff --git a/static/style.css b/static/style.css index 4db9765..6ddd134 100644 --- a/static/style.css +++ b/static/style.css @@ -207,15 +207,17 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b font-size: 11px; } -.chat-typing { +#chat-typing { flex-shrink: 0; - display: flex; + display: none; align-items: center; gap: 4px; padding: 8px 4px; font-size: 12px; color: var(--text-muted); } +#chat-typing[hidden] { display: none; } +#chat-typing:not([hidden]) { display: flex; } .typing-dot { width: 6px; @@ -303,7 +305,9 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b } /* โ”€โ”€ Config Panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ -.config-panel { padding: 0; overflow: hidden; display: flex; flex-direction: column; } +.config-panel { padding: 0; overflow: hidden; display: none; flex-direction: column; } +.config-panel[hidden] { display: none; } +.config-panel:not([hidden]) { display: flex; } .config-panel h2 { margin: 0; font-size: 16px; } .config-file-list, @@ -963,3 +967,552 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b .network-footer { padding: 6px 14px; flex-direction: column; gap: 4px; } .countdown-bar { width: 40px; } } + +/* โ”€โ”€ Panel Title โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.panel-title { + margin: 0 0 16px; + font-size: 20px; + display: flex; + align-items: center; + gap: 8px; +} + +/* โ”€โ”€ Dashboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.dash-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.dash-card { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(0,0,0,0.15); + transition: border-color 0.15s; + cursor: default; + min-height: 80px; +} + +.dash-card:hover { + border-color: var(--accent); +} + +.dash-card-icon { + font-size: 28px; + flex-shrink: 0; + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 10px; + background: rgba(0,212,255,0.08); +} + +.dash-card-body { + flex: 1; + min-width: 0; +} + +.dash-card-value { + font-size: 26px; + font-weight: 700; + line-height: 1.2; + color: var(--accent); +} + +.dash-card-label { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} + +.dash-section { + margin-top: 8px; +} + +.dash-section h3 { + font-size: 14px; + color: var(--text-muted); + margin-bottom: 10px; + font-weight: 500; +} + +.dash-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +/* โ”€โ”€ Kanban Board โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.kanban-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} + +.kanban-controls { + display: flex; + align-items: center; + gap: 8px; +} + +.kanban-select { + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: #0a0e14; + color: var(--text); + font-size: 13px; + outline: none; + cursor: pointer; + min-width: 200px; + min-height: 38px; +} + +.kanban-select:focus { + border-color: var(--accent); +} + +.kanban-summary { + display: flex; + gap: 0; + margin-bottom: 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.kanban-summary-item { + flex: 1; + text-align: center; + padding: 10px 4px; + font-size: 11px; + color: var(--text-muted); + border-right: 1px solid var(--border); +} + +.kanban-summary-item:last-child { + border-right: none; +} + +.ks-num { + display: block; + font-size: 20px; + font-weight: 700; + margin-bottom: 2px; +} + +#ks-done { color: var(--success); } +#ks-blocked { color: var(--danger); } +#ks-ready { color: var(--warning); } +#ks-todo { color: var(--text-muted); } +#ks-total { color: var(--accent); } + +.kanban-task-list { + max-height: 500px; + overflow-y: auto; +} + +.kanban-task-card { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 6px; + background: rgba(0,0,0,0.08); + transition: border-color 0.12s; + cursor: pointer; +} + +.kanban-task-card:hover { + border-color: var(--accent); +} + +.kanban-task-card:last-child { + margin-bottom: 0; +} + +.kanban-task-status { + flex-shrink: 0; + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + margin-top: 2px; +} + +.kanban-task-status.status-done { + background: rgba(63,185,80,0.15); + color: var(--success); +} + +.kanban-task-status.status-blocked { + background: rgba(248,81,73,0.15); + color: var(--danger); +} + +.kanban-task-status.status-ready { + background: rgba(210,153,34,0.15); + color: var(--warning); +} + +.kanban-task-status.status-todo { + background: rgba(139,148,158,0.15); + color: var(--text-muted); +} + +.kanban-task-info { + flex: 1; + min-width: 0; +} + +.kanban-task-title { + font-size: 13px; + font-weight: 500; + color: var(--text); + line-height: 1.4; + margin-bottom: 2px; +} + +.kanban-task-meta { + font-size: 11px; + color: var(--text-muted); + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.kanban-task-body { + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +/* โ”€โ”€ Services Panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.services-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + flex-wrap: wrap; + gap: 8px; +} + +#services-table-svc .svc-status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; +} + +#services-table-svc .svc-active-active { + background: rgba(63,185,80,0.15); + color: var(--success); +} + +#services-table-svc .svc-active-inactive, +#services-table-svc .svc-active-dead { + background: rgba(248,81,73,0.15); + color: var(--danger); +} + +.service-action-btn { + padding: 3px 8px; + font-size: 11px; + border-radius: 4px; + border: 1px solid var(--border); + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: all 0.12s; + min-height: 28px; +} + +.service-action-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +.service-action-btn.restart:hover { border-color: var(--warning); color: var(--warning); } +.service-action-btn.stop:hover { border-color: var(--danger); color: var(--danger); } +.service-action-btn.start:hover { border-color: var(--success); color: var(--success); } + +.service-actions { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +/* โ”€โ”€ Bottom Navigation (Mobile) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.bottom-nav { + display: none; + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 1000; + background: var(--surface); + border-top: 1px solid var(--border); + padding: 4px 0; + justify-content: space-around; + align-items: center; + padding-bottom: max(4px, env(safe-area-inset-bottom, 4px)); +} + +.bottom-nav .nav-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 6px 8px; + color: var(--text-muted); + text-decoration: none; + font-size: 20px; + border-radius: 8px; + transition: all 0.12s; + min-width: 48px; + -webkit-tap-highlight-color: transparent; +} + +.bottom-nav .nav-item span { + font-size: 9px; + line-height: 1; + font-weight: 500; +} + +.bottom-nav .nav-item.active { + color: var(--accent); + background: rgba(0,212,255,0.08); +} + +.bottom-nav .nav-item:active { + transform: scale(0.92); +} + +/* โ”€โ”€ Mobile Overhaul โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +@media (max-width: 768px) { + /* Hide desktop sidebar on mobile */ + .sidebar { + display: none; + } + + /* Show bottom nav on mobile */ + .bottom-nav { + display: flex; + } + + /* Content fills full width on mobile - use grid centering */ + .content { + margin-left: 0; + padding: 12px 12px 80px; + display: grid; + justify-items: center; + } + + /* All panels on mobile: centered via grid parent */ + .panel { + width: 100%; + max-width: 72%; + margin: 0 0 12px; + padding: 10px; + border-radius: 10px; + overflow-x: hidden; + box-sizing: border-box; + } + + /* Chat app โ€” prevent any horizontal overflow */ + #chat-app { + overflow-x: hidden; + } + + /* Dashboard cards - 2 columns */ + .dash-grid { + grid-template-columns: repeat(2, 1fr); + gap: 8px; + } + + .dash-card { + padding: 12px; + min-height: 70px; + } + + .dash-card-icon { + width: 36px; + height: 36px; + font-size: 20px; + } + + .dash-card-value { + font-size: 22px; + } + + .dash-actions { + flex-direction: column; + } + + .dash-actions .btn { + width: 100%; + text-align: center; + } + + /* Kanban mobile */ + .kanban-select { + width: 100%; + min-width: unset; + } + + .kanban-summary { + flex-wrap: wrap; + } + + .kanban-summary-item { + min-width: 33%; + border-bottom: 1px solid var(--border); + } + + .kanban-header { + flex-direction: column; + align-items: stretch; + } + + .kanban-controls { + flex-direction: column; + } + + .kanban-task-card { + padding: 10px; + } + + /* Services mobile */ + .services-table-wrap { + overflow-x: auto; + } + + #services-table-svc th, + #services-table-svc td { + padding: 6px 8px; + font-size: 12px; + } + + .service-actions { + flex-direction: column; + } + + /* Existing mobile improvements */ + .panel-title { + font-size: 17px; + margin-bottom: 12px; + } + + .services-panel-header { + flex-direction: column; + align-items: flex-start; + } + + /* Existing vault mobile at 900px */ + @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; } + } + + /* Existing config mobile */ + .config-panel { padding: 0; } + .config-file-list, .config-editor { padding: 12px; } + .config-file-item { padding: 8px 10px; } + .editor-body { flex-direction: column; } + .editor-line-numbers { display: none; } + .editor-textarea { min-height: 250px; } + .editor-header { flex-wrap: wrap; } + .editor-filename { width: 100%; font-size: 14px; } + .editor-status-bar { flex-wrap: wrap; gap: 8px; } + + /* Chat mobile improvements */ + #chat-app { + height: calc(100vh - 180px); + } + .chat-message { max-width: 65%; } + .chat-message .bubble { font-size: 14px; } + .chat-message.user { margin-right: 12px; } + .chat-message.agent { margin-left: 12px; } + .chat-welcome p { max-width: 260px; margin: 0 auto; } + .chat-welcome-icon { font-size: 40px; } + .chat-profile-tab { font-size: 11px; padding: 4px 8px; white-space: normal; word-break: break-word; } + .chat-profile-strip { gap: 4px; } + .chat-profile-tabs { gap: 2px; } + /* Ensure input textarea doesn't overflow */ + .chat-input-area textarea { min-width: 0; max-width: 70%; } + + /* Network mobile improvements */ + .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; } +} + +/* โ”€โ”€ Touch-friendly sizing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +button, a, select, input, textarea { + touch-action: manipulation; +} + +.btn, .nav-item, .kanban-task-card, .config-file-item, +.service-action-btn, .chat-profile-tab { + cursor: pointer; +} + +/* Min tap targets for mobile */ +@media (max-width: 768px) { + .btn, .service-action-btn { + min-height: 40px; + display: flex; + align-items: center; + justify-content: center; + } + + .chat-profile-tab { + min-height: 36px; + padding: 6px 12px; + } + + .config-file-item { + min-height: 44px; + padding: 10px 12px; + } + + .kanban-task-card { + min-height: 48px; + } + + textarea, input, select { + font-size: 16px !important; /* prevents iOS zoom */ + } +}