| app/app/routers/services.py | patch | view | blame | history | |
| static/index.html | patch | view | blame | history | |
| static/js/app.js | patch | view | blame | history |
app/app/routers/services.py
.. .. @@ -1,5 +1,7 @@ 1 +import os1 2 from typing import Any 2 3 4 +import yaml3 5 from fastapi import APIRouter, Depends, HTTPException, Query 4 6 5 7 from app.auth import verify_token .. .. @@ -8,14 +10,111 @@ 8 10 router = APIRouter() 9 11 10 12 _DOCKER = "docker" 13 +_REGISTRY_PATH = os.environ.get(14 + "REGISTRY_PATH",15 + "/opt/infrastructure/servers/hetzner-vps/registry.yaml",16 +)17 +18 +# ---------------------------------------------------------------------------19 +# Registry-based name prefix lookup (cached)20 +# ---------------------------------------------------------------------------21 +_prefix_cache: dict[str, str] | None = None11 22 12 23 13 -def _container_name(project: str, env: str, service: str) -> str:24 +def _load_prefixes() -> dict[str, str]:25 + """Load project -> name_prefix mapping from the ops registry."""26 + global _prefix_cache27 + if _prefix_cache is not None:28 + return _prefix_cache29 +30 + try:31 + with open(_REGISTRY_PATH) as f:32 + data = yaml.safe_load(f)33 + _prefix_cache = {}34 + for proj_name, cfg in data.get("projects", {}).items():35 + _prefix_cache[proj_name] = cfg.get("name_prefix", proj_name)36 + return _prefix_cache37 + except Exception:38 + return {}39 +40 +41 +# ---------------------------------------------------------------------------42 +# Container name resolution43 +# ---------------------------------------------------------------------------44 +45 +46 +async def _find_by_prefix(pattern: str) -> str | None:47 + """Find first running container whose name starts with `pattern`."""48 + result = await run_command(49 + [_DOCKER, "ps", "--filter", f"name={pattern}", "--format", "{{.Names}}"],50 + timeout=10,51 + )52 + if not result["success"]:53 + return None54 + for name in result["output"].strip().splitlines():55 + name = name.strip()56 + if name and name.startswith(pattern):57 + return name58 + return None59 +60 +61 +async def _find_exact(name: str) -> str | None:62 + """Find a running container with exactly this name."""63 + result = await run_command(64 + [_DOCKER, "ps", "--filter", f"name={name}", "--format", "{{.Names}}"],65 + timeout=10,66 + )67 + if not result["success"]:68 + return None69 + for n in result["output"].strip().splitlines():70 + if n.strip() == name:71 + return name72 + return None73 +74 +75 +async def _resolve_container(project: str, env: str, service: str) -> str:14 76 """ 15 - Derive the Docker container name from project, env, and service.16 - Docker Compose v2 default: {project}-{env}-{service}-177 + Resolve the actual Docker container name from project/env/service.78 +79 + Uses the ops registry name_prefix mapping and tries patterns in order:80 + 1. {env}-{prefix}-{service} (mdf, seriousletter: dev-mdf-mysql-UUID)81 + 2. {prefix}-{service} (ringsaday: ringsaday-website-UUID, coolify: coolify-db)82 + 3. {prefix}-{env} (ringsaday: ringsaday-dev-UUID)83 + 4. exact {prefix} (coolify infra: coolify)17 84 """ 18 - return f"{project}-{env}-{service}-1"85 + prefixes = _load_prefixes()86 + prefix = prefixes.get(project, project)87 +88 + # Pattern 1: {env}-{prefix}-{service}89 + hit = await _find_by_prefix(f"{env}-{prefix}-{service}")90 + if hit:91 + return hit92 +93 + # Pattern 2: {prefix}-{service}94 + hit = await _find_by_prefix(f"{prefix}-{service}")95 + if hit:96 + return hit97 +98 + # Pattern 3: {prefix}-{env}99 + hit = await _find_by_prefix(f"{prefix}-{env}")100 + if hit:101 + return hit102 +103 + # Pattern 4: exact match when service == prefix (e.g., coolify)104 + if service == prefix:105 + hit = await _find_exact(prefix)106 + if hit:107 + return hit108 +109 + raise HTTPException(110 + status_code=404,111 + detail=f"Container not found for {project}/{env}/{service}",112 + )113 +114 +115 +# ---------------------------------------------------------------------------116 +# Endpoints117 +# ---------------------------------------------------------------------------19 118 20 119 21 120 @router.get("/logs/{project}/{env}/{service}", summary="Get container logs") .. .. @@ -23,20 +122,19 @@ 23 122 project: str, 24 123 env: str, 25 124 service: str, 26 - lines: int = Query(default=100, ge=1, le=10000, description="Number of log lines to return"),125 + lines: int = Query(126 + default=100, ge=1, le=10000, description="Number of log lines to return"127 + ),27 128 _: str = Depends(verify_token), 28 129 ) -> dict[str, Any]: 29 - """30 - Fetch the last N lines of logs from a container.31 - Uses `docker logs --tail {lines} {container}`.32 - """33 - container = _container_name(project, env, service)130 + """Fetch the last N lines of logs from a container."""131 + container = await _resolve_container(project, env, service)34 132 result = await run_command( 35 133 [_DOCKER, "logs", "--tail", str(lines), container], 36 134 timeout=30, 37 135 ) 38 136 39 - # docker logs writes to stderr by default; treat combined output as logs137 + # docker logs writes to stderr by default; combine both streams40 138 combined = result["output"] + result["error"] 41 139 42 140 if not result["success"] and not combined.strip(): .. .. @@ -59,10 +157,8 @@ 59 157 service: str, 60 158 _: str = Depends(verify_token), 61 159 ) -> dict[str, Any]: 62 - """63 - Restart a Docker container via `docker restart {container}`.64 - """65 - container = _container_name(project, env, service)160 + """Restart a Docker container."""161 + container = await _resolve_container(project, env, service)66 162 result = await run_command( 67 163 [_DOCKER, "restart", container], 68 164 timeout=60, static/index.html
.. .. @@ -1,843 +1,131 @@ 1 1 <!DOCTYPE html> 2 -<html lang="en" class="h-full">2 +<html lang="en">3 3 <head> 4 - <meta charset="UTF-8" />5 - <meta name="viewport" content="width=device-width, initial-scale=1.0" />4 + <meta charset="UTF-8">5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">6 6 <title>OPS Dashboard</title> 7 -8 - <!-- Tailwind CSS Play CDN -->9 7 <script src="https://cdn.tailwindcss.com"></script> 10 - <script>11 - tailwind.config = {12 - theme: {13 - extend: {14 - colors: {15 - gray: {16 - 950: '#0a0e1a'17 - }18 - }19 - }20 - }21 - };22 - </script>23 -24 - <!-- Custom styles -->25 - <link rel="stylesheet" href="/static/css/style.css" />26 -27 - <!-- Alpine.js v3 -->28 - <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>29 -30 - <!-- App logic -->31 - <script src="/static/js/app.js"></script>8 + <link rel="stylesheet" href="/static/css/style.css">9 + <style>10 + body { background: #0f172a; color: #e2e8f0; margin: 0; }11 + #app { display: flex; min-height: 100vh; }12 + #sidebar { width: 240px; background: #111827; border-right: 1px solid #1f2937; display: flex; flex-direction: column; flex-shrink: 0; }13 + #main { flex: 1; display: flex; flex-direction: column; overflow-x: hidden; }14 + #topbar { background: #111827; border-bottom: 1px solid #1f2937; padding: 0.75rem 1.5rem; display: flex; align-items: center; gap: 1rem; }15 + #page-content { flex: 1; padding: 1.5rem; overflow-y: auto; }16 + .breadcrumb { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; color: #9ca3af; }17 + .breadcrumb a { color: #60a5fa; cursor: pointer; text-decoration: none; }18 + .breadcrumb a:hover { text-decoration: underline; }19 + .breadcrumb .sep { color: #4b5563; }20 + .breadcrumb .current { color: #e2e8f0; font-weight: 500; }21 + .hamburger { display: none; background: none; border: none; color: #9ca3af; font-size: 1.5rem; cursor: pointer; padding: 0.25rem; }22 + .sidebar-logo { padding: 1.25rem 1rem; font-size: 1.125rem; font-weight: 700; color: #f3f4f6; border-bottom: 1px solid #1f2937; display: flex; align-items: center; gap: 0.5rem; }23 + .sidebar-nav { padding: 0.75rem 0.5rem; flex: 1; }24 + .sidebar-footer { padding: 0.75rem 1rem; border-top: 1px solid #1f2937; font-size: 0.75rem; color: #6b7280; }25 + .project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }26 + .env-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; }27 + .service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; }28 + .stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }29 + .card-clickable { cursor: pointer; transition: border-color 0.2s, transform 0.15s; }30 + .card-clickable:hover { border-color: #60a5fa; transform: translateY(-1px); }31 + .mobile-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 40; }32 + @media (max-width: 768px) {33 + #sidebar { position: fixed; left: -240px; top: 0; bottom: 0; z-index: 50; transition: left 0.2s; }34 + #sidebar.open { left: 0; }35 + .mobile-overlay.open { display: block; }36 + .hamburger { display: block; }37 + .project-grid, .env-grid, .service-grid { grid-template-columns: 1fr; }38 + }39 + </style>32 40 </head> 41 +<body>33 42 34 -<body class="h-full bg-gray-900 text-gray-100 antialiased">35 -36 -<!-- ============================================================37 - Toast Notifications38 - ============================================================ -->39 -<div class="toast-container" x-data x-show="$store.toast.toasts.length > 0">40 - <template x-for="t in $store.toast.toasts" :key="t.id">41 - <div class="toast" :class="t.type">42 - <span class="text-base leading-none" x-text="$store.toast.iconFor(t.type)"></span>43 - <span x-text="t.msg" class="flex-1"></span>44 - <button class="toast-dismiss" @click="$store.toast.remove(t.id)">✕</button>45 - </div>46 - </template>47 -</div>48 -49 -<!-- ============================================================50 - Login Screen51 - ============================================================ -->52 -<div x-data x-show="!$store.auth.isAuthenticated" class="flex h-full items-center justify-center">53 - <div class="w-full max-w-sm px-4">54 - <div class="card text-center">55 - <!-- Server icon -->56 - <div class="flex justify-center mb-4">57 - <div class="bg-blue-600 rounded-xl p-3">58 - <svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">59 - <path stroke-linecap="round" stroke-linejoin="round"60 - d="M21.75 17.25v.75A2.25 2.25 0 0119.5 20.25h-15a2.25 2.25 0 01-2.25-2.25v-.75m19.5 0A2.25 2.25 0 0019.5 15h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v-3A2.25 2.25 0 0019.5 9.75h-15A2.25 2.25 0 002.25 12v3M12 12.75h.008v.008H12v-.008zM12 6.75h.008v.008H12V6.75z"/>61 - </svg>62 - </div>63 - </div>64 -65 - <h1 class="text-2xl font-bold text-white mb-1">OPS Dashboard</h1>66 - <p class="text-sm text-gray-500 mb-6">Enter your access token to continue</p>67 -68 - <form @submit.prevent="$store.auth.login()" class="space-y-4">69 - <div>70 - <input71 - type="password"72 - class="form-input text-center tracking-widest"73 - placeholder="••••••••••••••••"74 - x-model="$store.auth.loginInput"75 - autofocus76 - />77 - </div>78 -79 - <div x-show="$store.auth.loginError" class="text-red-400 text-sm" x-text="$store.auth.loginError"></div>80 -81 - <button type="submit" class="btn btn-primary w-full" :disabled="$store.auth.loading">82 - <span x-show="$store.auth.loading" class="spinner"></span>83 - <span x-text="$store.auth.loading ? 'Verifying…' : 'Sign In'"></span>84 - </button>85 - </form>86 - </div>43 +<!-- Login Overlay -->44 +<div id="login-overlay" style="position:fixed;inset:0;background:#0f172a;z-index:100;display:flex;align-items:center;justify-content:center;">45 + <div class="card" style="width:100%;max-width:380px;text-align:center;">46 + <div style="font-size:1.5rem;font-weight:700;margin-bottom:1.5rem;color:#f3f4f6;">OPS Dashboard</div>47 + <input type="password" id="login-token" placeholder="Enter access token" class="form-input" style="margin-bottom:1rem;"48 + onkeydown="if(event.key==='Enter')doLogin()">49 + <button onclick="doLogin()" class="btn btn-primary" style="width:100%;">Login</button>50 + <div id="login-error" style="color:#f87171;font-size:0.875rem;margin-top:0.75rem;display:none;"></div>87 51 </div> 88 52 </div> 89 53 90 -<!-- ============================================================91 - Main App Shell92 - ============================================================ -->93 -<div x-data x-show="$store.auth.isAuthenticated" class="flex h-full">54 +<!-- App Shell -->55 +<div id="app" style="display:none;">56 + <!-- Mobile overlay -->57 + <div id="mobile-overlay" class="mobile-overlay" onclick="toggleSidebar()"></div>94 58 95 - <!-- Mobile sidebar overlay -->96 - <div97 - x-show="$store.app.sidebarOpen"98 - class="sidebar-mobile-overlay md:hidden"99 - @click="$store.app.sidebarOpen = false"100 - ></div>101 -102 - <!-- ---- Sidebar ---- -->103 - <aside104 - class="fixed inset-y-0 left-0 z-50 flex flex-col w-60 bg-gray-950 border-r border-gray-800 transform transition-transform duration-200 md:relative md:translate-x-0"105 - :class="$store.app.sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"106 - >107 - <!-- App name -->108 - <div class="flex items-center gap-3 px-4 py-5 border-b border-gray-800">109 - <div class="bg-blue-600 rounded-lg p-1.5 flex-shrink-0">110 - <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">111 - <path stroke-linecap="round" stroke-linejoin="round"112 - d="M21.75 17.25v.75A2.25 2.25 0 0119.5 20.25h-15A2.25 2.25 0 012.25 18v-.75m19.5 0A2.25 2.25 0 0019.5 15h-15A2.25 2.25 0 002.25 17.25m19.5 0v-3A2.25 2.25 0 0019.5 9.75h-15A2.25 2.25 0 002.25 12v3M12 12.75h.008v.008H12v-.008zM12 6.75h.008v.008H12V6.75z"/>113 - </svg>114 - </div>115 - <div>116 - <div class="font-bold text-white text-base tracking-widest">OPS</div>117 - <div class="text-xs text-gray-500">tekmidian.com</div>118 - </div>59 + <!-- Sidebar -->60 + <aside id="sidebar">61 + <div class="sidebar-logo">62 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#60a5fa" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1" fill="#60a5fa"/><circle cx="6" cy="18" r="1" fill="#60a5fa"/></svg>63 + OPS Dashboard119 64 </div> 120 -121 - <!-- Nav links -->122 - <nav class="flex-1 p-3 space-y-1 overflow-y-auto">123 -124 - <button class="sidebar-link w-full text-left"125 - :class="$store.app.page === 'dashboard' ? 'active' : ''"126 - @click="$store.app.navigate('dashboard')">127 - <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">128 - <path stroke-linecap="round" stroke-linejoin="round"129 - d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"/>130 - </svg>65 + <nav class="sidebar-nav" id="sidebar-nav">66 + <a class="sidebar-link active" data-page="dashboard" onclick="showPage('dashboard')">67 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>131 68 Dashboard 132 - </button>133 -134 - <button class="sidebar-link w-full text-left"135 - :class="$store.app.page === 'backups' ? 'active' : ''"136 - @click="$store.app.navigate('backups')">137 - <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">138 - <path stroke-linecap="round" stroke-linejoin="round"139 - d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"/>140 - </svg>141 - Backups142 - </button>143 -144 - <button class="sidebar-link w-full text-left"145 - :class="$store.app.page === 'restore' ? 'active' : ''"146 - @click="$store.app.navigate('restore')">147 - <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">148 - <path stroke-linecap="round" stroke-linejoin="round"149 - d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"/>150 - </svg>151 - Restore152 - </button>153 -154 - <button class="sidebar-link w-full text-left"155 - :class="$store.app.page === 'services' ? 'active' : ''"156 - @click="$store.app.navigate('services')">157 - <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">158 - <path stroke-linecap="round" stroke-linejoin="round"159 - d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"/>160 - </svg>69 + </a>70 + <a class="sidebar-link" data-page="services" onclick="showPage('services')">71 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>161 72 Services 162 - </button>163 -164 - <button class="sidebar-link w-full text-left"165 - :class="$store.app.page === 'system' ? 'active' : ''"166 - @click="$store.app.navigate('system')">167 - <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">168 - <path stroke-linecap="round" stroke-linejoin="round"169 - d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23-.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"/>170 - </svg>73 + </a>74 + <a class="sidebar-link" data-page="backups" onclick="showPage('backups')">75 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>76 + Backups77 + </a>78 + <a class="sidebar-link" data-page="system" onclick="showPage('system')">79 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>171 80 System 172 - </button>81 + </a>82 + <a class="sidebar-link" data-page="restore" onclick="showPage('restore')">83 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>84 + Restore85 + </a>173 86 </nav> 174 -175 - <!-- Logout -->176 - <div class="p-3 border-t border-gray-800">177 - <button class="sidebar-link w-full text-left text-red-400 hover:text-red-300"178 - @click="$store.auth.logout()">179 - <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">180 - <path stroke-linecap="round" stroke-linejoin="round"181 - d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75"/>182 - </svg>183 - Sign Out184 - </button>87 + <div class="sidebar-footer">88 + <a onclick="doLogout()" style="color:#9ca3af;cursor:pointer;">Logout</a>185 89 </div> 186 90 </aside> 187 91 188 - <!-- ---- Main content ---- -->189 - <div class="flex-1 flex flex-col min-w-0 overflow-hidden">190 -191 - <!-- Top header -->192 - <header class="flex items-center justify-between px-4 py-3 bg-gray-950 border-b border-gray-800 flex-shrink-0">193 -194 - <!-- Mobile hamburger -->195 - <button class="md:hidden p-1 rounded text-gray-400 hover:text-white hover:bg-gray-800"196 - @click="$store.app.sidebarOpen = !$store.app.sidebarOpen">197 - <svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">198 - <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>199 - </svg>200 - </button>201 -202 - <!-- Page title -->203 - <h2 class="text-sm font-semibold text-gray-300 capitalize"204 - x-text="$store.app.page"></h2>205 -206 - <!-- Right side: refresh indicator -->207 - <div class="flex items-center gap-3">208 -209 - <!-- Auto-refresh for dashboard -->210 - <div x-show="$store.app.page === 'dashboard'" class="flex items-center gap-2 text-xs text-gray-500">211 - <button212 - @click="$store.dashboard.toggleAutoRefresh()"213 - class="flex items-center gap-1.5 hover:text-gray-300 transition-colors"214 - :title="$store.dashboard.autoRefreshEnabled ? 'Auto-refresh on (click to pause)' : 'Auto-refresh off (click to resume)'">215 - <div class="refresh-ring" :class="!$store.dashboard.autoRefreshEnabled ? 'paused' : ''"></div>216 - <span x-show="$store.dashboard.lastRefresh"217 - x-text="'Updated ' + $store.dashboard.timeAgo($store.dashboard.lastRefresh)"></span>218 - </button>219 -220 - <button @click="$store.dashboard.manualRefresh()"221 - class="btn btn-ghost btn-xs"222 - :disabled="$store.dashboard.loading">223 - <svg class="w-3 h-3" :class="$store.dashboard.refreshing ? 'animate-spin' : ''" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">224 - <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-4.566l3.181 3.182m0-4.991v4.99"/>225 - </svg>226 - Refresh227 - </button>228 - </div>229 -92 + <!-- Main Content -->93 + <div id="main">94 + <!-- Top bar -->95 + <div id="topbar">96 + <button class="hamburger" onclick="toggleSidebar()">☰</button>97 + <div id="breadcrumbs" class="breadcrumb" style="flex:1;"></div>98 + <div style="display:flex;align-items:center;gap:0.75rem;">99 + <div id="refresh-indicator" class="refresh-ring paused" title="Auto-refresh"></div>100 + <button class="btn btn-ghost btn-xs" onclick="refreshCurrentPage()" title="Refresh now">Refresh</button>230 101 </div> 231 - </header>102 + </div>232 103 233 - <!-- ---- Page content area ---- -->234 - <main class="flex-1 overflow-y-auto">235 -236 - <!-- ======================================================237 - DASHBOARD PAGE238 - ====================================================== -->239 - <div x-show="$store.app.page === 'dashboard'" class="p-4 md:p-6 page-enter">240 -241 - <!-- Loading -->242 - <div x-show="$store.dashboard.loading && !$store.dashboard.projects.length"243 - class="flex items-center justify-center h-48 text-gray-500">244 - <div class="text-center">245 - <div class="spinner spinner-lg mx-auto mb-3"></div>246 - <div class="text-sm">Loading containers…</div>247 - </div>248 - </div>249 -250 - <!-- Error -->251 - <div x-show="$store.dashboard.error" class="card border-red-800 text-red-400 mb-4">252 - <div class="flex items-center gap-2">253 - <span class="text-lg">⚠</span>254 - <span x-text="$store.dashboard.error"></span>255 - </div>256 - </div>257 -258 - <!-- Projects -->259 - <template x-for="proj in $store.dashboard.projects" :key="proj.name">260 - <div class="mb-8">261 - <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3"262 - x-text="proj.name"></h3>263 -264 - <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">265 - <template x-for="svc in proj.services" :key="svc.name || svc.service">266 - <div class="card flex flex-col gap-2">267 - <!-- Name + badge -->268 - <div class="flex items-start justify-between gap-2">269 - <div class="font-medium text-white text-sm truncate"270 - x-text="svc.name || svc.service"></div>271 - <div class="badge flex-shrink-0"272 - :class="$store.dashboard.badgeClass(svc.status, svc.health)">273 - <span class="status-dot"274 - :class="$store.dashboard.dotClass(svc.status, svc.health)"></span>275 - <span x-text="svc.status || 'unknown'"></span>276 - </div>277 - </div>278 -279 - <!-- Health + uptime row -->280 - <div class="flex items-center gap-3 text-xs text-gray-500">281 - <template x-if="svc.health && svc.health !== ''">282 - <span class="flex items-center gap-1">283 - <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">284 - <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>285 - </svg>286 - <span x-text="svc.health"></span>287 - </span>288 - </template>289 - <template x-if="svc.uptime || svc.started">290 - <span x-text="svc.uptime || ('Up ' + $store.dashboard.timeAgo(svc.started))"></span>291 - </template>292 - </div>293 -294 - <!-- Domain link -->295 - <template x-if="svc.domain">296 - <a :href="'https://' + svc.domain" target="_blank" rel="noopener"297 - class="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 truncate">298 - <svg class="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">299 - <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/>300 - </svg>301 - <span x-text="svc.domain" class="truncate"></span>302 - </a>303 - </template>304 - </div>305 - </template>306 - </div>307 - </div>308 - </template>309 -310 - <!-- Empty state -->311 - <div x-show="!$store.dashboard.loading && !$store.dashboard.projects.length && !$store.dashboard.error"312 - class="flex flex-col items-center justify-center h-48 text-gray-600">313 - <svg class="w-10 h-10 mb-3" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24">314 - <path stroke-linecap="round" stroke-linejoin="round"315 - d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7"/>316 - </svg>317 - <p class="text-sm">No containers found</p>318 - </div>319 - </div>320 -321 - <!-- ======================================================322 - BACKUPS PAGE323 - ====================================================== -->324 - <div x-show="$store.app.page === 'backups'" class="p-4 md:p-6 page-enter space-y-8">325 -326 - <!-- ---- Local Backups ---- -->327 - <section>328 - <div class="flex items-center justify-between mb-4">329 - <h3 class="text-sm font-semibold text-gray-400 uppercase tracking-widest">Local Backups</h3>330 - <button class="btn btn-ghost btn-sm" @click="$store.backups.fetchLocal()"331 - :disabled="$store.backups.loading">332 - <span x-show="$store.backups.loading" class="spinner"></span>333 - Refresh334 - </button>335 - </div>336 -337 - <div x-show="$store.backups.loading && !$store.backups.local.length"338 - class="flex items-center gap-2 text-gray-500 text-sm py-6 justify-center">339 - <div class="spinner"></div> Loading…340 - </div>341 -342 - <div x-show="!$store.backups.loading && !$store.backups.local.length"343 - class="text-gray-600 text-sm py-6 text-center">No local backups found</div>344 -345 - <div x-show="$store.backups.local.length > 0" class="table-wrapper">346 - <table class="ops-table">347 - <thead>348 - <tr>349 - <th>Project</th>350 - <th>Env</th>351 - <th>File</th>352 - <th>Size</th>353 - <th>Age</th>354 - <th>Actions</th>355 - </tr>356 - </thead>357 - <tbody>358 - <template x-for="(b, idx) in $store.backups.local" :key="idx">359 - <tr>360 - <td class="font-medium text-white" x-text="b.project || '—'"></td>361 - <td>362 - <span class="badge badge-blue" x-text="b.env || '—'"></span>363 - </td>364 - <td class="mono text-xs text-gray-400 max-w-xs truncate" x-text="b.filename || b.file || '—'"></td>365 - <td class="text-gray-400 text-xs" x-text="$store.backups.formatBytes(b.size)"></td>366 - <td>367 - <span class="badge"368 - :class="$store.backups.ageBadge(b.created_at || b.modified)"369 - x-text="$store.backups.timeAgo(b.created_at || b.modified)"></span>370 - </td>371 - <td>372 - <div class="flex items-center gap-2">373 - <button class="btn btn-success btn-xs"374 - @click="$store.backups.backupNow(b.project, b.env)"375 - :disabled="$store.backups.isRunning(b.project, b.env, 'backup')">376 - <span x-show="$store.backups.isRunning(b.project, b.env, 'backup')" class="spinner"></span>377 - <span x-text="$store.backups.isRunning(b.project, b.env, 'backup') ? 'Running…' : 'Backup Now'"></span>378 - </button>379 - <button class="btn btn-ghost btn-xs"380 - @click="$store.backups.uploadOffsite(b.project, b.env)"381 - :disabled="$store.backups.isRunning(b.project, b.env, 'upload')">382 - <span x-show="$store.backups.isRunning(b.project, b.env, 'upload')" class="spinner"></span>383 - <span x-text="$store.backups.isRunning(b.project, b.env, 'upload') ? 'Uploading…' : 'Upload Offsite'"></span>384 - </button>385 - </div>386 - </td>387 - </tr>388 - </template>389 - </tbody>390 - </table>391 - </div>392 - </section>393 -394 - <!-- ---- Offsite Backups ---- -->395 - <section>396 - <div class="flex items-center justify-between mb-4">397 - <h3 class="text-sm font-semibold text-gray-400 uppercase tracking-widest">Offsite Backups</h3>398 - <div class="flex items-center gap-2">399 - <button class="btn btn-warning btn-sm"400 - @click="$store.backups.applyRetention()"401 - :disabled="$store.backups.retentionRunning">402 - <span x-show="$store.backups.retentionRunning" class="spinner"></span>403 - Apply Retention404 - </button>405 - <button class="btn btn-ghost btn-sm" @click="$store.backups.fetchOffsite()"406 - :disabled="$store.backups.loadingOffsite">407 - <span x-show="$store.backups.loadingOffsite" class="spinner"></span>408 - Refresh409 - </button>410 - </div>411 - </div>412 -413 - <div x-show="$store.backups.loadingOffsite && !$store.backups.offsite.length"414 - class="flex items-center gap-2 text-gray-500 text-sm py-6 justify-center">415 - <div class="spinner"></div> Loading…416 - </div>417 -418 - <div x-show="!$store.backups.loadingOffsite && !$store.backups.offsite.length"419 - class="text-gray-600 text-sm py-6 text-center">No offsite backups found</div>420 -421 - <div x-show="$store.backups.offsite.length > 0" class="table-wrapper">422 - <table class="ops-table">423 - <thead>424 - <tr>425 - <th>Project</th>426 - <th>Env</th>427 - <th>File</th>428 - <th>Size</th>429 - <th>Age</th>430 - </tr>431 - </thead>432 - <tbody>433 - <template x-for="(b, idx) in $store.backups.offsite" :key="idx">434 - <tr>435 - <td class="font-medium text-white" x-text="b.project || '—'"></td>436 - <td>437 - <span class="badge badge-blue" x-text="b.env || '—'"></span>438 - </td>439 - <td class="mono text-xs text-gray-400 max-w-xs truncate" x-text="b.filename || b.file || '—'"></td>440 - <td class="text-gray-400 text-xs" x-text="$store.backups.formatBytes(b.size)"></td>441 - <td>442 - <span class="badge"443 - :class="$store.backups.ageBadge(b.created_at || b.modified)"444 - x-text="$store.backups.timeAgo(b.created_at || b.modified)"></span>445 - </td>446 - </tr>447 - </template>448 - </tbody>449 - </table>450 - </div>451 - </section>452 - </div>453 -454 - <!-- ======================================================455 - RESTORE PAGE456 - ====================================================== -->457 - <div x-show="$store.app.page === 'restore'" class="p-4 md:p-6 page-enter">458 - <div class="max-w-2xl">459 -460 - <!-- Config card -->461 - <div class="card mb-6">462 - <h3 class="text-sm font-semibold text-gray-300 mb-5">Restore Configuration</h3>463 -464 - <div class="grid grid-cols-1 sm:grid-cols-2 gap-5">465 -466 - <!-- Source -->467 - <div>468 - <label class="form-label">Source</label>469 - <div class="flex gap-4 mt-1">470 - <label class="flex items-center gap-2 cursor-pointer">471 - <input type="radio" name="restore-source" value="local"472 - x-model="$store.restore.source"473 - @change="$store.restore.onSourceChange()"474 - class="accent-blue-500 w-4 h-4" />475 - <span class="text-sm text-gray-300">Local</span>476 - </label>477 - <label class="flex items-center gap-2 cursor-pointer">478 - <input type="radio" name="restore-source" value="offsite"479 - x-model="$store.restore.source"480 - @change="$store.restore.onSourceChange()"481 - class="accent-blue-500 w-4 h-4" />482 - <span class="text-sm text-gray-300">Offsite</span>483 - </label>484 - </div>485 - </div>486 -487 - <!-- Dry run -->488 - <div class="flex items-center">489 - <label class="flex items-center gap-2 cursor-pointer mt-5 sm:mt-0">490 - <input type="checkbox" x-model="$store.restore.dryRun"491 - class="accent-blue-500 w-4 h-4 rounded" />492 - <span class="text-sm text-gray-300">Dry run (simulate only)</span>493 - </label>494 - </div>495 -496 - <!-- Project -->497 - <div>498 - <label class="form-label">Project</label>499 - <div x-show="$store.restore.loadingProjects" class="flex items-center gap-2 text-gray-500 text-sm mt-1">500 - <div class="spinner"></div> Loading…501 - </div>502 - <select class="form-select" x-show="!$store.restore.loadingProjects"503 - x-model="$store.restore.project"504 - @change="$store.restore.onProjectChange()">505 - <option value="" disabled>Select project</option>506 - <template x-for="p in $store.restore.projects" :key="p">507 - <option :value="p" x-text="p"></option>508 - </template>509 - </select>510 - </div>511 -512 - <!-- Environment -->513 - <div>514 - <label class="form-label">Environment</label>515 - <select class="form-select" x-model="$store.restore.env"516 - :disabled="!$store.restore.project">517 - <option value="" disabled>Select environment</option>518 - <template x-for="e in $store.restore.envs" :key="e">519 - <option :value="e" x-text="e"></option>520 - </template>521 - </select>522 - </div>523 - </div>524 -525 - <!-- Action bar -->526 - <div class="flex items-center gap-3 mt-6 pt-5 border-t border-gray-700">527 - <button class="btn btn-danger"528 - @click="$store.restore.confirm()"529 - :disabled="$store.restore.running || !$store.restore.project || !$store.restore.env">530 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">531 - <path stroke-linecap="round" stroke-linejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"/>532 - </svg>533 - <span x-text="$store.restore.dryRun ? 'Dry Run' : 'Restore'"></span>534 - </button>535 - <button x-show="$store.restore.running" class="btn btn-ghost"536 - @click="$store.restore.abort()">Abort</button>537 - <span x-show="$store.restore.running" class="flex items-center gap-2 text-sm text-yellow-400">538 - <div class="spinner"></div> Running…539 - </span>540 - </div>541 - </div>542 -543 - <!-- Output terminal -->544 - <div x-show="$store.restore.output.length > 0">545 - <div class="flex items-center justify-between mb-2">546 - <span class="text-xs text-gray-500 font-semibold uppercase tracking-widest">Output</span>547 - <button class="btn btn-ghost btn-xs" @click="$store.restore.output = []">Clear</button>548 - </div>549 - <div id="restore-output" class="terminal" style="height: 360px;">550 - <template x-for="(line, i) in $store.restore.output" :key="i">551 - <div :class="line.cls" x-text="line.text"></div>552 - </template>553 - </div>554 - </div>555 -556 - <!-- Error -->557 - <div x-show="$store.restore.error"558 - class="mt-4 card border-red-800 text-red-400 text-sm"559 - x-text="$store.restore.error"></div>560 - </div>561 -562 - <!-- Confirm dialog -->563 - <div x-show="$store.restore.confirming" class="modal-overlay" @click.self="$store.restore.cancel()">564 - <div class="modal-box max-w-md">565 - <div class="modal-header">566 - <h3 class="font-semibold text-white">Confirm Restore</h3>567 - </div>568 - <div class="modal-body text-sm text-gray-300 space-y-3">569 - <p>You are about to restore:</p>570 - <div class="bg-gray-900 rounded-lg p-3 space-y-2 text-xs font-mono">571 - <div><span class="text-gray-500">Project:</span> <span class="text-white" x-text="$store.restore.project"></span></div>572 - <div><span class="text-gray-500">Environment:</span> <span class="text-white" x-text="$store.restore.env"></span></div>573 - <div><span class="text-gray-500">Source:</span> <span class="text-white" x-text="$store.restore.source"></span></div>574 - <div><span class="text-gray-500">Dry Run:</span> <span :class="$store.restore.dryRun ? 'text-yellow-400' : 'text-white'" x-text="$store.restore.dryRun ? 'YES — simulate only' : 'NO — live restore'"></span></div>575 - </div>576 - <p x-show="!$store.restore.dryRun" class="text-yellow-400 font-medium">577 - This will overwrite existing data. This action cannot be undone.578 - </p>579 - </div>580 - <div class="modal-footer">581 - <button class="btn btn-ghost" @click="$store.restore.cancel()">Cancel</button>582 - <button class="btn btn-danger" @click="$store.restore.execute()">583 - <span x-text="$store.restore.dryRun ? 'Run Dry Test' : 'Yes, Restore Now'"></span>584 - </button>585 - </div>586 - </div>587 - </div>588 - </div>589 -590 - <!-- ======================================================591 - SERVICES PAGE592 - ====================================================== -->593 - <div x-show="$store.app.page === 'services'" class="p-4 md:p-6 page-enter">594 -595 - <!-- Loading -->596 - <div x-show="$store.services.loading && !$store.services.projects.length"597 - class="flex items-center justify-center h-48 text-gray-500">598 - <div class="spinner spinner-lg"></div>599 - </div>600 -601 - <!-- Error -->602 - <div x-show="$store.services.error" class="card border-red-800 text-red-400 text-sm mb-4"603 - x-text="$store.services.error"></div>604 -605 - <!-- Projects -->606 - <template x-for="proj in $store.services.projects" :key="proj.name">607 - <div class="mb-8">608 - <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3"609 - x-text="proj.name"></h3>610 -611 - <div class="space-y-2">612 - <template x-for="svc in proj.services" :key="svc.name || svc.service">613 - <div class="card flex flex-col sm:flex-row sm:items-center gap-3">614 - <!-- Status + name -->615 - <div class="flex items-center gap-3 flex-1 min-w-0">616 - <div class="badge flex-shrink-0"617 - :class="$store.services.badgeClass(svc.status, svc.health)">618 - <span class="status-dot"619 - :class="$store.services.dotClass(svc.status, svc.health)"></span>620 - <span x-text="svc.status || 'unknown'"></span>621 - </div>622 - <div>623 - <div class="font-medium text-white text-sm" x-text="svc.name || svc.service"></div>624 - <div class="text-xs text-gray-500" x-text="svc.uptime || ''"></div>625 - </div>626 - </div>627 -628 - <!-- Actions -->629 - <div class="flex items-center gap-2 flex-shrink-0">630 - <template x-if="svc.domain">631 - <a :href="'https://' + svc.domain" target="_blank" rel="noopener"632 - class="btn btn-ghost btn-xs">633 - <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">634 - <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/>635 - </svg>636 - Open637 - </a>638 - </template>639 -640 - <button class="btn btn-ghost btn-xs"641 - @click="$store.services.viewLogs(svc.project || proj.name, svc.env || 'prod', svc.name || svc.service)">642 - <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">643 - <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9h16.5m-16.5 6.75h16.5"/>644 - </svg>645 - Logs646 - </button>647 -648 - <button class="btn btn-warning btn-xs"649 - @click="$store.services.askRestart(svc.project || proj.name, svc.env || 'prod', svc.name || svc.service)">650 - <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">651 - <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-4.566l3.181 3.182m0-4.991v4.99"/>652 - </svg>653 - Restart654 - </button>655 - </div>656 - </div>657 - </template>658 - </div>659 - </div>660 - </template>661 -662 - <!-- Log Modal -->663 - <div x-show="$store.services.logModal.open" class="modal-overlay" @click.self="$store.services.closeLogs()">664 - <div class="modal-box" style="max-width: 780px;">665 - <div class="modal-header">666 - <h3 class="font-semibold text-white text-sm" x-text="$store.services.logModal.title"></h3>667 - <button class="text-gray-400 hover:text-white text-lg leading-none" @click="$store.services.closeLogs()">✕</button>668 - </div>669 - <div class="modal-body p-0">670 - <div x-show="$store.services.logModal.loading"671 - class="flex items-center justify-center h-32 text-gray-500">672 - <div class="spinner spinner-lg"></div>673 - </div>674 - <div id="log-output" class="terminal rounded-none rounded-b-xl"675 - x-show="!$store.services.logModal.loading"676 - style="height: 480px; border-radius: 0 0 0.875rem 0.875rem; border: none;">677 - <template x-for="(line, i) in $store.services.logModal.lines" :key="i">678 - <div x-text="typeof line === 'string' ? line : (line.text || JSON.stringify(line))"></div>679 - </template>680 - </div>681 - </div>682 - </div>683 - </div>684 -685 - <!-- Restart Confirm Modal -->686 - <div x-show="$store.services.confirmRestart.open" class="modal-overlay"687 - @click.self="$store.services.cancelRestart()">688 - <div class="modal-box max-w-md">689 - <div class="modal-header">690 - <h3 class="font-semibold text-white">Confirm Restart</h3>691 - </div>692 - <div class="modal-body text-sm text-gray-300">693 - <p>Restart service <strong class="text-white" x-text="$store.services.confirmRestart.service"></strong>?</p>694 - <p class="text-gray-500 mt-1 text-xs">This will briefly interrupt the service.</p>695 - </div>696 - <div class="modal-footer">697 - <button class="btn btn-ghost" @click="$store.services.cancelRestart()">Cancel</button>698 - <button class="btn btn-warning" @click="$store.services.doRestart()"699 - :disabled="$store.services.confirmRestart.running">700 - <span x-show="$store.services.confirmRestart.running" class="spinner"></span>701 - <span x-text="$store.services.confirmRestart.running ? 'Restarting…' : 'Restart'"></span>702 - </button>703 - </div>704 - </div>705 - </div>706 - </div>707 -708 - <!-- ======================================================709 - SYSTEM PAGE710 - ====================================================== -->711 - <div x-show="$store.app.page === 'system'" class="p-4 md:p-6 page-enter space-y-8">712 -713 - <!-- System Info -->714 - <section>715 - <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">System Info</h3>716 - <div x-show="$store.system.loading.info" class="flex items-center gap-2 text-gray-500 text-sm">717 - <div class="spinner"></div> Loading…718 - </div>719 - <div x-show="!$store.system.loading.info" class="grid grid-cols-2 sm:grid-cols-4 gap-3">720 - <div class="card text-center">721 - <div class="text-xs text-gray-500 mb-1">Uptime</div>722 - <div class="text-white font-medium text-sm" x-text="$store.system.info.uptime || '—'"></div>723 - </div>724 - <div class="card text-center">725 - <div class="text-xs text-gray-500 mb-1">Load (1m)</div>726 - <div class="text-white font-medium text-sm" x-text="$store.system.info.load_1 ?? $store.system.info.load_avg?.[0] ?? '—'"></div>727 - </div>728 - <div class="card text-center">729 - <div class="text-xs text-gray-500 mb-1">Load (5m)</div>730 - <div class="text-white font-medium text-sm" x-text="$store.system.info.load_5 ?? $store.system.info.load_avg?.[1] ?? '—'"></div>731 - </div>732 - <div class="card text-center">733 - <div class="text-xs text-gray-500 mb-1">Hostname</div>734 - <div class="text-white font-medium text-sm truncate" x-text="$store.system.info.hostname || '—'"></div>735 - </div>736 - </div>737 - </section>738 -739 - <!-- Disk Usage -->740 - <section>741 - <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">Disk Usage</h3>742 - <div x-show="$store.system.loading.disk" class="flex items-center gap-2 text-gray-500 text-sm">743 - <div class="spinner"></div> Loading…744 - </div>745 - <div x-show="!$store.system.loading.disk && !$store.system.disk.length"746 - class="text-gray-600 text-sm">No disk data</div>747 - <div x-show="!$store.system.loading.disk && $store.system.disk.length > 0" class="space-y-3">748 - <template x-for="(d, i) in $store.system.disk" :key="i">749 - <div class="card">750 - <div class="flex items-center justify-between mb-2">751 - <div>752 - <span class="text-white font-medium text-sm" x-text="d.mount || d.filesystem || '?'"></span>753 - <span class="text-gray-500 text-xs ml-2" x-text="d.filesystem || d.device || ''"></span>754 - </div>755 - <span class="text-sm font-semibold"756 - :class="(d.use_pct || d.pct || 0) >= 90 ? 'text-red-400' : (d.use_pct || d.pct || 0) >= 75 ? 'text-yellow-400' : 'text-green-400'"757 - x-text="(d.use_pct || d.pct || 0) + '%'"></span>758 - </div>759 - <div class="progress-bar-track">760 - <div class="progress-bar-fill"761 - :class="$store.system.diskBarClass(d.use_pct || d.pct || 0)"762 - :style="'width: ' + Math.min(d.use_pct || d.pct || 0, 100) + '%'"></div>763 - </div>764 - <div class="flex justify-between text-xs text-gray-500 mt-1">765 - <span x-text="'Used: ' + $store.system.formatBytes(d.used)"></span>766 - <span x-text="'Total: ' + $store.system.formatBytes(d.total || d.size)"></span>767 - </div>768 - </div>769 - </template>770 - </div>771 - </section>772 -773 - <!-- Health Checks -->774 - <section>775 - <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">Health Checks</h3>776 - <div x-show="$store.system.loading.health" class="flex items-center gap-2 text-gray-500 text-sm">777 - <div class="spinner"></div> Loading…778 - </div>779 - <div x-show="!$store.system.loading.health && !$store.system.health.length"780 - class="text-gray-600 text-sm">No health data</div>781 - <div x-show="!$store.system.loading.health && $store.system.health.length > 0" class="space-y-2">782 - <template x-for="(h, i) in $store.system.health" :key="i">783 - <div class="card flex items-center justify-between gap-3">784 - <div class="flex items-center gap-3">785 - <div class="w-2 h-2 rounded-full flex-shrink-0"786 - :class="h.ok || h.status === 'pass' ? 'bg-green-400' : 'bg-red-400'"></div>787 - <span class="text-sm text-gray-300 font-medium" x-text="h.name || h.check"></span>788 - </div>789 - <div class="flex items-center gap-2">790 - <span class="text-xs text-gray-500" x-text="h.detail || h.message || ''"></span>791 - <span class="badge"792 - :class="h.ok || h.status === 'pass' ? 'badge-green' : 'badge-red'"793 - x-text="h.ok || h.status === 'pass' ? 'PASS' : 'FAIL'"></span>794 - </div>795 - </div>796 - </template>797 - </div>798 - </section>799 -800 - <!-- Systemd Timers -->801 - <section>802 - <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">Systemd Timers</h3>803 - <div x-show="$store.system.loading.timers" class="flex items-center gap-2 text-gray-500 text-sm">804 - <div class="spinner"></div> Loading…805 - </div>806 - <div x-show="!$store.system.loading.timers && !$store.system.timers.length"807 - class="text-gray-600 text-sm">No timer data</div>808 - <div x-show="!$store.system.loading.timers && $store.system.timers.length > 0" class="table-wrapper">809 - <table class="ops-table">810 - <thead>811 - <tr>812 - <th>Timer</th>813 - <th>Last Run</th>814 - <th>Next Run</th>815 - <th>Status</th>816 - </tr>817 - </thead>818 - <tbody>819 - <template x-for="(t, i) in $store.system.timers" :key="i">820 - <tr>821 - <td class="font-medium text-white" x-text="t.name || t.timer"></td>822 - <td class="text-gray-400 text-xs mono" x-text="t.last || t.last_trigger || '—'"></td>823 - <td class="text-gray-400 text-xs mono" x-text="t.next || t.next_elapse || '—'"></td>824 - <td>825 - <span class="badge"826 - :class="t.active || t.status === 'active' ? 'badge-green' : 'badge-gray'"827 - x-text="t.status || (t.active ? 'active' : 'inactive')"></span>828 - </td>829 - </tr>830 - </template>831 - </tbody>832 - </table>833 - </div>834 - </section>835 -836 - </div>837 -838 - </main>104 + <!-- Page content -->105 + <div id="page-content"></div>839 106 </div> 840 107 </div> 841 108 109 +<!-- Toast Container -->110 +<div id="toast-container" class="toast-container"></div>111 +112 +<!-- Log Modal -->113 +<div id="log-modal" class="modal-overlay" style="display:none;" onclick="if(event.target===this)closeLogModal()">114 + <div class="modal-box" style="max-width:800px;">115 + <div class="modal-header">116 + <span id="log-modal-title" style="font-weight:600;color:#f3f4f6;">Container Logs</span>117 + <button onclick="closeLogModal()" style="background:none;border:none;color:#9ca3af;font-size:1.25rem;cursor:pointer;">×</button>118 + </div>119 + <div class="modal-body" style="padding:0;">120 + <div id="log-modal-content" class="terminal" style="max-height:60vh;border:none;border-radius:0;"></div>121 + </div>122 + <div class="modal-footer">123 + <button class="btn btn-ghost btn-sm" onclick="closeLogModal()">Close</button>124 + <button class="btn btn-primary btn-sm" id="log-refresh-btn" onclick="refreshLogs()">Refresh</button>125 + </div>126 + </div>127 +</div>128 +129 +<script src="/static/js/app.js"></script>842 130 </body> 843 131 </html> static/js/app.js
.. .. @@ -1,15 +1,30 @@ 1 -/* ============================================================2 - OPS Dashboard — Alpine.js Application Logic3 - ============================================================ */4 -5 1 'use strict'; 6 2 7 -// ----------------------------------------------------------------8 -// Helpers9 -// ----------------------------------------------------------------3 +// ============================================================4 +// OPS Dashboard — Vanilla JS Application5 +// ============================================================10 6 7 +// ---------------------------------------------------------------------------8 +// State9 +// ---------------------------------------------------------------------------10 +let allServices = [];11 +let currentPage = 'dashboard';12 +let drillLevel = 0; // 0=projects, 1=environments, 2=services13 +let drillProject = null;14 +let drillEnv = null;15 +let refreshTimer = null;16 +const REFRESH_INTERVAL = 30000;17 +18 +// Log modal state19 +let logModalProject = null;20 +let logModalEnv = null;21 +let logModalService = null;22 +23 +// ---------------------------------------------------------------------------24 +// Helpers25 +// ---------------------------------------------------------------------------11 26 function formatBytes(bytes) { 12 - if (bytes == null || bytes === '') return '—';27 + if (bytes == null || bytes === '') return '\u2014';13 28 const n = Number(bytes); 14 29 if (isNaN(n) || n === 0) return '0 B'; 15 30 const k = 1024; .. .. @@ -19,755 +34,779 @@ 19 34 } 20 35 21 36 function timeAgo(dateInput) { 22 - if (!dateInput) return '—';37 + if (!dateInput) return '\u2014';23 38 const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput; 24 - if (isNaN(date)) return '—';39 + if (isNaN(date)) return '\u2014';25 40 const secs = Math.floor((Date.now() - date.getTime()) / 1000); 26 - if (secs < 60) return secs + 's ago';27 - if (secs < 3600) return Math.floor(secs / 60) + 'm ago';28 - if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';41 + if (secs < 60) return secs + 's ago';42 + if (secs < 3600) return Math.floor(secs / 60) + 'm ago';43 + if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';29 44 return Math.floor(secs / 86400) + 'd ago'; 30 45 } 31 46 32 -function ageHours(dateInput) {33 - if (!dateInput) return 0;34 - const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;35 - if (isNaN(date)) return 0;36 - return (Date.now() - date.getTime()) / 3600000;37 -}38 -39 -function ageBadgeClass(dateInput) {40 - const h = ageHours(dateInput);41 - if (h >= 48) return 'badge-red';42 - if (h >= 24) return 'badge-yellow';43 - return 'badge-green';44 -}45 -46 -function statusBadgeClass(status, health) {47 - const s = (status || '').toLowerCase();48 - const h = (health || '').toLowerCase();49 - if (s === 'running' && (h === 'healthy' || h === '')) return 'badge-green';50 - if (s === 'running' && h === 'unhealthy') return 'badge-red';51 - if (s === 'running' && h === 'starting') return 'badge-yellow';52 - if (s === 'restarting' || h === 'starting') return 'badge-yellow';53 - if (s === 'exited' || s === 'dead' || s === 'removed') return 'badge-red';54 - if (s === 'paused') return 'badge-yellow';55 - return 'badge-gray';47 +function escapeHtml(str) {48 + const div = document.createElement('div');49 + div.textContent = str;50 + return div.innerHTML;56 51 } 57 52 58 53 function statusDotClass(status, health) { 59 - const cls = statusBadgeClass(status, health);60 - return cls.replace('badge-', 'status-dot-');54 + const s = (status || '').toLowerCase();55 + const h = (health || '').toLowerCase();56 + if (s === 'up' && (h === 'healthy' || h === '')) return 'status-dot-green';57 + if (s === 'up' && h === 'unhealthy') return 'status-dot-red';58 + if (s === 'up' && h === 'starting') return 'status-dot-yellow';59 + if (s === 'down' || s === 'exited') return 'status-dot-red';60 + return 'status-dot-gray';61 61 } 62 62 63 -function diskBarClass(pct) {64 - if (pct >= 90) return 'disk-danger';65 - if (pct >= 75) return 'disk-warn';63 +function badgeClass(status, health) {64 + const s = (status || '').toLowerCase();65 + const h = (health || '').toLowerCase();66 + if (s === 'up' && (h === 'healthy' || h === '')) return 'badge-green';67 + if (s === 'up' && h === 'unhealthy') return 'badge-red';68 + if (s === 'up' && h === 'starting') return 'badge-yellow';69 + if (s === 'down' || s === 'exited') return 'badge-red';70 + return 'badge-gray';71 +}72 +73 +function diskColorClass(pct) {74 + const n = parseInt(pct);75 + if (isNaN(n)) return 'disk-ok';76 + if (n >= 90) return 'disk-danger';77 + if (n >= 75) return 'disk-warn';66 78 return 'disk-ok'; 67 79 } 68 80 69 -// ----------------------------------------------------------------70 -// Auth Store71 -// ----------------------------------------------------------------72 -73 -function authStore() {74 - return {75 - token: localStorage.getItem('ops_token') || '',76 - loginInput: '',77 - loginError: '',78 - loading: false,79 -80 - get isAuthenticated() {81 - return !!this.token;82 - },83 -84 - async login() {85 - this.loginError = '';86 - if (!this.loginInput.trim()) {87 - this.loginError = 'Please enter your access token.';88 - return;89 - }90 - this.loading = true;91 - try {92 - const res = await fetch('/api/status/', {93 - headers: { 'Authorization': 'Bearer ' + this.loginInput.trim() }94 - });95 - if (res.ok || res.status === 200) {96 - this.token = this.loginInput.trim();97 - localStorage.setItem('ops_token', this.token);98 - this.loginInput = '';99 - // Trigger page load100 - this.$dispatch('authenticated');101 - } else if (res.status === 401) {102 - this.loginError = 'Invalid token. Please try again.';103 - } else {104 - this.loginError = 'Server error (' + res.status + '). Please try again.';105 - }106 - } catch {107 - this.loginError = 'Could not reach the server.';108 - } finally {109 - this.loading = false;110 - }111 - },112 -113 - logout() {114 - this.token = '';115 - localStorage.removeItem('ops_token');116 - }117 - };81 +// ---------------------------------------------------------------------------82 +// Auth83 +// ---------------------------------------------------------------------------84 +function getToken() {85 + return localStorage.getItem('ops_token');118 86 } 119 87 120 -// ----------------------------------------------------------------88 +function doLogin() {89 + const input = document.getElementById('login-token');90 + const errEl = document.getElementById('login-error');91 + const token = input.value.trim();92 + if (!token) {93 + errEl.textContent = 'Please enter a token';94 + errEl.style.display = 'block';95 + return;96 + }97 + errEl.style.display = 'none';98 +99 + // Validate token by calling the API100 + fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } })101 + .then(r => {102 + if (!r.ok) throw new Error('Invalid token');103 + return r.json();104 + })105 + .then(data => {106 + localStorage.setItem('ops_token', token);107 + allServices = data;108 + document.getElementById('login-overlay').style.display = 'none';109 + document.getElementById('app').style.display = 'flex';110 + showPage('dashboard');111 + startAutoRefresh();112 + })113 + .catch(() => {114 + errEl.textContent = 'Invalid token. Try again.';115 + errEl.style.display = 'block';116 + });117 +}118 +119 +function doLogout() {120 + localStorage.removeItem('ops_token');121 + stopAutoRefresh();122 + document.getElementById('app').style.display = 'none';123 + document.getElementById('login-overlay').style.display = 'flex';124 + document.getElementById('login-token').value = '';125 +}126 +127 +// ---------------------------------------------------------------------------121 128 // API Helper 122 -// ----------------------------------------------------------------123 -124 -function api(path, options = {}) {125 - const token = localStorage.getItem('ops_token') || '';126 - const headers = Object.assign({ 'Authorization': 'Bearer ' + token }, options.headers || {});127 - if (options.json) {128 - headers['Content-Type'] = 'application/json';129 - options.body = JSON.stringify(options.json);130 - delete options.json;129 +// ---------------------------------------------------------------------------130 +async function api(path, opts = {}) {131 + const token = getToken();132 + const headers = { ...(opts.headers || {}), 'Authorization': 'Bearer ' + token };133 + const resp = await fetch(path, { ...opts, headers });134 + if (resp.status === 401) {135 + doLogout();136 + throw new Error('Session expired');131 137 } 132 - return fetch(path, Object.assign({}, options, { headers })).then(res => {133 - if (res.status === 401) {134 - localStorage.removeItem('ops_token');135 - window.dispatchEvent(new CustomEvent('unauthorized'));136 - throw new Error('Unauthorized');137 - }138 - return res;138 + if (!resp.ok) {139 + const body = await resp.text();140 + throw new Error(body || 'HTTP ' + resp.status);141 + }142 + const ct = resp.headers.get('content-type') || '';143 + if (ct.includes('json')) return resp.json();144 + return resp.text();145 +}146 +147 +async function fetchStatus() {148 + allServices = await api('/api/status/');149 +}150 +151 +// ---------------------------------------------------------------------------152 +// Toast Notifications153 +// ---------------------------------------------------------------------------154 +function toast(message, type = 'info') {155 + const container = document.getElementById('toast-container');156 + const el = document.createElement('div');157 + el.className = 'toast toast-' + type;158 + el.innerHTML = `<span>${escapeHtml(message)}</span><span class="toast-dismiss" onclick="this.parentElement.remove()">×</span>`;159 + container.appendChild(el);160 + setTimeout(() => {161 + el.classList.add('toast-out');162 + setTimeout(() => el.remove(), 200);163 + }, 4000);164 +}165 +166 +// ---------------------------------------------------------------------------167 +// Sidebar & Navigation168 +// ---------------------------------------------------------------------------169 +function toggleSidebar() {170 + document.getElementById('sidebar').classList.toggle('open');171 + document.getElementById('mobile-overlay').classList.toggle('open');172 +}173 +174 +function showPage(page) {175 + currentPage = page;176 + drillLevel = 0;177 + drillProject = null;178 + drillEnv = null;179 +180 + // Update sidebar active181 + document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el => {182 + el.classList.toggle('active', el.dataset.page === page);139 183 }); 184 +185 + // Close mobile sidebar186 + document.getElementById('sidebar').classList.remove('open');187 + document.getElementById('mobile-overlay').classList.remove('open');188 +189 + renderPage();140 190 } 141 191 142 -// ----------------------------------------------------------------143 -// Toast Store144 -// ----------------------------------------------------------------192 +function renderPage() {193 + const content = document.getElementById('page-content');194 + content.innerHTML = '<div style="text-align:center;padding:3rem;"><div class="spinner spinner-lg"></div></div>';145 195 146 -function toastStore() {147 - return {148 - toasts: [],149 - _counter: 0,150 -151 - add(msg, type = 'info', duration = 4000) {152 - const id = ++this._counter;153 - this.toasts.push({ id, msg, type });154 - if (duration > 0) {155 - setTimeout(() => this.remove(id), duration);156 - }157 - return id;158 - },159 -160 - remove(id) {161 - const idx = this.toasts.findIndex(t => t.id === id);162 - if (idx !== -1) this.toasts.splice(idx, 1);163 - },164 -165 - success(msg) { return this.add(msg, 'toast-success'); },166 - error(msg) { return this.add(msg, 'toast-error', 6000); },167 - warn(msg) { return this.add(msg, 'toast-warning'); },168 - info(msg) { return this.add(msg, 'toast-info'); },169 -170 - iconFor(type) {171 - const icons = {172 - 'toast-success': '✓',173 - 'toast-error': '✕',174 - 'toast-warning': '⚠',175 - 'toast-info': 'ℹ'176 - };177 - return icons[type] || 'ℹ';178 - }179 - };180 -}181 -182 -// ----------------------------------------------------------------183 -// App Root Store184 -// ----------------------------------------------------------------185 -186 -function appStore() {187 - return {188 - page: 'dashboard',189 - sidebarOpen: false,190 - toast: null,191 -192 - init() {193 - this.toast = Alpine.store('toast');194 - window.addEventListener('unauthorized', () => {195 - Alpine.store('auth').logout();196 - });197 - window.addEventListener('authenticated', () => {198 - this.loadPage('dashboard');199 - });200 - },201 -202 - navigate(page) {203 - this.page = page;204 - this.sidebarOpen = false;205 - this.loadPage(page);206 - },207 -208 - loadPage(page) {209 - const storeMap = {210 - dashboard: 'dashboard',211 - backups: 'backups',212 - restore: 'restore',213 - services: 'services',214 - system: 'system'215 - };216 - const storeName = storeMap[page];217 - if (storeName && Alpine.store(storeName) && Alpine.store(storeName).load) {218 - Alpine.store(storeName).load();219 - }220 - }221 - };222 -}223 -224 -// ----------------------------------------------------------------225 -// Dashboard Store226 -// ----------------------------------------------------------------227 -228 -function dashboardStore() {229 - return {230 - projects: [],231 - loading: false,232 - error: null,233 - lastRefresh: null,234 - refreshInterval: null,235 - autoRefreshEnabled: true,236 - refreshing: false,237 -238 - load() {239 - this.fetch();240 - this.startAutoRefresh();241 - },242 -243 - async fetch() {244 - if (this.loading) return;245 - this.loading = true;246 - this.error = null;247 - try {248 - const res = await api('/api/status/');249 - if (!res.ok) throw new Error('HTTP ' + res.status);250 - const data = await res.json();251 - this.projects = this.groupByProject(data);252 - this.lastRefresh = new Date();253 - } catch (e) {254 - if (e.message !== 'Unauthorized') {255 - this.error = e.message || 'Failed to load status';256 - }257 - } finally {258 - this.loading = false;259 - this.refreshing = false;260 - }261 - },262 -263 - groupByProject(data) {264 - // data may be array of containers or object keyed by project265 - let containers = Array.isArray(data) ? data : Object.values(data).flat();266 - const map = {};267 - for (const c of containers) {268 - const proj = c.project || 'Other';269 - if (!map[proj]) map[proj] = [];270 - map[proj].push(c);271 - }272 - return Object.entries(map).map(([name, services]) => ({ name, services }))273 - .sort((a, b) => a.name.localeCompare(b.name));274 - },275 -276 - manualRefresh() {277 - this.refreshing = true;278 - this.fetch();279 - },280 -281 - startAutoRefresh() {282 - this.stopAutoRefresh();283 - if (this.autoRefreshEnabled) {284 - this.refreshInterval = setInterval(() => this.fetch(), 30000);285 - }286 - },287 -288 - stopAutoRefresh() {289 - if (this.refreshInterval) {290 - clearInterval(this.refreshInterval);291 - this.refreshInterval = null;292 - }293 - },294 -295 - toggleAutoRefresh() {296 - this.autoRefreshEnabled = !this.autoRefreshEnabled;297 - if (this.autoRefreshEnabled) {298 - this.startAutoRefresh();299 - } else {300 - this.stopAutoRefresh();301 - }302 - },303 -304 - destroy() {305 - this.stopAutoRefresh();306 - },307 -308 - badgeClass: statusBadgeClass,309 - dotClass: statusDotClass,310 - timeAgo311 - };312 -}313 -314 -// ----------------------------------------------------------------315 -// Backups Store316 -// ----------------------------------------------------------------317 -318 -function backupsStore() {319 - return {320 - local: [],321 - offsite: [],322 - loading: false,323 - loadingOffsite: false,324 - error: null,325 - ops: {}, // track per-row operation state: key -> { loading, done, error }326 -327 - load() {328 - this.fetchLocal();329 - this.fetchOffsite();330 - },331 -332 - async fetchLocal() {333 - this.loading = true;334 - this.error = null;335 - try {336 - const res = await api('/api/backups/');337 - if (!res.ok) throw new Error('HTTP ' + res.status);338 - const data = await res.json();339 - this.local = Array.isArray(data) ? data : Object.values(data).flat();340 - } catch (e) {341 - if (e.message !== 'Unauthorized') this.error = e.message;342 - } finally {343 - this.loading = false;344 - }345 - },346 -347 - async fetchOffsite() {348 - this.loadingOffsite = true;349 - try {350 - const res = await api('/api/backups/offsite');351 - if (!res.ok) throw new Error('HTTP ' + res.status);352 - const data = await res.json();353 - this.offsite = Array.isArray(data) ? data : Object.values(data).flat();354 - } catch (e) {355 - if (e.message !== 'Unauthorized')356 - Alpine.store('toast').error('Offsite: ' + (e.message || 'Failed'));357 - } finally {358 - this.loadingOffsite = false;359 - }360 - },361 -362 - opKey(project, env, action) {363 - return `${action}::${project}::${env}`;364 - },365 -366 - isRunning(project, env, action) {367 - return !!(this.ops[this.opKey(project, env, action)]?.loading);368 - },369 -370 - async backupNow(project, env) {371 - const key = this.opKey(project, env, 'backup');372 - this.ops = { ...this.ops, [key]: { loading: true } };373 - try {374 - const res = await api(`/api/backups/${project}/${env}`, { method: 'POST' });375 - if (!res.ok) throw new Error('HTTP ' + res.status);376 - Alpine.store('toast').success(`Backup started: ${project}/${env}`);377 - setTimeout(() => this.fetchLocal(), 2000);378 - } catch (e) {379 - if (e.message !== 'Unauthorized')380 - Alpine.store('toast').error(`Backup failed: ${e.message}`);381 - } finally {382 - this.ops = { ...this.ops, [key]: { loading: false } };383 - }384 - },385 -386 - async uploadOffsite(project, env) {387 - const key = this.opKey(project, env, 'upload');388 - this.ops = { ...this.ops, [key]: { loading: true } };389 - try {390 - const res = await api(`/api/backups/offsite/upload/${project}/${env}`, { method: 'POST' });391 - if (!res.ok) throw new Error('HTTP ' + res.status);392 - Alpine.store('toast').success(`Upload started: ${project}/${env}`);393 - setTimeout(() => this.fetchOffsite(), 3000);394 - } catch (e) {395 - if (e.message !== 'Unauthorized')396 - Alpine.store('toast').error(`Upload failed: ${e.message}`);397 - } finally {398 - this.ops = { ...this.ops, [key]: { loading: false } };399 - }400 - },401 -402 - retentionRunning: false,403 -404 - async applyRetention() {405 - this.retentionRunning = true;406 - try {407 - const res = await api('/api/backups/offsite/retention', { method: 'POST' });408 - if (!res.ok) throw new Error('HTTP ' + res.status);409 - Alpine.store('toast').success('Retention policy applied');410 - setTimeout(() => this.fetchOffsite(), 2000);411 - } catch (e) {412 - if (e.message !== 'Unauthorized')413 - Alpine.store('toast').error(`Retention failed: ${e.message}`);414 - } finally {415 - this.retentionRunning = false;416 - }417 - },418 -419 - ageBadge: ageBadgeClass,420 - timeAgo,421 - formatBytes422 - };423 -}424 -425 -// ----------------------------------------------------------------426 -// Restore Store427 -// ----------------------------------------------------------------428 -429 -function restoreStore() {430 - return {431 - source: 'local',432 - project: '',433 - env: '',434 - dryRun: false,435 - confirming: false,436 - running: false,437 - output: [],438 - sseSource: null,439 - projects: [],440 - envs: [],441 - loadingProjects: false,442 - error: null,443 -444 - load() {445 - this.loadProjectList();446 - },447 -448 - async loadProjectList() {449 - this.loadingProjects = true;450 - try {451 - const endpoint = this.source === 'offsite' ? '/api/backups/offsite' : '/api/backups/';452 - const res = await api(endpoint);453 - if (!res.ok) throw new Error('HTTP ' + res.status);454 - const data = await res.json();455 - const items = Array.isArray(data) ? data : Object.values(data).flat();456 - const projSet = new Set(items.map(i => i.project).filter(Boolean));457 - this.projects = Array.from(projSet).sort();458 - this.project = this.projects[0] || '';459 - this.updateEnvs(items);460 - } catch (e) {461 - if (e.message !== 'Unauthorized') this.error = e.message;462 - } finally {463 - this.loadingProjects = false;464 - }465 - },466 -467 - updateEnvs(items) {468 - if (!items) return;469 - const envSet = new Set(470 - items.filter(i => i.project === this.project).map(i => i.env).filter(Boolean)471 - );472 - this.envs = Array.from(envSet).sort();473 - this.env = this.envs[0] || '';474 - },475 -476 - onSourceChange() {477 - this.project = '';478 - this.env = '';479 - this.envs = [];480 - this.loadProjectList();481 - },482 -483 - onProjectChange() {484 - // Re-fetch envs for this project from the already loaded data485 - this.loadProjectList();486 - },487 -488 - confirm() {489 - if (!this.project || !this.env) {490 - Alpine.store('toast').warn('Select project and environment first');491 - return;492 - }493 - this.confirming = true;494 - },495 -496 - cancel() {497 - this.confirming = false;498 - },499 -500 - async execute() {501 - this.confirming = false;502 - this.running = true;503 - this.output = [];504 -505 - const params = new URLSearchParams({506 - source: this.source,507 - dry_run: this.dryRun ? '1' : '0'508 - });509 - const url = `/api/restore/${this.project}/${this.env}?${params}`;510 -511 - try {512 - this.sseSource = new EventSource(url + '&token=' + encodeURIComponent(localStorage.getItem('ops_token') || ''));513 - this.sseSource.onmessage = (e) => {514 - try {515 - const msg = JSON.parse(e.data);516 - if (msg.done) {517 - this.sseSource.close();518 - this.sseSource = null;519 - this.running = false;520 - if (msg.success) {521 - Alpine.store('toast').success('Restore completed');522 - } else {523 - Alpine.store('toast').error('Restore finished with errors');524 - }525 - return;526 - }527 - const text = msg.line || e.data;528 - this.output.push({ text, cls: this.classifyLine(text) });529 - } catch {530 - this.output.push({ text: e.data, cls: this.classifyLine(e.data) });531 - }532 - this.$nextTick(() => {533 - const el = document.getElementById('restore-output');534 - if (el) el.scrollTop = el.scrollHeight;535 - });536 - };537 - this.sseSource.onerror = () => {538 - if (this.running) {539 - this.running = false;540 - if (this.sseSource) this.sseSource.close();541 - this.sseSource = null;542 - }543 - };544 - } catch (e) {545 - this.running = false;546 - Alpine.store('toast').error('Restore failed: ' + (e.message || 'Unknown error'));547 - }548 - },549 -550 - classifyLine(text) {551 - const t = text.toLowerCase();552 - if (t.includes('error') || t.includes('fail') || t.includes('critical')) return 'line-error';553 - if (t.includes('warn')) return 'line-warn';554 - if (t.includes('ok') || t.includes('success') || t.includes('done')) return 'line-ok';555 - if (t.startsWith('$') || t.startsWith('#') || t.startsWith('>')) return 'line-cmd';556 - return '';557 - },558 -559 - abort() {560 - if (this.sseSource) {561 - this.sseSource.close();562 - this.sseSource = null;563 - }564 - this.running = false;565 - this.output.push({ text: '--- aborted by user ---', cls: 'line-warn' });566 - }567 - };568 -}569 -570 -// ----------------------------------------------------------------571 -// Services Store572 -// ----------------------------------------------------------------573 -574 -function servicesStore() {575 - return {576 - projects: [],577 - loading: false,578 - error: null,579 - logModal: { open: false, title: '', lines: [], loading: false },580 - confirmRestart: { open: false, project: '', env: '', service: '', running: false },581 -582 - load() {583 - this.fetch();584 - },585 -586 - async fetch() {587 - this.loading = true;588 - this.error = null;589 - try {590 - const res = await api('/api/status/');591 - if (!res.ok) throw new Error('HTTP ' + res.status);592 - const data = await res.json();593 - this.projects = this.groupByProject(data);594 - } catch (e) {595 - if (e.message !== 'Unauthorized') this.error = e.message;596 - } finally {597 - this.loading = false;598 - }599 - },600 -601 - groupByProject(data) {602 - let containers = Array.isArray(data) ? data : Object.values(data).flat();603 - const map = {};604 - for (const c of containers) {605 - const proj = c.project || 'Other';606 - if (!map[proj]) map[proj] = [];607 - map[proj].push(c);608 - }609 - return Object.entries(map).map(([name, services]) => ({ name, services }))610 - .sort((a, b) => a.name.localeCompare(b.name));611 - },612 -613 - async viewLogs(project, env, service) {614 - this.logModal = {615 - open: true,616 - title: `${service} — logs`,617 - lines: [],618 - loading: true619 - };620 - try {621 - const res = await api(`/api/services/logs/${project}/${env}/${service}?lines=150`);622 - if (!res.ok) throw new Error('HTTP ' + res.status);623 - const data = await res.json();624 - this.logModal.lines = (data.logs || '').split('\n');625 - } catch (e) {626 - if (e.message !== 'Unauthorized')627 - this.logModal.lines = ['Error: ' + e.message];628 - } finally {629 - this.logModal.loading = false;630 - this.$nextTick(() => {631 - const el = document.getElementById('log-output');632 - if (el) el.scrollTop = el.scrollHeight;633 - });634 - }635 - },636 -637 - closeLogs() {638 - this.logModal.open = false;639 - },640 -641 - askRestart(project, env, service) {642 - this.confirmRestart = { open: true, project, env, service, running: false };643 - },644 -645 - cancelRestart() {646 - this.confirmRestart.open = false;647 - },648 -649 - async doRestart() {650 - const { project, env, service } = this.confirmRestart;651 - this.confirmRestart.running = true;652 - try {653 - const res = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' });654 - if (!res.ok) throw new Error('HTTP ' + res.status);655 - Alpine.store('toast').success(`${service} restarted`);656 - this.confirmRestart.open = false;657 - setTimeout(() => this.fetch(), 2000);658 - } catch (e) {659 - if (e.message !== 'Unauthorized')660 - Alpine.store('toast').error(`Restart failed: ${e.message}`);661 - } finally {662 - this.confirmRestart.running = false;663 - }664 - },665 -666 - badgeClass: statusBadgeClass,667 - dotClass: statusDotClass668 - };669 -}670 -671 -// ----------------------------------------------------------------672 -// System Store673 -// ----------------------------------------------------------------674 -675 -function systemStore() {676 - return {677 - disk: [],678 - health: [],679 - timers: [],680 - info: {},681 - loading: { disk: false, health: false, timers: false, info: false },682 - error: null,683 -684 - load() {685 - this.fetchDisk();686 - this.fetchHealth();687 - this.fetchTimers();688 - this.fetchInfo();689 - },690 -691 - async fetchDisk() {692 - this.loading.disk = true;693 - try {694 - const res = await api('/api/system//disk');695 - if (!res.ok) throw new Error('HTTP ' + res.status);696 - const data = await res.json();697 - this.disk = Array.isArray(data) ? data : (data.filesystems || []);698 - } catch (e) {699 - if (e.message !== 'Unauthorized') this.error = e.message;700 - } finally {701 - this.loading.disk = false;702 - }703 - },704 -705 - async fetchHealth() {706 - this.loading.health = true;707 - try {708 - const res = await api('/api/system//health');709 - if (!res.ok) throw new Error('HTTP ' + res.status);710 - const data = await res.json();711 - this.health = Array.isArray(data) ? data : (data.checks || []);712 - } catch (e) {713 - if (e.message !== 'Unauthorized') {}714 - } finally {715 - this.loading.health = false;716 - }717 - },718 -719 - async fetchTimers() {720 - this.loading.timers = true;721 - try {722 - const res = await api('/api/system//timers');723 - if (!res.ok) throw new Error('HTTP ' + res.status);724 - const data = await res.json();725 - this.timers = Array.isArray(data) ? data : (data.timers || []);726 - } catch (e) {727 - if (e.message !== 'Unauthorized') {}728 - } finally {729 - this.loading.timers = false;730 - }731 - },732 -733 - async fetchInfo() {734 - this.loading.info = true;735 - try {736 - const res = await api('/api/system//info');737 - if (!res.ok) throw new Error('HTTP ' + res.status);738 - this.info = await res.json();739 - } catch (e) {740 - if (e.message !== 'Unauthorized') {}741 - } finally {742 - this.loading.info = false;743 - }744 - },745 -746 - diskBarClass,747 - formatBytes,748 - timeAgo749 - };750 -}751 -752 -// ----------------------------------------------------------------753 -// Alpine initialization754 -// ----------------------------------------------------------------755 -756 -document.addEventListener('alpine:init', () => {757 - Alpine.store('auth', authStore());758 - Alpine.store('toast', toastStore());759 - Alpine.store('app', appStore());760 - Alpine.store('dashboard', dashboardStore());761 - Alpine.store('backups', backupsStore());762 - Alpine.store('restore', restoreStore());763 - Alpine.store('services', servicesStore());764 - Alpine.store('system', systemStore());765 -766 - // Init app store767 - Alpine.store('app').init();768 -769 - // Load the dashboard if already authenticated770 - if (Alpine.store('auth').isAuthenticated) {771 - Alpine.store('dashboard').load();196 + switch (currentPage) {197 + case 'dashboard': renderDashboard(); break;198 + case 'services': renderServicesFlat(); break;199 + case 'backups': renderBackups(); break;200 + case 'system': renderSystem(); break;201 + case 'restore': renderRestore(); break;202 + default: renderDashboard();772 203 } 773 -});204 +}205 +206 +function refreshCurrentPage() {207 + showRefreshSpinner();208 + fetchStatus()209 + .then(() => renderPage())210 + .catch(e => toast('Refresh failed: ' + e.message, 'error'))211 + .finally(() => hideRefreshSpinner());212 +}213 +214 +// ---------------------------------------------------------------------------215 +// Auto-refresh216 +// ---------------------------------------------------------------------------217 +function startAutoRefresh() {218 + stopAutoRefresh();219 + refreshTimer = setInterval(() => {220 + fetchStatus()221 + .then(() => {222 + if (currentPage === 'dashboard' || currentPage === 'services') renderPage();223 + })224 + .catch(() => {});225 + }, REFRESH_INTERVAL);226 +}227 +228 +function stopAutoRefresh() {229 + if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }230 +}231 +232 +function showRefreshSpinner() {233 + document.getElementById('refresh-indicator').classList.remove('paused');234 +}235 +function hideRefreshSpinner() {236 + document.getElementById('refresh-indicator').classList.add('paused');237 +}238 +239 +// ---------------------------------------------------------------------------240 +// Breadcrumbs241 +// ---------------------------------------------------------------------------242 +function updateBreadcrumbs() {243 + const bc = document.getElementById('breadcrumbs');244 + let html = '';245 +246 + if (currentPage === 'dashboard') {247 + if (drillLevel === 0) {248 + html = '<span class="current">Dashboard</span>';249 + } else if (drillLevel === 1) {250 + html = '<a onclick="drillBack(0)">Dashboard</a><span class="sep">/</span><span class="current">' + escapeHtml(drillProject) + '</span>';251 + } else if (drillLevel === 2) {252 + html = '<a onclick="drillBack(0)">Dashboard</a><span class="sep">/</span><a onclick="drillBack(1)">' + escapeHtml(drillProject) + '</a><span class="sep">/</span><span class="current">' + escapeHtml(drillEnv) + '</span>';253 + }254 + } else {255 + const names = { services: 'Services', backups: 'Backups', system: 'System', restore: 'Restore' };256 + html = '<span class="current">' + (names[currentPage] || currentPage) + '</span>';257 + }258 +259 + bc.innerHTML = html;260 +}261 +262 +function drillBack(level) {263 + if (level === 0) {264 + drillLevel = 0;265 + drillProject = null;266 + drillEnv = null;267 + } else if (level === 1) {268 + drillLevel = 1;269 + drillEnv = null;270 + }271 + renderDashboard();272 +}273 +274 +// ---------------------------------------------------------------------------275 +// Dashboard — 3-level Drill276 +// ---------------------------------------------------------------------------277 +function renderDashboard() {278 + currentPage = 'dashboard';279 + if (drillLevel === 0) renderProjects();280 + else if (drillLevel === 1) renderEnvironments();281 + else if (drillLevel === 2) renderServices();282 + updateBreadcrumbs();283 +}284 +285 +function renderProjects() {286 + const content = document.getElementById('page-content');287 + const projects = groupByProject(allServices);288 +289 + // Summary stats290 + const totalUp = allServices.filter(s => s.status === 'Up').length;291 + const totalDown = allServices.length - totalUp;292 +293 + let html = '<div class="page-enter" style="padding:0;">';294 +295 + // Summary bar296 + html += '<div class="stat-grid" style="margin-bottom:1.5rem;">';297 + html += statCard('Projects', Object.keys(projects).length, '#3b82f6');298 + html += statCard('Services', allServices.length, '#8b5cf6');299 + html += statCard('Healthy', totalUp, '#10b981');300 + html += statCard('Down', totalDown, totalDown > 0 ? '#ef4444' : '#6b7280');301 + html += '</div>';302 +303 + // Project cards304 + html += '<div class="project-grid">';305 + for (const [name, proj] of Object.entries(projects)) {306 + const upCount = proj.services.filter(s => s.status === 'Up').length;307 + const total = proj.services.length;308 + const allUp = upCount === total;309 + const envNames = [...new Set(proj.services.map(s => s.env))];310 +311 + html += `<div class="card card-clickable" onclick="drillToProject('${escapeHtml(name)}')">312 + <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">313 + <span class="status-dot ${allUp ? 'status-dot-green' : 'status-dot-red'}"></span>314 + <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${escapeHtml(name)}</span>315 + <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${total} services</span>316 + </div>317 + <div style="display:flex;flex-wrap:wrap;gap:0.375rem;margin-bottom:0.5rem;">318 + ${envNames.map(e => `<span class="badge badge-blue">${escapeHtml(e)}</span>`).join('')}319 + </div>320 + <div style="font-size:0.8125rem;color:#9ca3af;">${upCount}/${total} healthy</div>321 + </div>`;322 + }323 + html += '</div></div>';324 + content.innerHTML = html;325 +}326 +327 +function renderEnvironments() {328 + const content = document.getElementById('page-content');329 + const projServices = allServices.filter(s => s.project === drillProject);330 + const envs = groupByEnv(projServices);331 +332 + let html = '<div class="page-enter" style="padding:0;">';333 + html += '<div class="env-grid">';334 +335 + for (const [envName, services] of Object.entries(envs)) {336 + const upCount = services.filter(s => s.status === 'Up').length;337 + const total = services.length;338 + const allUp = upCount === total;339 +340 + html += `<div class="card card-clickable" onclick="drillToEnv('${escapeHtml(envName)}')">341 + <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">342 + <span class="status-dot ${allUp ? 'status-dot-green' : 'status-dot-red'}"></span>343 + <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${escapeHtml(envName).toUpperCase()}</span>344 + <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${total} services</span>345 + </div>346 + <div style="display:flex;flex-wrap:wrap;gap:0.375rem;margin-bottom:0.5rem;">347 + ${services.map(s => `<span class="badge ${badgeClass(s.status, s.health)}">${escapeHtml(s.service)}</span>`).join('')}348 + </div>349 + <div style="font-size:0.8125rem;color:#9ca3af;">${upCount}/${total} healthy</div>350 + </div>`;351 + }352 +353 + html += '</div></div>';354 + content.innerHTML = html;355 +}356 +357 +function renderServices() {358 + const content = document.getElementById('page-content');359 + const services = allServices.filter(s => s.project === drillProject && s.env === drillEnv);360 +361 + let html = '<div class="page-enter" style="padding:0;">';362 + html += '<div class="service-grid">';363 +364 + for (const svc of services) {365 + html += serviceCard(svc);366 + }367 +368 + html += '</div></div>';369 + content.innerHTML = html;370 +}371 +372 +function drillToProject(name) {373 + drillProject = name;374 + drillLevel = 1;375 + renderDashboard();376 +}377 +378 +function drillToEnv(name) {379 + drillEnv = name;380 + drillLevel = 2;381 + renderDashboard();382 +}383 +384 +// ---------------------------------------------------------------------------385 +// Service Card (shared component)386 +// ---------------------------------------------------------------------------387 +function serviceCard(svc) {388 + const proj = escapeHtml(svc.project);389 + const env = escapeHtml(svc.env);390 + const service = escapeHtml(svc.service);391 + const bc = badgeClass(svc.status, svc.health);392 + const dc = statusDotClass(svc.status, svc.health);393 +394 + return `<div class="card">395 + <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">396 + <span class="status-dot ${dc}"></span>397 + <span style="font-weight:600;color:#f3f4f6;">${service}</span>398 + <span class="badge ${bc}" style="margin-left:auto;">${escapeHtml(svc.status)}</span>399 + </div>400 + <div style="font-size:0.8125rem;color:#9ca3af;margin-bottom:0.75rem;">401 + Health: ${escapeHtml(svc.health || 'n/a')} · Uptime: ${escapeHtml(svc.uptime || 'n/a')}402 + </div>403 + <div style="display:flex;gap:0.5rem;flex-wrap:wrap;">404 + <button class="btn btn-ghost btn-xs" onclick="viewLogs('${proj}','${env}','${service}')">Logs</button>405 + <button class="btn btn-warning btn-xs" onclick="restartService('${proj}','${env}','${service}')">Restart</button>406 + </div>407 + </div>`;408 +}409 +410 +function statCard(label, value, color) {411 + return `<div class="card" style="text-align:center;">412 + <div style="font-size:1.75rem;font-weight:700;color:${color};">${value}</div>413 + <div style="font-size:0.8125rem;color:#9ca3af;">${label}</div>414 + </div>`;415 +}416 +417 +// ---------------------------------------------------------------------------418 +// Services (flat list page)419 +// ---------------------------------------------------------------------------420 +function renderServicesFlat() {421 + updateBreadcrumbs();422 + const content = document.getElementById('page-content');423 +424 + if (allServices.length === 0) {425 + content.innerHTML = '<div style="text-align:center;padding:3rem;color:#6b7280;">No services found.</div>';426 + return;427 + }428 +429 + let html = '<div class="page-enter" style="padding:0;">';430 + html += '<div class="table-wrapper"><table class="ops-table">';431 + html += '<thead><tr><th>Project</th><th>Env</th><th>Service</th><th>Status</th><th>Health</th><th>Uptime</th><th>Actions</th></tr></thead>';432 + html += '<tbody>';433 +434 + for (const svc of allServices) {435 + const bc = badgeClass(svc.status, svc.health);436 + const proj = escapeHtml(svc.project);437 + const env = escapeHtml(svc.env);438 + const service = escapeHtml(svc.service);439 +440 + html += `<tr>441 + <td style="font-weight:500;">${proj}</td>442 + <td><span class="badge badge-blue">${env}</span></td>443 + <td class="mono">${service}</td>444 + <td><span class="badge ${bc}">${escapeHtml(svc.status)}</span></td>445 + <td>${escapeHtml(svc.health || 'n/a')}</td>446 + <td>${escapeHtml(svc.uptime || 'n/a')}</td>447 + <td style="white-space:nowrap;">448 + <button class="btn btn-ghost btn-xs" onclick="viewLogs('${proj}','${env}','${service}')">Logs</button>449 + <button class="btn btn-warning btn-xs" onclick="restartService('${proj}','${env}','${service}')">Restart</button>450 + </td>451 + </tr>`;452 + }453 +454 + html += '</tbody></table></div></div>';455 + content.innerHTML = html;456 +}457 +458 +// ---------------------------------------------------------------------------459 +// Backups Page460 +// ---------------------------------------------------------------------------461 +async function renderBackups() {462 + updateBreadcrumbs();463 + const content = document.getElementById('page-content');464 +465 + try {466 + const [local, offsite] = await Promise.all([467 + api('/api/backups/'),468 + api('/api/backups/offsite').catch(() => []),469 + ]);470 +471 + let html = '<div class="page-enter" style="padding:0;">';472 +473 + // Quick backup buttons474 + html += '<div style="margin-bottom:1.5rem;">';475 + html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Create Backup</h2>';476 + html += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">';477 + for (const proj of ['mdf', 'seriousletter']) {478 + for (const env of ['dev', 'int', 'prod']) {479 + html += `<button class="btn btn-ghost btn-sm" onclick="createBackup('${proj}','${env}')">${proj}/${env}</button>`;480 + }481 + }482 + html += '</div></div>';483 +484 + // Local backups485 + html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Local Backups</h2>';486 + if (local.length === 0) {487 + html += '<div class="card" style="color:#6b7280;">No local backups found.</div>';488 + } else {489 + html += '<div class="table-wrapper"><table class="ops-table">';490 + html += '<thead><tr><th>Project</th><th>Env</th><th>Date</th><th>Size</th><th>Files</th></tr></thead><tbody>';491 + for (const b of local) {492 + html += `<tr>493 + <td>${escapeHtml(b.project || '')}</td>494 + <td><span class="badge badge-blue">${escapeHtml(b.env || b.environment || '')}</span></td>495 + <td>${escapeHtml(b.date || b.timestamp || '')}</td>496 + <td>${escapeHtml(b.size || '')}</td>497 + <td class="mono" style="font-size:0.75rem;">${escapeHtml(b.file || b.files || '')}</td>498 + </tr>`;499 + }500 + html += '</tbody></table></div>';501 + }502 +503 + // Offsite backups504 + html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin:1.5rem 0 0.75rem;">Offsite Backups</h2>';505 + if (offsite.length === 0) {506 + html += '<div class="card" style="color:#6b7280;">No offsite backups found.</div>';507 + } else {508 + html += '<div class="table-wrapper"><table class="ops-table">';509 + html += '<thead><tr><th>Project</th><th>Env</th><th>Date</th><th>Size</th></tr></thead><tbody>';510 + for (const b of offsite) {511 + html += `<tr>512 + <td>${escapeHtml(b.project || '')}</td>513 + <td><span class="badge badge-blue">${escapeHtml(b.env || b.environment || '')}</span></td>514 + <td>${escapeHtml(b.date || b.timestamp || '')}</td>515 + <td>${escapeHtml(b.size || '')}</td>516 + </tr>`;517 + }518 + html += '</tbody></table></div>';519 + }520 +521 + html += '</div>';522 + content.innerHTML = html;523 + } catch (e) {524 + content.innerHTML = '<div class="card" style="color:#f87171;">Failed to load backups: ' + escapeHtml(e.message) + '</div>';525 + }526 +}527 +528 +// ---------------------------------------------------------------------------529 +// System Page530 +// ---------------------------------------------------------------------------531 +async function renderSystem() {532 + updateBreadcrumbs();533 + const content = document.getElementById('page-content');534 +535 + try {536 + const [disk, health, timers, info] = await Promise.all([537 + api('/api/system/disk').catch(e => ({ filesystems: [], raw: e.message })),538 + api('/api/system/health').catch(e => ({ checks: [], raw: e.message })),539 + api('/api/system/timers').catch(e => ({ timers: [], raw: e.message })),540 + api('/api/system/info').catch(e => ({ uptime: 'error', load: 'error' })),541 + ]);542 +543 + let html = '<div class="page-enter" style="padding:0;">';544 +545 + // System info bar546 + html += '<div class="stat-grid" style="margin-bottom:1.5rem;">';547 + html += statCard('Uptime', info.uptime || 'n/a', '#3b82f6');548 + html += statCard('Load', info.load || 'n/a', '#8b5cf6');549 + html += '</div>';550 +551 + // Disk usage552 + html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Disk Usage</h2>';553 + if (disk.filesystems && disk.filesystems.length > 0) {554 + html += '<div style="display:grid;gap:0.75rem;margin-bottom:1.5rem;">';555 + for (const fs of disk.filesystems) {556 + const pct = parseInt(fs.use_percent) || 0;557 + html += `<div class="card">558 + <div style="display:flex;justify-content:space-between;margin-bottom:0.5rem;">559 + <span class="mono" style="font-size:0.8125rem;">${escapeHtml(fs.mount || fs.filesystem)}</span>560 + <span style="font-size:0.8125rem;color:#9ca3af;">${escapeHtml(fs.used)} / ${escapeHtml(fs.size)} (${escapeHtml(fs.use_percent)})</span>561 + </div>562 + <div class="progress-bar-track">563 + <div class="progress-bar-fill ${diskColorClass(fs.use_percent)}" style="width:${pct}%;"></div>564 + </div>565 + </div>`;566 + }567 + html += '</div>';568 + } else {569 + html += '<div class="card" style="color:#6b7280;">No disk data available.</div>';570 + }571 +572 + // Health checks573 + html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Health Checks</h2>';574 + if (health.checks && health.checks.length > 0) {575 + html += '<div style="display:grid;gap:0.5rem;margin-bottom:1.5rem;">';576 + for (const c of health.checks) {577 + const st = (c.status || '').toUpperCase();578 + const cls = st === 'OK' ? 'badge-green' : st === 'FAIL' ? 'badge-red' : 'badge-gray';579 + html += `<div class="card" style="display:flex;align-items:center;gap:0.75rem;padding:0.75rem 1rem;">580 + <span class="badge ${cls}">${escapeHtml(st)}</span>581 + <span style="font-size:0.875rem;">${escapeHtml(c.check)}</span>582 + </div>`;583 + }584 + html += '</div>';585 + } else {586 + html += '<div class="card" style="color:#6b7280;">No health check data.</div>';587 + }588 +589 + // Timers590 + html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Systemd Timers</h2>';591 + if (timers.timers && timers.timers.length > 0) {592 + html += '<div class="table-wrapper"><table class="ops-table">';593 + html += '<thead><tr><th>Unit</th><th>Next</th><th>Left</th><th>Last</th><th>Passed</th></tr></thead><tbody>';594 + for (const t of timers.timers) {595 + html += `<tr>596 + <td class="mono">${escapeHtml(t.unit)}</td>597 + <td>${escapeHtml(t.next)}</td>598 + <td>${escapeHtml(t.left)}</td>599 + <td>${escapeHtml(t.last)}</td>600 + <td>${escapeHtml(t.passed)}</td>601 + </tr>`;602 + }603 + html += '</tbody></table></div>';604 + } else {605 + html += '<div class="card" style="color:#6b7280;">No timers found.</div>';606 + }607 +608 + html += '</div>';609 + content.innerHTML = html;610 + } catch (e) {611 + content.innerHTML = '<div class="card" style="color:#f87171;">Failed to load system info: ' + escapeHtml(e.message) + '</div>';612 + }613 +}614 +615 +// ---------------------------------------------------------------------------616 +// Restore Page617 +// ---------------------------------------------------------------------------618 +function renderRestore() {619 + updateBreadcrumbs();620 + const content = document.getElementById('page-content');621 +622 + let html = '<div class="page-enter" style="padding:0;">';623 + html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Restore Backup</h2>';624 + html += '<div class="card" style="max-width:480px;">';625 +626 + html += '<div style="margin-bottom:1rem;">';627 + html += '<label class="form-label">Project</label>';628 + html += '<select id="restore-project" class="form-select"><option value="mdf">mdf</option><option value="seriousletter">seriousletter</option></select>';629 + html += '</div>';630 +631 + html += '<div style="margin-bottom:1rem;">';632 + html += '<label class="form-label">Environment</label>';633 + html += '<select id="restore-env" class="form-select"><option value="dev">dev</option><option value="int">int</option><option value="prod">prod</option></select>';634 + html += '</div>';635 +636 + html += '<div style="margin-bottom:1rem;">';637 + html += '<label class="form-label">Source</label>';638 + html += '<select id="restore-source" class="form-select"><option value="local">Local</option><option value="offsite">Offsite</option></select>';639 + html += '</div>';640 +641 + html += '<div style="margin-bottom:1rem;">';642 + html += '<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;color:#9ca3af;">';643 + html += '<input type="checkbox" id="restore-dry" checked> Dry run (preview only)';644 + html += '</label>';645 + html += '</div>';646 +647 + html += '<button class="btn btn-danger" onclick="startRestore()">Start Restore</button>';648 + html += '</div>';649 +650 + html += '<div id="restore-output" style="display:none;margin-top:1rem;">';651 + html += '<h3 style="font-size:1rem;font-weight:600;color:#f3f4f6;margin-bottom:0.5rem;">Output</h3>';652 + html += '<div id="restore-terminal" class="terminal" style="max-height:400px;"></div>';653 + html += '</div>';654 +655 + html += '</div>';656 + content.innerHTML = html;657 +}658 +659 +async function startRestore() {660 + const project = document.getElementById('restore-project').value;661 + const env = document.getElementById('restore-env').value;662 + const source = document.getElementById('restore-source').value;663 + const dryRun = document.getElementById('restore-dry').checked;664 +665 + if (!confirm(`Restore ${project}/${env} from ${source}${dryRun ? ' (dry run)' : ''}? This may overwrite data.`)) return;666 +667 + const outputDiv = document.getElementById('restore-output');668 + const terminal = document.getElementById('restore-terminal');669 + outputDiv.style.display = 'block';670 + terminal.textContent = 'Starting restore...\n';671 +672 + const url = `/api/restore/${project}/${env}?source=${source}&dry_run=${dryRun}&token=${encodeURIComponent(getToken())}`;673 + const evtSource = new EventSource(url);674 +675 + evtSource.onmessage = function(e) {676 + const data = JSON.parse(e.data);677 + if (data.done) {678 + evtSource.close();679 + terminal.textContent += data.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n';680 + toast(data.success ? 'Restore completed' : 'Restore failed', data.success ? 'success' : 'error');681 + return;682 + }683 + if (data.line) {684 + terminal.textContent += data.line + '\n';685 + terminal.scrollTop = terminal.scrollHeight;686 + }687 + };688 +689 + evtSource.onerror = function() {690 + evtSource.close();691 + terminal.textContent += '\n--- Connection lost ---\n';692 + toast('Restore connection lost', 'error');693 + };694 +}695 +696 +// ---------------------------------------------------------------------------697 +// Service Actions698 +// ---------------------------------------------------------------------------699 +async function restartService(project, env, service) {700 + if (!confirm(`Restart ${service} in ${project}/${env}?`)) return;701 +702 + toast('Restarting ' + service + '...', 'info');703 + try {704 + const result = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' });705 + toast(result.message || 'Restarted successfully', 'success');706 + setTimeout(() => refreshCurrentPage(), 3000);707 + } catch (e) {708 + toast('Restart failed: ' + e.message, 'error');709 + }710 +}711 +712 +async function viewLogs(project, env, service) {713 + logModalProject = project;714 + logModalEnv = env;715 + logModalService = service;716 +717 + document.getElementById('log-modal-title').textContent = `Logs: ${project}/${env}/${service}`;718 + document.getElementById('log-modal-content').textContent = 'Loading...';719 + document.getElementById('log-modal').style.display = 'flex';720 +721 + await refreshLogs();722 +}723 +724 +async function refreshLogs() {725 + if (!logModalProject) return;726 + try {727 + const data = await api(`/api/services/logs/${logModalProject}/${logModalEnv}/${logModalService}?lines=200`);728 + const terminal = document.getElementById('log-modal-content');729 + terminal.textContent = data.logs || 'No logs available.';730 + terminal.scrollTop = terminal.scrollHeight;731 + } catch (e) {732 + document.getElementById('log-modal-content').textContent = 'Error loading logs: ' + e.message;733 + }734 +}735 +736 +function closeLogModal() {737 + document.getElementById('log-modal').style.display = 'none';738 + logModalProject = null;739 + logModalEnv = null;740 + logModalService = null;741 +}742 +743 +// ---------------------------------------------------------------------------744 +// Backup Actions745 +// ---------------------------------------------------------------------------746 +async function createBackup(project, env) {747 + if (!confirm(`Create backup for ${project}/${env}?`)) return;748 + toast('Creating backup for ' + project + '/' + env + '...', 'info');749 + try {750 + await api(`/api/backups/${project}/${env}`, { method: 'POST' });751 + toast('Backup created for ' + project + '/' + env, 'success');752 + if (currentPage === 'backups') renderBackups();753 + } catch (e) {754 + toast('Backup failed: ' + e.message, 'error');755 + }756 +}757 +758 +// ---------------------------------------------------------------------------759 +// Data Grouping760 +// ---------------------------------------------------------------------------761 +function groupByProject(services) {762 + const map = {};763 + for (const s of services) {764 + const key = s.project || 'other';765 + if (!map[key]) map[key] = { name: key, services: [] };766 + map[key].services.push(s);767 + }768 + return map;769 +}770 +771 +function groupByEnv(services) {772 + const map = {};773 + for (const s of services) {774 + const key = s.env || 'default';775 + if (!map[key]) map[key] = [];776 + map[key].push(s);777 + }778 + return map;779 +}780 +781 +// ---------------------------------------------------------------------------782 +// Init783 +// ---------------------------------------------------------------------------784 +(function init() {785 + const token = getToken();786 + if (token) {787 + // Validate and load788 + fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } })789 + .then(r => {790 + if (!r.ok) throw new Error('Invalid token');791 + return r.json();792 + })793 + .then(data => {794 + allServices = data;795 + document.getElementById('login-overlay').style.display = 'none';796 + document.getElementById('app').style.display = 'flex';797 + showPage('dashboard');798 + startAutoRefresh();799 + })800 + .catch(() => {801 + localStorage.removeItem('ops_token');802 + document.getElementById('login-overlay').style.display = 'flex';803 + });804 + }805 +806 + // ESC to close modals807 + document.addEventListener('keydown', e => {808 + if (e.key === 'Escape') {809 + closeLogModal();810 + }811 + });812 +})();