| <!DOCTYPE html>
|
| <html lang="en" class="h-full">
|
| <head>
|
| <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>
|
| </head>
|
|
|
| <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>
|
| </div>
|
| </div>
|
|
|
| <!-- ============================================================
|
| Main App Shell
|
| ============================================================ -->
|
| <div x-data x-show="$store.auth.isAuthenticated" class="flex h-full">
|
|
|
| <!-- 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>
|
| </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>
|
| 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>
|
| 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>
|
| System
|
| </button>
|
| </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>
|
| </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>
|
|
|
| </div>
|
| </header>
|
|
|
| <!-- ---- 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>
|
| </div>
|
| </div>
|
|
|
| </body>
|
| </html>
|