Skip to content

Commit 26a4435

Browse files
ferr079claude
andcommitted
feat: add Live Infrastructure dashboard section on homepage (EN+FR)
8 bricks fetching /api/stats client-side: services UP, uptime, commits, PVE nodes, HTB flags, Root-Me, Ansible, total services. Animated counters, green pulsing dot, graceful fallback when KV empty. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 341b0f6 commit 26a4435

3 files changed

Lines changed: 227 additions & 0 deletions

File tree

src/components/LiveStats.astro

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
---
2+
interface Brick {
3+
key: string;
4+
label: string;
5+
suffix?: string;
6+
fallback: string;
7+
}
8+
9+
interface Props {
10+
bricks: Brick[];
11+
heading: string;
12+
no_data_label: string;
13+
}
14+
15+
const { bricks, heading, no_data_label } = Astro.props;
16+
const lang = Astro.url.pathname.startsWith('/fr') ? 'fr' : 'en';
17+
---
18+
19+
<section class="live-stats reveal" data-lang={lang}>
20+
<div class="container">
21+
<div class="live-stats-header">
22+
<span class="live-dot" id="live-dot"></span>
23+
<h2 class="live-heading">{heading}</h2>
24+
<span class="live-timestamp" id="live-timestamp">{no_data_label}</span>
25+
</div>
26+
<div class="live-grid">
27+
{bricks.map((brick, i) => (
28+
<div class="live-brick" style={`--delay: ${i * 80}ms`}>
29+
<span class="brick-value" data-key={brick.key} data-suffix={brick.suffix || ''} data-fallback={brick.fallback}>
30+
{brick.fallback}
31+
</span>
32+
<span class="brick-label">{brick.label}</span>
33+
</div>
34+
))}
35+
</div>
36+
</div>
37+
</section>
38+
39+
<script>
40+
function animateValue(el: Element, target: number, suffix: string) {
41+
const dur = 1500, start = performance.now();
42+
const isFloat = target % 1 !== 0;
43+
(function step(now: number) {
44+
const p = Math.min((now - start) / dur, 1);
45+
const e = 1 - Math.pow(1 - p, 3);
46+
(el as HTMLElement).textContent = (isFloat ? (e * target).toFixed(1) : String(Math.round(e * target))) + suffix;
47+
if (p < 1) requestAnimationFrame(step);
48+
})(performance.now());
49+
}
50+
51+
(async function loadLiveStats() {
52+
const bricks = document.querySelectorAll('.brick-value[data-key]');
53+
const dot = document.getElementById('live-dot');
54+
const ts = document.getElementById('live-timestamp');
55+
const section = document.querySelector('.live-stats');
56+
if (!bricks.length) return;
57+
58+
const lang = section?.getAttribute('data-lang') || 'en';
59+
const labels = lang === 'fr'
60+
? { just: "à l'instant", ago: ' min', waiting: 'en attente de sync' }
61+
: { just: 'just now', ago: ' min ago', waiting: 'awaiting first sync' };
62+
63+
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
64+
65+
try {
66+
const res = await fetch('/api/stats');
67+
if (!res.ok) return;
68+
const data = await res.json();
69+
if (!data.stats) return;
70+
71+
bricks.forEach(el => {
72+
const key = (el as HTMLElement).dataset.key!;
73+
const val = data.stats[key];
74+
if (val == null) return;
75+
const suffix = (el as HTMLElement).dataset.suffix || '';
76+
const num = parseFloat(val);
77+
if (!isNaN(num) && !reducedMotion) {
78+
animateValue(el, num, suffix);
79+
} else {
80+
(el as HTMLElement).textContent = val + suffix;
81+
}
82+
});
83+
84+
if (dot) dot.classList.add('live');
85+
if (ts && data.updated_at) {
86+
const mins = Math.round((Date.now() - new Date(data.updated_at).getTime()) / 60000);
87+
ts.textContent = mins < 1 ? labels.just : mins + labels.ago;
88+
}
89+
} catch (_) { /* graceful — keep fallbacks */ }
90+
})();
91+
</script>
92+
93+
<style>
94+
.live-stats {
95+
padding: 3rem 0 2rem;
96+
}
97+
98+
.live-stats-header {
99+
display: flex;
100+
align-items: center;
101+
gap: 0.75rem;
102+
margin-bottom: 2rem;
103+
flex-wrap: wrap;
104+
}
105+
106+
.live-dot {
107+
width: 8px;
108+
height: 8px;
109+
border-radius: 50%;
110+
background: var(--color-text-muted);
111+
flex-shrink: 0;
112+
}
113+
114+
.live-dot.live {
115+
background: #22c55e;
116+
box-shadow: 0 0 6px rgba(34, 197, 94, 0.6);
117+
animation: pulse-dot 2s ease-in-out infinite;
118+
}
119+
120+
@keyframes pulse-dot {
121+
0%, 100% { opacity: 1; box-shadow: 0 0 6px rgba(34, 197, 94, 0.6); }
122+
50% { opacity: 0.6; box-shadow: 0 0 12px rgba(34, 197, 94, 0.4); }
123+
}
124+
125+
.live-heading {
126+
font-size: 1.1rem;
127+
font-family: var(--font-mono);
128+
color: var(--color-text);
129+
font-weight: 600;
130+
margin: 0;
131+
}
132+
133+
.live-timestamp {
134+
font-family: var(--font-mono);
135+
font-size: 0.75rem;
136+
color: var(--color-text-muted);
137+
margin-left: auto;
138+
}
139+
140+
.live-grid {
141+
display: grid;
142+
grid-template-columns: repeat(4, 1fr);
143+
gap: 1rem;
144+
}
145+
146+
.live-brick {
147+
background: var(--color-surface);
148+
border: 1px solid var(--color-border);
149+
border-radius: 8px;
150+
padding: 1.25rem 1rem;
151+
text-align: center;
152+
transition: border-color 0.3s, transform 0.3s, box-shadow 0.3s;
153+
}
154+
155+
.live-brick:hover {
156+
border-color: var(--color-accent);
157+
transform: translateY(-2px);
158+
box-shadow: 0 0 12px rgba(56, 189, 248, 0.08), 0 4px 16px rgba(0, 0, 0, 0.2);
159+
}
160+
161+
.brick-value {
162+
display: block;
163+
font-family: var(--font-mono);
164+
font-size: 1.75rem;
165+
font-weight: 800;
166+
color: var(--color-accent);
167+
line-height: 1;
168+
margin-bottom: 0.4rem;
169+
}
170+
171+
.brick-label {
172+
display: block;
173+
font-size: 0.8rem;
174+
color: var(--color-text-muted);
175+
line-height: 1.3;
176+
}
177+
178+
@media (max-width: 640px) {
179+
.live-grid {
180+
grid-template-columns: repeat(2, 1fr);
181+
gap: 0.75rem;
182+
}
183+
.live-brick { padding: 1rem 0.75rem; }
184+
.brick-value { font-size: 1.5rem; }
185+
.live-timestamp { margin-left: 0; width: 100%; }
186+
}
187+
188+
@media (prefers-reduced-motion: reduce) {
189+
.live-dot.live { animation: none; }
190+
.live-brick { transition: none; }
191+
.live-brick:hover { transform: none; }
192+
}
193+
</style>

src/pages/fr/index.astro

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import Base from '../../layouts/Base.astro';
33
import HeroTerminal from '../../components/HeroTerminal.astro';
44
import StatsBar from '../../components/StatsBar.astro';
5+
import LiveStats from '../../components/LiveStats.astro';
56
import Card from '../../components/Card.astro';
67
import SectionHeading from '../../components/SectionHeading.astro';
78
@@ -101,6 +102,22 @@ const cards = [
101102
{ number: '0€', label: 'cloud externe' },
102103
]} />
103104

105+
<!-- Infrastructure live -->
106+
<LiveStats
107+
heading="Infrastructure live"
108+
no_data_label="en attente de synchronisation"
109+
bricks={[
110+
{ key: 'services_up', label: 'Services UP', fallback: '--' },
111+
{ key: 'uptime_pct', label: 'Disponibilité', suffix: '%', fallback: '--' },
112+
{ key: 'forgejo_commits_30d', label: 'Commits (30j)', fallback: '--' },
113+
{ key: 'proxmox_nodes', label: 'Nœuds PVE', fallback: '--' },
114+
{ key: 'htb_flags', label: 'Flags HTB', fallback: '--' },
115+
{ key: 'rootme_score', label: 'Pts Root-Me', fallback: '--' },
116+
{ key: 'ansible_playbooks', label: 'Playbooks Ansible', fallback: '--' },
117+
{ key: 'services_total', label: 'Services total', fallback: '--' },
118+
]}
119+
/>
120+
104121
<!-- Open source -->
105122
<section class="opensource reveal">
106123
<div class="container">

src/pages/index.astro

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import Base from '../layouts/Base.astro';
33
import HeroTerminal from '../components/HeroTerminal.astro';
44
import StatsBar from '../components/StatsBar.astro';
5+
import LiveStats from '../components/LiveStats.astro';
56
import Card from '../components/Card.astro';
67
import SectionHeading from '../components/SectionHeading.astro';
78
@@ -101,6 +102,22 @@ const cards = [
101102
{ number: '0€', label: 'external cloud' },
102103
]} />
103104

105+
<!-- Live infrastructure -->
106+
<LiveStats
107+
heading="Live infrastructure"
108+
no_data_label="awaiting first sync"
109+
bricks={[
110+
{ key: 'services_up', label: 'Services UP', fallback: '--' },
111+
{ key: 'uptime_pct', label: 'Uptime', suffix: '%', fallback: '--' },
112+
{ key: 'forgejo_commits_30d', label: 'Commits (30d)', fallback: '--' },
113+
{ key: 'proxmox_nodes', label: 'PVE nodes', fallback: '--' },
114+
{ key: 'htb_flags', label: 'HTB flags', fallback: '--' },
115+
{ key: 'rootme_score', label: 'Root-Me pts', fallback: '--' },
116+
{ key: 'ansible_playbooks', label: 'Ansible playbooks', fallback: '--' },
117+
{ key: 'services_total', label: 'Total services', fallback: '--' },
118+
]}
119+
/>
120+
104121
<!-- Open source -->
105122
<section class="opensource reveal">
106123
<div class="container">

0 commit comments

Comments
 (0)