diff --git a/frontend/src/ThemeContext.tsx b/frontend/src/ThemeContext.tsx index e19e3c6..70c8d0e 100644 --- a/frontend/src/ThemeContext.tsx +++ b/frontend/src/ThemeContext.tsx @@ -1,4 +1,5 @@ import { createContext, useContext, useEffect, useState } from 'react'; +import { CONFIG } from './config'; export type Theme = 'dark' | 'light' | 'auto'; @@ -9,12 +10,12 @@ interface ThemeContextValue { } const ThemeContext = createContext({ - theme: 'dark', + theme: CONFIG.DEFAULT_THEME, resolved: 'dark', setTheme: () => {}, }); -const STORAGE_KEY = 'soc_theme'; +const STORAGE_KEY = CONFIG.THEME_STORAGE_KEY; function resolveTheme(theme: Theme): 'dark' | 'light' { if (theme === 'auto') { @@ -26,11 +27,11 @@ function resolveTheme(theme: Theme): 'dark' | 'light' { export function ThemeProvider({ children }: { children: React.ReactNode }) { const [theme, setThemeState] = useState(() => { const stored = localStorage.getItem(STORAGE_KEY) as Theme | null; - return stored ?? 'dark'; // SOC default: dark + return stored ?? CONFIG.DEFAULT_THEME; }); const [resolved, setResolved] = useState<'dark' | 'light'>(() => resolveTheme( - (localStorage.getItem(STORAGE_KEY) as Theme | null) ?? 'dark' + (localStorage.getItem(STORAGE_KEY) as Theme | null) ?? CONFIG.DEFAULT_THEME )); const applyTheme = (t: Theme) => { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 09d2187..7753b46 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,9 +1,8 @@ import axios from 'axios'; - -const API_BASE_URL = '/api'; +import { CONFIG } from '../config'; export const api = axios.create({ - baseURL: API_BASE_URL, + baseURL: CONFIG.API_BASE_URL, headers: { 'Content-Type': 'application/json', }, @@ -60,6 +59,9 @@ export interface Detection { client_headers: string; asn_score?: number | null; asn_rep_label?: string; + anubis_bot_name?: string; + anubis_bot_action?: string; + anubis_bot_category?: string; } export interface DetectionsListResponse { diff --git a/frontend/src/components/BotnetMapView.tsx b/frontend/src/components/BotnetMapView.tsx index 9db8366..fa96f32 100644 --- a/frontend/src/components/BotnetMapView.tsx +++ b/frontend/src/components/BotnetMapView.tsx @@ -30,7 +30,7 @@ type SortField = 'unique_ips' | 'unique_countries' | 'targeted_hosts'; // ─── Helpers ────────────────────────────────────────────────────────────────── function formatNumber(n: number): string { - return n.toLocaleString('fr-FR'); + return n.toLocaleString(navigator.language || undefined); } function getCountryFlag(code: string): string { diff --git a/frontend/src/components/BruteForceView.tsx b/frontend/src/components/BruteForceView.tsx index 661a761..b53c76e 100644 --- a/frontend/src/components/BruteForceView.tsx +++ b/frontend/src/components/BruteForceView.tsx @@ -34,7 +34,7 @@ type ActiveTab = 'targets' | 'attackers' | 'timeline'; // ─── Helpers ────────────────────────────────────────────────────────────────── function formatNumber(n: number): string { - return n.toLocaleString('fr-FR'); + return n.toLocaleString(navigator.language || undefined); } // ─── Sub-components ─────────────────────────────────────────────────────────── diff --git a/frontend/src/components/CampaignsView.tsx b/frontend/src/components/CampaignsView.tsx index dc412b9..5b9e81d 100644 --- a/frontend/src/components/CampaignsView.tsx +++ b/frontend/src/components/CampaignsView.tsx @@ -855,7 +855,7 @@ interface BotnetCountryEntry { } function formatNumber(n: number): string { - return n.toLocaleString('fr-FR'); + return n.toLocaleString(navigator.language || undefined); } function getCountryFlag(code: string): string { diff --git a/frontend/src/components/DetailsView.tsx b/frontend/src/components/DetailsView.tsx index 12a074d..f841fba 100644 --- a/frontend/src/components/DetailsView.tsx +++ b/frontend/src/components/DetailsView.tsx @@ -1,6 +1,7 @@ import { useParams, useNavigate, Link } from 'react-router-dom'; import { useVariability } from '../hooks/useVariability'; import { VariabilityPanel } from './VariabilityPanel'; +import { formatDateShort } from '../utils/dateUtils'; export function DetailsView() { const { type, value } = useParams<{ type: string; value: string }>(); @@ -48,10 +49,7 @@ export function DetailsView() { const last = data.date_range.last_seen ? new Date(data.date_range.last_seen) : null; const sameDate = first && last && first.getTime() === last.getTime(); - const fmtDate = (d: Date) => - d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }) + - ' ' + - d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); + const fmtDate = (d: Date) => formatDateShort(d.toISOString()); return (
diff --git a/frontend/src/components/DetectionsList.tsx b/frontend/src/components/DetectionsList.tsx index 1807fa8..d6962cb 100644 --- a/frontend/src/components/DetectionsList.tsx +++ b/frontend/src/components/DetectionsList.tsx @@ -2,6 +2,9 @@ import { useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useDetections } from '../hooks/useDetections'; import DataTable, { Column } from './ui/DataTable'; +import { InfoTip } from './ui/Tooltip'; +import { TIPS } from './ui/tooltips'; +import { formatDate, formatDateOnly, formatTimeOnly } from '../utils/dateUtils'; type SortField = 'detected_at' | 'threat_level' | 'anomaly_score' | 'src_ip' | 'country_code' | 'asn_number' | 'host' | 'hits' | 'hit_velocity'; type SortOrder = 'asc' | 'desc'; @@ -33,6 +36,9 @@ interface DetectionRow { unique_ja4s?: string[]; unique_hosts?: string[]; unique_client_headers?: string[]; + anubis_bot_name?: string; + anubis_bot_action?: string; + anubis_bot_category?: string; } export function DetectionsList() { @@ -65,6 +71,7 @@ export function DetectionsList() { { key: 'client_headers', label: 'Client Headers', visible: false, sortable: false }, { key: 'model_name', label: 'Modèle', visible: true, sortable: true }, { key: 'anomaly_score', label: 'Score', visible: true, sortable: true }, + { key: 'anubis', label: '🤖 Anubis', visible: true, sortable: false }, { key: 'hits', label: 'Hits', visible: true, sortable: true }, { key: 'hit_velocity', label: 'Velocity', visible: true, sortable: true }, { key: 'asn', label: 'ASN', visible: true, sortable: true }, @@ -236,6 +243,38 @@ export function DetectionsList() { sortable: true, render: (_, row) => , }; + case 'anubis': + return { + key: 'anubis_bot_name', + label: ( + + 🤖 Anubis + + + ), + sortable: false, + render: (_, row) => { + const name = row.anubis_bot_name; + const action = row.anubis_bot_action; + const category = row.anubis_bot_category; + if (!name) return ; + const actionColor = + action === 'ALLOW' ? 'bg-green-500/15 text-green-400 border-green-500/30' : + action === 'DENY' ? 'bg-red-500/15 text-red-400 border-red-500/30' : + 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30'; + return ( +
+
+ {name} +
+
+ {action && {action}} + {category && · {category}} +
+
+ ); + }, + }; case 'anomaly_score': return { key: 'anomaly_score', @@ -313,8 +352,7 @@ export function DetectionsList() { const first = new Date(row.first_seen!); const last = new Date(row.last_seen!); const sameTime = first.getTime() === last.getTime(); - const fmt = (d: Date) => - `${d.toLocaleDateString('fr-FR')} ${d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`; + const fmt = (d: Date) => formatDate(d.toISOString()); return sameTime ? (
{fmt(last)}
) : ( @@ -330,10 +368,10 @@ export function DetectionsList() { })() : ( <>
- {new Date(row.detected_at).toLocaleDateString('fr-FR')} + {formatDateOnly(row.detected_at)}
- {new Date(row.detected_at).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} + {formatTimeOnly(row.detected_at)}
), diff --git a/frontend/src/components/EntityInvestigationView.tsx b/frontend/src/components/EntityInvestigationView.tsx index 6a390f5..bef3c00 100644 --- a/frontend/src/components/EntityInvestigationView.tsx +++ b/frontend/src/components/EntityInvestigationView.tsx @@ -2,6 +2,7 @@ import { useParams, useNavigate } from 'react-router-dom'; import { useEffect, useState } from 'react'; import { InfoTip } from './ui/Tooltip'; import { TIPS } from './ui/tooltips'; +import { formatDateOnly } from '../utils/dateUtils'; interface EntityStats { entity_type: string; @@ -162,11 +163,11 @@ export function EntityInvestigationView() { />
diff --git a/frontend/src/components/FingerprintsView.tsx b/frontend/src/components/FingerprintsView.tsx index c3969c0..6a4823d 100644 --- a/frontend/src/components/FingerprintsView.tsx +++ b/frontend/src/components/FingerprintsView.tsx @@ -4,6 +4,7 @@ import DataTable, { Column } from './ui/DataTable'; import ThreatBadge from './ui/ThreatBadge'; import { InfoTip } from './ui/Tooltip'; import { TIPS } from './ui/tooltips'; +import { formatNumber as fmtNum, formatDateShort } from '../utils/dateUtils'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -129,7 +130,7 @@ function getCountryFlag(code: string): string { } function formatNumber(n: number): string { - return n.toLocaleString('fr-FR'); + return fmtNum(n); } function botUaPercentage(userAgents: AttributeValue[]): number { @@ -1463,7 +1464,7 @@ type RotationSubTab = 'rotators' | 'persistent' | 'sophistication' | 'hunt'; function formatDate(iso: string): string { if (!iso) return '—'; try { - return new Date(iso).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); + return formatDateShort(iso); } catch { return iso; } diff --git a/frontend/src/components/HeaderFingerprintView.tsx b/frontend/src/components/HeaderFingerprintView.tsx index 3c15ca3..211b7da 100644 --- a/frontend/src/components/HeaderFingerprintView.tsx +++ b/frontend/src/components/HeaderFingerprintView.tsx @@ -28,7 +28,7 @@ interface ClusterIP { // ─── Helpers ────────────────────────────────────────────────────────────────── function formatNumber(n: number): string { - return n.toLocaleString('fr-FR'); + return n.toLocaleString(navigator.language || undefined); } function mismatchColor(pct: number): string { diff --git a/frontend/src/components/HeatmapView.tsx b/frontend/src/components/HeatmapView.tsx index db47057..8f08bd4 100644 --- a/frontend/src/components/HeatmapView.tsx +++ b/frontend/src/components/HeatmapView.tsx @@ -25,7 +25,7 @@ interface HeatmapMatrix { // ─── Helpers ────────────────────────────────────────────────────────────────── function formatNumber(n: number): string { - return n.toLocaleString('fr-FR'); + return n.toLocaleString(navigator.language || undefined); } function heatmapCellStyle(value: number, maxValue: number): React.CSSProperties { diff --git a/frontend/src/components/IncidentsView.tsx b/frontend/src/components/IncidentsView.tsx index 19409cc..00a445a 100644 --- a/frontend/src/components/IncidentsView.tsx +++ b/frontend/src/components/IncidentsView.tsx @@ -161,8 +161,8 @@ export function IncidentsView() { {icon}
{label}
-
{m.today.toLocaleString('fr-FR')}
-
hier: {m.yesterday.toLocaleString('fr-FR')}
+
{m.today.toLocaleString(navigator.language || undefined)}
+
hier: {m.yesterday.toLocaleString(navigator.language || undefined)}
Brute Force
-
{data.bruteforce.total_hits.toLocaleString('fr-FR')} hits
+
{data.bruteforce.total_hits.toLocaleString(navigator.language || undefined)} hits
{data.bruteforce.top_hosts[0] ?? '—'}
diff --git a/frontend/src/components/JA4InvestigationView.tsx b/frontend/src/components/JA4InvestigationView.tsx index aa78b71..0e10316 100644 --- a/frontend/src/components/JA4InvestigationView.tsx +++ b/frontend/src/components/JA4InvestigationView.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { JA4CorrelationSummary } from './analysis/JA4CorrelationSummary'; import { InfoTip } from './ui/Tooltip'; import { TIPS } from './ui/tooltips'; +import { formatDateShort } from '../utils/dateUtils'; interface JA4InvestigationData { ja4: string; @@ -331,11 +332,5 @@ function StatBox({ label, value, tip }: { label: string; value: string; tip?: st function formatDate(dateStr: string): string { if (!dateStr) return '-'; - const date = new Date(dateStr); - return date.toLocaleDateString('fr-FR', { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit' - }); + return formatDateShort(dateStr); } diff --git a/frontend/src/components/MLFeaturesView.tsx b/frontend/src/components/MLFeaturesView.tsx index da38495..c1b11ee 100644 --- a/frontend/src/components/MLFeaturesView.tsx +++ b/frontend/src/components/MLFeaturesView.tsx @@ -48,7 +48,7 @@ interface ScatterPoint { // ─── Helpers ────────────────────────────────────────────────────────────────── function formatNumber(n: number): string { - return n.toLocaleString('fr-FR'); + return n.toLocaleString(navigator.language || undefined); } function attackTypeEmoji(type: string): string { diff --git a/frontend/src/components/ReputationPanel.tsx b/frontend/src/components/ReputationPanel.tsx index 4374a25..ae6c35d 100644 --- a/frontend/src/components/ReputationPanel.tsx +++ b/frontend/src/components/ReputationPanel.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { formatDate } from '../utils/dateUtils'; interface ReputationData { ip: string; @@ -181,7 +182,7 @@ export function ReputationPanel({ ip }: ReputationPanelProps) { {/* Sources */}
- Sources: {Object.keys(reputation.sources).join(', ')} • {new Date(reputation.timestamp).toLocaleString('fr-FR')} + Sources: {Object.keys(reputation.sources).join(', ')} • {formatDate(reputation.timestamp)}
); diff --git a/frontend/src/components/RotationView.tsx b/frontend/src/components/RotationView.tsx index 5a789cb..808963e 100644 --- a/frontend/src/components/RotationView.tsx +++ b/frontend/src/components/RotationView.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; +import { formatDateShort } from '../utils/dateUtils'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -51,13 +52,13 @@ type ActiveTab = 'rotators' | 'persistent' | 'sophistication' | 'hunt'; // ─── Helpers ────────────────────────────────────────────────────────────────── function formatNumber(n: number): string { - return n.toLocaleString('fr-FR'); + return n.toLocaleString(navigator.language || undefined); } function formatDate(iso: string): string { if (!iso) return '—'; try { - return new Date(iso).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); + return formatDateShort(iso); } catch { return iso; } diff --git a/frontend/src/components/SubnetInvestigation.tsx b/frontend/src/components/SubnetInvestigation.tsx index 8649085..ba79601 100644 --- a/frontend/src/components/SubnetInvestigation.tsx +++ b/frontend/src/components/SubnetInvestigation.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { InfoTip } from './ui/Tooltip'; import { TIPS } from './ui/tooltips'; +import { formatDateShort } from '../utils/dateUtils'; interface SubnetIP { ip: string; @@ -77,13 +78,7 @@ export function SubnetInvestigation() { const formatDate = (dateString: string) => { if (!dateString) return '-'; - const date = new Date(dateString); - return date.toLocaleDateString('fr-FR', { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit' - }); + return formatDateShort(dateString); }; if (loading) { diff --git a/frontend/src/components/TcpSpoofingView.tsx b/frontend/src/components/TcpSpoofingView.tsx index 968350b..310ad45 100644 --- a/frontend/src/components/TcpSpoofingView.tsx +++ b/frontend/src/components/TcpSpoofingView.tsx @@ -52,7 +52,7 @@ type ActiveTab = 'detections' | 'matrix'; // ─── Helpers ────────────────────────────────────────────────────────────────── function formatNumber(n: number): string { - return n.toLocaleString('fr-FR'); + return n.toLocaleString(navigator.language || undefined); } function confidenceBar(conf: number): JSX.Element { diff --git a/frontend/src/components/ThreatIntelView.tsx b/frontend/src/components/ThreatIntelView.tsx index b5e16e4..1c5df9c 100644 --- a/frontend/src/components/ThreatIntelView.tsx +++ b/frontend/src/components/ThreatIntelView.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { InfoTip } from './ui/Tooltip'; import { TIPS } from './ui/tooltips'; +import { formatDateShort } from '../utils/dateUtils'; interface Classification { ip?: string; @@ -239,9 +240,7 @@ export function ThreatIntelView() { {filteredClassifications.slice(0, 50).map((classification, idx) => ( - {new Date(classification.created_at).toLocaleDateString('fr-FR', { - day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' - })} + {formatDateShort(classification.created_at)}
diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx index 4798579..cf40893 100644 --- a/frontend/src/components/ui/DataTable.tsx +++ b/frontend/src/components/ui/DataTable.tsx @@ -4,7 +4,7 @@ import { InfoTip } from './Tooltip'; export interface Column { key: string; - label: string; + label: React.ReactNode; tooltip?: string; sortable?: boolean; align?: 'left' | 'right' | 'center'; diff --git a/frontend/src/components/ui/tooltips.ts b/frontend/src/components/ui/tooltips.ts index afb6fb5..bc4f463 100644 --- a/frontend/src/components/ui/tooltips.ts +++ b/frontend/src/components/ui/tooltips.ts @@ -398,4 +398,18 @@ export const TIPS = { 'Nœud de corrélation : entité reliée à l\'IP analysée.\n' + 'Les connexions (arêtes) représentent des relations directes\n' + '(même subnet, même ASN, même JA4, même host cible).', + + anubis_identification: + 'Identification des bots par les règles Anubis\n' + + '(github.com/TecharoHQ/anubis) :\n\n' + + '• User-Agent : correspondance par expression régulière\n' + + ' (ex. Googlebot, GPTBot, AhrefsBot…)\n' + + '• IP / CIDR : plages d\'adresses connues des crawlers\n' + + '• ASN : numéro de système autonome (ex. AS15169 = Google)\n' + + '• Pays : code ISO du pays source\n\n' + + 'La règle la plus spécifique prend la priorité.\n\n' + + 'Actions :\n' + + ' ALLOW → bot légitime, exclu de l\'analyse ML\n' + + ' DENY → menace connue, flaggée directement\n' + + ' WEIGH → suspect, scoré par l\'IsolationForest', }; diff --git a/frontend/src/config.ts b/frontend/src/config.ts new file mode 100644 index 0000000..f30e551 --- /dev/null +++ b/frontend/src/config.ts @@ -0,0 +1,56 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Configuration centrale du dashboard JA4 SOC +// Toutes les valeurs modifiables sont regroupées ici. +// ───────────────────────────────────────────────────────────────────────────── + +export const CONFIG = { + // ── API ────────────────────────────────────────────────────────────────── + /** URL de base de l'API backend (relative, proxifiée par Vite en dev) */ + API_BASE_URL: '/api' as const, + + // ── Thème ───────────────────────────────────────────────────────────────── + /** Thème appliqué au premier chargement si aucune préférence n'est sauvegardée. + * 'auto' = suit prefers-color-scheme du navigateur */ + DEFAULT_THEME: 'auto' as 'dark' | 'light' | 'auto', + + /** Clé localStorage pour la préférence de thème */ + THEME_STORAGE_KEY: 'soc_theme', + + // ── Pagination ──────────────────────────────────────────────────────────── + /** Taille de page par défaut pour les listes */ + DEFAULT_PAGE_SIZE: 50, + + /** Tailles de page disponibles */ + PAGE_SIZES: [25, 50, 100, 250] as const, + + // ── Rafraîchissement ────────────────────────────────────────────────────── + /** Intervalle de rafraîchissement automatique des métriques (ms). 0 = désactivé */ + METRICS_REFRESH_MS: 0, + + // ── Détections ──────────────────────────────────────────────────────────── + /** Score d'anomalie en-dessous duquel une IP est considérée critique */ + CRITICAL_THRESHOLD: -0.5, + + /** Score d'anomalie en-dessous duquel une IP est considérée HIGH */ + HIGH_THRESHOLD: -0.3, + + // ── Anubis ──────────────────────────────────────────────────────────────── + /** + * Les bots sont identifiés par les règles Anubis (https://github.com/TecharoHQ/anubis). + * Chaque règle peut correspondre sur : + * - User-Agent (expression régulière) + * - Adresse IP ou plage CIDR (IP_TRIE ClickHouse) + * - Numéro ASN (Autonomous System Number) + * - Code pays + * La règle la plus spécifique (ID le plus bas dans le REGEXP_TREE) est appliquée en premier. + * Actions possibles : + * ALLOW → bot légitime identifié (Googlebot, Bingbot…) — exclu de l'analyse IF + * DENY → menace connue — flaggée directement, bypass IsolationForest + * WEIGH → trafic suspect — scoré par l'IsolationForest avec signal anubis_is_flagged=1 + */ + ANUBIS_RULES_URL: 'https://github.com/TecharoHQ/anubis/tree/main/data', + + // ── Application ─────────────────────────────────────────────────────────── + APP_NAME: 'JA4 SOC Dashboard', + APP_VERSION: '12', +} as const; diff --git a/frontend/src/utils/dateUtils.ts b/frontend/src/utils/dateUtils.ts new file mode 100644 index 0000000..c9037e9 --- /dev/null +++ b/frontend/src/utils/dateUtils.ts @@ -0,0 +1,86 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Utilitaires de formatage des dates et des nombres +// +// Les dates sont stockées en UTC dans ClickHouse (sans suffixe TZ). +// Ces fonctions les convertissent dans le fuseau horaire local du navigateur +// et utilisent la locale du navigateur pour l'affichage. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Normalise une chaîne datetime ClickHouse (sans TZ) en Date UTC. + * ClickHouse retourne "2024-01-15 14:32:00" → on force Z pour UTC. + */ +function parseUTC(iso: string): Date { + if (!iso) return new Date(NaN); + // Déjà un ISO complet avec TZ → pas de modification + if (iso.endsWith('Z') || iso.includes('+')) return new Date(iso); + // "2024-01-15 14:32:00" ou "2024-01-15T14:32:00" → forcer UTC + const normalized = iso.replace(' ', 'T'); + return new Date(normalized + 'Z'); +} + +/** + * Formate une date/heure complète dans la locale et le fuseau du navigateur. + * Exemple : "15/01/2024, 15:32" (fr) ou "1/15/2024, 3:32 PM" (en-US) + */ +export function formatDate(iso: string): string { + const d = parseUTC(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleString(navigator.language || undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); +} + +/** + * Formate une date courte (jour/mois heure:min) pour les tableaux. + * Exemple : "15/01 15:32" + */ +export function formatDateShort(iso: string): string { + const d = parseUTC(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleString(navigator.language || undefined, { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); +} + +/** + * Formate uniquement la partie date. + * Exemple : "15/01/2024" + */ +export function formatDateOnly(iso: string): string { + const d = parseUTC(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleDateString(navigator.language || undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); +} + +/** + * Formate uniquement l'heure (heure:min). + * Exemple : "15:32" + */ +export function formatTimeOnly(iso: string): string { + const d = parseUTC(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleTimeString(navigator.language || undefined, { + hour: '2-digit', + minute: '2-digit', + }); +} + +/** + * Formate un nombre entier avec séparateurs de milliers selon la locale du navigateur. + * Exemple : 1234567 → "1 234 567" (fr) ou "1,234,567" (en-US) + */ +export function formatNumber(n: number): string { + return n.toLocaleString(navigator.language || undefined); +}