|
| 1 | +--- |
| 2 | +import Base from '../../layouts/Base.astro'; |
| 3 | +import SectionHeading from '../../components/SectionHeading.astro'; |
| 4 | +--- |
| 5 | + |
| 6 | +<Base title="Statut — pixelium.win" description="Statut live de l'infrastructure : 30+ services, 3 nœuds Proxmox, monitoring temps réel. Données poussées depuis le homelab par OpenFang toutes les 5 minutes."> |
| 7 | + <main> |
| 8 | + <section class="hero-mini"> |
| 9 | + <div class="container"> |
| 10 | + <p class="terminal-prompt">$ ping --all</p> |
| 11 | + <h1>Statut<span class="dot">.</span></h1> |
| 12 | + <p class="subtitle">Dashboard infrastructure live. Données poussées par OpenFang toutes les 5 minutes — zéro port exposé.</p> |
| 13 | + </div> |
| 14 | + </section> |
| 15 | + |
| 16 | + <!-- Résumé --> |
| 17 | + <section class="status-summary reveal"> |
| 18 | + <div class="container"> |
| 19 | + <div class="summary-grid"> |
| 20 | + <div class="summary-card"> |
| 21 | + <span class="summary-dot" id="global-dot"></span> |
| 22 | + <span class="summary-value" id="summary-up">--</span> |
| 23 | + <span class="summary-sep">/</span> |
| 24 | + <span class="summary-value summary-total" id="summary-total">--</span> |
| 25 | + <span class="summary-label">services UP</span> |
| 26 | + </div> |
| 27 | + <div class="summary-card"> |
| 28 | + <span class="summary-value" id="summary-uptime">--</span> |
| 29 | + <span class="summary-label">disponibilité</span> |
| 30 | + </div> |
| 31 | + <div class="summary-card"> |
| 32 | + <span class="summary-value" id="summary-nodes">--</span> |
| 33 | + <span class="summary-label">nœuds PVE</span> |
| 34 | + </div> |
| 35 | + <div class="summary-card"> |
| 36 | + <span class="summary-value summary-timestamp" id="summary-updated">en attente de synchronisation</span> |
| 37 | + <span class="summary-label">dernière vérification</span> |
| 38 | + </div> |
| 39 | + </div> |
| 40 | + </div> |
| 41 | + </section> |
| 42 | + |
| 43 | + <!-- Services --> |
| 44 | + <section class="layer reveal"> |
| 45 | + <div class="container"> |
| 46 | + <SectionHeading id="services" title="Services" /> |
| 47 | + <div id="services-loading" class="status-loading"> |
| 48 | + <div class="skeleton-grid"> |
| 49 | + {Array.from({ length: 12 }).map(() => ( |
| 50 | + <div class="skeleton-card"></div> |
| 51 | + ))} |
| 52 | + </div> |
| 53 | + <p class="status-waiting">En attente des premières données d'OpenFang...</p> |
| 54 | + </div> |
| 55 | + <div id="services-grid" class="services-container" style="display:none;"></div> |
| 56 | + </div> |
| 57 | + </section> |
| 58 | + |
| 59 | + <!-- Nœuds --> |
| 60 | + <section class="layer reveal"> |
| 61 | + <div class="container"> |
| 62 | + <SectionHeading id="nodes" title="Nœuds Proxmox" /> |
| 63 | + <div id="nodes-loading" class="status-loading"> |
| 64 | + <div class="skeleton-grid skeleton-nodes"> |
| 65 | + {Array.from({ length: 3 }).map(() => ( |
| 66 | + <div class="skeleton-card skeleton-node"></div> |
| 67 | + ))} |
| 68 | + </div> |
| 69 | + </div> |
| 70 | + <div id="nodes-grid" class="nodes-grid" style="display:none;"></div> |
| 71 | + </div> |
| 72 | + </section> |
| 73 | + |
| 74 | + <!-- Note de bas de page --> |
| 75 | + <section class="status-footer reveal"> |
| 76 | + <div class="container"> |
| 77 | + <p class="status-note">Données poussées depuis le homelab par <strong>OpenFang</strong> (agent IA, CT 192) via l'API Cloudflare KV. Zéro port exposé — push HTTPS sortant uniquement.</p> |
| 78 | + </div> |
| 79 | + </section> |
| 80 | + </main> |
| 81 | +</Base> |
| 82 | + |
| 83 | +<script> |
| 84 | + // Category display order and labels — data is trusted (from our own KV, pushed by OpenFang) |
| 85 | + const categoryLabels: Record<string, string> = { |
| 86 | + infra: 'INFRASTRUCTURE', |
| 87 | + apps: 'APPLICATIONS', |
| 88 | + monitoring: 'MONITORING & SÉCURITÉ', |
| 89 | + storage: 'STOCKAGE & SAUVEGARDE', |
| 90 | + }; |
| 91 | + |
| 92 | + function barColor(pct: number): string { |
| 93 | + if (pct > 85) return '#ef4444'; |
| 94 | + if (pct > 65) return '#eab308'; |
| 95 | + return '#38bdf8'; |
| 96 | + } |
| 97 | + |
| 98 | + function el(tag: string, className: string, text?: string): HTMLElement { |
| 99 | + const e = document.createElement(tag); |
| 100 | + e.className = className; |
| 101 | + if (text) e.textContent = text; |
| 102 | + return e; |
| 103 | + } |
| 104 | + |
| 105 | + (async function loadStatus() { |
| 106 | + try { |
| 107 | + const res = await fetch('/api/status'); |
| 108 | + if (!res.ok) return; |
| 109 | + const data = await res.json(); |
| 110 | + if (!data.services || !data.services.length) return; |
| 111 | + |
| 112 | + // Summary |
| 113 | + const s = data.summary; |
| 114 | + if (s) { |
| 115 | + const upEl = document.getElementById('summary-up'); |
| 116 | + const totalEl = document.getElementById('summary-total'); |
| 117 | + const uptimeEl = document.getElementById('summary-uptime'); |
| 118 | + const nodesEl = document.getElementById('summary-nodes'); |
| 119 | + const dot = document.getElementById('global-dot'); |
| 120 | + if (upEl) upEl.textContent = String(s.up); |
| 121 | + if (totalEl) totalEl.textContent = String(s.total); |
| 122 | + if (uptimeEl) uptimeEl.textContent = s.uptime_pct + '%'; |
| 123 | + if (nodesEl) nodesEl.textContent = data.nodes ? String(data.nodes.filter((n: any) => n.status !== 'offline').length) : '--'; |
| 124 | + if (dot) dot.classList.add(s.down > 0 ? 'degraded' : 'healthy'); |
| 125 | + } |
| 126 | + |
| 127 | + // Timestamp |
| 128 | + if (data.updated_at) { |
| 129 | + const mins = Math.round((Date.now() - new Date(data.updated_at).getTime()) / 60000); |
| 130 | + const tsEl = document.getElementById('summary-updated'); |
| 131 | + if (tsEl) tsEl.textContent = mins < 1 ? "à l'instant" : 'il y a ' + mins + ' min'; |
| 132 | + } |
| 133 | + |
| 134 | + // Services grouped by category |
| 135 | + const grouped: Record<string, any[]> = {}; |
| 136 | + for (const svc of data.services) { |
| 137 | + const cat = svc.category || 'apps'; |
| 138 | + if (!grouped[cat]) grouped[cat] = []; |
| 139 | + grouped[cat].push(svc); |
| 140 | + } |
| 141 | + |
| 142 | + const container = document.getElementById('services-grid'); |
| 143 | + if (!container) return; |
| 144 | + |
| 145 | + for (const [cat, label] of Object.entries(categoryLabels)) { |
| 146 | + const svcs = grouped[cat]; |
| 147 | + if (!svcs || !svcs.length) continue; |
| 148 | + |
| 149 | + const section = el('div', 'service-category'); |
| 150 | + section.appendChild(el('h3', 'category-title', label)); |
| 151 | + |
| 152 | + const grid = el('div', 'service-grid'); |
| 153 | + for (const svc of svcs) { |
| 154 | + const isUp = svc.status === 'up'; |
| 155 | + const card = el('div', 'service-card'); |
| 156 | + card.appendChild(el('span', 'svc-dot ' + (isUp ? 'up' : 'down'))); |
| 157 | + card.appendChild(el('span', 'svc-name', svc.name)); |
| 158 | + card.appendChild(el('span', 'svc-latency', svc.latency != null ? svc.latency + 'ms' : '--')); |
| 159 | + grid.appendChild(card); |
| 160 | + } |
| 161 | + |
| 162 | + section.appendChild(grid); |
| 163 | + container.appendChild(section); |
| 164 | + } |
| 165 | + |
| 166 | + document.getElementById('services-loading')!.style.display = 'none'; |
| 167 | + container.style.display = 'block'; |
| 168 | + |
| 169 | + // Nodes |
| 170 | + if (data.nodes && data.nodes.length) { |
| 171 | + const nodesContainer = document.getElementById('nodes-grid'); |
| 172 | + if (!nodesContainer) return; |
| 173 | + |
| 174 | + for (const node of data.nodes) { |
| 175 | + const card = el('div', 'node-card'); |
| 176 | + card.appendChild(el('h4', 'node-name', node.name)); |
| 177 | + |
| 178 | + if (node.status === 'offline') { |
| 179 | + card.appendChild(el('span', 'node-offline', 'offline')); |
| 180 | + } else { |
| 181 | + for (const [metric, value] of [['CPU', node.cpu], ['RAM', node.ram]] as [string, number][]) { |
| 182 | + const row = el('div', 'node-metric'); |
| 183 | + row.appendChild(el('span', 'metric-label', metric)); |
| 184 | + const bar = el('div', 'bar'); |
| 185 | + const fill = el('div', 'bar-fill'); |
| 186 | + (fill as HTMLElement).style.width = value + '%'; |
| 187 | + (fill as HTMLElement).style.background = barColor(value); |
| 188 | + bar.appendChild(fill); |
| 189 | + row.appendChild(bar); |
| 190 | + row.appendChild(el('span', 'metric-value', value + '%')); |
| 191 | + card.appendChild(row); |
| 192 | + } |
| 193 | + card.appendChild(el('div', 'node-uptime', node.uptime_days + 'd uptime')); |
| 194 | + } |
| 195 | + |
| 196 | + nodesContainer.appendChild(card); |
| 197 | + } |
| 198 | + |
| 199 | + document.getElementById('nodes-loading')!.style.display = 'none'; |
| 200 | + nodesContainer.style.display = 'grid'; |
| 201 | + } |
| 202 | + } catch (_) { /* keep skeletons */ } |
| 203 | + })(); |
| 204 | +</script> |
| 205 | + |
| 206 | +<style> |
| 207 | + .status-summary { padding: 2rem 0; border-bottom: 1px solid var(--color-border); } |
| 208 | + .summary-grid { display: flex; justify-content: center; align-items: center; gap: 2.5rem; flex-wrap: wrap; } |
| 209 | + .summary-card { display: flex; align-items: center; gap: 0.5rem; } |
| 210 | + .summary-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--color-text-muted); flex-shrink: 0; } |
| 211 | + .summary-dot.healthy { background: #22c55e; box-shadow: 0 0 8px rgba(34, 197, 94, 0.6); animation: pulse-dot 2s ease-in-out infinite; } |
| 212 | + .summary-dot.degraded { background: #eab308; box-shadow: 0 0 8px rgba(234, 179, 8, 0.6); animation: pulse-dot 2s ease-in-out infinite; } |
| 213 | + @keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } |
| 214 | + .summary-value { font-family: var(--font-mono); font-size: 1.75rem; font-weight: 800; color: var(--color-accent); } |
| 215 | + .summary-total { color: var(--color-text-muted); font-weight: 400; } |
| 216 | + .summary-sep { font-family: var(--font-mono); font-size: 1.5rem; color: var(--color-border); } |
| 217 | + .summary-label { font-size: 0.8rem; color: var(--color-text-muted); } |
| 218 | + .summary-timestamp { font-size: 1rem; font-weight: 600; } |
| 219 | + |
| 220 | + .skeleton-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; } |
| 221 | + .skeleton-card { height: 2.5rem; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 6px; animation: skeleton-pulse 1.5s ease-in-out infinite; } |
| 222 | + .skeleton-nodes { grid-template-columns: repeat(3, 1fr); } |
| 223 | + .skeleton-node { height: 8rem; } |
| 224 | + @keyframes skeleton-pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 0.8; } } |
| 225 | + .status-waiting { text-align: center; color: var(--color-text-muted); font-family: var(--font-mono); font-size: 0.85rem; margin-top: 1.5rem; } |
| 226 | + .status-loading { padding: 1rem 0; } |
| 227 | + |
| 228 | + .service-category { margin-bottom: 2rem; } |
| 229 | + .category-title { font-family: var(--font-mono); font-size: 0.9rem; color: var(--color-accent); margin-bottom: 0.75rem; letter-spacing: 0.05em; } |
| 230 | + .service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.5rem; } |
| 231 | + .service-card { display: flex; align-items: center; gap: 0.6rem; padding: 0.6rem 0.9rem; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 6px; transition: border-color 0.2s; } |
| 232 | + .service-card:hover { border-color: var(--color-accent); } |
| 233 | + .svc-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } |
| 234 | + .svc-dot.up { background: #22c55e; } |
| 235 | + .svc-dot.down { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); } |
| 236 | + .svc-name { font-family: var(--font-mono); font-size: 0.85rem; color: var(--color-text); flex: 1; } |
| 237 | + .svc-latency { font-family: var(--font-mono); font-size: 0.75rem; color: var(--color-text-muted); } |
| 238 | + |
| 239 | + .nodes-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; } |
| 240 | + .node-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; padding: 1.25rem; transition: border-color 0.2s; } |
| 241 | + .node-card:hover { border-color: var(--color-accent); } |
| 242 | + .node-name { font-family: var(--font-mono); font-size: 1rem; font-weight: 700; color: var(--color-text); margin: 0 0 1rem; } |
| 243 | + .node-metric { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.5rem; } |
| 244 | + .metric-label { font-family: var(--font-mono); font-size: 0.75rem; color: var(--color-text-muted); width: 2.5rem; } |
| 245 | + .bar { flex: 1; height: 6px; background: var(--color-border); border-radius: 3px; overflow: hidden; } |
| 246 | + .bar-fill { height: 100%; border-radius: 3px; transition: width 0.8s ease-out; } |
| 247 | + .metric-value { font-family: var(--font-mono); font-size: 0.8rem; color: var(--color-text); width: 3rem; text-align: right; } |
| 248 | + .node-uptime { font-family: var(--font-mono); font-size: 0.75rem; color: var(--color-text-muted); margin-top: 0.5rem; } |
| 249 | + .node-offline { font-family: var(--font-mono); font-size: 0.85rem; color: var(--color-text-muted); font-style: italic; } |
| 250 | + |
| 251 | + .status-footer { padding: 2rem 0; } |
| 252 | + .status-note { font-size: 0.8rem; color: var(--color-text-muted); text-align: center; font-family: var(--font-mono); } |
| 253 | + |
| 254 | + @media (max-width: 640px) { |
| 255 | + .summary-grid { gap: 1.5rem; } |
| 256 | + .summary-value { font-size: 1.25rem; } |
| 257 | + .nodes-grid { grid-template-columns: 1fr; } |
| 258 | + .service-grid { grid-template-columns: 1fr; } |
| 259 | + } |
| 260 | + |
| 261 | + @media (prefers-reduced-motion: reduce) { |
| 262 | + .summary-dot.healthy, .summary-dot.degraded { animation: none; } |
| 263 | + .skeleton-card { animation: none; opacity: 0.6; } |
| 264 | + .bar-fill { transition: none; } |
| 265 | + } |
| 266 | +</style> |
0 commit comments