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

@ -4,8 +4,12 @@ import { BackupsView } from './components/backups/BackupsView';
import { DashboardView } from './components/dashboard/DashboardView'; import { DashboardView } from './components/dashboard/DashboardView';
import { PhotosView } from './components/photos/PhotosView'; import { PhotosView } from './components/photos/PhotosView';
import { SettingsView } from './components/settings/SettingsView'; import { SettingsView } from './components/settings/SettingsView';
import { LandingPage } from './components/marketing/LandingPage';
import { ArchitecturePage } from './components/marketing/ArchitecturePage';
import { StatusPage } from './components/marketing/StatusPage';
import { NavigationProvider, useNavigation } from './contexts/NavigationContext'; import { NavigationProvider, useNavigation } from './contexts/NavigationContext';
import { SessionProvider, useSession } from './contexts/SessionContext'; import { SessionProvider, useSession } from './contexts/SessionContext';
import { useRoute } from './hooks/useRoute';
/** Content router — a plain state switch (NavigationContext.nav), no react-router. */ /** Content router — a plain state switch (NavigationContext.nav), no react-router. */
function AppContent() { function AppContent() {
@ -23,19 +27,34 @@ function AppContent() {
} }
} }
/** Site public (sans session) : vitrine, architecture, statut, connexion —
* routé par chemin (voir useRoute), Caddy sert index.html sur tout chemin
* inconnu (try_files) donc un rechargement direct fonctionne aussi. */
function PublicSite() {
const { path } = useRoute();
if (path === '/architecture') return <ArchitecturePage />;
if (path === '/statut' || path === '/status') return <StatusPage />;
if (path === '/connexion' || path === '/auth') return <AuthLayout />;
return <LandingPage />;
}
function Root() { function Root() {
const { view, restoring } = useSession(); const { view, restoring } = useSession();
if (restoring) return null; if (restoring) return null;
if (view === 'auth') return <AuthLayout />;
return ( if (view === 'app') {
<NavigationProvider> return (
<AppShell> <NavigationProvider>
<AppContent /> <AppShell>
</AppShell> <AppContent />
</NavigationProvider> </AppShell>
); </NavigationProvider>
);
}
return <PublicSite />;
} }
export default function App() { export default function App() {

18
app/src/api/search.ts Normal file
View file

@ -0,0 +1,18 @@
import { apiFetch } from './client';
export interface SearchResult {
id: string;
filename: string;
content_type: string;
category: string;
size_bytes: number;
created_at: string;
/** Extrait du contenu autour des termes trouvés, ou `null` quand seul le nom a matché. */
snippet: string | null;
}
export async function searchFiles(query: string): Promise<SearchResult[]> {
const q = query.trim();
if (!q) return [];
return apiFetch<SearchResult[]>(`/api/search?q=${encodeURIComponent(q)}`);
}

View file

@ -1,5 +1,6 @@
import { AuthBackground } from './AuthBackground'; import { AuthBackground } from './AuthBackground';
import { AuthPanel } from './AuthPanel'; import { AuthPanel } from './AuthPanel';
import { RouteLink } from '../marketing/RouteLink';
import { TripoliLogo, TripoliWordmark } from '../layout/TripoliLogo'; import { TripoliLogo, TripoliWordmark } from '../layout/TripoliLogo';
export function AuthLayout() { export function AuthLayout() {
@ -8,10 +9,10 @@ export function AuthLayout() {
<AuthBackground /> <AuthBackground />
<div className="relative flex min-h-screen flex-col justify-between gap-11 p-6 sm:p-10 lg:p-[60px]"> <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" /> <TripoliLogo size={30} variant="hero" />
<TripoliWordmark className="text-base" /> <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="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"> <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 { useNavigation } from '../../contexts/NavigationContext';
import { useIsMobile } from '../../hooks/useIsMobile'; import { useIsMobile } from '../../hooks/useIsMobile';
import { NotificationCenter } from '../notifications/NotificationCenter'; 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() { export function TopBar() {
const { openDrawer } = useNavigation(); const { openDrawer } = useNavigation();
const isMobile = useIsMobile(); 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 ( 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"> <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 && ( {isMobile && (
@ -20,13 +65,55 @@ export function TopBar() {
</button> </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"> <div ref={boxRef} className="relative max-w-[440px] flex-1">
<Search size={16} className="flex-none opacity-50" strokeWidth={1.8} /> <div className="flex items-center gap-2.5 rounded-xl border border-line bg-field px-3.5 py-2.5">
<input {loading ? (
aria-label="Rechercher un fichier" <Loader2 size={16} className="flex-none animate-spin opacity-50" strokeWidth={1.8} />
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" <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>
<div className="ml-auto flex items-center gap-2"> <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>
);
}

27
app/src/hooks/useRoute.ts Normal file
View file

@ -0,0 +1,27 @@
import { useEffect, useState } from 'react';
/**
* Routing minimal par History API — pas de react-router pour trois
* pages publiques. `navigate` et `useRoute` communiquent par un simple
* événement `popstate` synthétique, sans Context : n'importe quel
* composant peut naviguer sans passer par un Provider dédié.
*/
export function navigate(path: string): void {
if (window.location.pathname !== path) {
window.history.pushState({}, '', path);
}
window.dispatchEvent(new PopStateEvent('popstate'));
window.scrollTo(0, 0);
}
export function useRoute(): { path: string } {
const [path, setPath] = useState(() => window.location.pathname);
useEffect(() => {
const onChange = () => setPath(window.location.pathname);
window.addEventListener('popstate', onChange);
return () => window.removeEventListener('popstate', onChange);
}, []);
return { path };
}

View file

@ -0,0 +1,13 @@
-- Recherche plein texte façon Spotlight : sur le nom du fichier *et* sur
-- son contenu extrait à l'upload (voir files::upload). Colonne générée
-- STORED plutôt que calculée à la volée dans chaque requête : l'index
-- GIN a besoin d'une colonne matérialisée pour être utilisable.
ALTER TABLE files ADD COLUMN content_text TEXT;
ALTER TABLE files ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('french', coalesce(filename, '')), 'A') ||
setweight(to_tsvector('french', coalesce(content_text, '')), 'B')
) STORED;
CREATE INDEX files_search_vector_idx ON files USING GIN (search_vector);

View file

@ -17,6 +17,58 @@ use crate::state::SharedState;
const CATEGORIES: [&str; 3] = ["file", "photo", "backup"]; const CATEGORIES: [&str; 3] = ["file", "photo", "backup"];
/// Extensions traitées comme du texte indexable quand le type MIME seul
/// ne suffit pas à trancher (navigateurs envoyant souvent
/// "application/octet-stream" pour du code ou du markdown).
const TEXT_EXTENSIONS: [&str; 19] = [
"txt", "md", "markdown", "csv", "json", "yaml", "yml", "toml", "log", "rs", "ts", "tsx", "js",
"jsx", "py", "html", "css", "sql", "sh",
];
/// Bornes volontairement basses : ce serveur partage 2 vCPU / 3,7 Go de
/// RAM avec six autres projets. Indexer un fichier de plusieurs centaines
/// de Mo en mémoire pour en tirer du texte serait un coût disproportionné
/// pour une recherche "façon Spotlight" sur un coffre personnel.
const MAX_INDEXABLE_BYTES: i64 = 5 * 1024 * 1024;
const MAX_INDEXED_CHARS: usize = 200_000;
fn looks_like_text(content_type: &str, filename: &str) -> bool {
if content_type.starts_with("text/") || content_type == "application/json" {
return true;
}
filename
.rsplit('.')
.next()
.map(|ext| TEXT_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
.unwrap_or(false)
}
/// Extraction de texte volontairement minimale : formats texte bruts
/// uniquement (pas de PDF/DOCX — demanderait une dépendance de parsing
/// binaire absente de ce projet). Un fichier binaire reste cherchable
/// par son nom seul, jamais indexé à moitié ou de façon trompeuse.
async fn extract_indexable_text(
path: &std::path::Path,
content_type: &str,
filename: &str,
size_bytes: i64,
) -> Option<String> {
if !looks_like_text(content_type, filename) {
return None;
}
if size_bytes <= 0 || size_bytes > MAX_INDEXABLE_BYTES {
return None;
}
let bytes = tokio::fs::read(path).await.ok()?;
let text = String::from_utf8_lossy(&bytes).into_owned();
let truncated: String = text.chars().take(MAX_INDEXED_CHARS).collect();
if truncated.trim().is_empty() {
None
} else {
Some(truncated)
}
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct FileView { pub struct FileView {
pub id: Uuid, pub id: Uuid,
@ -133,9 +185,11 @@ pub async fn upload(
out.flush().await?; out.flush().await?;
tokio::fs::rename(&tmp_path, &final_path).await?; tokio::fs::rename(&tmp_path, &final_path).await?;
let content_text = extract_indexable_text(&final_path, &content_type, &filename, size_bytes).await;
let created_at: chrono::DateTime<chrono::Utc> = sqlx::query( let created_at: chrono::DateTime<chrono::Utc> = sqlx::query(
"INSERT INTO files (id, owner_id, filename, content_type, category, size_bytes) \ "INSERT INTO files (id, owner_id, filename, content_type, category, size_bytes, content_text) \
VALUES ($1, $2, $3, $4, $5, $6) RETURNING created_at", VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING created_at",
) )
.bind(file_id) .bind(file_id)
.bind(user.id) .bind(user.id)
@ -143,6 +197,7 @@ pub async fn upload(
.bind(&content_type) .bind(&content_type)
.bind(&category) .bind(&category)
.bind(size_bytes) .bind(size_bytes)
.bind(&content_text)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await? .await?
.get("created_at"); .get("created_at");

View file

@ -3,6 +3,7 @@ mod config;
mod error; mod error;
mod files; mod files;
mod notifications; mod notifications;
mod search;
mod state; mod state;
mod storage; mod storage;
@ -65,6 +66,7 @@ async fn main() {
post(notifications::mark_read), post(notifications::mark_read),
) )
.route("/api/storage", get(storage::get)) .route("/api/storage", get(storage::get))
.route("/api/search", get(search::search))
.with_state(state); .with_state(state);
tracing::info!(listen_addr = %listen_addr, "tripoli-api démarré"); tracing::info!(listen_addr = %listen_addr, "tripoli-api démarré");

83
tripoli-api/src/search.rs Normal file
View file

@ -0,0 +1,83 @@
use axum::extract::{Query, State};
use axum::Json;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::error::AppError;
use crate::state::SharedState;
#[derive(Deserialize)]
pub struct SearchQuery {
q: String,
}
#[derive(Serialize)]
pub struct SearchResult {
pub id: Uuid,
pub filename: String,
pub content_type: String,
pub category: String,
pub size_bytes: i64,
pub created_at: chrono::DateTime<chrono::Utc>,
/// Extrait du contenu autour des termes trouvés (`ts_headline`).
/// `None` quand le résultat provient uniquement du nom de fichier
/// (pas de texte indexé, ex. une image ou un binaire).
pub snippet: Option<String>,
}
/// Recherche façon Spotlight : porte sur le nom de fichier *et* le
/// contenu textuel extrait à l'upload (`files.content_text`), pas
/// seulement le nom. `websearch_to_tsquery` accepte une saisie libre
/// (mots, guillemets) sans jamais lever d'erreur de syntaxe, contrairement
/// à `to_tsquery` — c'est ce qu'attend une barre de recherche.
pub async fn search(
State(state): State<SharedState>,
user: AuthUser,
Query(query): Query<SearchQuery>,
) -> Result<Json<Vec<SearchResult>>, AppError> {
let q = query.q.trim();
if q.is_empty() {
return Ok(Json(Vec::new()));
}
let rows = sqlx::query_as::<
_,
(
Uuid,
String,
String,
String,
i64,
chrono::DateTime<chrono::Utc>,
Option<String>,
),
>(
"SELECT id, filename, content_type, category, size_bytes, created_at, \
NULLIF(ts_headline('french', coalesce(content_text, ''), websearch_to_tsquery('french', $2), \
'MaxFragments=1, MaxWords=28, MinWords=10, ShortWord=3'), '') AS snippet \
FROM files \
WHERE owner_id = $1 AND search_vector @@ websearch_to_tsquery('french', $2) \
ORDER BY ts_rank(search_vector, websearch_to_tsquery('french', $2)) DESC \
LIMIT 20",
)
.bind(user.id)
.bind(q)
.fetch_all(&state.pool)
.await?
.into_iter()
.map(
|(id, filename, content_type, category, size_bytes, created_at, snippet)| SearchResult {
id,
filename,
content_type,
category,
size_bytes,
created_at,
snippet,
},
)
.collect();
Ok(Json(rows))
}