feat: system logs page + dynamic IV watchlist with auto-add from cycle

Backend:
- DB: add system_logs table (level/source/cycle_id/ticker/message) and
  iv_watchlist table (ticker/added_by/is_active); seed builtin 18 tickers
- DBLogHandler attached at startup — all WARNING+ logs auto-persist to DB
- log_system_event() helper for structured manual events
- New router /api/logs: GET with filters (level, source, cycle_id, ticker,
  date range), GET /sources, GET /cycles for dropdowns, DELETE /clear
- iv_watchlist now read from DB instead of hardcoded constant; options_vol
  watchlist/refresh/bootstrap endpoints all use get_watchlist_tickers()
- New endpoints: POST/DELETE /options-vol/watchlist-tickers/{ticker} to
  add/remove tickers; adding triggers background 1-year bootstrap
- auto_cycle: after log_trade_entries(), auto-detect new underlying proxies
  not yet in watchlist, add them and bootstrap their IV history

Frontend:
- New page SystemLogs (/logs): log table with level/source/cycle/ticker/date
  filters, color-coded rows, expandable JSON details, auto-refresh 30s
- Options Lab: WatchlistManager section — add ticker input, chip list with
  builtin/auto/manual color coding, remove button for non-builtins
- Sidebar: Logs Système nav link (ScrollText icon)
- useApi: useSystemLogs, useLogSources, useLogCycles, useClearLogs,
  useWatchlistTickers, useAddWatchlistTicker, useRemoveWatchlistTicker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-18 13:07:35 +02:00
parent 3b4e035819
commit 05a475fb04
10 changed files with 759 additions and 24 deletions

View File

@@ -16,6 +16,7 @@ import Config from './pages/Config'
import Analytics from './pages/Analytics'
import AnalyticsAdvanced from './pages/AnalyticsAdvanced'
import RiskDashboard from './pages/RiskDashboard'
import SystemLogs from './pages/SystemLogs'
import { useCycleWatcher } from './hooks/useApi'
function GlobalWatcher() {
@@ -47,6 +48,7 @@ export default function App() {
<Route path="/analytics" element={<Analytics />} />
<Route path="/analytics-advanced" element={<AnalyticsAdvanced />} />
<Route path="/risk" element={<RiskDashboard />} />
<Route path="/logs" element={<SystemLogs />} />
</Routes>
</main>
</div>

View File

@@ -1,7 +1,7 @@
import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, Globe, BarChart2, FlaskConical,
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText
} from 'lucide-react'
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
import clsx from 'clsx'
@@ -22,6 +22,7 @@ const nav = [
{ to: '/risk', icon: ShieldAlert, label: 'Risk Dashboard' },
{ to: '/backtest', icon: History, label: 'Backtest' },
{ to: '/calendar', icon: Calendar, label: 'Calendrier' },
{ to: '/logs', icon: ScrollText, label: 'Logs Système' },
{ to: '/config', icon: Settings, label: 'Configuration' },
]

View File

@@ -705,3 +705,61 @@ export const useRiskDashboard = () =>
staleTime: 2 * 60_000,
refetchInterval: 5 * 60_000,
})
// ── System Logs ───────────────────────────────────────────────────────────────
export interface LogFilters {
level?: string
source?: string
cycle_id?: string
ticker?: string
date_from?: string
date_to?: string
limit?: number
}
export const useSystemLogs = (filters: LogFilters = {}) =>
useQuery({
queryKey: ['system-logs', filters],
queryFn: () => api.get('/logs', { params: filters }).then(r => r.data),
staleTime: 30_000,
refetchInterval: 30_000,
})
export const useLogSources = () =>
useQuery({
queryKey: ['log-sources'],
queryFn: () => api.get('/logs/sources').then(r => r.data),
staleTime: 60_000,
})
export const useLogCycles = () =>
useQuery({
queryKey: ['log-cycles'],
queryFn: () => api.get('/logs/cycles').then(r => r.data),
staleTime: 60_000,
})
export const useClearLogs = () =>
useMutation({
mutationFn: (days: number) => api.delete('/logs/clear', { params: { older_than_days: days } }).then(r => r.data),
})
// ── IV Watchlist Management ───────────────────────────────────────────────────
export const useWatchlistTickers = () =>
useQuery({
queryKey: ['watchlist-tickers'],
queryFn: () => api.get('/options-vol/watchlist-tickers').then(r => r.data),
staleTime: 60_000,
})
export const useAddWatchlistTicker = () =>
useMutation({
mutationFn: (ticker: string) => api.post(`/options-vol/watchlist-tickers/${encodeURIComponent(ticker)}`).then(r => r.data),
})
export const useRemoveWatchlistTicker = () =>
useMutation({
mutationFn: (ticker: string) => api.delete(`/options-vol/watchlist-tickers/${encodeURIComponent(ticker)}`).then(r => r.data),
})

View File

@@ -1,7 +1,8 @@
import { useState } from 'react'
import { useIvWatchlist, useIvSnapshot, useIvHistory } from '../hooks/useApi'
import { Activity, TrendingUp, TrendingDown, Minus, RefreshCw, ChevronDown, ChevronUp, Database } from 'lucide-react'
import { useIvWatchlist, useIvSnapshot, useIvHistory, useWatchlistTickers, useAddWatchlistTicker, useRemoveWatchlistTicker } from '../hooks/useApi'
import { Activity, TrendingUp, TrendingDown, Minus, RefreshCw, ChevronDown, ChevronUp, Database, Plus, Trash2, List } from 'lucide-react'
import { api } from '../hooks/useApi'
import { useQueryClient } from '@tanstack/react-query'
import clsx from 'clsx'
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -247,6 +248,106 @@ function WatchlistRow({ item }: { item: any }) {
)
}
// ── Watchlist Manager ─────────────────────────────────────────────────────────
function WatchlistManager() {
const qc = useQueryClient()
const { data } = useWatchlistTickers()
const { mutate: addTicker, isPending: adding } = useAddWatchlistTicker()
const { mutate: removeTicker } = useRemoveWatchlistTicker()
const [input, setInput] = useState('')
const [showInactive, setShowInactive] = useState(false)
const entries: any[] = data?.tickers ?? []
const active = entries.filter(e => e.is_active)
const inactive = entries.filter(e => !e.is_active)
const handleAdd = () => {
const t = input.trim().toUpperCase()
if (!t) return
addTicker(t, {
onSuccess: () => {
setInput('')
qc.invalidateQueries({ queryKey: ['watchlist-tickers'] })
qc.invalidateQueries({ queryKey: ['iv-watchlist'] })
}
})
}
const handleRemove = (ticker: string) => {
removeTicker(ticker, {
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['watchlist-tickers'] })
qc.invalidateQueries({ queryKey: ['iv-watchlist'] })
}
})
}
return (
<div className="card space-y-3">
<div className="flex items-center gap-2">
<List className="w-4 h-4 text-slate-400" />
<span className="text-sm font-semibold text-slate-300">Gestion de la Watchlist IV</span>
<span className="ml-auto text-xs text-slate-600">{active.length} tickers actifs</span>
</div>
{/* Add ticker */}
<div className="flex gap-2">
<input
className="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-xs text-slate-300 placeholder:text-slate-600"
placeholder="Ajouter ticker (ex: AAPL, FXI, EEM…)"
value={input}
onChange={e => setInput(e.target.value.toUpperCase())}
onKeyDown={e => e.key === 'Enter' && handleAdd()}
/>
<button
onClick={handleAdd}
disabled={adding || !input.trim()}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600/20 hover:bg-blue-600/30 border border-blue-600/40 text-blue-400 rounded disabled:opacity-40 transition-all"
>
<Plus className="w-3.5 h-3.5" />
{adding ? 'Ajout...' : 'Ajouter'}
</button>
</div>
<p className="text-[10px] text-slate-600">Le système bootstrappe automatiquement 1 an d'historique IV pour chaque nouveau ticker ajouté.</p>
{/* Active tickers */}
<div className="flex flex-wrap gap-1.5">
{active.map(e => (
<span key={e.ticker} className={clsx(
'inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-mono',
e.added_by === 'builtin' ? 'bg-slate-800 text-slate-400 border border-slate-700/50' :
e.added_by === 'cycle' ? 'bg-blue-900/30 text-blue-300 border border-blue-700/30' :
'bg-emerald-900/20 text-emerald-300 border border-emerald-700/30'
)}>
{e.ticker}
{e.added_by !== 'builtin' && (
<button onClick={() => handleRemove(e.ticker)} className="opacity-50 hover:opacity-100 ml-0.5">
<Trash2 className="w-2.5 h-2.5" />
</button>
)}
</span>
))}
</div>
<div className="text-[9px] text-slate-700 flex gap-3">
<span className="text-slate-800 border-b border-slate-700">■</span> Builtins &nbsp;
<span className="text-blue-700 border-b border-blue-700">■</span> Auto (cycle) &nbsp;
<span className="text-emerald-700 border-b border-emerald-700">■</span> Manuel
</div>
{inactive.length > 0 && (
<button onClick={() => setShowInactive(!showInactive)} className="text-[10px] text-slate-600 hover:text-slate-400">
{showInactive ? 'Masquer' : `Voir ${inactive.length} ticker(s) désactivés`}
</button>
)}
{showInactive && inactive.map(e => (
<span key={e.ticker} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-mono bg-slate-900 text-slate-700 line-through mr-1">
{e.ticker}
</span>
))}
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function OptionsLab() {
const { data, isLoading, refetch, isFetching } = useIvWatchlist()
@@ -390,6 +491,8 @@ export default function OptionsLab() {
)}
</div>
)}
<WatchlistManager />
</div>
)
}

View File

@@ -0,0 +1,256 @@
import { useState, useMemo } from 'react'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import { AlertTriangle, XCircle, Info, RefreshCw, Trash2, ChevronDown, ChevronRight } from 'lucide-react'
import clsx from 'clsx'
import { useSystemLogs, useLogSources, useLogCycles, useClearLogs, 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>
)}
</>
)
}
export default function SystemLogs() {
const qc = useQueryClient()
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 événements 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>
<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 &gt;30j
</button>
</div>
</div>
{/* 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>
)
}