From 485476a297c111e37fec9913535a63a2383ca06e Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 21 Feb 2026 16:32:53 +0100
Subject: [PATCH] feat: Rewrite dashboard with sidebar nav, drill-down, and registry-based container resolution
---
static/index.html | 924 ++-------------------
static/js/app.js | 1515 ++++++++++++++++++-----------------
app/app/routers/services.py | 126 ++
3 files changed, 994 insertions(+), 1,571 deletions(-)
diff --git a/app/app/routers/services.py b/app/app/routers/services.py
index a23bade..7cdad19 100644
--- a/app/app/routers/services.py
+++ b/app/app/routers/services.py
@@ -1,5 +1,7 @@
+import os
from typing import Any
+import yaml
from fastapi import APIRouter, Depends, HTTPException, Query
from app.auth import verify_token
@@ -8,14 +10,111 @@
router = APIRouter()
_DOCKER = "docker"
+_REGISTRY_PATH = os.environ.get(
+ "REGISTRY_PATH",
+ "/opt/infrastructure/servers/hetzner-vps/registry.yaml",
+)
+
+# ---------------------------------------------------------------------------
+# Registry-based name prefix lookup (cached)
+# ---------------------------------------------------------------------------
+_prefix_cache: dict[str, str] | None = None
-def _container_name(project: str, env: str, service: str) -> str:
+def _load_prefixes() -> dict[str, str]:
+ """Load project -> name_prefix mapping from the ops registry."""
+ global _prefix_cache
+ if _prefix_cache is not None:
+ return _prefix_cache
+
+ try:
+ with open(_REGISTRY_PATH) as f:
+ data = yaml.safe_load(f)
+ _prefix_cache = {}
+ for proj_name, cfg in data.get("projects", {}).items():
+ _prefix_cache[proj_name] = cfg.get("name_prefix", proj_name)
+ return _prefix_cache
+ except Exception:
+ return {}
+
+
+# ---------------------------------------------------------------------------
+# Container name resolution
+# ---------------------------------------------------------------------------
+
+
+async def _find_by_prefix(pattern: str) -> str | None:
+ """Find first running container whose name starts with `pattern`."""
+ result = await run_command(
+ [_DOCKER, "ps", "--filter", f"name={pattern}", "--format", "{{.Names}}"],
+ timeout=10,
+ )
+ if not result["success"]:
+ return None
+ for name in result["output"].strip().splitlines():
+ name = name.strip()
+ if name and name.startswith(pattern):
+ return name
+ return None
+
+
+async def _find_exact(name: str) -> str | None:
+ """Find a running container with exactly this name."""
+ result = await run_command(
+ [_DOCKER, "ps", "--filter", f"name={name}", "--format", "{{.Names}}"],
+ timeout=10,
+ )
+ if not result["success"]:
+ return None
+ for n in result["output"].strip().splitlines():
+ if n.strip() == name:
+ return name
+ return None
+
+
+async def _resolve_container(project: str, env: str, service: str) -> str:
"""
- Derive the Docker container name from project, env, and service.
- Docker Compose v2 default: {project}-{env}-{service}-1
+ Resolve the actual Docker container name from project/env/service.
+
+ Uses the ops registry name_prefix mapping and tries patterns in order:
+ 1. {env}-{prefix}-{service} (mdf, seriousletter: dev-mdf-mysql-UUID)
+ 2. {prefix}-{service} (ringsaday: ringsaday-website-UUID, coolify: coolify-db)
+ 3. {prefix}-{env} (ringsaday: ringsaday-dev-UUID)
+ 4. exact {prefix} (coolify infra: coolify)
"""
- return f"{project}-{env}-{service}-1"
+ prefixes = _load_prefixes()
+ prefix = prefixes.get(project, project)
+
+ # Pattern 1: {env}-{prefix}-{service}
+ hit = await _find_by_prefix(f"{env}-{prefix}-{service}")
+ if hit:
+ return hit
+
+ # Pattern 2: {prefix}-{service}
+ hit = await _find_by_prefix(f"{prefix}-{service}")
+ if hit:
+ return hit
+
+ # Pattern 3: {prefix}-{env}
+ hit = await _find_by_prefix(f"{prefix}-{env}")
+ if hit:
+ return hit
+
+ # Pattern 4: exact match when service == prefix (e.g., coolify)
+ if service == prefix:
+ hit = await _find_exact(prefix)
+ if hit:
+ return hit
+
+ raise HTTPException(
+ status_code=404,
+ detail=f"Container not found for {project}/{env}/{service}",
+ )
+
+
+# ---------------------------------------------------------------------------
+# Endpoints
+# ---------------------------------------------------------------------------
@router.get("/logs/{project}/{env}/{service}", summary="Get container logs")
@@ -23,20 +122,19 @@
project: str,
env: str,
service: str,
- lines: int = Query(default=100, ge=1, le=10000, description="Number of log lines to return"),
+ lines: int = Query(
+ default=100, ge=1, le=10000, description="Number of log lines to return"
+ ),
_: str = Depends(verify_token),
) -> dict[str, Any]:
- """
- Fetch the last N lines of logs from a container.
- Uses `docker logs --tail {lines} {container}`.
- """
- container = _container_name(project, env, service)
+ """Fetch the last N lines of logs from a container."""
+ container = await _resolve_container(project, env, service)
result = await run_command(
[_DOCKER, "logs", "--tail", str(lines), container],
timeout=30,
)
- # docker logs writes to stderr by default; treat combined output as logs
+ # docker logs writes to stderr by default; combine both streams
combined = result["output"] + result["error"]
if not result["success"] and not combined.strip():
@@ -59,10 +157,8 @@
service: str,
_: str = Depends(verify_token),
) -> dict[str, Any]:
- """
- Restart a Docker container via `docker restart {container}`.
- """
- container = _container_name(project, env, service)
+ """Restart a Docker container."""
+ container = await _resolve_container(project, env, service)
result = await run_command(
[_DOCKER, "restart", container],
timeout=60,
diff --git a/static/index.html b/static/index.html
index 4197aaa..d39e965 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1,843 +1,131 @@
<!DOCTYPE html>
-<html lang="en" class="h-full">
+<html lang="en">
<head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OPS Dashboard</title>
-
- <!-- Tailwind CSS Play CDN -->
<script src="https://cdn.tailwindcss.com"></script>
- <script>
- tailwind.config = {
- theme: {
- extend: {
- colors: {
- gray: {
- 950: '#0a0e1a'
- }
- }
- }
- }
- };
- </script>
-
- <!-- Custom styles -->
- <link rel="stylesheet" href="/static/css/style.css" />
-
- <!-- Alpine.js v3 -->
- <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
-
- <!-- App logic -->
- <script src="/static/js/app.js"></script>
+ <link rel="stylesheet" href="/static/css/style.css">
+ <style>
+ body { background: #0f172a; color: #e2e8f0; margin: 0; }
+ #app { display: flex; min-height: 100vh; }
+ #sidebar { width: 240px; background: #111827; border-right: 1px solid #1f2937; display: flex; flex-direction: column; flex-shrink: 0; }
+ #main { flex: 1; display: flex; flex-direction: column; overflow-x: hidden; }
+ #topbar { background: #111827; border-bottom: 1px solid #1f2937; padding: 0.75rem 1.5rem; display: flex; align-items: center; gap: 1rem; }
+ #page-content { flex: 1; padding: 1.5rem; overflow-y: auto; }
+ .breadcrumb { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; color: #9ca3af; }
+ .breadcrumb a { color: #60a5fa; cursor: pointer; text-decoration: none; }
+ .breadcrumb a:hover { text-decoration: underline; }
+ .breadcrumb .sep { color: #4b5563; }
+ .breadcrumb .current { color: #e2e8f0; font-weight: 500; }
+ .hamburger { display: none; background: none; border: none; color: #9ca3af; font-size: 1.5rem; cursor: pointer; padding: 0.25rem; }
+ .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; }
+ .sidebar-nav { padding: 0.75rem 0.5rem; flex: 1; }
+ .sidebar-footer { padding: 0.75rem 1rem; border-top: 1px solid #1f2937; font-size: 0.75rem; color: #6b7280; }
+ .project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
+ .env-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; }
+ .service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; }
+ .stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
+ .card-clickable { cursor: pointer; transition: border-color 0.2s, transform 0.15s; }
+ .card-clickable:hover { border-color: #60a5fa; transform: translateY(-1px); }
+ .mobile-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 40; }
+ @media (max-width: 768px) {
+ #sidebar { position: fixed; left: -240px; top: 0; bottom: 0; z-index: 50; transition: left 0.2s; }
+ #sidebar.open { left: 0; }
+ .mobile-overlay.open { display: block; }
+ .hamburger { display: block; }
+ .project-grid, .env-grid, .service-grid { grid-template-columns: 1fr; }
+ }
+ </style>
</head>
+<body>
-<body class="h-full bg-gray-900 text-gray-100 antialiased">
-
-<!-- ============================================================
- Toast Notifications
- ============================================================ -->
-<div class="toast-container" x-data x-show="$store.toast.toasts.length > 0">
- <template x-for="t in $store.toast.toasts" :key="t.id">
- <div class="toast" :class="t.type">
- <span class="text-base leading-none" x-text="$store.toast.iconFor(t.type)"></span>
- <span x-text="t.msg" class="flex-1"></span>
- <button class="toast-dismiss" @click="$store.toast.remove(t.id)">✕</button>
- </div>
- </template>
-</div>
-
-<!-- ============================================================
- Login Screen
- ============================================================ -->
-<div x-data x-show="!$store.auth.isAuthenticated" class="flex h-full items-center justify-center">
- <div class="w-full max-w-sm px-4">
- <div class="card text-center">
- <!-- Server icon -->
- <div class="flex justify-center mb-4">
- <div class="bg-blue-600 rounded-xl p-3">
- <svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round"
- 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"/>
- </svg>
- </div>
- </div>
-
- <h1 class="text-2xl font-bold text-white mb-1">OPS Dashboard</h1>
- <p class="text-sm text-gray-500 mb-6">Enter your access token to continue</p>
-
- <form @submit.prevent="$store.auth.login()" class="space-y-4">
- <div>
- <input
- type="password"
- class="form-input text-center tracking-widest"
- placeholder="••••••••••••••••"
- x-model="$store.auth.loginInput"
- autofocus
- />
- </div>
-
- <div x-show="$store.auth.loginError" class="text-red-400 text-sm" x-text="$store.auth.loginError"></div>
-
- <button type="submit" class="btn btn-primary w-full" :disabled="$store.auth.loading">
- <span x-show="$store.auth.loading" class="spinner"></span>
- <span x-text="$store.auth.loading ? 'Verifying…' : 'Sign In'"></span>
- </button>
- </form>
- </div>
+<!-- Login Overlay -->
+<div id="login-overlay" style="position:fixed;inset:0;background:#0f172a;z-index:100;display:flex;align-items:center;justify-content:center;">
+ <div class="card" style="width:100%;max-width:380px;text-align:center;">
+ <div style="font-size:1.5rem;font-weight:700;margin-bottom:1.5rem;color:#f3f4f6;">OPS Dashboard</div>
+ <input type="password" id="login-token" placeholder="Enter access token" class="form-input" style="margin-bottom:1rem;"
+ onkeydown="if(event.key==='Enter')doLogin()">
+ <button onclick="doLogin()" class="btn btn-primary" style="width:100%;">Login</button>
+ <div id="login-error" style="color:#f87171;font-size:0.875rem;margin-top:0.75rem;display:none;"></div>
</div>
</div>
-<!-- ============================================================
- Main App Shell
- ============================================================ -->
-<div x-data x-show="$store.auth.isAuthenticated" class="flex h-full">
+<!-- App Shell -->
+<div id="app" style="display:none;">
+ <!-- Mobile overlay -->
+ <div id="mobile-overlay" class="mobile-overlay" onclick="toggleSidebar()"></div>
- <!-- Mobile sidebar overlay -->
- <div
- x-show="$store.app.sidebarOpen"
- class="sidebar-mobile-overlay md:hidden"
- @click="$store.app.sidebarOpen = false"
- ></div>
-
- <!-- ---- Sidebar ---- -->
- <aside
- 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"
- :class="$store.app.sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"
- >
- <!-- App name -->
- <div class="flex items-center gap-3 px-4 py-5 border-b border-gray-800">
- <div class="bg-blue-600 rounded-lg p-1.5 flex-shrink-0">
- <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round"
- 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"/>
- </svg>
- </div>
- <div>
- <div class="font-bold text-white text-base tracking-widest">OPS</div>
- <div class="text-xs text-gray-500">tekmidian.com</div>
- </div>
+ <!-- Sidebar -->
+ <aside id="sidebar">
+ <div class="sidebar-logo">
+ <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>
+ OPS Dashboard
</div>
-
- <!-- Nav links -->
- <nav class="flex-1 p-3 space-y-1 overflow-y-auto">
-
- <button class="sidebar-link w-full text-left"
- :class="$store.app.page === 'dashboard' ? 'active' : ''"
- @click="$store.app.navigate('dashboard')">
- <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round"
- 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"/>
- </svg>
+ <nav class="sidebar-nav" id="sidebar-nav">
+ <a class="sidebar-link active" data-page="dashboard" onclick="showPage('dashboard')">
+ <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>
Dashboard
- </button>
-
- <button class="sidebar-link w-full text-left"
- :class="$store.app.page === 'backups' ? 'active' : ''"
- @click="$store.app.navigate('backups')">
- <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round"
- 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"/>
- </svg>
- Backups
- </button>
-
- <button class="sidebar-link w-full text-left"
- :class="$store.app.page === 'restore' ? 'active' : ''"
- @click="$store.app.navigate('restore')">
- <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round"
- d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"/>
- </svg>
- Restore
- </button>
-
- <button class="sidebar-link w-full text-left"
- :class="$store.app.page === 'services' ? 'active' : ''"
- @click="$store.app.navigate('services')">
- <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round"
- 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"/>
- </svg>
+ </a>
+ <a class="sidebar-link" data-page="services" onclick="showPage('services')">
+ <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>
Services
- </button>
-
- <button class="sidebar-link w-full text-left"
- :class="$store.app.page === 'system' ? 'active' : ''"
- @click="$store.app.navigate('system')">
- <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round"
- 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"/>
- </svg>
+ </a>
+ <a class="sidebar-link" data-page="backups" onclick="showPage('backups')">
+ <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>
+ Backups
+ </a>
+ <a class="sidebar-link" data-page="system" onclick="showPage('system')">
+ <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>
System
- </button>
+ </a>
+ <a class="sidebar-link" data-page="restore" onclick="showPage('restore')">
+ <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>
+ Restore
+ </a>
</nav>
-
- <!-- Logout -->
- <div class="p-3 border-t border-gray-800">
- <button class="sidebar-link w-full text-left text-red-400 hover:text-red-300"
- @click="$store.auth.logout()">
- <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round"
- 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"/>
- </svg>
- Sign Out
- </button>
+ <div class="sidebar-footer">
+ <a onclick="doLogout()" style="color:#9ca3af;cursor:pointer;">Logout</a>
</div>
</aside>
- <!-- ---- Main content ---- -->
- <div class="flex-1 flex flex-col min-w-0 overflow-hidden">
-
- <!-- Top header -->
- <header class="flex items-center justify-between px-4 py-3 bg-gray-950 border-b border-gray-800 flex-shrink-0">
-
- <!-- Mobile hamburger -->
- <button class="md:hidden p-1 rounded text-gray-400 hover:text-white hover:bg-gray-800"
- @click="$store.app.sidebarOpen = !$store.app.sidebarOpen">
- <svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
- </svg>
- </button>
-
- <!-- Page title -->
- <h2 class="text-sm font-semibold text-gray-300 capitalize"
- x-text="$store.app.page"></h2>
-
- <!-- Right side: refresh indicator -->
- <div class="flex items-center gap-3">
-
- <!-- Auto-refresh for dashboard -->
- <div x-show="$store.app.page === 'dashboard'" class="flex items-center gap-2 text-xs text-gray-500">
- <button
- @click="$store.dashboard.toggleAutoRefresh()"
- class="flex items-center gap-1.5 hover:text-gray-300 transition-colors"
- :title="$store.dashboard.autoRefreshEnabled ? 'Auto-refresh on (click to pause)' : 'Auto-refresh off (click to resume)'">
- <div class="refresh-ring" :class="!$store.dashboard.autoRefreshEnabled ? 'paused' : ''"></div>
- <span x-show="$store.dashboard.lastRefresh"
- x-text="'Updated ' + $store.dashboard.timeAgo($store.dashboard.lastRefresh)"></span>
- </button>
-
- <button @click="$store.dashboard.manualRefresh()"
- class="btn btn-ghost btn-xs"
- :disabled="$store.dashboard.loading">
- <svg class="w-3 h-3" :class="$store.dashboard.refreshing ? 'animate-spin' : ''" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
- <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"/>
- </svg>
- Refresh
- </button>
- </div>
-
+ <!-- Main Content -->
+ <div id="main">
+ <!-- Top bar -->
+ <div id="topbar">
+ <button class="hamburger" onclick="toggleSidebar()">☰</button>
+ <div id="breadcrumbs" class="breadcrumb" style="flex:1;"></div>
+ <div style="display:flex;align-items:center;gap:0.75rem;">
+ <div id="refresh-indicator" class="refresh-ring paused" title="Auto-refresh"></div>
+ <button class="btn btn-ghost btn-xs" onclick="refreshCurrentPage()" title="Refresh now">Refresh</button>
</div>
- </header>
+ </div>
- <!-- ---- Page content area ---- -->
- <main class="flex-1 overflow-y-auto">
-
- <!-- ======================================================
- DASHBOARD PAGE
- ====================================================== -->
- <div x-show="$store.app.page === 'dashboard'" class="p-4 md:p-6 page-enter">
-
- <!-- Loading -->
- <div x-show="$store.dashboard.loading && !$store.dashboard.projects.length"
- class="flex items-center justify-center h-48 text-gray-500">
- <div class="text-center">
- <div class="spinner spinner-lg mx-auto mb-3"></div>
- <div class="text-sm">Loading containers…</div>
- </div>
- </div>
-
- <!-- Error -->
- <div x-show="$store.dashboard.error" class="card border-red-800 text-red-400 mb-4">
- <div class="flex items-center gap-2">
- <span class="text-lg">⚠</span>
- <span x-text="$store.dashboard.error"></span>
- </div>
- </div>
-
- <!-- Projects -->
- <template x-for="proj in $store.dashboard.projects" :key="proj.name">
- <div class="mb-8">
- <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3"
- x-text="proj.name"></h3>
-
- <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
- <template x-for="svc in proj.services" :key="svc.name || svc.service">
- <div class="card flex flex-col gap-2">
- <!-- Name + badge -->
- <div class="flex items-start justify-between gap-2">
- <div class="font-medium text-white text-sm truncate"
- x-text="svc.name || svc.service"></div>
- <div class="badge flex-shrink-0"
- :class="$store.dashboard.badgeClass(svc.status, svc.health)">
- <span class="status-dot"
- :class="$store.dashboard.dotClass(svc.status, svc.health)"></span>
- <span x-text="svc.status || 'unknown'"></span>
- </div>
- </div>
-
- <!-- Health + uptime row -->
- <div class="flex items-center gap-3 text-xs text-gray-500">
- <template x-if="svc.health && svc.health !== ''">
- <span class="flex items-center gap-1">
- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
- </svg>
- <span x-text="svc.health"></span>
- </span>
- </template>
- <template x-if="svc.uptime || svc.started">
- <span x-text="svc.uptime || ('Up ' + $store.dashboard.timeAgo(svc.started))"></span>
- </template>
- </div>
-
- <!-- Domain link -->
- <template x-if="svc.domain">
- <a :href="'https://' + svc.domain" target="_blank" rel="noopener"
- class="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 truncate">
- <svg class="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
- <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"/>
- </svg>
- <span x-text="svc.domain" class="truncate"></span>
- </a>
- </template>
- </div>
- </template>
- </div>
- </div>
- </template>
-
- <!-- Empty state -->
- <div x-show="!$store.dashboard.loading && !$store.dashboard.projects.length && !$store.dashboard.error"
- class="flex flex-col items-center justify-center h-48 text-gray-600">
- <svg class="w-10 h-10 mb-3" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round"
- 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"/>
- </svg>
- <p class="text-sm">No containers found</p>
- </div>
- </div>
-
- <!-- ======================================================
- BACKUPS PAGE
- ====================================================== -->
- <div x-show="$store.app.page === 'backups'" class="p-4 md:p-6 page-enter space-y-8">
-
- <!-- ---- Local Backups ---- -->
- <section>
- <div class="flex items-center justify-between mb-4">
- <h3 class="text-sm font-semibold text-gray-400 uppercase tracking-widest">Local Backups</h3>
- <button class="btn btn-ghost btn-sm" @click="$store.backups.fetchLocal()"
- :disabled="$store.backups.loading">
- <span x-show="$store.backups.loading" class="spinner"></span>
- Refresh
- </button>
- </div>
-
- <div x-show="$store.backups.loading && !$store.backups.local.length"
- class="flex items-center gap-2 text-gray-500 text-sm py-6 justify-center">
- <div class="spinner"></div> Loading…
- </div>
-
- <div x-show="!$store.backups.loading && !$store.backups.local.length"
- class="text-gray-600 text-sm py-6 text-center">No local backups found</div>
-
- <div x-show="$store.backups.local.length > 0" class="table-wrapper">
- <table class="ops-table">
- <thead>
- <tr>
- <th>Project</th>
- <th>Env</th>
- <th>File</th>
- <th>Size</th>
- <th>Age</th>
- <th>Actions</th>
- </tr>
- </thead>
- <tbody>
- <template x-for="(b, idx) in $store.backups.local" :key="idx">
- <tr>
- <td class="font-medium text-white" x-text="b.project || '—'"></td>
- <td>
- <span class="badge badge-blue" x-text="b.env || '—'"></span>
- </td>
- <td class="mono text-xs text-gray-400 max-w-xs truncate" x-text="b.filename || b.file || '—'"></td>
- <td class="text-gray-400 text-xs" x-text="$store.backups.formatBytes(b.size)"></td>
- <td>
- <span class="badge"
- :class="$store.backups.ageBadge(b.created_at || b.modified)"
- x-text="$store.backups.timeAgo(b.created_at || b.modified)"></span>
- </td>
- <td>
- <div class="flex items-center gap-2">
- <button class="btn btn-success btn-xs"
- @click="$store.backups.backupNow(b.project, b.env)"
- :disabled="$store.backups.isRunning(b.project, b.env, 'backup')">
- <span x-show="$store.backups.isRunning(b.project, b.env, 'backup')" class="spinner"></span>
- <span x-text="$store.backups.isRunning(b.project, b.env, 'backup') ? 'Running…' : 'Backup Now'"></span>
- </button>
- <button class="btn btn-ghost btn-xs"
- @click="$store.backups.uploadOffsite(b.project, b.env)"
- :disabled="$store.backups.isRunning(b.project, b.env, 'upload')">
- <span x-show="$store.backups.isRunning(b.project, b.env, 'upload')" class="spinner"></span>
- <span x-text="$store.backups.isRunning(b.project, b.env, 'upload') ? 'Uploading…' : 'Upload Offsite'"></span>
- </button>
- </div>
- </td>
- </tr>
- </template>
- </tbody>
- </table>
- </div>
- </section>
-
- <!-- ---- Offsite Backups ---- -->
- <section>
- <div class="flex items-center justify-between mb-4">
- <h3 class="text-sm font-semibold text-gray-400 uppercase tracking-widest">Offsite Backups</h3>
- <div class="flex items-center gap-2">
- <button class="btn btn-warning btn-sm"
- @click="$store.backups.applyRetention()"
- :disabled="$store.backups.retentionRunning">
- <span x-show="$store.backups.retentionRunning" class="spinner"></span>
- Apply Retention
- </button>
- <button class="btn btn-ghost btn-sm" @click="$store.backups.fetchOffsite()"
- :disabled="$store.backups.loadingOffsite">
- <span x-show="$store.backups.loadingOffsite" class="spinner"></span>
- Refresh
- </button>
- </div>
- </div>
-
- <div x-show="$store.backups.loadingOffsite && !$store.backups.offsite.length"
- class="flex items-center gap-2 text-gray-500 text-sm py-6 justify-center">
- <div class="spinner"></div> Loading…
- </div>
-
- <div x-show="!$store.backups.loadingOffsite && !$store.backups.offsite.length"
- class="text-gray-600 text-sm py-6 text-center">No offsite backups found</div>
-
- <div x-show="$store.backups.offsite.length > 0" class="table-wrapper">
- <table class="ops-table">
- <thead>
- <tr>
- <th>Project</th>
- <th>Env</th>
- <th>File</th>
- <th>Size</th>
- <th>Age</th>
- </tr>
- </thead>
- <tbody>
- <template x-for="(b, idx) in $store.backups.offsite" :key="idx">
- <tr>
- <td class="font-medium text-white" x-text="b.project || '—'"></td>
- <td>
- <span class="badge badge-blue" x-text="b.env || '—'"></span>
- </td>
- <td class="mono text-xs text-gray-400 max-w-xs truncate" x-text="b.filename || b.file || '—'"></td>
- <td class="text-gray-400 text-xs" x-text="$store.backups.formatBytes(b.size)"></td>
- <td>
- <span class="badge"
- :class="$store.backups.ageBadge(b.created_at || b.modified)"
- x-text="$store.backups.timeAgo(b.created_at || b.modified)"></span>
- </td>
- </tr>
- </template>
- </tbody>
- </table>
- </div>
- </section>
- </div>
-
- <!-- ======================================================
- RESTORE PAGE
- ====================================================== -->
- <div x-show="$store.app.page === 'restore'" class="p-4 md:p-6 page-enter">
- <div class="max-w-2xl">
-
- <!-- Config card -->
- <div class="card mb-6">
- <h3 class="text-sm font-semibold text-gray-300 mb-5">Restore Configuration</h3>
-
- <div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
-
- <!-- Source -->
- <div>
- <label class="form-label">Source</label>
- <div class="flex gap-4 mt-1">
- <label class="flex items-center gap-2 cursor-pointer">
- <input type="radio" name="restore-source" value="local"
- x-model="$store.restore.source"
- @change="$store.restore.onSourceChange()"
- class="accent-blue-500 w-4 h-4" />
- <span class="text-sm text-gray-300">Local</span>
- </label>
- <label class="flex items-center gap-2 cursor-pointer">
- <input type="radio" name="restore-source" value="offsite"
- x-model="$store.restore.source"
- @change="$store.restore.onSourceChange()"
- class="accent-blue-500 w-4 h-4" />
- <span class="text-sm text-gray-300">Offsite</span>
- </label>
- </div>
- </div>
-
- <!-- Dry run -->
- <div class="flex items-center">
- <label class="flex items-center gap-2 cursor-pointer mt-5 sm:mt-0">
- <input type="checkbox" x-model="$store.restore.dryRun"
- class="accent-blue-500 w-4 h-4 rounded" />
- <span class="text-sm text-gray-300">Dry run (simulate only)</span>
- </label>
- </div>
-
- <!-- Project -->
- <div>
- <label class="form-label">Project</label>
- <div x-show="$store.restore.loadingProjects" class="flex items-center gap-2 text-gray-500 text-sm mt-1">
- <div class="spinner"></div> Loading…
- </div>
- <select class="form-select" x-show="!$store.restore.loadingProjects"
- x-model="$store.restore.project"
- @change="$store.restore.onProjectChange()">
- <option value="" disabled>Select project</option>
- <template x-for="p in $store.restore.projects" :key="p">
- <option :value="p" x-text="p"></option>
- </template>
- </select>
- </div>
-
- <!-- Environment -->
- <div>
- <label class="form-label">Environment</label>
- <select class="form-select" x-model="$store.restore.env"
- :disabled="!$store.restore.project">
- <option value="" disabled>Select environment</option>
- <template x-for="e in $store.restore.envs" :key="e">
- <option :value="e" x-text="e"></option>
- </template>
- </select>
- </div>
- </div>
-
- <!-- Action bar -->
- <div class="flex items-center gap-3 mt-6 pt-5 border-t border-gray-700">
- <button class="btn btn-danger"
- @click="$store.restore.confirm()"
- :disabled="$store.restore.running || !$store.restore.project || !$store.restore.env">
- <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"/>
- </svg>
- <span x-text="$store.restore.dryRun ? 'Dry Run' : 'Restore'"></span>
- </button>
- <button x-show="$store.restore.running" class="btn btn-ghost"
- @click="$store.restore.abort()">Abort</button>
- <span x-show="$store.restore.running" class="flex items-center gap-2 text-sm text-yellow-400">
- <div class="spinner"></div> Running…
- </span>
- </div>
- </div>
-
- <!-- Output terminal -->
- <div x-show="$store.restore.output.length > 0">
- <div class="flex items-center justify-between mb-2">
- <span class="text-xs text-gray-500 font-semibold uppercase tracking-widest">Output</span>
- <button class="btn btn-ghost btn-xs" @click="$store.restore.output = []">Clear</button>
- </div>
- <div id="restore-output" class="terminal" style="height: 360px;">
- <template x-for="(line, i) in $store.restore.output" :key="i">
- <div :class="line.cls" x-text="line.text"></div>
- </template>
- </div>
- </div>
-
- <!-- Error -->
- <div x-show="$store.restore.error"
- class="mt-4 card border-red-800 text-red-400 text-sm"
- x-text="$store.restore.error"></div>
- </div>
-
- <!-- Confirm dialog -->
- <div x-show="$store.restore.confirming" class="modal-overlay" @click.self="$store.restore.cancel()">
- <div class="modal-box max-w-md">
- <div class="modal-header">
- <h3 class="font-semibold text-white">Confirm Restore</h3>
- </div>
- <div class="modal-body text-sm text-gray-300 space-y-3">
- <p>You are about to restore:</p>
- <div class="bg-gray-900 rounded-lg p-3 space-y-2 text-xs font-mono">
- <div><span class="text-gray-500">Project:</span> <span class="text-white" x-text="$store.restore.project"></span></div>
- <div><span class="text-gray-500">Environment:</span> <span class="text-white" x-text="$store.restore.env"></span></div>
- <div><span class="text-gray-500">Source:</span> <span class="text-white" x-text="$store.restore.source"></span></div>
- <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>
- </div>
- <p x-show="!$store.restore.dryRun" class="text-yellow-400 font-medium">
- This will overwrite existing data. This action cannot be undone.
- </p>
- </div>
- <div class="modal-footer">
- <button class="btn btn-ghost" @click="$store.restore.cancel()">Cancel</button>
- <button class="btn btn-danger" @click="$store.restore.execute()">
- <span x-text="$store.restore.dryRun ? 'Run Dry Test' : 'Yes, Restore Now'"></span>
- </button>
- </div>
- </div>
- </div>
- </div>
-
- <!-- ======================================================
- SERVICES PAGE
- ====================================================== -->
- <div x-show="$store.app.page === 'services'" class="p-4 md:p-6 page-enter">
-
- <!-- Loading -->
- <div x-show="$store.services.loading && !$store.services.projects.length"
- class="flex items-center justify-center h-48 text-gray-500">
- <div class="spinner spinner-lg"></div>
- </div>
-
- <!-- Error -->
- <div x-show="$store.services.error" class="card border-red-800 text-red-400 text-sm mb-4"
- x-text="$store.services.error"></div>
-
- <!-- Projects -->
- <template x-for="proj in $store.services.projects" :key="proj.name">
- <div class="mb-8">
- <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3"
- x-text="proj.name"></h3>
-
- <div class="space-y-2">
- <template x-for="svc in proj.services" :key="svc.name || svc.service">
- <div class="card flex flex-col sm:flex-row sm:items-center gap-3">
- <!-- Status + name -->
- <div class="flex items-center gap-3 flex-1 min-w-0">
- <div class="badge flex-shrink-0"
- :class="$store.services.badgeClass(svc.status, svc.health)">
- <span class="status-dot"
- :class="$store.services.dotClass(svc.status, svc.health)"></span>
- <span x-text="svc.status || 'unknown'"></span>
- </div>
- <div>
- <div class="font-medium text-white text-sm" x-text="svc.name || svc.service"></div>
- <div class="text-xs text-gray-500" x-text="svc.uptime || ''"></div>
- </div>
- </div>
-
- <!-- Actions -->
- <div class="flex items-center gap-2 flex-shrink-0">
- <template x-if="svc.domain">
- <a :href="'https://' + svc.domain" target="_blank" rel="noopener"
- class="btn btn-ghost btn-xs">
- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
- <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"/>
- </svg>
- Open
- </a>
- </template>
-
- <button class="btn btn-ghost btn-xs"
- @click="$store.services.viewLogs(svc.project || proj.name, svc.env || 'prod', svc.name || svc.service)">
- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9h16.5m-16.5 6.75h16.5"/>
- </svg>
- Logs
- </button>
-
- <button class="btn btn-warning btn-xs"
- @click="$store.services.askRestart(svc.project || proj.name, svc.env || 'prod', svc.name || svc.service)">
- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
- <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"/>
- </svg>
- Restart
- </button>
- </div>
- </div>
- </template>
- </div>
- </div>
- </template>
-
- <!-- Log Modal -->
- <div x-show="$store.services.logModal.open" class="modal-overlay" @click.self="$store.services.closeLogs()">
- <div class="modal-box" style="max-width: 780px;">
- <div class="modal-header">
- <h3 class="font-semibold text-white text-sm" x-text="$store.services.logModal.title"></h3>
- <button class="text-gray-400 hover:text-white text-lg leading-none" @click="$store.services.closeLogs()">✕</button>
- </div>
- <div class="modal-body p-0">
- <div x-show="$store.services.logModal.loading"
- class="flex items-center justify-center h-32 text-gray-500">
- <div class="spinner spinner-lg"></div>
- </div>
- <div id="log-output" class="terminal rounded-none rounded-b-xl"
- x-show="!$store.services.logModal.loading"
- style="height: 480px; border-radius: 0 0 0.875rem 0.875rem; border: none;">
- <template x-for="(line, i) in $store.services.logModal.lines" :key="i">
- <div x-text="typeof line === 'string' ? line : (line.text || JSON.stringify(line))"></div>
- </template>
- </div>
- </div>
- </div>
- </div>
-
- <!-- Restart Confirm Modal -->
- <div x-show="$store.services.confirmRestart.open" class="modal-overlay"
- @click.self="$store.services.cancelRestart()">
- <div class="modal-box max-w-md">
- <div class="modal-header">
- <h3 class="font-semibold text-white">Confirm Restart</h3>
- </div>
- <div class="modal-body text-sm text-gray-300">
- <p>Restart service <strong class="text-white" x-text="$store.services.confirmRestart.service"></strong>?</p>
- <p class="text-gray-500 mt-1 text-xs">This will briefly interrupt the service.</p>
- </div>
- <div class="modal-footer">
- <button class="btn btn-ghost" @click="$store.services.cancelRestart()">Cancel</button>
- <button class="btn btn-warning" @click="$store.services.doRestart()"
- :disabled="$store.services.confirmRestart.running">
- <span x-show="$store.services.confirmRestart.running" class="spinner"></span>
- <span x-text="$store.services.confirmRestart.running ? 'Restarting…' : 'Restart'"></span>
- </button>
- </div>
- </div>
- </div>
- </div>
-
- <!-- ======================================================
- SYSTEM PAGE
- ====================================================== -->
- <div x-show="$store.app.page === 'system'" class="p-4 md:p-6 page-enter space-y-8">
-
- <!-- System Info -->
- <section>
- <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">System Info</h3>
- <div x-show="$store.system.loading.info" class="flex items-center gap-2 text-gray-500 text-sm">
- <div class="spinner"></div> Loading…
- </div>
- <div x-show="!$store.system.loading.info" class="grid grid-cols-2 sm:grid-cols-4 gap-3">
- <div class="card text-center">
- <div class="text-xs text-gray-500 mb-1">Uptime</div>
- <div class="text-white font-medium text-sm" x-text="$store.system.info.uptime || '—'"></div>
- </div>
- <div class="card text-center">
- <div class="text-xs text-gray-500 mb-1">Load (1m)</div>
- <div class="text-white font-medium text-sm" x-text="$store.system.info.load_1 ?? $store.system.info.load_avg?.[0] ?? '—'"></div>
- </div>
- <div class="card text-center">
- <div class="text-xs text-gray-500 mb-1">Load (5m)</div>
- <div class="text-white font-medium text-sm" x-text="$store.system.info.load_5 ?? $store.system.info.load_avg?.[1] ?? '—'"></div>
- </div>
- <div class="card text-center">
- <div class="text-xs text-gray-500 mb-1">Hostname</div>
- <div class="text-white font-medium text-sm truncate" x-text="$store.system.info.hostname || '—'"></div>
- </div>
- </div>
- </section>
-
- <!-- Disk Usage -->
- <section>
- <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">Disk Usage</h3>
- <div x-show="$store.system.loading.disk" class="flex items-center gap-2 text-gray-500 text-sm">
- <div class="spinner"></div> Loading…
- </div>
- <div x-show="!$store.system.loading.disk && !$store.system.disk.length"
- class="text-gray-600 text-sm">No disk data</div>
- <div x-show="!$store.system.loading.disk && $store.system.disk.length > 0" class="space-y-3">
- <template x-for="(d, i) in $store.system.disk" :key="i">
- <div class="card">
- <div class="flex items-center justify-between mb-2">
- <div>
- <span class="text-white font-medium text-sm" x-text="d.mount || d.filesystem || '?'"></span>
- <span class="text-gray-500 text-xs ml-2" x-text="d.filesystem || d.device || ''"></span>
- </div>
- <span class="text-sm font-semibold"
- :class="(d.use_pct || d.pct || 0) >= 90 ? 'text-red-400' : (d.use_pct || d.pct || 0) >= 75 ? 'text-yellow-400' : 'text-green-400'"
- x-text="(d.use_pct || d.pct || 0) + '%'"></span>
- </div>
- <div class="progress-bar-track">
- <div class="progress-bar-fill"
- :class="$store.system.diskBarClass(d.use_pct || d.pct || 0)"
- :style="'width: ' + Math.min(d.use_pct || d.pct || 0, 100) + '%'"></div>
- </div>
- <div class="flex justify-between text-xs text-gray-500 mt-1">
- <span x-text="'Used: ' + $store.system.formatBytes(d.used)"></span>
- <span x-text="'Total: ' + $store.system.formatBytes(d.total || d.size)"></span>
- </div>
- </div>
- </template>
- </div>
- </section>
-
- <!-- Health Checks -->
- <section>
- <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">Health Checks</h3>
- <div x-show="$store.system.loading.health" class="flex items-center gap-2 text-gray-500 text-sm">
- <div class="spinner"></div> Loading…
- </div>
- <div x-show="!$store.system.loading.health && !$store.system.health.length"
- class="text-gray-600 text-sm">No health data</div>
- <div x-show="!$store.system.loading.health && $store.system.health.length > 0" class="space-y-2">
- <template x-for="(h, i) in $store.system.health" :key="i">
- <div class="card flex items-center justify-between gap-3">
- <div class="flex items-center gap-3">
- <div class="w-2 h-2 rounded-full flex-shrink-0"
- :class="h.ok || h.status === 'pass' ? 'bg-green-400' : 'bg-red-400'"></div>
- <span class="text-sm text-gray-300 font-medium" x-text="h.name || h.check"></span>
- </div>
- <div class="flex items-center gap-2">
- <span class="text-xs text-gray-500" x-text="h.detail || h.message || ''"></span>
- <span class="badge"
- :class="h.ok || h.status === 'pass' ? 'badge-green' : 'badge-red'"
- x-text="h.ok || h.status === 'pass' ? 'PASS' : 'FAIL'"></span>
- </div>
- </div>
- </template>
- </div>
- </section>
-
- <!-- Systemd Timers -->
- <section>
- <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">Systemd Timers</h3>
- <div x-show="$store.system.loading.timers" class="flex items-center gap-2 text-gray-500 text-sm">
- <div class="spinner"></div> Loading…
- </div>
- <div x-show="!$store.system.loading.timers && !$store.system.timers.length"
- class="text-gray-600 text-sm">No timer data</div>
- <div x-show="!$store.system.loading.timers && $store.system.timers.length > 0" class="table-wrapper">
- <table class="ops-table">
- <thead>
- <tr>
- <th>Timer</th>
- <th>Last Run</th>
- <th>Next Run</th>
- <th>Status</th>
- </tr>
- </thead>
- <tbody>
- <template x-for="(t, i) in $store.system.timers" :key="i">
- <tr>
- <td class="font-medium text-white" x-text="t.name || t.timer"></td>
- <td class="text-gray-400 text-xs mono" x-text="t.last || t.last_trigger || '—'"></td>
- <td class="text-gray-400 text-xs mono" x-text="t.next || t.next_elapse || '—'"></td>
- <td>
- <span class="badge"
- :class="t.active || t.status === 'active' ? 'badge-green' : 'badge-gray'"
- x-text="t.status || (t.active ? 'active' : 'inactive')"></span>
- </td>
- </tr>
- </template>
- </tbody>
- </table>
- </div>
- </section>
-
- </div>
-
- </main>
+ <!-- Page content -->
+ <div id="page-content"></div>
</div>
</div>
+<!-- Toast Container -->
+<div id="toast-container" class="toast-container"></div>
+
+<!-- Log Modal -->
+<div id="log-modal" class="modal-overlay" style="display:none;" onclick="if(event.target===this)closeLogModal()">
+ <div class="modal-box" style="max-width:800px;">
+ <div class="modal-header">
+ <span id="log-modal-title" style="font-weight:600;color:#f3f4f6;">Container Logs</span>
+ <button onclick="closeLogModal()" style="background:none;border:none;color:#9ca3af;font-size:1.25rem;cursor:pointer;">×</button>
+ </div>
+ <div class="modal-body" style="padding:0;">
+ <div id="log-modal-content" class="terminal" style="max-height:60vh;border:none;border-radius:0;"></div>
+ </div>
+ <div class="modal-footer">
+ <button class="btn btn-ghost btn-sm" onclick="closeLogModal()">Close</button>
+ <button class="btn btn-primary btn-sm" id="log-refresh-btn" onclick="refreshLogs()">Refresh</button>
+ </div>
+ </div>
+</div>
+
+<script src="/static/js/app.js"></script>
</body>
</html>
diff --git a/static/js/app.js b/static/js/app.js
index 256b139..41fd842 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -1,15 +1,30 @@
-/* ============================================================
- OPS Dashboard — Alpine.js Application Logic
- ============================================================ */
-
'use strict';
-// ----------------------------------------------------------------
-// Helpers
-// ----------------------------------------------------------------
+// ============================================================
+// OPS Dashboard — Vanilla JS Application
+// ============================================================
+// ---------------------------------------------------------------------------
+// State
+// ---------------------------------------------------------------------------
+let allServices = [];
+let currentPage = 'dashboard';
+let drillLevel = 0; // 0=projects, 1=environments, 2=services
+let drillProject = null;
+let drillEnv = null;
+let refreshTimer = null;
+const REFRESH_INTERVAL = 30000;
+
+// Log modal state
+let logModalProject = null;
+let logModalEnv = null;
+let logModalService = null;
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
function formatBytes(bytes) {
- if (bytes == null || bytes === '') return '—';
+ if (bytes == null || bytes === '') return '\u2014';
const n = Number(bytes);
if (isNaN(n) || n === 0) return '0 B';
const k = 1024;
@@ -19,755 +34,779 @@
}
function timeAgo(dateInput) {
- if (!dateInput) return '—';
+ if (!dateInput) return '\u2014';
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
- if (isNaN(date)) return '—';
+ if (isNaN(date)) return '\u2014';
const secs = Math.floor((Date.now() - date.getTime()) / 1000);
- if (secs < 60) return secs + 's ago';
- if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
- if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
+ if (secs < 60) return secs + 's ago';
+ if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
+ if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
return Math.floor(secs / 86400) + 'd ago';
}
-function ageHours(dateInput) {
- if (!dateInput) return 0;
- const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
- if (isNaN(date)) return 0;
- return (Date.now() - date.getTime()) / 3600000;
-}
-
-function ageBadgeClass(dateInput) {
- const h = ageHours(dateInput);
- if (h >= 48) return 'badge-red';
- if (h >= 24) return 'badge-yellow';
- return 'badge-green';
-}
-
-function statusBadgeClass(status, health) {
- const s = (status || '').toLowerCase();
- const h = (health || '').toLowerCase();
- if (s === 'running' && (h === 'healthy' || h === '')) return 'badge-green';
- if (s === 'running' && h === 'unhealthy') return 'badge-red';
- if (s === 'running' && h === 'starting') return 'badge-yellow';
- if (s === 'restarting' || h === 'starting') return 'badge-yellow';
- if (s === 'exited' || s === 'dead' || s === 'removed') return 'badge-red';
- if (s === 'paused') return 'badge-yellow';
- return 'badge-gray';
+function escapeHtml(str) {
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
}
function statusDotClass(status, health) {
- const cls = statusBadgeClass(status, health);
- return cls.replace('badge-', 'status-dot-');
+ const s = (status || '').toLowerCase();
+ const h = (health || '').toLowerCase();
+ if (s === 'up' && (h === 'healthy' || h === '')) return 'status-dot-green';
+ if (s === 'up' && h === 'unhealthy') return 'status-dot-red';
+ if (s === 'up' && h === 'starting') return 'status-dot-yellow';
+ if (s === 'down' || s === 'exited') return 'status-dot-red';
+ return 'status-dot-gray';
}
-function diskBarClass(pct) {
- if (pct >= 90) return 'disk-danger';
- if (pct >= 75) return 'disk-warn';
+function badgeClass(status, health) {
+ const s = (status || '').toLowerCase();
+ const h = (health || '').toLowerCase();
+ if (s === 'up' && (h === 'healthy' || h === '')) return 'badge-green';
+ if (s === 'up' && h === 'unhealthy') return 'badge-red';
+ if (s === 'up' && h === 'starting') return 'badge-yellow';
+ if (s === 'down' || s === 'exited') return 'badge-red';
+ return 'badge-gray';
+}
+
+function diskColorClass(pct) {
+ const n = parseInt(pct);
+ if (isNaN(n)) return 'disk-ok';
+ if (n >= 90) return 'disk-danger';
+ if (n >= 75) return 'disk-warn';
return 'disk-ok';
}
-// ----------------------------------------------------------------
-// Auth Store
-// ----------------------------------------------------------------
-
-function authStore() {
- return {
- token: localStorage.getItem('ops_token') || '',
- loginInput: '',
- loginError: '',
- loading: false,
-
- get isAuthenticated() {
- return !!this.token;
- },
-
- async login() {
- this.loginError = '';
- if (!this.loginInput.trim()) {
- this.loginError = 'Please enter your access token.';
- return;
- }
- this.loading = true;
- try {
- const res = await fetch('/api/status/', {
- headers: { 'Authorization': 'Bearer ' + this.loginInput.trim() }
- });
- if (res.ok || res.status === 200) {
- this.token = this.loginInput.trim();
- localStorage.setItem('ops_token', this.token);
- this.loginInput = '';
- // Trigger page load
- this.$dispatch('authenticated');
- } else if (res.status === 401) {
- this.loginError = 'Invalid token. Please try again.';
- } else {
- this.loginError = 'Server error (' + res.status + '). Please try again.';
- }
- } catch {
- this.loginError = 'Could not reach the server.';
- } finally {
- this.loading = false;
- }
- },
-
- logout() {
- this.token = '';
- localStorage.removeItem('ops_token');
- }
- };
+// ---------------------------------------------------------------------------
+// Auth
+// ---------------------------------------------------------------------------
+function getToken() {
+ return localStorage.getItem('ops_token');
}
-// ----------------------------------------------------------------
+function doLogin() {
+ const input = document.getElementById('login-token');
+ const errEl = document.getElementById('login-error');
+ const token = input.value.trim();
+ if (!token) {
+ errEl.textContent = 'Please enter a token';
+ errEl.style.display = 'block';
+ return;
+ }
+ errEl.style.display = 'none';
+
+ // Validate token by calling the API
+ fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } })
+ .then(r => {
+ if (!r.ok) throw new Error('Invalid token');
+ return r.json();
+ })
+ .then(data => {
+ localStorage.setItem('ops_token', token);
+ allServices = data;
+ document.getElementById('login-overlay').style.display = 'none';
+ document.getElementById('app').style.display = 'flex';
+ showPage('dashboard');
+ startAutoRefresh();
+ })
+ .catch(() => {
+ errEl.textContent = 'Invalid token. Try again.';
+ errEl.style.display = 'block';
+ });
+}
+
+function doLogout() {
+ localStorage.removeItem('ops_token');
+ stopAutoRefresh();
+ document.getElementById('app').style.display = 'none';
+ document.getElementById('login-overlay').style.display = 'flex';
+ document.getElementById('login-token').value = '';
+}
+
+// ---------------------------------------------------------------------------
// API Helper
-// ----------------------------------------------------------------
-
-function api(path, options = {}) {
- const token = localStorage.getItem('ops_token') || '';
- const headers = Object.assign({ 'Authorization': 'Bearer ' + token }, options.headers || {});
- if (options.json) {
- headers['Content-Type'] = 'application/json';
- options.body = JSON.stringify(options.json);
- delete options.json;
+// ---------------------------------------------------------------------------
+async function api(path, opts = {}) {
+ const token = getToken();
+ const headers = { ...(opts.headers || {}), 'Authorization': 'Bearer ' + token };
+ const resp = await fetch(path, { ...opts, headers });
+ if (resp.status === 401) {
+ doLogout();
+ throw new Error('Session expired');
}
- return fetch(path, Object.assign({}, options, { headers })).then(res => {
- if (res.status === 401) {
- localStorage.removeItem('ops_token');
- window.dispatchEvent(new CustomEvent('unauthorized'));
- throw new Error('Unauthorized');
- }
- return res;
+ if (!resp.ok) {
+ const body = await resp.text();
+ throw new Error(body || 'HTTP ' + resp.status);
+ }
+ const ct = resp.headers.get('content-type') || '';
+ if (ct.includes('json')) return resp.json();
+ return resp.text();
+}
+
+async function fetchStatus() {
+ allServices = await api('/api/status/');
+}
+
+// ---------------------------------------------------------------------------
+// Toast Notifications
+// ---------------------------------------------------------------------------
+function toast(message, type = 'info') {
+ const container = document.getElementById('toast-container');
+ const el = document.createElement('div');
+ el.className = 'toast toast-' + type;
+ el.innerHTML = `<span>${escapeHtml(message)}</span><span class="toast-dismiss" onclick="this.parentElement.remove()">×</span>`;
+ container.appendChild(el);
+ setTimeout(() => {
+ el.classList.add('toast-out');
+ setTimeout(() => el.remove(), 200);
+ }, 4000);
+}
+
+// ---------------------------------------------------------------------------
+// Sidebar & Navigation
+// ---------------------------------------------------------------------------
+function toggleSidebar() {
+ document.getElementById('sidebar').classList.toggle('open');
+ document.getElementById('mobile-overlay').classList.toggle('open');
+}
+
+function showPage(page) {
+ currentPage = page;
+ drillLevel = 0;
+ drillProject = null;
+ drillEnv = null;
+
+ // Update sidebar active
+ document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el => {
+ el.classList.toggle('active', el.dataset.page === page);
});
+
+ // Close mobile sidebar
+ document.getElementById('sidebar').classList.remove('open');
+ document.getElementById('mobile-overlay').classList.remove('open');
+
+ renderPage();
}
-// ----------------------------------------------------------------
-// Toast Store
-// ----------------------------------------------------------------
+function renderPage() {
+ const content = document.getElementById('page-content');
+ content.innerHTML = '<div style="text-align:center;padding:3rem;"><div class="spinner spinner-lg"></div></div>';
-function toastStore() {
- return {
- toasts: [],
- _counter: 0,
-
- add(msg, type = 'info', duration = 4000) {
- const id = ++this._counter;
- this.toasts.push({ id, msg, type });
- if (duration > 0) {
- setTimeout(() => this.remove(id), duration);
- }
- return id;
- },
-
- remove(id) {
- const idx = this.toasts.findIndex(t => t.id === id);
- if (idx !== -1) this.toasts.splice(idx, 1);
- },
-
- success(msg) { return this.add(msg, 'toast-success'); },
- error(msg) { return this.add(msg, 'toast-error', 6000); },
- warn(msg) { return this.add(msg, 'toast-warning'); },
- info(msg) { return this.add(msg, 'toast-info'); },
-
- iconFor(type) {
- const icons = {
- 'toast-success': '✓',
- 'toast-error': '✕',
- 'toast-warning': '⚠',
- 'toast-info': 'ℹ'
- };
- return icons[type] || 'ℹ';
- }
- };
-}
-
-// ----------------------------------------------------------------
-// App Root Store
-// ----------------------------------------------------------------
-
-function appStore() {
- return {
- page: 'dashboard',
- sidebarOpen: false,
- toast: null,
-
- init() {
- this.toast = Alpine.store('toast');
- window.addEventListener('unauthorized', () => {
- Alpine.store('auth').logout();
- });
- window.addEventListener('authenticated', () => {
- this.loadPage('dashboard');
- });
- },
-
- navigate(page) {
- this.page = page;
- this.sidebarOpen = false;
- this.loadPage(page);
- },
-
- loadPage(page) {
- const storeMap = {
- dashboard: 'dashboard',
- backups: 'backups',
- restore: 'restore',
- services: 'services',
- system: 'system'
- };
- const storeName = storeMap[page];
- if (storeName && Alpine.store(storeName) && Alpine.store(storeName).load) {
- Alpine.store(storeName).load();
- }
- }
- };
-}
-
-// ----------------------------------------------------------------
-// Dashboard Store
-// ----------------------------------------------------------------
-
-function dashboardStore() {
- return {
- projects: [],
- loading: false,
- error: null,
- lastRefresh: null,
- refreshInterval: null,
- autoRefreshEnabled: true,
- refreshing: false,
-
- load() {
- this.fetch();
- this.startAutoRefresh();
- },
-
- async fetch() {
- if (this.loading) return;
- this.loading = true;
- this.error = null;
- try {
- const res = await api('/api/status/');
- if (!res.ok) throw new Error('HTTP ' + res.status);
- const data = await res.json();
- this.projects = this.groupByProject(data);
- this.lastRefresh = new Date();
- } catch (e) {
- if (e.message !== 'Unauthorized') {
- this.error = e.message || 'Failed to load status';
- }
- } finally {
- this.loading = false;
- this.refreshing = false;
- }
- },
-
- groupByProject(data) {
- // data may be array of containers or object keyed by project
- let containers = Array.isArray(data) ? data : Object.values(data).flat();
- const map = {};
- for (const c of containers) {
- const proj = c.project || 'Other';
- if (!map[proj]) map[proj] = [];
- map[proj].push(c);
- }
- return Object.entries(map).map(([name, services]) => ({ name, services }))
- .sort((a, b) => a.name.localeCompare(b.name));
- },
-
- manualRefresh() {
- this.refreshing = true;
- this.fetch();
- },
-
- startAutoRefresh() {
- this.stopAutoRefresh();
- if (this.autoRefreshEnabled) {
- this.refreshInterval = setInterval(() => this.fetch(), 30000);
- }
- },
-
- stopAutoRefresh() {
- if (this.refreshInterval) {
- clearInterval(this.refreshInterval);
- this.refreshInterval = null;
- }
- },
-
- toggleAutoRefresh() {
- this.autoRefreshEnabled = !this.autoRefreshEnabled;
- if (this.autoRefreshEnabled) {
- this.startAutoRefresh();
- } else {
- this.stopAutoRefresh();
- }
- },
-
- destroy() {
- this.stopAutoRefresh();
- },
-
- badgeClass: statusBadgeClass,
- dotClass: statusDotClass,
- timeAgo
- };
-}
-
-// ----------------------------------------------------------------
-// Backups Store
-// ----------------------------------------------------------------
-
-function backupsStore() {
- return {
- local: [],
- offsite: [],
- loading: false,
- loadingOffsite: false,
- error: null,
- ops: {}, // track per-row operation state: key -> { loading, done, error }
-
- load() {
- this.fetchLocal();
- this.fetchOffsite();
- },
-
- async fetchLocal() {
- this.loading = true;
- this.error = null;
- try {
- const res = await api('/api/backups/');
- if (!res.ok) throw new Error('HTTP ' + res.status);
- const data = await res.json();
- this.local = Array.isArray(data) ? data : Object.values(data).flat();
- } catch (e) {
- if (e.message !== 'Unauthorized') this.error = e.message;
- } finally {
- this.loading = false;
- }
- },
-
- async fetchOffsite() {
- this.loadingOffsite = true;
- try {
- const res = await api('/api/backups/offsite');
- if (!res.ok) throw new Error('HTTP ' + res.status);
- const data = await res.json();
- this.offsite = Array.isArray(data) ? data : Object.values(data).flat();
- } catch (e) {
- if (e.message !== 'Unauthorized')
- Alpine.store('toast').error('Offsite: ' + (e.message || 'Failed'));
- } finally {
- this.loadingOffsite = false;
- }
- },
-
- opKey(project, env, action) {
- return `${action}::${project}::${env}`;
- },
-
- isRunning(project, env, action) {
- return !!(this.ops[this.opKey(project, env, action)]?.loading);
- },
-
- async backupNow(project, env) {
- const key = this.opKey(project, env, 'backup');
- this.ops = { ...this.ops, [key]: { loading: true } };
- try {
- const res = await api(`/api/backups/${project}/${env}`, { method: 'POST' });
- if (!res.ok) throw new Error('HTTP ' + res.status);
- Alpine.store('toast').success(`Backup started: ${project}/${env}`);
- setTimeout(() => this.fetchLocal(), 2000);
- } catch (e) {
- if (e.message !== 'Unauthorized')
- Alpine.store('toast').error(`Backup failed: ${e.message}`);
- } finally {
- this.ops = { ...this.ops, [key]: { loading: false } };
- }
- },
-
- async uploadOffsite(project, env) {
- const key = this.opKey(project, env, 'upload');
- this.ops = { ...this.ops, [key]: { loading: true } };
- try {
- const res = await api(`/api/backups/offsite/upload/${project}/${env}`, { method: 'POST' });
- if (!res.ok) throw new Error('HTTP ' + res.status);
- Alpine.store('toast').success(`Upload started: ${project}/${env}`);
- setTimeout(() => this.fetchOffsite(), 3000);
- } catch (e) {
- if (e.message !== 'Unauthorized')
- Alpine.store('toast').error(`Upload failed: ${e.message}`);
- } finally {
- this.ops = { ...this.ops, [key]: { loading: false } };
- }
- },
-
- retentionRunning: false,
-
- async applyRetention() {
- this.retentionRunning = true;
- try {
- const res = await api('/api/backups/offsite/retention', { method: 'POST' });
- if (!res.ok) throw new Error('HTTP ' + res.status);
- Alpine.store('toast').success('Retention policy applied');
- setTimeout(() => this.fetchOffsite(), 2000);
- } catch (e) {
- if (e.message !== 'Unauthorized')
- Alpine.store('toast').error(`Retention failed: ${e.message}`);
- } finally {
- this.retentionRunning = false;
- }
- },
-
- ageBadge: ageBadgeClass,
- timeAgo,
- formatBytes
- };
-}
-
-// ----------------------------------------------------------------
-// Restore Store
-// ----------------------------------------------------------------
-
-function restoreStore() {
- return {
- source: 'local',
- project: '',
- env: '',
- dryRun: false,
- confirming: false,
- running: false,
- output: [],
- sseSource: null,
- projects: [],
- envs: [],
- loadingProjects: false,
- error: null,
-
- load() {
- this.loadProjectList();
- },
-
- async loadProjectList() {
- this.loadingProjects = true;
- try {
- const endpoint = this.source === 'offsite' ? '/api/backups/offsite' : '/api/backups/';
- const res = await api(endpoint);
- if (!res.ok) throw new Error('HTTP ' + res.status);
- const data = await res.json();
- const items = Array.isArray(data) ? data : Object.values(data).flat();
- const projSet = new Set(items.map(i => i.project).filter(Boolean));
- this.projects = Array.from(projSet).sort();
- this.project = this.projects[0] || '';
- this.updateEnvs(items);
- } catch (e) {
- if (e.message !== 'Unauthorized') this.error = e.message;
- } finally {
- this.loadingProjects = false;
- }
- },
-
- updateEnvs(items) {
- if (!items) return;
- const envSet = new Set(
- items.filter(i => i.project === this.project).map(i => i.env).filter(Boolean)
- );
- this.envs = Array.from(envSet).sort();
- this.env = this.envs[0] || '';
- },
-
- onSourceChange() {
- this.project = '';
- this.env = '';
- this.envs = [];
- this.loadProjectList();
- },
-
- onProjectChange() {
- // Re-fetch envs for this project from the already loaded data
- this.loadProjectList();
- },
-
- confirm() {
- if (!this.project || !this.env) {
- Alpine.store('toast').warn('Select project and environment first');
- return;
- }
- this.confirming = true;
- },
-
- cancel() {
- this.confirming = false;
- },
-
- async execute() {
- this.confirming = false;
- this.running = true;
- this.output = [];
-
- const params = new URLSearchParams({
- source: this.source,
- dry_run: this.dryRun ? '1' : '0'
- });
- const url = `/api/restore/${this.project}/${this.env}?${params}`;
-
- try {
- this.sseSource = new EventSource(url + '&token=' + encodeURIComponent(localStorage.getItem('ops_token') || ''));
- this.sseSource.onmessage = (e) => {
- try {
- const msg = JSON.parse(e.data);
- if (msg.done) {
- this.sseSource.close();
- this.sseSource = null;
- this.running = false;
- if (msg.success) {
- Alpine.store('toast').success('Restore completed');
- } else {
- Alpine.store('toast').error('Restore finished with errors');
- }
- return;
- }
- const text = msg.line || e.data;
- this.output.push({ text, cls: this.classifyLine(text) });
- } catch {
- this.output.push({ text: e.data, cls: this.classifyLine(e.data) });
- }
- this.$nextTick(() => {
- const el = document.getElementById('restore-output');
- if (el) el.scrollTop = el.scrollHeight;
- });
- };
- this.sseSource.onerror = () => {
- if (this.running) {
- this.running = false;
- if (this.sseSource) this.sseSource.close();
- this.sseSource = null;
- }
- };
- } catch (e) {
- this.running = false;
- Alpine.store('toast').error('Restore failed: ' + (e.message || 'Unknown error'));
- }
- },
-
- classifyLine(text) {
- const t = text.toLowerCase();
- if (t.includes('error') || t.includes('fail') || t.includes('critical')) return 'line-error';
- if (t.includes('warn')) return 'line-warn';
- if (t.includes('ok') || t.includes('success') || t.includes('done')) return 'line-ok';
- if (t.startsWith('$') || t.startsWith('#') || t.startsWith('>')) return 'line-cmd';
- return '';
- },
-
- abort() {
- if (this.sseSource) {
- this.sseSource.close();
- this.sseSource = null;
- }
- this.running = false;
- this.output.push({ text: '--- aborted by user ---', cls: 'line-warn' });
- }
- };
-}
-
-// ----------------------------------------------------------------
-// Services Store
-// ----------------------------------------------------------------
-
-function servicesStore() {
- return {
- projects: [],
- loading: false,
- error: null,
- logModal: { open: false, title: '', lines: [], loading: false },
- confirmRestart: { open: false, project: '', env: '', service: '', running: false },
-
- load() {
- this.fetch();
- },
-
- async fetch() {
- this.loading = true;
- this.error = null;
- try {
- const res = await api('/api/status/');
- if (!res.ok) throw new Error('HTTP ' + res.status);
- const data = await res.json();
- this.projects = this.groupByProject(data);
- } catch (e) {
- if (e.message !== 'Unauthorized') this.error = e.message;
- } finally {
- this.loading = false;
- }
- },
-
- groupByProject(data) {
- let containers = Array.isArray(data) ? data : Object.values(data).flat();
- const map = {};
- for (const c of containers) {
- const proj = c.project || 'Other';
- if (!map[proj]) map[proj] = [];
- map[proj].push(c);
- }
- return Object.entries(map).map(([name, services]) => ({ name, services }))
- .sort((a, b) => a.name.localeCompare(b.name));
- },
-
- async viewLogs(project, env, service) {
- this.logModal = {
- open: true,
- title: `${service} — logs`,
- lines: [],
- loading: true
- };
- try {
- const res = await api(`/api/services/logs/${project}/${env}/${service}?lines=150`);
- if (!res.ok) throw new Error('HTTP ' + res.status);
- const data = await res.json();
- this.logModal.lines = (data.logs || '').split('\n');
- } catch (e) {
- if (e.message !== 'Unauthorized')
- this.logModal.lines = ['Error: ' + e.message];
- } finally {
- this.logModal.loading = false;
- this.$nextTick(() => {
- const el = document.getElementById('log-output');
- if (el) el.scrollTop = el.scrollHeight;
- });
- }
- },
-
- closeLogs() {
- this.logModal.open = false;
- },
-
- askRestart(project, env, service) {
- this.confirmRestart = { open: true, project, env, service, running: false };
- },
-
- cancelRestart() {
- this.confirmRestart.open = false;
- },
-
- async doRestart() {
- const { project, env, service } = this.confirmRestart;
- this.confirmRestart.running = true;
- try {
- const res = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' });
- if (!res.ok) throw new Error('HTTP ' + res.status);
- Alpine.store('toast').success(`${service} restarted`);
- this.confirmRestart.open = false;
- setTimeout(() => this.fetch(), 2000);
- } catch (e) {
- if (e.message !== 'Unauthorized')
- Alpine.store('toast').error(`Restart failed: ${e.message}`);
- } finally {
- this.confirmRestart.running = false;
- }
- },
-
- badgeClass: statusBadgeClass,
- dotClass: statusDotClass
- };
-}
-
-// ----------------------------------------------------------------
-// System Store
-// ----------------------------------------------------------------
-
-function systemStore() {
- return {
- disk: [],
- health: [],
- timers: [],
- info: {},
- loading: { disk: false, health: false, timers: false, info: false },
- error: null,
-
- load() {
- this.fetchDisk();
- this.fetchHealth();
- this.fetchTimers();
- this.fetchInfo();
- },
-
- async fetchDisk() {
- this.loading.disk = true;
- try {
- const res = await api('/api/system//disk');
- if (!res.ok) throw new Error('HTTP ' + res.status);
- const data = await res.json();
- this.disk = Array.isArray(data) ? data : (data.filesystems || []);
- } catch (e) {
- if (e.message !== 'Unauthorized') this.error = e.message;
- } finally {
- this.loading.disk = false;
- }
- },
-
- async fetchHealth() {
- this.loading.health = true;
- try {
- const res = await api('/api/system//health');
- if (!res.ok) throw new Error('HTTP ' + res.status);
- const data = await res.json();
- this.health = Array.isArray(data) ? data : (data.checks || []);
- } catch (e) {
- if (e.message !== 'Unauthorized') {}
- } finally {
- this.loading.health = false;
- }
- },
-
- async fetchTimers() {
- this.loading.timers = true;
- try {
- const res = await api('/api/system//timers');
- if (!res.ok) throw new Error('HTTP ' + res.status);
- const data = await res.json();
- this.timers = Array.isArray(data) ? data : (data.timers || []);
- } catch (e) {
- if (e.message !== 'Unauthorized') {}
- } finally {
- this.loading.timers = false;
- }
- },
-
- async fetchInfo() {
- this.loading.info = true;
- try {
- const res = await api('/api/system//info');
- if (!res.ok) throw new Error('HTTP ' + res.status);
- this.info = await res.json();
- } catch (e) {
- if (e.message !== 'Unauthorized') {}
- } finally {
- this.loading.info = false;
- }
- },
-
- diskBarClass,
- formatBytes,
- timeAgo
- };
-}
-
-// ----------------------------------------------------------------
-// Alpine initialization
-// ----------------------------------------------------------------
-
-document.addEventListener('alpine:init', () => {
- Alpine.store('auth', authStore());
- Alpine.store('toast', toastStore());
- Alpine.store('app', appStore());
- Alpine.store('dashboard', dashboardStore());
- Alpine.store('backups', backupsStore());
- Alpine.store('restore', restoreStore());
- Alpine.store('services', servicesStore());
- Alpine.store('system', systemStore());
-
- // Init app store
- Alpine.store('app').init();
-
- // Load the dashboard if already authenticated
- if (Alpine.store('auth').isAuthenticated) {
- Alpine.store('dashboard').load();
+ switch (currentPage) {
+ case 'dashboard': renderDashboard(); break;
+ case 'services': renderServicesFlat(); break;
+ case 'backups': renderBackups(); break;
+ case 'system': renderSystem(); break;
+ case 'restore': renderRestore(); break;
+ default: renderDashboard();
}
-});
+}
+
+function refreshCurrentPage() {
+ showRefreshSpinner();
+ fetchStatus()
+ .then(() => renderPage())
+ .catch(e => toast('Refresh failed: ' + e.message, 'error'))
+ .finally(() => hideRefreshSpinner());
+}
+
+// ---------------------------------------------------------------------------
+// Auto-refresh
+// ---------------------------------------------------------------------------
+function startAutoRefresh() {
+ stopAutoRefresh();
+ refreshTimer = setInterval(() => {
+ fetchStatus()
+ .then(() => {
+ if (currentPage === 'dashboard' || currentPage === 'services') renderPage();
+ })
+ .catch(() => {});
+ }, REFRESH_INTERVAL);
+}
+
+function stopAutoRefresh() {
+ if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
+}
+
+function showRefreshSpinner() {
+ document.getElementById('refresh-indicator').classList.remove('paused');
+}
+function hideRefreshSpinner() {
+ document.getElementById('refresh-indicator').classList.add('paused');
+}
+
+// ---------------------------------------------------------------------------
+// Breadcrumbs
+// ---------------------------------------------------------------------------
+function updateBreadcrumbs() {
+ const bc = document.getElementById('breadcrumbs');
+ let html = '';
+
+ if (currentPage === 'dashboard') {
+ if (drillLevel === 0) {
+ html = '<span class="current">Dashboard</span>';
+ } else if (drillLevel === 1) {
+ html = '<a onclick="drillBack(0)">Dashboard</a><span class="sep">/</span><span class="current">' + escapeHtml(drillProject) + '</span>';
+ } else if (drillLevel === 2) {
+ 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>';
+ }
+ } else {
+ const names = { services: 'Services', backups: 'Backups', system: 'System', restore: 'Restore' };
+ html = '<span class="current">' + (names[currentPage] || currentPage) + '</span>';
+ }
+
+ bc.innerHTML = html;
+}
+
+function drillBack(level) {
+ if (level === 0) {
+ drillLevel = 0;
+ drillProject = null;
+ drillEnv = null;
+ } else if (level === 1) {
+ drillLevel = 1;
+ drillEnv = null;
+ }
+ renderDashboard();
+}
+
+// ---------------------------------------------------------------------------
+// Dashboard — 3-level Drill
+// ---------------------------------------------------------------------------
+function renderDashboard() {
+ currentPage = 'dashboard';
+ if (drillLevel === 0) renderProjects();
+ else if (drillLevel === 1) renderEnvironments();
+ else if (drillLevel === 2) renderServices();
+ updateBreadcrumbs();
+}
+
+function renderProjects() {
+ const content = document.getElementById('page-content');
+ const projects = groupByProject(allServices);
+
+ // Summary stats
+ const totalUp = allServices.filter(s => s.status === 'Up').length;
+ const totalDown = allServices.length - totalUp;
+
+ let html = '<div class="page-enter" style="padding:0;">';
+
+ // Summary bar
+ html += '<div class="stat-grid" style="margin-bottom:1.5rem;">';
+ html += statCard('Projects', Object.keys(projects).length, '#3b82f6');
+ html += statCard('Services', allServices.length, '#8b5cf6');
+ html += statCard('Healthy', totalUp, '#10b981');
+ html += statCard('Down', totalDown, totalDown > 0 ? '#ef4444' : '#6b7280');
+ html += '</div>';
+
+ // Project cards
+ html += '<div class="project-grid">';
+ for (const [name, proj] of Object.entries(projects)) {
+ const upCount = proj.services.filter(s => s.status === 'Up').length;
+ const total = proj.services.length;
+ const allUp = upCount === total;
+ const envNames = [...new Set(proj.services.map(s => s.env))];
+
+ html += `<div class="card card-clickable" onclick="drillToProject('${escapeHtml(name)}')">
+ <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
+ <span class="status-dot ${allUp ? 'status-dot-green' : 'status-dot-red'}"></span>
+ <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${escapeHtml(name)}</span>
+ <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${total} services</span>
+ </div>
+ <div style="display:flex;flex-wrap:wrap;gap:0.375rem;margin-bottom:0.5rem;">
+ ${envNames.map(e => `<span class="badge badge-blue">${escapeHtml(e)}</span>`).join('')}
+ </div>
+ <div style="font-size:0.8125rem;color:#9ca3af;">${upCount}/${total} healthy</div>
+ </div>`;
+ }
+ html += '</div></div>';
+ content.innerHTML = html;
+}
+
+function renderEnvironments() {
+ const content = document.getElementById('page-content');
+ const projServices = allServices.filter(s => s.project === drillProject);
+ const envs = groupByEnv(projServices);
+
+ let html = '<div class="page-enter" style="padding:0;">';
+ html += '<div class="env-grid">';
+
+ for (const [envName, services] of Object.entries(envs)) {
+ const upCount = services.filter(s => s.status === 'Up').length;
+ const total = services.length;
+ const allUp = upCount === total;
+
+ html += `<div class="card card-clickable" onclick="drillToEnv('${escapeHtml(envName)}')">
+ <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
+ <span class="status-dot ${allUp ? 'status-dot-green' : 'status-dot-red'}"></span>
+ <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${escapeHtml(envName).toUpperCase()}</span>
+ <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${total} services</span>
+ </div>
+ <div style="display:flex;flex-wrap:wrap;gap:0.375rem;margin-bottom:0.5rem;">
+ ${services.map(s => `<span class="badge ${badgeClass(s.status, s.health)}">${escapeHtml(s.service)}</span>`).join('')}
+ </div>
+ <div style="font-size:0.8125rem;color:#9ca3af;">${upCount}/${total} healthy</div>
+ </div>`;
+ }
+
+ html += '</div></div>';
+ content.innerHTML = html;
+}
+
+function renderServices() {
+ const content = document.getElementById('page-content');
+ const services = allServices.filter(s => s.project === drillProject && s.env === drillEnv);
+
+ let html = '<div class="page-enter" style="padding:0;">';
+ html += '<div class="service-grid">';
+
+ for (const svc of services) {
+ html += serviceCard(svc);
+ }
+
+ html += '</div></div>';
+ content.innerHTML = html;
+}
+
+function drillToProject(name) {
+ drillProject = name;
+ drillLevel = 1;
+ renderDashboard();
+}
+
+function drillToEnv(name) {
+ drillEnv = name;
+ drillLevel = 2;
+ renderDashboard();
+}
+
+// ---------------------------------------------------------------------------
+// Service Card (shared component)
+// ---------------------------------------------------------------------------
+function serviceCard(svc) {
+ const proj = escapeHtml(svc.project);
+ const env = escapeHtml(svc.env);
+ const service = escapeHtml(svc.service);
+ const bc = badgeClass(svc.status, svc.health);
+ const dc = statusDotClass(svc.status, svc.health);
+
+ return `<div class="card">
+ <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
+ <span class="status-dot ${dc}"></span>
+ <span style="font-weight:600;color:#f3f4f6;">${service}</span>
+ <span class="badge ${bc}" style="margin-left:auto;">${escapeHtml(svc.status)}</span>
+ </div>
+ <div style="font-size:0.8125rem;color:#9ca3af;margin-bottom:0.75rem;">
+ Health: ${escapeHtml(svc.health || 'n/a')} · Uptime: ${escapeHtml(svc.uptime || 'n/a')}
+ </div>
+ <div style="display:flex;gap:0.5rem;flex-wrap:wrap;">
+ <button class="btn btn-ghost btn-xs" onclick="viewLogs('${proj}','${env}','${service}')">Logs</button>
+ <button class="btn btn-warning btn-xs" onclick="restartService('${proj}','${env}','${service}')">Restart</button>
+ </div>
+ </div>`;
+}
+
+function statCard(label, value, color) {
+ return `<div class="card" style="text-align:center;">
+ <div style="font-size:1.75rem;font-weight:700;color:${color};">${value}</div>
+ <div style="font-size:0.8125rem;color:#9ca3af;">${label}</div>
+ </div>`;
+}
+
+// ---------------------------------------------------------------------------
+// Services (flat list page)
+// ---------------------------------------------------------------------------
+function renderServicesFlat() {
+ updateBreadcrumbs();
+ const content = document.getElementById('page-content');
+
+ if (allServices.length === 0) {
+ content.innerHTML = '<div style="text-align:center;padding:3rem;color:#6b7280;">No services found.</div>';
+ return;
+ }
+
+ let html = '<div class="page-enter" style="padding:0;">';
+ html += '<div class="table-wrapper"><table class="ops-table">';
+ 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>';
+ html += '<tbody>';
+
+ for (const svc of allServices) {
+ const bc = badgeClass(svc.status, svc.health);
+ const proj = escapeHtml(svc.project);
+ const env = escapeHtml(svc.env);
+ const service = escapeHtml(svc.service);
+
+ html += `<tr>
+ <td style="font-weight:500;">${proj}</td>
+ <td><span class="badge badge-blue">${env}</span></td>
+ <td class="mono">${service}</td>
+ <td><span class="badge ${bc}">${escapeHtml(svc.status)}</span></td>
+ <td>${escapeHtml(svc.health || 'n/a')}</td>
+ <td>${escapeHtml(svc.uptime || 'n/a')}</td>
+ <td style="white-space:nowrap;">
+ <button class="btn btn-ghost btn-xs" onclick="viewLogs('${proj}','${env}','${service}')">Logs</button>
+ <button class="btn btn-warning btn-xs" onclick="restartService('${proj}','${env}','${service}')">Restart</button>
+ </td>
+ </tr>`;
+ }
+
+ html += '</tbody></table></div></div>';
+ content.innerHTML = html;
+}
+
+// ---------------------------------------------------------------------------
+// Backups Page
+// ---------------------------------------------------------------------------
+async function renderBackups() {
+ updateBreadcrumbs();
+ const content = document.getElementById('page-content');
+
+ try {
+ const [local, offsite] = await Promise.all([
+ api('/api/backups/'),
+ api('/api/backups/offsite').catch(() => []),
+ ]);
+
+ let html = '<div class="page-enter" style="padding:0;">';
+
+ // Quick backup buttons
+ html += '<div style="margin-bottom:1.5rem;">';
+ html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Create Backup</h2>';
+ html += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">';
+ for (const proj of ['mdf', 'seriousletter']) {
+ for (const env of ['dev', 'int', 'prod']) {
+ html += `<button class="btn btn-ghost btn-sm" onclick="createBackup('${proj}','${env}')">${proj}/${env}</button>`;
+ }
+ }
+ html += '</div></div>';
+
+ // Local backups
+ html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Local Backups</h2>';
+ if (local.length === 0) {
+ html += '<div class="card" style="color:#6b7280;">No local backups found.</div>';
+ } else {
+ html += '<div class="table-wrapper"><table class="ops-table">';
+ html += '<thead><tr><th>Project</th><th>Env</th><th>Date</th><th>Size</th><th>Files</th></tr></thead><tbody>';
+ for (const b of local) {
+ html += `<tr>
+ <td>${escapeHtml(b.project || '')}</td>
+ <td><span class="badge badge-blue">${escapeHtml(b.env || b.environment || '')}</span></td>
+ <td>${escapeHtml(b.date || b.timestamp || '')}</td>
+ <td>${escapeHtml(b.size || '')}</td>
+ <td class="mono" style="font-size:0.75rem;">${escapeHtml(b.file || b.files || '')}</td>
+ </tr>`;
+ }
+ html += '</tbody></table></div>';
+ }
+
+ // Offsite backups
+ html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin:1.5rem 0 0.75rem;">Offsite Backups</h2>';
+ if (offsite.length === 0) {
+ html += '<div class="card" style="color:#6b7280;">No offsite backups found.</div>';
+ } else {
+ html += '<div class="table-wrapper"><table class="ops-table">';
+ html += '<thead><tr><th>Project</th><th>Env</th><th>Date</th><th>Size</th></tr></thead><tbody>';
+ for (const b of offsite) {
+ html += `<tr>
+ <td>${escapeHtml(b.project || '')}</td>
+ <td><span class="badge badge-blue">${escapeHtml(b.env || b.environment || '')}</span></td>
+ <td>${escapeHtml(b.date || b.timestamp || '')}</td>
+ <td>${escapeHtml(b.size || '')}</td>
+ </tr>`;
+ }
+ html += '</tbody></table></div>';
+ }
+
+ html += '</div>';
+ content.innerHTML = html;
+ } catch (e) {
+ content.innerHTML = '<div class="card" style="color:#f87171;">Failed to load backups: ' + escapeHtml(e.message) + '</div>';
+ }
+}
+
+// ---------------------------------------------------------------------------
+// System Page
+// ---------------------------------------------------------------------------
+async function renderSystem() {
+ updateBreadcrumbs();
+ const content = document.getElementById('page-content');
+
+ try {
+ const [disk, health, timers, info] = await Promise.all([
+ api('/api/system/disk').catch(e => ({ filesystems: [], raw: e.message })),
+ api('/api/system/health').catch(e => ({ checks: [], raw: e.message })),
+ api('/api/system/timers').catch(e => ({ timers: [], raw: e.message })),
+ api('/api/system/info').catch(e => ({ uptime: 'error', load: 'error' })),
+ ]);
+
+ let html = '<div class="page-enter" style="padding:0;">';
+
+ // System info bar
+ html += '<div class="stat-grid" style="margin-bottom:1.5rem;">';
+ html += statCard('Uptime', info.uptime || 'n/a', '#3b82f6');
+ html += statCard('Load', info.load || 'n/a', '#8b5cf6');
+ html += '</div>';
+
+ // Disk usage
+ html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Disk Usage</h2>';
+ if (disk.filesystems && disk.filesystems.length > 0) {
+ html += '<div style="display:grid;gap:0.75rem;margin-bottom:1.5rem;">';
+ for (const fs of disk.filesystems) {
+ const pct = parseInt(fs.use_percent) || 0;
+ html += `<div class="card">
+ <div style="display:flex;justify-content:space-between;margin-bottom:0.5rem;">
+ <span class="mono" style="font-size:0.8125rem;">${escapeHtml(fs.mount || fs.filesystem)}</span>
+ <span style="font-size:0.8125rem;color:#9ca3af;">${escapeHtml(fs.used)} / ${escapeHtml(fs.size)} (${escapeHtml(fs.use_percent)})</span>
+ </div>
+ <div class="progress-bar-track">
+ <div class="progress-bar-fill ${diskColorClass(fs.use_percent)}" style="width:${pct}%;"></div>
+ </div>
+ </div>`;
+ }
+ html += '</div>';
+ } else {
+ html += '<div class="card" style="color:#6b7280;">No disk data available.</div>';
+ }
+
+ // Health checks
+ html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Health Checks</h2>';
+ if (health.checks && health.checks.length > 0) {
+ html += '<div style="display:grid;gap:0.5rem;margin-bottom:1.5rem;">';
+ for (const c of health.checks) {
+ const st = (c.status || '').toUpperCase();
+ const cls = st === 'OK' ? 'badge-green' : st === 'FAIL' ? 'badge-red' : 'badge-gray';
+ html += `<div class="card" style="display:flex;align-items:center;gap:0.75rem;padding:0.75rem 1rem;">
+ <span class="badge ${cls}">${escapeHtml(st)}</span>
+ <span style="font-size:0.875rem;">${escapeHtml(c.check)}</span>
+ </div>`;
+ }
+ html += '</div>';
+ } else {
+ html += '<div class="card" style="color:#6b7280;">No health check data.</div>';
+ }
+
+ // Timers
+ html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Systemd Timers</h2>';
+ if (timers.timers && timers.timers.length > 0) {
+ html += '<div class="table-wrapper"><table class="ops-table">';
+ html += '<thead><tr><th>Unit</th><th>Next</th><th>Left</th><th>Last</th><th>Passed</th></tr></thead><tbody>';
+ for (const t of timers.timers) {
+ html += `<tr>
+ <td class="mono">${escapeHtml(t.unit)}</td>
+ <td>${escapeHtml(t.next)}</td>
+ <td>${escapeHtml(t.left)}</td>
+ <td>${escapeHtml(t.last)}</td>
+ <td>${escapeHtml(t.passed)}</td>
+ </tr>`;
+ }
+ html += '</tbody></table></div>';
+ } else {
+ html += '<div class="card" style="color:#6b7280;">No timers found.</div>';
+ }
+
+ html += '</div>';
+ content.innerHTML = html;
+ } catch (e) {
+ content.innerHTML = '<div class="card" style="color:#f87171;">Failed to load system info: ' + escapeHtml(e.message) + '</div>';
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Restore Page
+// ---------------------------------------------------------------------------
+function renderRestore() {
+ updateBreadcrumbs();
+ const content = document.getElementById('page-content');
+
+ let html = '<div class="page-enter" style="padding:0;">';
+ html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Restore Backup</h2>';
+ html += '<div class="card" style="max-width:480px;">';
+
+ html += '<div style="margin-bottom:1rem;">';
+ html += '<label class="form-label">Project</label>';
+ html += '<select id="restore-project" class="form-select"><option value="mdf">mdf</option><option value="seriousletter">seriousletter</option></select>';
+ html += '</div>';
+
+ html += '<div style="margin-bottom:1rem;">';
+ html += '<label class="form-label">Environment</label>';
+ html += '<select id="restore-env" class="form-select"><option value="dev">dev</option><option value="int">int</option><option value="prod">prod</option></select>';
+ html += '</div>';
+
+ html += '<div style="margin-bottom:1rem;">';
+ html += '<label class="form-label">Source</label>';
+ html += '<select id="restore-source" class="form-select"><option value="local">Local</option><option value="offsite">Offsite</option></select>';
+ html += '</div>';
+
+ html += '<div style="margin-bottom:1rem;">';
+ html += '<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;color:#9ca3af;">';
+ html += '<input type="checkbox" id="restore-dry" checked> Dry run (preview only)';
+ html += '</label>';
+ html += '</div>';
+
+ html += '<button class="btn btn-danger" onclick="startRestore()">Start Restore</button>';
+ html += '</div>';
+
+ html += '<div id="restore-output" style="display:none;margin-top:1rem;">';
+ html += '<h3 style="font-size:1rem;font-weight:600;color:#f3f4f6;margin-bottom:0.5rem;">Output</h3>';
+ html += '<div id="restore-terminal" class="terminal" style="max-height:400px;"></div>';
+ html += '</div>';
+
+ html += '</div>';
+ content.innerHTML = html;
+}
+
+async function startRestore() {
+ const project = document.getElementById('restore-project').value;
+ const env = document.getElementById('restore-env').value;
+ const source = document.getElementById('restore-source').value;
+ const dryRun = document.getElementById('restore-dry').checked;
+
+ if (!confirm(`Restore ${project}/${env} from ${source}${dryRun ? ' (dry run)' : ''}? This may overwrite data.`)) return;
+
+ const outputDiv = document.getElementById('restore-output');
+ const terminal = document.getElementById('restore-terminal');
+ outputDiv.style.display = 'block';
+ terminal.textContent = 'Starting restore...\n';
+
+ const url = `/api/restore/${project}/${env}?source=${source}&dry_run=${dryRun}&token=${encodeURIComponent(getToken())}`;
+ const evtSource = new EventSource(url);
+
+ evtSource.onmessage = function(e) {
+ const data = JSON.parse(e.data);
+ if (data.done) {
+ evtSource.close();
+ terminal.textContent += data.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n';
+ toast(data.success ? 'Restore completed' : 'Restore failed', data.success ? 'success' : 'error');
+ return;
+ }
+ if (data.line) {
+ terminal.textContent += data.line + '\n';
+ terminal.scrollTop = terminal.scrollHeight;
+ }
+ };
+
+ evtSource.onerror = function() {
+ evtSource.close();
+ terminal.textContent += '\n--- Connection lost ---\n';
+ toast('Restore connection lost', 'error');
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Service Actions
+// ---------------------------------------------------------------------------
+async function restartService(project, env, service) {
+ if (!confirm(`Restart ${service} in ${project}/${env}?`)) return;
+
+ toast('Restarting ' + service + '...', 'info');
+ try {
+ const result = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' });
+ toast(result.message || 'Restarted successfully', 'success');
+ setTimeout(() => refreshCurrentPage(), 3000);
+ } catch (e) {
+ toast('Restart failed: ' + e.message, 'error');
+ }
+}
+
+async function viewLogs(project, env, service) {
+ logModalProject = project;
+ logModalEnv = env;
+ logModalService = service;
+
+ document.getElementById('log-modal-title').textContent = `Logs: ${project}/${env}/${service}`;
+ document.getElementById('log-modal-content').textContent = 'Loading...';
+ document.getElementById('log-modal').style.display = 'flex';
+
+ await refreshLogs();
+}
+
+async function refreshLogs() {
+ if (!logModalProject) return;
+ try {
+ const data = await api(`/api/services/logs/${logModalProject}/${logModalEnv}/${logModalService}?lines=200`);
+ const terminal = document.getElementById('log-modal-content');
+ terminal.textContent = data.logs || 'No logs available.';
+ terminal.scrollTop = terminal.scrollHeight;
+ } catch (e) {
+ document.getElementById('log-modal-content').textContent = 'Error loading logs: ' + e.message;
+ }
+}
+
+function closeLogModal() {
+ document.getElementById('log-modal').style.display = 'none';
+ logModalProject = null;
+ logModalEnv = null;
+ logModalService = null;
+}
+
+// ---------------------------------------------------------------------------
+// Backup Actions
+// ---------------------------------------------------------------------------
+async function createBackup(project, env) {
+ if (!confirm(`Create backup for ${project}/${env}?`)) return;
+ toast('Creating backup for ' + project + '/' + env + '...', 'info');
+ try {
+ await api(`/api/backups/${project}/${env}`, { method: 'POST' });
+ toast('Backup created for ' + project + '/' + env, 'success');
+ if (currentPage === 'backups') renderBackups();
+ } catch (e) {
+ toast('Backup failed: ' + e.message, 'error');
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Data Grouping
+// ---------------------------------------------------------------------------
+function groupByProject(services) {
+ const map = {};
+ for (const s of services) {
+ const key = s.project || 'other';
+ if (!map[key]) map[key] = { name: key, services: [] };
+ map[key].services.push(s);
+ }
+ return map;
+}
+
+function groupByEnv(services) {
+ const map = {};
+ for (const s of services) {
+ const key = s.env || 'default';
+ if (!map[key]) map[key] = [];
+ map[key].push(s);
+ }
+ return map;
+}
+
+// ---------------------------------------------------------------------------
+// Init
+// ---------------------------------------------------------------------------
+(function init() {
+ const token = getToken();
+ if (token) {
+ // Validate and load
+ fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } })
+ .then(r => {
+ if (!r.ok) throw new Error('Invalid token');
+ return r.json();
+ })
+ .then(data => {
+ allServices = data;
+ document.getElementById('login-overlay').style.display = 'none';
+ document.getElementById('app').style.display = 'flex';
+ showPage('dashboard');
+ startAutoRefresh();
+ })
+ .catch(() => {
+ localStorage.removeItem('ops_token');
+ document.getElementById('login-overlay').style.display = 'flex';
+ });
+ }
+
+ // ESC to close modals
+ document.addEventListener('keydown', e => {
+ if (e.key === 'Escape') {
+ closeLogModal();
+ }
+ });
+})();
--
Gitblit v1.3.1