Skip to content

Commit d45baed

Browse files
ferr079claude
andcommitted
feat: add /status page — live service dashboard with nodes (EN+FR)
33 services grouped by category, 3 PVE node cards with CPU/RAM bars, summary with UP/total count and uptime. Client-side fetch /api/status. Skeleton loading, graceful fallback, responsive grid. STATUS_KV seeded with real service data. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0ad3280 commit d45baed

2 files changed

Lines changed: 532 additions & 0 deletions

File tree

src/pages/fr/status.astro

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
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

Comments
 (0)