Vitrine publique (accueil, architecture, statut) + recherche plein texte
All checks were successful
CI / build-and-test (push) Successful in 2m16s
CI / deploy (push) Successful in 3m16s

- Nouvelles pages publiques sans routeur externe (History API minimale,
  useRoute/RouteLink) : accueil, /architecture (état réel du projet,
  honnête sur les limites), /statut (télémétrie live de tripoli-edge
  lue directement sur /healthz et /metrics, aucune donnée simulée).
- Recherche façon Spotlight dans TopBar : nouvel endpoint
  GET /api/search côté tripoli-api, recherche plein texte Postgres
  (tsvector généré + index GIN) sur le nom ET le contenu des fichiers
  texte extrait à l'upload (formats texte/code, borné à 5 Mio) ;
  extraits de résultat via ts_headline.
- AuthLayout : logo cliquable vers l'accueil.

Vérifié : tsc --noEmit propre, npm run build propre, cargo build -p
tripoli-api propre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-09-24 13:03:52 +02:00
parent ddd0fbc0cb
commit 3172b3e256
15 changed files with 858 additions and 20 deletions

View file

@ -1,5 +1,6 @@
import { AuthBackground } from './AuthBackground';
import { AuthPanel } from './AuthPanel';
import { RouteLink } from '../marketing/RouteLink';
import { TripoliLogo, TripoliWordmark } from '../layout/TripoliLogo';
export function AuthLayout() {
@ -8,10 +9,10 @@ export function AuthLayout() {
<AuthBackground />
<div className="relative flex min-h-screen flex-col justify-between gap-11 p-6 sm:p-10 lg:p-[60px]">
<div className="flex items-center gap-2.5 text-white">
<RouteLink to="/" className="flex items-center gap-2.5 text-white">
<TripoliLogo size={30} variant="hero" />
<TripoliWordmark className="text-base" />
</div>
</RouteLink>
<div className="flex flex-wrap items-center justify-between gap-8 sm:gap-14 lg:gap-[72px]">
<div className="min-w-0 max-w-[560px] flex-1 basis-[340px] text-white">

View file

@ -1,12 +1,57 @@
import { Menu, Search } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { Loader2, Menu, Search as SearchIcon } from 'lucide-react';
import { useNavigation } from '../../contexts/NavigationContext';
import { useIsMobile } from '../../hooks/useIsMobile';
import { NotificationCenter } from '../notifications/NotificationCenter';
import { searchFiles, type SearchResult } from '../../api/search';
import { downloadUrl } from '../../api/files';
import { formatBytes } from '../../lib/format';
/** ts_headline (Postgres) entoure les termes trouvés de <b>…</b> ; on ne
* rend jamais ce HTML tel quel (le contenu vient d'un fichier uploadé par
* l'utilisateur, donc potentiellement pas fiable) — juste le texte, sans
* les marqueurs de surlignage. */
function stripHeadlineTags(snippet: string): string {
return snippet.replace(/<\/?b>/g, '');
}
export function TopBar() {
const { openDrawer } = useNavigation();
const isMobile = useIsMobile();
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const trimmed = query.trim();
if (!trimmed) {
setResults([]);
setLoading(false);
return;
}
setLoading(true);
const timer = window.setTimeout(() => {
searchFiles(trimmed)
.then(setResults)
.catch(() => setResults([]))
.finally(() => setLoading(false));
}, 300);
return () => window.clearTimeout(timer);
}, [query]);
useEffect(() => {
const onClickOutside = (e: MouseEvent) => {
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('mousedown', onClickOutside);
}, []);
const showPanel = open && query.trim().length > 0;
return (
<header className="sticky top-0 z-[60] flex items-center gap-3 border-b border-line bg-panel p-3.5 backdrop-blur-xl sm:px-8">
{isMobile && (
@ -20,13 +65,55 @@ export function TopBar() {
</button>
)}
<div className="flex max-w-[440px] flex-1 items-center gap-2.5 rounded-xl border border-line bg-field px-3.5 py-2.5">
<Search size={16} className="flex-none opacity-50" strokeWidth={1.8} />
<input
aria-label="Rechercher un fichier"
placeholder="Rechercher un document, un dossier, une référence…"
className="w-full min-w-0 flex-1 bg-transparent text-[13.5px] text-ink outline-none"
/>
<div ref={boxRef} className="relative max-w-[440px] flex-1">
<div className="flex items-center gap-2.5 rounded-xl border border-line bg-field px-3.5 py-2.5">
{loading ? (
<Loader2 size={16} className="flex-none animate-spin opacity-50" strokeWidth={1.8} />
) : (
<SearchIcon size={16} className="flex-none opacity-50" strokeWidth={1.8} />
)}
<input
aria-label="Rechercher dans le contenu des fichiers"
placeholder="Rechercher un document, un dossier, un mot dans le contenu…"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
className="w-full min-w-0 flex-1 bg-transparent text-[13.5px] text-ink outline-none"
/>
</div>
{showPanel && (
<div className="absolute left-0 right-0 top-[calc(100%+8px)] max-h-[420px] overflow-y-auto rounded-xl border border-line bg-panel shadow-tc">
{loading && results.length === 0 && <p className="p-4 text-[13px] text-ink-soft">Recherche…</p>}
{!loading && results.length === 0 && (
<p className="p-4 text-[13px] text-ink-soft">
Aucun résultat pour « {query.trim()} », ni dans les noms ni dans le contenu indexé.
</p>
)}
{results.map((r) => (
<a
key={r.id}
href={downloadUrl(r.id)}
target="_blank"
rel="noopener noreferrer"
className="block border-b border-line/70 px-4 py-3 last:border-b-0 hover:bg-field"
>
<div className="flex items-center justify-between gap-3">
<span className="truncate text-[13.5px] font-semibold text-ink">{r.filename}</span>
<span className="flex-none text-[11px] text-ink-soft">{formatBytes(r.size_bytes)}</span>
</div>
{r.snippet && (
<p className="mt-1 line-clamp-2 text-[12.5px] leading-snug text-ink-soft">
{stripHeadlineTags(r.snippet)}
</p>
)}
</a>
))}
</div>
)}
</div>
<div className="ml-auto flex items-center gap-2">

View file

@ -0,0 +1,157 @@
import type { ReactNode } from 'react';
import { Cpu, GitBranch, Network, Radio, Server, ShieldCheck } from 'lucide-react';
import { MarketingHeader } from './MarketingHeader';
import { MarketingFooter } from './MarketingFooter';
const FLOW = [
{
title: 'Caddy (edge partagé)',
detail:
"TLS Let's Encrypt automatique, routage par nom d'hôte. Ce Caddy sert aussi six autres domaines sur ce même serveur — tripoli-edge n'a jamais eu les ports 80/443 pour lui seul.",
},
{
title: 'tripoli-edge — Rust (hyper/tokio)',
detail:
'Reverse-proxy interne (172.17.0.1:9000, passerelle docker0, jamais exposé directement). Expose /healthz et /metrics en clair, sans framework de métriques : cinq compteurs atomiques suffisaient.',
},
{
title: 'tripoli-api — Rust (axum/sqlx)',
detail:
"Authentification par session opaque (Argon2, pas de JWT — révoquer une session, c'est supprimer une ligne). Upload en streaming vers disque, recherche plein texte Postgres.",
},
{
title: 'PostgreSQL + blobs',
detail:
"Conteneur Docker dédié à Tripoli Cloud, séparé de celui d'un autre projet sur ce même serveur. Les fichiers sont nommés par UUID sur disque — le nom d'origine ne vit qu'en base.",
},
];
const LABS: { icon: ReactNode; title: string; detail: string }[] = [
{
icon: <Network size={18} />,
title: 'Tunnel WireGuard',
detail:
"10.100.0.0/24 entre le Mac d'administration et le serveur — canal SSH/Ansible, jamais dans le chemin de trafic applicatif.",
},
{
icon: <Server size={18} />,
title: 'tripoli-netctl',
detail:
'Netns + veth créés à la demande via rtnetlink (Rust). Testé (création, ping traversant, destruction propre) — volontairement jamais branché sur le trafic de prod.',
},
{
icon: <Radio size={18} />,
title: 'bird-lab — BGP + BFD',
detail:
'Deux namespaces en session BGP via une racine en transit (AS 65000/65010/65011). Sessions Established, BFD Up et détection de panne vérifiées, puis démonté proprement.',
},
];
const STATE: [string, string][] = [
['Frontend (app/)', 'Fait — branché sur les vraies données de tripoli-api, aucun mock.'],
['WireGuard', 'Fait — 10.100.0.0/24.'],
['tripoli-edge', 'Fait, en prod — /healthz, /metrics vérifiés.'],
['tripoli-api', 'Fait, en prod — auth, fichiers, notifications, recherche.'],
['tripoli-netctl', 'Fait — labo, jamais branché sur le trafic de prod.'],
['Observabilité', 'Fait — scrape Prometheus/Grafana partagé, alertes versionnées.'],
['bird-lab (BGP/BFD)', 'Fait, bonus — démonté après vérification.'],
['CI/CD', 'Fait — Forgejo Actions, compte deploy sans privilège.'],
['OpenTofu', 'Code écrit, non appliqué — identifiants OVH/Hetzner non fournis.'],
];
export function ArchitecturePage() {
return (
<div className="min-h-screen bg-bg text-ink">
<MarketingHeader />
<main className="mx-auto max-w-[880px] px-6 py-16 sm:px-10 sm:py-24">
<span className="inline-flex items-center gap-2 rounded-full border border-line bg-field px-3.5 py-1.5 text-xs font-semibold tracking-wide text-ink-soft">
<Cpu size={13} />
Un seul serveur, pas un cluster
</span>
<h1 className="m-0 my-5 font-display text-[36px] font-semibold leading-[1.08] tracking-tight sm:text-[48px]">
Ce qui tourne <span className="italic">réellement</span>.
</h1>
<p className="m-0 max-w-[62ch] text-[15.5px] leading-relaxed text-ink-soft">
Un VPS Hetzner CPX22 (2 vCPU, 3,7 Go de RAM), partagé avec six autres projets personnels.
Ce qui suit est honnête sur ce qui est en place — pas de cluster simulé, pas de haute
disponibilité qui n'existe pas.
</p>
<section className="mt-14">
<h2 className="m-0 mb-6 font-display text-[22px] font-semibold text-ink">
Le chemin d'une requête
</h2>
<div className="flex flex-col gap-3">
{FLOW.map((step, i) => (
<div key={step.title} className="flex gap-4 rounded-2xl border border-line bg-panel p-5 shadow-tc">
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-field text-[13px] font-bold text-ink">
{i + 1}
</span>
<div>
<p className="m-0 mb-1 text-[14.5px] font-bold text-ink">{step.title}</p>
<p className="m-0 text-[13.5px] leading-relaxed text-ink-soft">{step.detail}</p>
</div>
</div>
))}
</div>
</section>
<section className="mt-14">
<h2 className="m-0 mb-2 font-display text-[22px] font-semibold text-ink">
Hors du chemin de trafic
</h2>
<p className="m-0 mb-6 text-[13.5px] text-ink-soft">
Labos pédagogiques — jamais branchés sur le trafic de production.
</p>
<div className="grid gap-3 sm:grid-cols-3">
{LABS.map((lab) => (
<div key={lab.title} className="rounded-2xl border border-line bg-panel p-5 shadow-tc">
<div className="mb-2 text-ink-soft">{lab.icon}</div>
<p className="m-0 mb-1 text-[13.5px] font-bold text-ink">{lab.title}</p>
<p className="m-0 text-[12.5px] leading-relaxed text-ink-soft">{lab.detail}</p>
</div>
))}
</div>
</section>
<section className="mt-14">
<h2 className="m-0 mb-6 font-display text-[22px] font-semibold text-ink">État réel</h2>
<div className="overflow-hidden rounded-2xl border border-line bg-panel shadow-tc">
{STATE.map(([name, status], i) => (
<div
key={name}
className={`flex items-center justify-between gap-4 px-5 py-3.5 text-[13px] ${
i !== STATE.length - 1 ? 'border-b border-line/70' : ''
}`}
>
<span className="font-semibold text-ink">{name}</span>
<span className="text-right text-ink-soft">{status}</span>
</div>
))}
</div>
</section>
<section className="mt-14 rounded-2xl border border-line bg-field p-6">
<div className="mb-2 flex items-center gap-2 text-ink">
<ShieldCheck size={16} />
<p className="m-0 text-[13.5px] font-bold">Limites assumées</p>
</div>
<p className="m-0 text-[13px] leading-relaxed text-ink-soft">
SSH public encore ouvert (choix explicite, pas encore restreint au VPN). Pas de
virtualisation imbriquée sur ce VPS. Alertes Prometheus qui se déclenchent mais ne sont
pas encore routées par notification. Détail complet dans le README du dépôt.
</p>
</section>
<div className="mt-10 flex items-center gap-2 text-[13px] text-ink-soft">
<GitBranch size={15} />
Code et historique de commits versionnés sur Forgejo.
</div>
</main>
<MarketingFooter />
</div>
);
}

View file

@ -0,0 +1,111 @@
import { ArrowRight, HardDrive, Image as ImagesIcon, Lock, Share2 } from 'lucide-react';
import { MarketingHeader } from './MarketingHeader';
import { MarketingFooter } from './MarketingFooter';
import { RouteLink } from './RouteLink';
const FEATURES = [
{
icon: <HardDrive size={20} />,
title: 'Sauvegardes réelles',
detail:
"Upload en streaming vers disque avec progression réelle, pas une barre simulée — les gros fichiers partent au fil de l'eau, pas d'un coup une fois tout reçu.",
},
{
icon: <ImagesIcon size={20} />,
title: 'Photos',
detail: "Vos photos, catégorisées et servies directement depuis votre coffre — aucune n'est envoyée à un service tiers.",
},
{
icon: <Share2 size={20} />,
title: 'Fichiers, à plat',
detail:
"Recherche par nom et par contenu du fichier. Pas de hiérarchie de dossiers à gérer pour l'instant — assumé, pas simulé.",
},
{
icon: <Lock size={20} />,
title: 'Chiffré, hashé',
detail:
'HTTPS de bout en bout, mots de passe hachés en Argon2, sessions opaques révocables — jamais de mot de passe en clair, jamais de JWT à faire tourner.',
},
];
export function LandingPage() {
return (
<div className="min-h-screen bg-bg text-ink">
<MarketingHeader />
<main className="mx-auto max-w-[1160px] px-6 pb-24 pt-16 sm:px-10 sm:pt-24">
<span className="inline-flex items-center gap-2 rounded-full border border-line bg-field px-3.5 py-1.5 text-xs font-semibold tracking-wide text-ink-soft">
<span className="h-1.5 w-1.5 animate-tc-pulse rounded-full bg-success" />
Auto-hébergé sur un vrai serveur
</span>
<h1 className="m-0 my-6 max-w-[16ch] font-display text-[42px] font-semibold leading-[1.05] tracking-tight text-balance sm:text-[64px]">
Votre cloud,
<br />
<span className="italic">sur votre serveur.</span>
</h1>
<p className="m-0 max-w-[58ch] text-[16px] leading-relaxed text-ink-soft sm:text-[18px]">
Photos, sauvegardes et partage de fichiers — sans dépendre d'un hébergeur tiers pour vos
données. Construit et exploité comme un vrai service en production, pas une démo.
</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<RouteLink
to="/connexion"
className="flex items-center gap-2 rounded-xl bg-ink px-5 py-3 text-[14px] font-bold text-bg transition-opacity hover:opacity-90"
>
Se connecter
<ArrowRight size={16} />
</RouteLink>
<RouteLink
to="/architecture"
className="flex items-center gap-2 rounded-xl border border-line bg-panel px-5 py-3 text-[14px] font-bold text-ink transition-colors hover:bg-field"
>
Voir l'architecture
</RouteLink>
</div>
<div className="mt-24 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{FEATURES.map((f) => (
<div key={f.title} className="rounded-2xl border border-line bg-panel p-6 shadow-tc">
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-xl bg-field text-ink">
{f.icon}
</div>
<p className="m-0 mb-2 text-[15px] font-bold text-ink">{f.title}</p>
<p className="m-0 text-[13.5px] leading-relaxed text-ink-soft">{f.detail}</p>
</div>
))}
</div>
<div className="mt-24 flex flex-col items-start justify-between gap-6 rounded-2xl border border-line bg-field p-8 sm:flex-row sm:items-center">
<div>
<p className="m-0 mb-1 font-display text-[22px] font-semibold text-ink">
Envie de voir sous le capot ?
</p>
<p className="m-0 text-[13.5px] text-ink-soft">
WireGuard, Rust, PostgreSQL, Prometheus/Grafana, CI/CD — détaillé sans filtre marketing.
</p>
</div>
<div className="flex flex-none gap-3">
<RouteLink
to="/architecture"
className="rounded-xl border border-line bg-panel px-4 py-2.5 text-[13.5px] font-bold text-ink hover:bg-bg"
>
Architecture
</RouteLink>
<RouteLink
to="/statut"
className="rounded-xl border border-line bg-panel px-4 py-2.5 text-[13.5px] font-bold text-ink hover:bg-bg"
>
Statut en direct
</RouteLink>
</div>
</div>
</main>
<MarketingFooter />
</div>
);
}

View file

@ -0,0 +1,11 @@
export function MarketingFooter() {
return (
<footer className="border-t border-line px-6 py-8 text-xs text-ink-soft sm:px-10">
<div className="mx-auto flex max-w-[1160px] flex-wrap gap-5">
<span>Tripoli Cloud — 2026</span>
<span>Hébergé sur votre propre serveur</span>
<span>Sans publicité, sans traçage</span>
</div>
</footer>
);
}

View file

@ -0,0 +1,45 @@
import { TripoliLogo, TripoliWordmark } from '../layout/TripoliLogo';
import { RouteLink } from './RouteLink';
import { useRoute } from '../../hooks/useRoute';
const LINKS = [
{ to: '/', label: 'Accueil' },
{ to: '/architecture', label: 'Architecture' },
{ to: '/statut', label: 'Statut' },
];
export function MarketingHeader() {
const { path } = useRoute();
return (
<header className="sticky top-0 z-50 border-b border-line bg-bg/85 backdrop-blur-xl">
<div className="mx-auto flex max-w-[1160px] items-center gap-8 px-6 py-4 sm:px-10">
<RouteLink to="/" className="flex items-center gap-2.5 text-ink">
<TripoliLogo size={26} />
<TripoliWordmark />
</RouteLink>
<nav className="ml-2 hidden items-center gap-1 sm:flex">
{LINKS.map((link) => (
<RouteLink
key={link.to}
to={link.to}
className={`rounded-lg px-3 py-1.5 text-[13.5px] font-semibold transition-colors ${
path === link.to ? 'bg-field text-ink' : 'text-ink-soft hover:text-ink'
}`}
>
{link.label}
</RouteLink>
))}
</nav>
<RouteLink
to="/connexion"
className="ml-auto flex items-center gap-2 rounded-xl bg-ink px-4 py-2 text-[13px] font-bold text-bg transition-opacity hover:opacity-90"
>
Se connecter
</RouteLink>
</div>
</header>
);
}

View file

@ -0,0 +1,19 @@
import type { AnchorHTMLAttributes, MouseEvent } from 'react';
import { navigate } from '../../hooks/useRoute';
interface RouteLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
to: string;
}
/** Vrai `<a href>` (accessibilité, ouverture dans un nouvel onglet) dont
* le clic simple est intercepté pour naviguer côté client. */
export function RouteLink({ to, onClick, ...rest }: RouteLinkProps) {
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
e.preventDefault();
navigate(to);
onClick?.(e);
};
return <a href={to} onClick={handleClick} {...rest} />;
}

View file

@ -0,0 +1,190 @@
import { type ReactNode, useEffect, useState } from 'react';
import { Activity, CheckCircle2, Gauge, Radio, XCircle } from 'lucide-react';
import { MarketingHeader } from './MarketingHeader';
import { MarketingFooter } from './MarketingFooter';
interface EdgeMetrics {
requestsTotal: number;
responses2xx: number;
responses3xx: number;
responses4xx: number;
responses5xx: number;
inFlight: number;
avgLatencyMs: number;
}
function parseMetrics(text: string): EdgeMetrics {
const num = (pattern: RegExp): number => {
const match = text.match(pattern);
return match ? Number.parseFloat(match[1]!) : 0;
};
return {
requestsTotal: num(/tripoli_edge_requests_total (\d+)/),
responses2xx: num(/responses_total\{class="2xx"\} (\d+)/),
responses3xx: num(/responses_total\{class="3xx"\} (\d+)/),
responses4xx: num(/responses_total\{class="4xx"\} (\d+)/),
responses5xx: num(/responses_total\{class="5xx"\} (\d+)/),
inFlight: num(/in_flight_requests (-?\d+)/),
avgLatencyMs: num(/request_duration_ms_avg ([\d.]+)/),
};
}
const POLL_MS = 12_000;
export function StatusPage() {
const [healthy, setHealthy] = useState<boolean | null>(null);
const [metrics, setMetrics] = useState<EdgeMetrics | null>(null);
const [checkedAt, setCheckedAt] = useState<Date | null>(null);
useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
const [healthRes, metricsRes] = await Promise.all([fetch('/healthz'), fetch('/metrics')]);
const healthText = await healthRes.text();
const metricsText = await metricsRes.text();
if (cancelled) return;
setHealthy(healthRes.ok && healthText.trim() === 'ok');
setMetrics(parseMetrics(metricsText));
setCheckedAt(new Date());
} catch {
if (!cancelled) setHealthy(false);
}
};
void poll();
const id = window.setInterval(() => void poll(), POLL_MS);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, []);
const totalResponses = metrics
? metrics.responses2xx + metrics.responses3xx + metrics.responses4xx + metrics.responses5xx
: 0;
const bars = metrics
? [
{ label: '2xx', value: metrics.responses2xx, className: 'bg-success' },
{ label: '3xx', value: metrics.responses3xx, className: 'bg-brand' },
{ label: '4xx', value: metrics.responses4xx, className: 'bg-accent' },
{ label: '5xx', value: metrics.responses5xx, className: 'bg-danger' },
]
: [];
return (
<div className="min-h-screen bg-bg text-ink">
<MarketingHeader />
<main className="mx-auto max-w-[880px] px-6 py-16 sm:px-10 sm:py-24">
<span className="inline-flex items-center gap-2 rounded-full border border-line bg-field px-3.5 py-1.5 text-xs font-semibold tracking-wide text-ink-soft">
<Radio size={13} />
Télémétrie en direct
</span>
<h1 className="m-0 my-5 font-display text-[36px] font-semibold leading-[1.08] tracking-tight sm:text-[48px]">
Statut du service.
</h1>
<p className="m-0 max-w-[56ch] text-[15.5px] leading-relaxed text-ink-soft">
Ces chiffres sont les compteurs réels de{' '}
<code className="rounded bg-field px-1.5 py-0.5 text-[13px]">tripoli-edge</code>, le
reverse-proxy Rust qui reçoit chaque requête — lus en direct sur{' '}
<a href="/metrics" className="underline decoration-ink/30 underline-offset-2 hover:decoration-ink">
/metrics
</a>{' '}
et{' '}
<a href="/healthz" className="underline decoration-ink/30 underline-offset-2 hover:decoration-ink">
/healthz
</a>
. Rien n'est simulé ; un redémarrage du service remet ces compteurs à zéro.
</p>
<div className="mt-10 flex items-center gap-3 rounded-2xl border border-line bg-panel p-5 shadow-tc">
{healthy === null ? (
<Activity size={22} className="flex-none animate-pulse text-ink-soft" />
) : healthy ? (
<CheckCircle2 size={22} className="flex-none text-success" />
) : (
<XCircle size={22} className="flex-none text-danger" />
)}
<div>
<p className="m-0 text-[14.5px] font-bold text-ink">
{healthy === null ? 'Vérification…' : healthy ? 'Opérationnel' : 'Dégradé'}
</p>
<p className="m-0 text-[12.5px] text-ink-soft">
{checkedAt
? `Dernière vérification à ${checkedAt.toLocaleTimeString('fr-FR')}`
: 'Interrogation de tripoli-edge…'}
</p>
</div>
<span className="ml-auto h-2 w-2 flex-none animate-tc-pulse rounded-full bg-success" />
</div>
<div className="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3">
<StatBox
label="Requêtes totales"
value={metrics ? metrics.requestsTotal.toLocaleString('fr-FR') : '—'}
icon={<Gauge size={16} />}
/>
<StatBox
label="En cours"
value={metrics ? String(metrics.inFlight) : '—'}
icon={<Activity size={16} />}
/>
<StatBox
label="Latence moyenne"
value={metrics ? `${metrics.avgLatencyMs.toFixed(1)} ms` : '—'}
icon={<Radio size={16} />}
/>
</div>
<div className="mt-6 rounded-2xl border border-line bg-panel p-5 shadow-tc">
<p className="m-0 mb-3 text-[13px] font-semibold text-ink-soft">Répartition des réponses HTTP</p>
{totalResponses === 0 ? (
<p className="m-0 text-[13px] text-ink-soft">
Pas encore de trafic enregistré depuis le dernier redémarrage.
</p>
) : (
<>
<div className="flex h-2.5 overflow-hidden rounded-full bg-field">
{bars.map((bar) =>
bar.value > 0 ? (
<div
key={bar.label}
className={bar.className}
style={{ width: `${(bar.value / totalResponses) * 100}%` }}
/>
) : null,
)}
</div>
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-1.5 text-[12.5px] text-ink-soft">
{bars.map((bar) => (
<span key={bar.label} className="flex items-center gap-1.5">
<span className={`h-2 w-2 rounded-full ${bar.className}`} />
{bar.label} — {bar.value.toLocaleString('fr-FR')}
</span>
))}
</div>
</>
)}
</div>
</main>
<MarketingFooter />
</div>
);
}
function StatBox({ label, value, icon }: { label: string; value: string; icon: ReactNode }) {
return (
<div className="rounded-2xl border border-line bg-panel p-5 shadow-tc">
<div className="mb-2 flex items-center gap-2 text-ink-soft">
{icon}
<span className="text-[12px] font-semibold uppercase tracking-wide">{label}</span>
</div>
<p className="m-0 font-display text-[26px] font-semibold text-ink">{value}</p>
</div>
);
}