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:
@ -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) => {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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' :
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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
56
frontend/src/config.ts
Normal 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;
|
||||||
86
frontend/src/utils/dateUtils.ts
Normal file
86
frontend/src/utils/dateUtils.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user