Phase 4 — Price Discovery Status (la pièce maîtresse) :
- price_discovery.py (nouveau) : capture_price_snapshots() sauve les prix des tickers
liés à chaque news scorée (energy→BZ=F/NG=F, metals→GC=F/HG=F, indices→^GSPC/IWM)
- compute_absorptions() mesure combien du mouvement attendu s'est déjà produit
(status: not_yet_priced <30% / partially_priced 30-80% / fully_priced >80%)
- build_price_discovery_block() → bloc prompt avec opportunités classées
- database.py : table news_price_snapshots + save/get/purge fonctions
- auto_cycle.py : capture après ai_score_news_batch, compute avant suggestions,
block injecté dans suggestion + scoring prompts + context snapshot
- ai_analyzer.py : param price_discovery_block dans suggest + score
Phase 5 — Replay historique :
- cycle.py : POST /api/cycle/contexts/{run_id}/replay — recharge le snapshot historique
et relance suggest_patterns_from_market_context avec le contexte original
- useApi.ts : hook useReplayCycle
- SystemLogs.tsx : bouton "Rejouer ce cycle" dans onglet Contexte IA avec champ
notes, résultats inline (liste des patterns générés), section price_discovery
ouverte par défaut en rouge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
450 lines
19 KiB
TypeScript
450 lines
19 KiB
TypeScript
import { useState, useMemo } from 'react'
|
|
import { format } from 'date-fns'
|
|
import { fr } from 'date-fns/locale'
|
|
import { AlertTriangle, XCircle, Info, RefreshCw, Trash2, ChevronDown, ChevronRight, Brain, Play, Loader2 } from 'lucide-react'
|
|
import clsx from 'clsx'
|
|
import { useSystemLogs, useLogSources, useLogCycles, useClearLogs, useCycleContextSnapshots, useCycleContextSnapshot, useReplayCycle, type LogFilters } from '../hooks/useApi'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
|
|
const LEVELS = ['', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
|
|
|
function LevelBadge({ level }: { level: string }) {
|
|
const cfg: Record<string, { cls: string; icon: React.ReactNode }> = {
|
|
INFO: { cls: 'bg-slate-700 text-slate-300', icon: <Info className="w-3 h-3" /> },
|
|
WARNING: { cls: 'bg-amber-900/60 text-amber-300 border border-amber-700/40', icon: <AlertTriangle className="w-3 h-3" /> },
|
|
ERROR: { cls: 'bg-red-900/60 text-red-300 border border-red-700/40', icon: <XCircle className="w-3 h-3" /> },
|
|
CRITICAL: { cls: 'bg-red-800 text-white border border-red-600', icon: <XCircle className="w-3 h-3" /> },
|
|
}
|
|
const { cls, icon } = cfg[level] ?? cfg['INFO']
|
|
return (
|
|
<span className={clsx('inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold font-mono', cls)}>
|
|
{icon} {level}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function LogRow({ log }: { log: any }) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
const hasDetails = !!log.details
|
|
const ts = (() => {
|
|
try { return format(new Date(log.ts), 'dd/MM HH:mm:ss', { locale: fr }) } catch { return log.ts }
|
|
})()
|
|
|
|
const rowCls = clsx('border-b border-slate-800 hover:bg-slate-800/40 transition-colors', {
|
|
'bg-amber-950/10': log.level === 'WARNING',
|
|
'bg-red-950/20': log.level === 'ERROR' || log.level === 'CRITICAL',
|
|
})
|
|
|
|
return (
|
|
<>
|
|
<tr className={rowCls}>
|
|
<td className="px-3 py-1.5 text-[10px] font-mono text-slate-500 whitespace-nowrap">{ts}</td>
|
|
<td className="px-3 py-1.5 whitespace-nowrap"><LevelBadge level={log.level} /></td>
|
|
<td className="px-3 py-1.5 text-[10px] text-slate-400 max-w-[120px] truncate font-mono">{log.source}</td>
|
|
<td className="px-3 py-1.5 text-[10px] text-slate-500 max-w-[100px] truncate font-mono">
|
|
{log.cycle_id ? log.cycle_id.slice(0, 16) + '…' : '—'}
|
|
</td>
|
|
<td className="px-3 py-1.5 text-[10px] text-blue-400 font-mono">{log.ticker ?? '—'}</td>
|
|
<td className="px-2 py-1.5 w-full">
|
|
<div className="flex items-start gap-1.5">
|
|
{hasDetails && (
|
|
<button
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="mt-0.5 shrink-0 text-slate-600 hover:text-slate-400"
|
|
>
|
|
{expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
|
</button>
|
|
)}
|
|
<span className={clsx('text-[11px] leading-snug break-all', {
|
|
'text-slate-300': log.level === 'INFO',
|
|
'text-amber-200': log.level === 'WARNING',
|
|
'text-red-300': log.level === 'ERROR' || log.level === 'CRITICAL',
|
|
})}>{log.message}</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{expanded && hasDetails && (
|
|
<tr className={rowCls}>
|
|
<td colSpan={6} className="px-4 pb-2 pt-0">
|
|
<pre className="text-[10px] text-slate-400 bg-slate-900 rounded p-2 overflow-x-auto max-h-40 leading-relaxed">
|
|
{JSON.stringify(JSON.parse(log.details), null, 2)}
|
|
</pre>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function ContextTab() {
|
|
const [selectedRunId, setSelectedRunId] = useState<string | null>(null)
|
|
const [replayNotes, setReplayNotes] = useState('')
|
|
const [replayResult, setReplayResult] = useState<any>(null)
|
|
const { data: snapshotsData, isLoading: loadingList } = useCycleContextSnapshots(30)
|
|
const { data: snapData, isLoading: loadingSnap } = useCycleContextSnapshot(selectedRunId)
|
|
const { mutate: replayCycle, isPending: replaying } = useReplayCycle()
|
|
const snapshots: any[] = snapshotsData?.snapshots ?? []
|
|
|
|
const fmtTs = (ts: string) => {
|
|
try { return format(new Date(ts), 'dd/MM HH:mm:ss', { locale: fr }) } catch { return ts }
|
|
}
|
|
|
|
const handleReplay = () => {
|
|
if (!selectedRunId) return
|
|
setReplayResult(null)
|
|
replayCycle(
|
|
{ runId: selectedRunId, notes: replayNotes || undefined },
|
|
{ onSuccess: (data) => setReplayResult(data) }
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="grid grid-cols-[280px_1fr] gap-4 min-h-[500px]">
|
|
{/* Left: list of snapshots */}
|
|
<div className="card overflow-y-auto max-h-[700px]">
|
|
{loadingList ? (
|
|
<div className="p-4 text-xs text-slate-500">Chargement…</div>
|
|
) : snapshots.length === 0 ? (
|
|
<div className="p-4 text-xs text-slate-600">
|
|
Aucun snapshot — les contextes seront sauvegardés lors du prochain cycle.
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-slate-800">
|
|
{snapshots.map(s => (
|
|
<button
|
|
key={s.run_id}
|
|
onClick={() => { setSelectedRunId(s.run_id); setReplayResult(null) }}
|
|
className={clsx(
|
|
'w-full text-left px-3 py-2.5 hover:bg-slate-800/60 transition-colors',
|
|
selectedRunId === s.run_id && 'bg-purple-900/30 border-l-2 border-purple-500'
|
|
)}
|
|
>
|
|
<div className="text-xs font-mono text-slate-300">{fmtTs(s.ts)}</div>
|
|
<div className="text-[10px] text-slate-600 font-mono mt-0.5">{s.run_id.slice(0, 20)}…</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: context JSON viewer + replay */}
|
|
<div className="card overflow-hidden flex flex-col">
|
|
{!selectedRunId ? (
|
|
<div className="p-8 text-center text-slate-600 text-sm flex-1">
|
|
<Brain className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
|
Sélectionne un cycle à gauche pour voir le contexte complet envoyé à l'IA.
|
|
</div>
|
|
) : loadingSnap ? (
|
|
<div className="p-8 text-center text-slate-500 text-sm flex-1">Chargement du contexte…</div>
|
|
) : !snapData ? (
|
|
<div className="p-8 text-center text-slate-500 text-sm flex-1">Erreur lors du chargement.</div>
|
|
) : (
|
|
<>
|
|
{/* Header strip with meta + replay */}
|
|
<div className="flex items-center gap-3 px-4 py-2 border-b border-slate-800 bg-slate-900/60 flex-wrap">
|
|
<span className="text-[10px] font-mono text-slate-400">{snapData.run_id.slice(0, 24)}…</span>
|
|
<span className="text-[10px] text-slate-500">{fmtTs(snapData.ts)}</span>
|
|
{snapData.context?.cycle_meta && (
|
|
<>
|
|
<span className="text-[10px] text-purple-400">
|
|
Δ {snapData.context.cycle_meta.delta_minutes?.toFixed(0)}min
|
|
</span>
|
|
<span className="text-[10px] text-slate-400">{snapData.context.cycle_meta.calibration_label}</span>
|
|
</>
|
|
)}
|
|
<div className="flex items-center gap-2 ml-auto">
|
|
<input
|
|
className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-[10px] text-slate-300 w-48"
|
|
placeholder="Notes de replay (optionnel)"
|
|
value={replayNotes}
|
|
onChange={e => setReplayNotes(e.target.value)}
|
|
/>
|
|
<button
|
|
onClick={handleReplay}
|
|
disabled={replaying}
|
|
className="flex items-center gap-1.5 bg-purple-700 hover:bg-purple-600 disabled:opacity-40 text-white px-3 py-1.5 rounded text-xs font-semibold"
|
|
>
|
|
{replaying
|
|
? <><Loader2 className="w-3 h-3 animate-spin" /> Replay en cours…</>
|
|
: <><Play className="w-3 h-3" /> Rejouer ce cycle</>}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Replay result */}
|
|
{replayResult && (
|
|
<div className="border-b border-slate-800 bg-green-950/20 p-3">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className="text-xs font-semibold text-green-400">
|
|
Replay terminé — {replayResult.suggestions_count} patterns générés
|
|
</span>
|
|
<span className="text-[10px] text-slate-500">à {fmtTs(replayResult.replayed_at)}</span>
|
|
</div>
|
|
<div className="space-y-1 max-h-48 overflow-y-auto">
|
|
{(replayResult.suggestions || []).map((s: any, i: number) => (
|
|
<div key={i} className="text-[10px] text-slate-300 bg-slate-900 rounded px-2 py-1">
|
|
<span className="text-green-400 font-mono">{s.name}</span>
|
|
{s.macro_fit && <span className="text-slate-500 ml-2">— {s.macro_fit?.slice(0, 80)}</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Context sections */}
|
|
<div className="overflow-y-auto flex-1 p-3 space-y-2">
|
|
{snapData.context && Object.entries(snapData.context).map(([key, val]) => (
|
|
<ContextSection key={key} label={key} value={val} />
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ContextSection({ label, value }: { label: string; value: any }) {
|
|
const [open, setOpen] = useState(['cycle_meta', 'news_partitioned', 'fred_releases', 'price_discovery'].includes(label))
|
|
const json = JSON.stringify(value, null, 2)
|
|
const lineCount = json.split('\n').length
|
|
|
|
const labelColor: Record<string, string> = {
|
|
cycle_meta: 'text-purple-400',
|
|
macro_regime: 'text-blue-400',
|
|
geo_score: 'text-orange-400',
|
|
news_partitioned: 'text-yellow-400',
|
|
fred_releases: 'text-green-400',
|
|
price_discovery: 'text-red-400',
|
|
tech_indicators_block: 'text-cyan-400',
|
|
iv_context_preview: 'text-pink-400',
|
|
calendar: 'text-slate-300',
|
|
quotes_summary: 'text-slate-300',
|
|
}
|
|
|
|
return (
|
|
<div className="border border-slate-800 rounded overflow-hidden">
|
|
<button
|
|
onClick={() => setOpen(o => !o)}
|
|
className="w-full flex items-center gap-2 px-3 py-2 bg-slate-900/60 hover:bg-slate-800/60 text-left"
|
|
>
|
|
{open ? <ChevronDown className="w-3 h-3 text-slate-500 shrink-0" /> : <ChevronRight className="w-3 h-3 text-slate-500 shrink-0" />}
|
|
<span className={clsx('text-xs font-mono font-semibold', labelColor[label] ?? 'text-slate-300')}>{label}</span>
|
|
<span className="text-[10px] text-slate-600 ml-auto">{lineCount} lignes</span>
|
|
</button>
|
|
{open && (
|
|
<pre className="text-[10px] font-mono text-slate-400 bg-slate-950 p-3 overflow-x-auto max-h-80 leading-relaxed">
|
|
{json}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function SystemLogs() {
|
|
const qc = useQueryClient()
|
|
const [activeTab, setActiveTab] = useState<'logs' | 'context'>('logs')
|
|
const [filters, setFilters] = useState<LogFilters>({ limit: 300 })
|
|
const [tickerInput, setTickerInput] = useState('')
|
|
const [cycleInput, setCycleInput] = useState('')
|
|
|
|
const { data, isLoading, isRefetching } = useSystemLogs(filters)
|
|
const { data: sourcesData } = useLogSources()
|
|
const { data: cyclesData } = useLogCycles()
|
|
const { mutate: clearLogs, isPending: clearing } = useClearLogs()
|
|
|
|
const logs: any[] = data?.logs ?? []
|
|
const sources: string[] = sourcesData?.sources ?? []
|
|
const cycles: any[] = cyclesData?.cycles ?? []
|
|
|
|
// Summary counts
|
|
const counts = useMemo(() => {
|
|
const c = { INFO: 0, WARNING: 0, ERROR: 0, CRITICAL: 0 }
|
|
for (const l of logs) c[l.level as keyof typeof c] = (c[l.level as keyof typeof c] ?? 0) + 1
|
|
return c
|
|
}, [logs])
|
|
|
|
const applyFilter = (key: keyof LogFilters, val: string) => {
|
|
setFilters(f => ({ ...f, [key]: val || undefined }))
|
|
}
|
|
|
|
const handleClear = () => {
|
|
if (window.confirm('Supprimer les logs de plus de 30 jours ?')) {
|
|
clearLogs(30, { onSuccess: () => qc.invalidateQueries({ queryKey: ['system-logs'] }) })
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 space-y-4">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-bold text-white">Logs Système</h1>
|
|
<p className="text-xs text-slate-500 mt-0.5">Erreurs, avertissements et contextes complets des cycles IA</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => qc.invalidateQueries({ queryKey: ['system-logs'] })}
|
|
className="btn-secondary flex items-center gap-1.5 text-xs px-3 py-1.5"
|
|
>
|
|
<RefreshCw className={clsx('w-3.5 h-3.5', (isLoading || isRefetching) && 'animate-spin')} />
|
|
Actualiser
|
|
</button>
|
|
{activeTab === 'logs' && (
|
|
<button
|
|
onClick={handleClear}
|
|
disabled={clearing}
|
|
className="btn-secondary flex items-center gap-1.5 text-xs px-3 py-1.5 text-red-400 border-red-800/40 hover:bg-red-900/20"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
Purger >30j
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-1 border-b border-slate-800">
|
|
{([
|
|
{ key: 'logs', label: 'Logs système', icon: <Info className="w-3.5 h-3.5" /> },
|
|
{ key: 'context', label: 'Contexte IA', icon: <Brain className="w-3.5 h-3.5" /> },
|
|
] as const).map(tab => (
|
|
<button
|
|
key={tab.key}
|
|
onClick={() => setActiveTab(tab.key)}
|
|
className={clsx(
|
|
'flex items-center gap-1.5 px-4 py-2 text-xs font-medium border-b-2 -mb-px transition-colors',
|
|
activeTab === tab.key
|
|
? 'border-purple-500 text-purple-300'
|
|
: 'border-transparent text-slate-500 hover:text-slate-300'
|
|
)}
|
|
>
|
|
{tab.icon} {tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{activeTab === 'context' && <ContextTab />}
|
|
|
|
{activeTab === 'logs' && <>
|
|
{/* Summary chips */}
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
{[
|
|
{ level: 'INFO', cls: 'bg-slate-800 text-slate-300' },
|
|
{ level: 'WARNING', cls: 'bg-amber-900/40 text-amber-300 border border-amber-700/30' },
|
|
{ level: 'ERROR', cls: 'bg-red-900/40 text-red-300 border border-red-700/30' },
|
|
{ level: 'CRITICAL', cls: 'bg-red-800/60 text-red-200 border border-red-600/30' },
|
|
].map(({ level, cls }) => (
|
|
<button
|
|
key={level}
|
|
onClick={() => applyFilter('level', filters.level === level ? '' : level)}
|
|
className={clsx(
|
|
'px-3 py-1 rounded text-xs font-semibold transition-all',
|
|
cls,
|
|
filters.level === level ? 'ring-1 ring-white/30' : 'opacity-70 hover:opacity-100'
|
|
)}
|
|
>
|
|
{level} · {counts[level as keyof typeof counts] ?? 0}
|
|
</button>
|
|
))}
|
|
<span className="text-xs text-slate-600 ml-auto">{logs.length} entrées</span>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="card p-3 grid grid-cols-2 gap-3 md:grid-cols-4">
|
|
<div>
|
|
<label className="text-[9px] text-slate-500 uppercase tracking-wide block mb-1">Source</label>
|
|
<select
|
|
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300"
|
|
value={filters.source ?? ''}
|
|
onChange={e => applyFilter('source', e.target.value)}
|
|
>
|
|
<option value="">Toutes</option>
|
|
{sources.map(s => <option key={s} value={s}>{s}</option>)}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-[9px] text-slate-500 uppercase tracking-wide block mb-1">Cycle</label>
|
|
<select
|
|
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300"
|
|
value={filters.cycle_id ?? ''}
|
|
onChange={e => applyFilter('cycle_id', e.target.value)}
|
|
>
|
|
<option value="">Tous</option>
|
|
{cycles.map(c => (
|
|
<option key={c.cycle_id} value={c.cycle_id}>
|
|
{c.first_ts ? format(new Date(c.first_ts), 'dd/MM HH:mm') : c.cycle_id.slice(0, 16)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-[9px] text-slate-500 uppercase tracking-wide block mb-1">Ticker</label>
|
|
<input
|
|
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300"
|
|
placeholder="ex: SPY"
|
|
value={tickerInput}
|
|
onChange={e => setTickerInput(e.target.value.toUpperCase())}
|
|
onBlur={() => applyFilter('ticker', tickerInput)}
|
|
onKeyDown={e => e.key === 'Enter' && applyFilter('ticker', tickerInput)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<label className="text-[9px] text-slate-500 uppercase tracking-wide block mb-1">Depuis</label>
|
|
<input
|
|
type="date"
|
|
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300"
|
|
value={filters.date_from ?? ''}
|
|
onChange={e => applyFilter('date_from', e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-[9px] text-slate-500 uppercase tracking-wide block mb-1">Jusqu'au</label>
|
|
<input
|
|
type="date"
|
|
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300"
|
|
value={filters.date_to ?? ''}
|
|
onChange={e => applyFilter('date_to', e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Log table */}
|
|
<div className="card overflow-hidden">
|
|
{isLoading ? (
|
|
<div className="p-8 text-center text-slate-500 text-sm">Chargement…</div>
|
|
) : logs.length === 0 ? (
|
|
<div className="p-8 text-center text-slate-600 text-sm">
|
|
Aucun log trouvé pour ces filtres.<br />
|
|
<span className="text-xs text-slate-700">Les WARNING et ERROR des cycles apparaissent ici automatiquement.</span>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="border-b border-slate-700 text-[9px] uppercase tracking-wide text-slate-500">
|
|
<th className="px-3 py-2 text-left font-medium whitespace-nowrap">Horodatage</th>
|
|
<th className="px-3 py-2 text-left font-medium">Niveau</th>
|
|
<th className="px-3 py-2 text-left font-medium">Source</th>
|
|
<th className="px-3 py-2 text-left font-medium">Cycle</th>
|
|
<th className="px-3 py-2 text-left font-medium">Ticker</th>
|
|
<th className="px-3 py-2 text-left font-medium w-full">Message</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{logs.map(log => <LogRow key={log.id} log={log} />)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>}
|
|
</div>
|
|
)
|
|
}
|