feat(dashboard): thème auto, config centralisée, dates UTC→TZ navigateur, tooltip Anubis

- ThemeContext: thème par défaut 'auto' (suit prefers-color-scheme du navigateur)
- config.ts: fichier de configuration centrale (API_BASE_URL, DEFAULT_THEME,
  PAGE_SIZES, seuils, description du mécanisme d'identification Anubis)
- dateUtils.ts: utilitaire partagé formatDate/formatDateShort/formatDateOnly/
  formatTimeOnly/formatNumber — convertit les dates UTC ClickHouse dans le
  fuseau horaire et la locale du navigateur (plus de 'fr-FR' hardcodé)
- tooltips.ts: ajout TIPS.anubis_identification — explique que les bots sont
  identifiés par UA (regex), IP/CIDR, ASN, pays via les règles Anubis
- DetectionsList: colonne Anubis avec icône ⓘ affichant le tooltip explicatif
- DataTable: Column.label étendu à React.ReactNode (pour JSX dans les headers)
- 24 composants mis à jour: fr-FR remplacé par locale navigateur partout

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
SOC Analyst
2026-03-19 18:01:11 +01:00
parent 2f73860cc8
commit 9ee3d01059
24 changed files with 238 additions and 50 deletions

View File

@ -1,4 +1,5 @@
import { createContext, useContext, useEffect, useState } from 'react'; import { createContext, useContext, useEffect, useState } from 'react';
import { CONFIG } from './config';
export type Theme = 'dark' | 'light' | 'auto'; export type Theme = 'dark' | 'light' | 'auto';
@ -9,12 +10,12 @@ interface ThemeContextValue {
} }
const ThemeContext = createContext<ThemeContextValue>({ const ThemeContext = createContext<ThemeContextValue>({
theme: 'dark', theme: CONFIG.DEFAULT_THEME,
resolved: 'dark', resolved: 'dark',
setTheme: () => {}, setTheme: () => {},
}); });
const STORAGE_KEY = 'soc_theme'; const STORAGE_KEY = CONFIG.THEME_STORAGE_KEY;
function resolveTheme(theme: Theme): 'dark' | 'light' { function resolveTheme(theme: Theme): 'dark' | 'light' {
if (theme === 'auto') { if (theme === 'auto') {
@ -26,11 +27,11 @@ function resolveTheme(theme: Theme): 'dark' | 'light' {
export function ThemeProvider({ children }: { children: React.ReactNode }) { export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => { const [theme, setThemeState] = useState<Theme>(() => {
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null; 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( 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) => { const applyTheme = (t: Theme) => {

View File

@ -1,9 +1,8 @@
import axios from 'axios'; import axios from 'axios';
import { CONFIG } from '../config';
const API_BASE_URL = '/api';
export const api = axios.create({ export const api = axios.create({
baseURL: API_BASE_URL, baseURL: CONFIG.API_BASE_URL,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@ -60,6 +59,9 @@ export interface Detection {
client_headers: string; client_headers: string;
asn_score?: number | null; asn_score?: number | null;
asn_rep_label?: string; asn_rep_label?: string;
anubis_bot_name?: string;
anubis_bot_action?: string;
anubis_bot_category?: string;
} }
export interface DetectionsListResponse { export interface DetectionsListResponse {

View File

@ -30,7 +30,7 @@ type SortField = 'unique_ips' | 'unique_countries' | 'targeted_hosts';
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function formatNumber(n: number): string { function formatNumber(n: number): string {
return n.toLocaleString('fr-FR'); return n.toLocaleString(navigator.language || undefined);
} }
function getCountryFlag(code: string): string { function getCountryFlag(code: string): string {

View File

@ -34,7 +34,7 @@ type ActiveTab = 'targets' | 'attackers' | 'timeline';
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function formatNumber(n: number): string { function formatNumber(n: number): string {
return n.toLocaleString('fr-FR'); return n.toLocaleString(navigator.language || undefined);
} }
// ─── Sub-components ─────────────────────────────────────────────────────────── // ─── Sub-components ───────────────────────────────────────────────────────────

View File

@ -855,7 +855,7 @@ interface BotnetCountryEntry {
} }
function formatNumber(n: number): string { function formatNumber(n: number): string {
return n.toLocaleString('fr-FR'); return n.toLocaleString(navigator.language || undefined);
} }
function getCountryFlag(code: string): string { function getCountryFlag(code: string): string {

View File

@ -1,6 +1,7 @@
import { useParams, useNavigate, Link } from 'react-router-dom'; import { useParams, useNavigate, Link } from 'react-router-dom';
import { useVariability } from '../hooks/useVariability'; import { useVariability } from '../hooks/useVariability';
import { VariabilityPanel } from './VariabilityPanel'; import { VariabilityPanel } from './VariabilityPanel';
import { formatDateShort } from '../utils/dateUtils';
export function DetailsView() { export function DetailsView() {
const { type, value } = useParams<{ type: string; value: string }>(); 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 last = data.date_range.last_seen ? new Date(data.date_range.last_seen) : null;
const sameDate = first && last && first.getTime() === last.getTime(); const sameDate = first && last && first.getTime() === last.getTime();
const fmtDate = (d: Date) => const fmtDate = (d: Date) => formatDateShort(d.toISOString());
d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }) +
' ' +
d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
return ( return (
<div className="space-y-5 animate-fade-in"> <div className="space-y-5 animate-fade-in">

View File

@ -2,6 +2,9 @@ import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useDetections } from '../hooks/useDetections'; import { useDetections } from '../hooks/useDetections';
import DataTable, { Column } from './ui/DataTable'; 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 SortField = 'detected_at' | 'threat_level' | 'anomaly_score' | 'src_ip' | 'country_code' | 'asn_number' | 'host' | 'hits' | 'hit_velocity';
type SortOrder = 'asc' | 'desc'; type SortOrder = 'asc' | 'desc';
@ -33,6 +36,9 @@ interface DetectionRow {
unique_ja4s?: string[]; unique_ja4s?: string[];
unique_hosts?: string[]; unique_hosts?: string[];
unique_client_headers?: string[]; unique_client_headers?: string[];
anubis_bot_name?: string;
anubis_bot_action?: string;
anubis_bot_category?: string;
} }
export function DetectionsList() { export function DetectionsList() {
@ -65,6 +71,7 @@ export function DetectionsList() {
{ key: 'client_headers', label: 'Client Headers', visible: false, sortable: false }, { key: 'client_headers', label: 'Client Headers', visible: false, sortable: false },
{ key: 'model_name', label: 'Modèle', visible: true, sortable: true }, { key: 'model_name', label: 'Modèle', visible: true, sortable: true },
{ key: 'anomaly_score', label: 'Score', 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: 'hits', label: 'Hits', visible: true, sortable: true },
{ key: 'hit_velocity', label: 'Velocity', visible: true, sortable: true }, { key: 'hit_velocity', label: 'Velocity', visible: true, sortable: true },
{ key: 'asn', label: 'ASN', visible: true, sortable: true }, { key: 'asn', label: 'ASN', visible: true, sortable: true },
@ -236,6 +243,38 @@ export function DetectionsList() {
sortable: true, sortable: true,
render: (_, row) => <ModelBadge model={row.model_name} />, render: (_, row) => <ModelBadge model={row.model_name} />,
}; };
case 'anubis':
return {
key: 'anubis_bot_name',
label: (
<span className="inline-flex items-center gap-1">
🤖 Anubis
<InfoTip content={TIPS.anubis_identification} />
</span>
),
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 <span className="text-text-disabled text-xs"></span>;
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 (
<div className="space-y-0.5">
<div className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border ${actionColor}`}>
<span className="font-medium">{name}</span>
</div>
<div className="flex gap-1 flex-wrap">
{action && <span className="text-[10px] text-text-secondary">{action}</span>}
{category && <span className="text-[10px] text-text-disabled">· {category}</span>}
</div>
</div>
);
},
};
case 'anomaly_score': case 'anomaly_score':
return { return {
key: 'anomaly_score', key: 'anomaly_score',
@ -313,8 +352,7 @@ export function DetectionsList() {
const first = new Date(row.first_seen!); const first = new Date(row.first_seen!);
const last = new Date(row.last_seen!); const last = new Date(row.last_seen!);
const sameTime = first.getTime() === last.getTime(); const sameTime = first.getTime() === last.getTime();
const fmt = (d: Date) => const fmt = (d: Date) => formatDate(d.toISOString());
`${d.toLocaleDateString('fr-FR')} ${d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`;
return sameTime ? ( return sameTime ? (
<div className="text-xs text-text-secondary">{fmt(last)}</div> <div className="text-xs text-text-secondary">{fmt(last)}</div>
) : ( ) : (
@ -330,10 +368,10 @@ export function DetectionsList() {
})() : ( })() : (
<> <>
<div className="text-sm text-text-primary"> <div className="text-sm text-text-primary">
{new Date(row.detected_at).toLocaleDateString('fr-FR')} {formatDateOnly(row.detected_at)}
</div> </div>
<div className="text-xs text-text-secondary"> <div className="text-xs text-text-secondary">
{new Date(row.detected_at).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} {formatTimeOnly(row.detected_at)}
</div> </div>
</> </>
), ),

View File

@ -2,6 +2,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { InfoTip } from './ui/Tooltip'; import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips'; import { TIPS } from './ui/tooltips';
import { formatDateOnly } from '../utils/dateUtils';
interface EntityStats { interface EntityStats {
entity_type: string; entity_type: string;
@ -162,11 +163,11 @@ export function EntityInvestigationView() {
/> />
<StatCard <StatCard
label="Première Détection" label="Première Détection"
value={new Date(data.stats.first_seen).toLocaleDateString('fr-FR')} value={formatDateOnly(data.stats.first_seen)}
/> />
<StatCard <StatCard
label="Dernière Détection" label="Dernière Détection"
value={new Date(data.stats.last_seen).toLocaleDateString('fr-FR')} value={formatDateOnly(data.stats.last_seen)}
/> />
</div> </div>

View File

@ -4,6 +4,7 @@ import DataTable, { Column } from './ui/DataTable';
import ThreatBadge from './ui/ThreatBadge'; import ThreatBadge from './ui/ThreatBadge';
import { InfoTip } from './ui/Tooltip'; import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips'; import { TIPS } from './ui/tooltips';
import { formatNumber as fmtNum, formatDateShort } from '../utils/dateUtils';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -129,7 +130,7 @@ function getCountryFlag(code: string): string {
} }
function formatNumber(n: number): string { function formatNumber(n: number): string {
return n.toLocaleString('fr-FR'); return fmtNum(n);
} }
function botUaPercentage(userAgents: AttributeValue[]): number { function botUaPercentage(userAgents: AttributeValue[]): number {
@ -1463,7 +1464,7 @@ type RotationSubTab = 'rotators' | 'persistent' | 'sophistication' | 'hunt';
function formatDate(iso: string): string { function formatDate(iso: string): string {
if (!iso) return ''; if (!iso) return '';
try { try {
return new Date(iso).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); return formatDateShort(iso);
} catch { } catch {
return iso; return iso;
} }

View File

@ -28,7 +28,7 @@ interface ClusterIP {
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function formatNumber(n: number): string { function formatNumber(n: number): string {
return n.toLocaleString('fr-FR'); return n.toLocaleString(navigator.language || undefined);
} }
function mismatchColor(pct: number): string { function mismatchColor(pct: number): string {

View File

@ -25,7 +25,7 @@ interface HeatmapMatrix {
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function formatNumber(n: number): string { function formatNumber(n: number): string {
return n.toLocaleString('fr-FR'); return n.toLocaleString(navigator.language || undefined);
} }
function heatmapCellStyle(value: number, maxValue: number): React.CSSProperties { function heatmapCellStyle(value: number, maxValue: number): React.CSSProperties {

View File

@ -161,8 +161,8 @@ export function IncidentsView() {
<span className="text-xl">{icon}</span> <span className="text-xl">{icon}</span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-xs text-text-disabled uppercase tracking-wide flex items-center gap-1">{label}<InfoTip content={tip} /></div> <div className="text-xs text-text-disabled uppercase tracking-wide flex items-center gap-1">{label}<InfoTip content={tip} /></div>
<div className="text-xl font-bold text-text-primary">{m.today.toLocaleString('fr-FR')}</div> <div className="text-xl font-bold text-text-primary">{m.today.toLocaleString(navigator.language || undefined)}</div>
<div className="text-xs text-text-secondary">hier: {m.yesterday.toLocaleString('fr-FR')}</div> <div className="text-xs text-text-secondary">hier: {m.yesterday.toLocaleString(navigator.language || undefined)}</div>
</div> </div>
<div className={`text-sm font-bold px-2 py-1 rounded ${ <div className={`text-sm font-bold px-2 py-1 rounded ${
neutral ? 'text-text-disabled' : neutral ? 'text-text-disabled' :

View File

@ -131,7 +131,7 @@ function IPActivitySummary({ ip }: { ip: string }) {
{data.bruteforce.active && ( {data.bruteforce.active && (
<div className="bg-background-card rounded p-2"> <div className="bg-background-card rounded p-2">
<div className="text-text-disabled mb-1">Brute Force</div> <div className="text-text-disabled mb-1">Brute Force</div>
<div className="text-threat-high font-medium">{data.bruteforce.total_hits.toLocaleString('fr-FR')} hits</div> <div className="text-threat-high font-medium">{data.bruteforce.total_hits.toLocaleString(navigator.language || undefined)} hits</div>
<div className="text-text-secondary truncate" title={data.bruteforce.top_hosts.join(', ')}> <div className="text-text-secondary truncate" title={data.bruteforce.top_hosts.join(', ')}>
{data.bruteforce.top_hosts[0] ?? ''} {data.bruteforce.top_hosts[0] ?? ''}
</div> </div>

View File

@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { JA4CorrelationSummary } from './analysis/JA4CorrelationSummary'; import { JA4CorrelationSummary } from './analysis/JA4CorrelationSummary';
import { InfoTip } from './ui/Tooltip'; import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips'; import { TIPS } from './ui/tooltips';
import { formatDateShort } from '../utils/dateUtils';
interface JA4InvestigationData { interface JA4InvestigationData {
ja4: string; ja4: string;
@ -331,11 +332,5 @@ function StatBox({ label, value, tip }: { label: string; value: string; tip?: st
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
if (!dateStr) return '-'; if (!dateStr) return '-';
const date = new Date(dateStr); return formatDateShort(dateStr);
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} }

View File

@ -48,7 +48,7 @@ interface ScatterPoint {
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function formatNumber(n: number): string { function formatNumber(n: number): string {
return n.toLocaleString('fr-FR'); return n.toLocaleString(navigator.language || undefined);
} }
function attackTypeEmoji(type: string): string { function attackTypeEmoji(type: string): string {

View File

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { formatDate } from '../utils/dateUtils';
interface ReputationData { interface ReputationData {
ip: string; ip: string;
@ -181,7 +182,7 @@ export function ReputationPanel({ ip }: ReputationPanelProps) {
{/* Sources */} {/* Sources */}
<div className="text-xs text-text-secondary text-center"> <div className="text-xs text-text-secondary text-center">
Sources: {Object.keys(reputation.sources).join(', ')} {new Date(reputation.timestamp).toLocaleString('fr-FR')} Sources: {Object.keys(reputation.sources).join(', ')} {formatDate(reputation.timestamp)}
</div> </div>
</div> </div>
); );

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { formatDateShort } from '../utils/dateUtils';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -51,13 +52,13 @@ type ActiveTab = 'rotators' | 'persistent' | 'sophistication' | 'hunt';
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function formatNumber(n: number): string { function formatNumber(n: number): string {
return n.toLocaleString('fr-FR'); return n.toLocaleString(navigator.language || undefined);
} }
function formatDate(iso: string): string { function formatDate(iso: string): string {
if (!iso) return '—'; if (!iso) return '—';
try { try {
return new Date(iso).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); return formatDateShort(iso);
} catch { } catch {
return iso; return iso;
} }

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { InfoTip } from './ui/Tooltip'; import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips'; import { TIPS } from './ui/tooltips';
import { formatDateShort } from '../utils/dateUtils';
interface SubnetIP { interface SubnetIP {
ip: string; ip: string;
@ -77,13 +78,7 @@ export function SubnetInvestigation() {
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
if (!dateString) return '-'; if (!dateString) return '-';
const date = new Date(dateString); return formatDateShort(dateString);
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}; };
if (loading) { if (loading) {

View File

@ -52,7 +52,7 @@ type ActiveTab = 'detections' | 'matrix';
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function formatNumber(n: number): string { function formatNumber(n: number): string {
return n.toLocaleString('fr-FR'); return n.toLocaleString(navigator.language || undefined);
} }
function confidenceBar(conf: number): JSX.Element { function confidenceBar(conf: number): JSX.Element {

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { InfoTip } from './ui/Tooltip'; import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips'; import { TIPS } from './ui/tooltips';
import { formatDateShort } from '../utils/dateUtils';
interface Classification { interface Classification {
ip?: string; ip?: string;
@ -239,9 +240,7 @@ export function ThreatIntelView() {
{filteredClassifications.slice(0, 50).map((classification, idx) => ( {filteredClassifications.slice(0, 50).map((classification, idx) => (
<tr key={idx} className="hover:bg-background-card/50 transition-colors"> <tr key={idx} className="hover:bg-background-card/50 transition-colors">
<td className="px-4 py-3 text-sm text-text-secondary"> <td className="px-4 py-3 text-sm text-text-secondary">
{new Date(classification.created_at).toLocaleDateString('fr-FR', { {formatDateShort(classification.created_at)}
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
})}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="font-mono text-sm text-text-primary"> <div className="font-mono text-sm text-text-primary">

View File

@ -4,7 +4,7 @@ import { InfoTip } from './Tooltip';
export interface Column<T> { export interface Column<T> {
key: string; key: string;
label: string; label: React.ReactNode;
tooltip?: string; tooltip?: string;
sortable?: boolean; sortable?: boolean;
align?: 'left' | 'right' | 'center'; align?: 'left' | 'right' | 'center';

View File

@ -398,4 +398,18 @@ export const TIPS = {
'Nœud de corrélation : entité reliée à l\'IP analysée.\n' + 'Nœud de corrélation : entité reliée à l\'IP analysée.\n' +
'Les connexions (arêtes) représentent des relations directes\n' + 'Les connexions (arêtes) représentent des relations directes\n' +
'(même subnet, même ASN, même JA4, même host cible).', '(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',
}; };

56
frontend/src/config.ts Normal file
View File

@ -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;

View File

@ -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);
}