diff --git a/backend/main.py b/backend/main.py index cbd5c1b..bdb2f7c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,10 +1,30 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from routers import market_data, geopolitical, options, backtest, ai, portfolio, config, patterns, journal, cycle as cycle_router, profiles as profiles_router, reasoning as reasoning_router, knowledge as knowledge_router, options_vol as options_vol_router, analytics as analytics_router, risk as risk_router +from routers import logs as logs_router from services.database import init_db, get_config, cleanup_stale_running_cycles import os +import logging import uvicorn + +class _DBLogHandler(logging.Handler): + """Persist WARNING+ log records to system_logs table.""" + def emit(self, record: logging.LogRecord): + if record.levelno < logging.WARNING: + return + try: + from services.database import log_system_event + log_system_event( + level=record.levelname, + source=record.name, + message=self.format(record), + cycle_id=getattr(record, "cycle_id", None), + ticker=getattr(record, "ticker", None), + ) + except Exception: + pass # Never raise from inside a log handler + app = FastAPI( title="GeoOptions Intelligence", description="Geopolitical Options Trading Cockpit API", @@ -22,7 +42,11 @@ app.add_middleware( @app.on_event("startup") def startup(): - import logging + # Attach DB log handler to root logger (captures WARNING+ from all modules) + _db_handler = _DBLogHandler() + _db_handler.setFormatter(logging.Formatter("%(message)s")) + logging.getLogger().addHandler(_db_handler) + _log = logging.getLogger(__name__) init_db() @@ -74,6 +98,7 @@ app.include_router(knowledge_router.router) app.include_router(options_vol_router.router) app.include_router(analytics_router.router) app.include_router(risk_router.router) +app.include_router(logs_router.router) @app.get("/") diff --git a/backend/routers/logs.py b/backend/routers/logs.py new file mode 100644 index 0000000..18e36df --- /dev/null +++ b/backend/routers/logs.py @@ -0,0 +1,75 @@ +""" +System logs endpoint — exposes structured log events stored in system_logs table. +""" +from fastapi import APIRouter +from typing import Optional + +router = APIRouter(prefix="/api/logs", tags=["logs"]) + + +@router.get("") +def get_logs( + level: Optional[str] = None, + source: Optional[str] = None, + cycle_id: Optional[str] = None, + ticker: Optional[str] = None, + date_from: Optional[str] = None, + date_to: Optional[str] = None, + limit: int = 300, +): + """ + Query system logs with optional filters. + - level: INFO | WARNING | ERROR | CRITICAL + - source: logger name substring (e.g. 'auto_cycle', 'iv_engine') + - cycle_id: exact run_id UUID + - ticker: exact ticker symbol + - date_from / date_to: ISO date strings YYYY-MM-DD + - limit: max rows returned (default 300, max 1000) + """ + from services.database import get_system_logs + limit = min(limit, 1000) + logs = get_system_logs( + level=level, + source=source, + cycle_id=cycle_id, + ticker=ticker, + date_from=date_from, + date_to=date_to, + limit=limit, + ) + return {"logs": logs, "count": len(logs)} + + +@router.get("/sources") +def get_log_sources(): + """List distinct source names present in system_logs (for filter dropdown).""" + from services.database import get_conn + conn = get_conn() + rows = conn.execute( + "SELECT DISTINCT source FROM system_logs WHERE source IS NOT NULL ORDER BY source" + ).fetchall() + conn.close() + return {"sources": [r["source"] for r in rows]} + + +@router.get("/cycles") +def get_log_cycles(limit: int = 50): + """List distinct cycle_ids (most recent first) for filter dropdown.""" + from services.database import get_conn + conn = get_conn() + rows = conn.execute( + """SELECT DISTINCT cycle_id, MIN(ts) as first_ts + FROM system_logs WHERE cycle_id IS NOT NULL + GROUP BY cycle_id ORDER BY first_ts DESC LIMIT ?""", + (limit,) + ).fetchall() + conn.close() + return {"cycles": [{"cycle_id": r["cycle_id"], "first_ts": r["first_ts"]} for r in rows]} + + +@router.delete("/clear") +def clear_old_logs(older_than_days: int = 30): + """Delete logs older than N days.""" + from services.database import clear_system_logs + deleted = clear_system_logs(older_than_days) + return {"deleted": deleted, "older_than_days": older_than_days} diff --git a/backend/routers/options_vol.py b/backend/routers/options_vol.py index b4503a7..7866bad 100644 --- a/backend/routers/options_vol.py +++ b/backend/routers/options_vol.py @@ -68,14 +68,14 @@ def get_iv_watchlist(): IV for the core IV watchlist (portfolio-relevant tickers). Returns a summary list sorted by IV Rank descending. """ - from services.iv_engine import IV_WATCHLIST, get_atm_iv, _resolve_ticker - from services.database import get_iv_rank_percentile, save_iv_snapshot, get_iv_history + from services.iv_engine import get_atm_iv, _resolve_ticker + from services.database import get_iv_rank_percentile, save_iv_snapshot, get_iv_history, get_watchlist_tickers from datetime import date results = [] today = date.today().isoformat() - for ticker in IV_WATCHLIST: + for ticker in get_watchlist_tickers(): key = f"watchlist:{ticker}" if not _is_stale(key): results.append(_iv_cache[key]["data"]) @@ -128,16 +128,48 @@ def get_iv_history_endpoint(ticker: str, days: int = 90): return {"ticker": ticker, "proxy": proxy, "history": history, "count": len(history)} +@router.get("/watchlist-tickers") +def list_watchlist_tickers(): + """List all watchlist tickers (active + inactive) with metadata.""" + from services.database import get_watchlist_entries + return {"tickers": get_watchlist_entries()} + + +@router.post("/watchlist-tickers/{ticker}") +def add_to_watchlist(ticker: str, background_tasks: BackgroundTasks): + """Add a ticker to the IV watchlist and start background bootstrap.""" + from services.database import add_watchlist_ticker + from services.iv_engine import _resolve_ticker + proxy = _resolve_ticker(ticker.upper()) + is_new = add_watchlist_ticker(proxy, added_by="manual") + if is_new: + def _bootstrap(): + from services.iv_engine import bootstrap_iv_history + bootstrap_iv_history(tickers=[proxy], min_existing=0) + logger.info(f"[Watchlist] Bootstrap done for new ticker {proxy}") + background_tasks.add_task(_bootstrap) + return {"ticker": proxy, "is_new": is_new, "bootstrap": is_new} + + +@router.delete("/watchlist-tickers/{ticker}") +def remove_from_watchlist(ticker: str): + """Remove (soft-delete) a ticker from the watchlist.""" + from services.database import remove_watchlist_ticker + from services.iv_engine import _resolve_ticker + proxy = _resolve_ticker(ticker.upper()) + ok = remove_watchlist_ticker(proxy) + return {"ticker": proxy, "removed": ok} + + @router.post("/refresh-watchlist") def refresh_watchlist(background_tasks: BackgroundTasks): - """ - Trigger a background refresh of IV data for all watchlist tickers. - Call this once daily to build up the 52-week IV history. - """ + """Trigger a background refresh of IV data for all watchlist tickers.""" def _refresh(): - from services.iv_engine import IV_WATCHLIST, get_full_iv_snapshot - logger.info(f"[IVRefresh] Refreshing IV for {len(IV_WATCHLIST)} tickers") - for ticker in IV_WATCHLIST: + from services.iv_engine import get_full_iv_snapshot + from services.database import get_watchlist_tickers + tickers = get_watchlist_tickers() + logger.info(f"[IVRefresh] Refreshing IV for {len(tickers)} tickers") + for ticker in tickers: try: snap = get_full_iv_snapshot(ticker) key = f"snapshot:{ticker}" @@ -148,21 +180,21 @@ def refresh_watchlist(background_tasks: BackgroundTasks): logger.warning(f"[IVRefresh] {ticker} failed: {e}") logger.info("[IVRefresh] Done") + from services.database import get_watchlist_tickers + count = len(get_watchlist_tickers()) background_tasks.add_task(_refresh) - return {"status": "refresh started", "tickers": len(__import__("services.iv_engine", fromlist=["IV_WATCHLIST"]).IV_WATCHLIST)} + return {"status": "refresh started", "tickers": count} @router.post("/bootstrap-history") def bootstrap_iv_history_endpoint(background_tasks: BackgroundTasks, min_existing: int = 30): - """ - Bootstrap iv_history with 1 year of realized volatility for all watchlist tickers. - Only fills missing dates — safe to call multiple times. - Runs in background (takes ~30-60s for 18 tickers). - """ + """Bootstrap iv_history with 1 year of realized vol for all watchlist tickers.""" def _run(): - from services.iv_engine import bootstrap_iv_history, IV_WATCHLIST - logger.info(f"[IV Bootstrap] Starting for {len(IV_WATCHLIST)} tickers") - result = bootstrap_iv_history(tickers=IV_WATCHLIST, min_existing=min_existing) + from services.iv_engine import bootstrap_iv_history + from services.database import get_watchlist_tickers + tickers = get_watchlist_tickers() + logger.info(f"[IV Bootstrap] Starting for {len(tickers)} tickers") + result = bootstrap_iv_history(tickers=tickers, min_existing=min_existing) total = sum(v.get("inserted", 0) for v in result.values() if isinstance(v, dict)) logger.info(f"[IV Bootstrap] Done — {total} rows inserted across {len(result)} tickers") diff --git a/backend/services/auto_cycle.py b/backend/services/auto_cycle.py index 8ccee10..fff6acf 100644 --- a/backend/services/auto_cycle.py +++ b/backend/services/auto_cycle.py @@ -116,7 +116,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: get_config, get_custom_patterns, save_custom_pattern, save_pattern_scores, log_macro_regime, log_geo_alert, log_trade_entries, add_cycle_run, update_cycle_run, save_reasoning_trace, - get_latest_portfolio_lessons, + get_latest_portfolio_lessons, log_system_event, ) from services.data_fetcher import fetch_geo_news, get_all_quotes, get_macro_gauges, score_macro_scenarios from services.geo_analyzer import compute_geo_risk_score @@ -466,6 +466,28 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: log_trade_entries(run_id=scoring_run_id, scored_patterns=scored, quotes=quotes) + # Auto-add any new underlying tickers to the IV watchlist + try: + from services.database import add_watchlist_ticker, get_watchlist_tickers + from services.iv_engine import _resolve_ticker, bootstrap_iv_history + existing = set(get_watchlist_tickers()) + new_proxies = set() + for sp in scored: + for trade in (sp.get("trade_rankings") or sp.get("suggested_trades") or []): + underlying = trade.get("underlying") or sp.get("underlying") or "" + if underlying: + proxy = _resolve_ticker(underlying) + if proxy not in existing: + new_proxies.add(proxy) + for proxy in new_proxies: + if add_watchlist_ticker(proxy, added_by="cycle"): + _log.info(f"[Watchlist] Auto-added new ticker: {proxy}") + log_system_event("INFO", "auto_cycle", f"Nouveau ticker ajouté à la watchlist IV: {proxy}", cycle_id=scoring_run_id, ticker=proxy) + if new_proxies: + bootstrap_iv_history(tickers=list(new_proxies), min_existing=0) + except Exception as _we: + _log.warning(f"[Watchlist] Auto-add failed: {_we}") + gauges_summary = { k: {"value": v.get("value"), "change_pct": v.get("change_pct"), "label": v.get("label")} for k, v in gauges.items() diff --git a/backend/services/database.py b/backend/services/database.py index 66a07e2..7e2da92 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -342,10 +342,31 @@ def init_db(): created_at TEXT DEFAULT (datetime('now')) )""") + c.execute("""CREATE TABLE IF NOT EXISTS iv_watchlist ( + ticker TEXT PRIMARY KEY, + added_date TEXT NOT NULL DEFAULT (date('now')), + added_by TEXT DEFAULT 'builtin', + is_active INTEGER DEFAULT 1 + )""") + + c.execute("""CREATE TABLE IF NOT EXISTS system_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL DEFAULT (datetime('now')), + level TEXT NOT NULL, + source TEXT, + cycle_id TEXT, + ticker TEXT, + message TEXT NOT NULL, + details TEXT + )""") + try: c.execute("CREATE INDEX IF NOT EXISTS idx_kb_category ON knowledge_base(category, status)") c.execute("CREATE INDEX IF NOT EXISTS idx_rs_version ON reasoning_state(version DESC)") c.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_iv_history_ticker_date ON iv_history(ticker, recorded_date)") + c.execute("CREATE INDEX IF NOT EXISTS idx_syslog_ts ON system_logs(ts DESC)") + c.execute("CREATE INDEX IF NOT EXISTS idx_syslog_level ON system_logs(level, ts DESC)") + c.execute("CREATE INDEX IF NOT EXISTS idx_syslog_cycle ON system_logs(cycle_id, ts DESC)") except Exception: pass @@ -361,6 +382,18 @@ def init_db(): (_key, _val) ) + # Seed built-in watchlist tickers (idempotent) + _builtin_tickers = [ + "SPY", "QQQ", "GLD", "SLV", "USO", "BNO", "UNG", + "XLE", "UUP", "TLT", "GDX", "EWJ", "FEZ", + "XOM", "CVX", "LMT", "RTX", "BA", + ] + for _t in _builtin_tickers: + c.execute( + "INSERT OR IGNORE INTO iv_watchlist (ticker, added_by) VALUES (?, 'builtin')", + (_t,) + ) + conn.commit() conn.close() @@ -1637,6 +1670,134 @@ def get_iv_history(ticker: str, days: int = 90) -> List[Dict]: return [dict(r) for r in rows] +# ── IV Watchlist ────────────────────────────────────────────────────────────── + +def get_watchlist_tickers() -> List[str]: + conn = get_conn() + rows = conn.execute( + "SELECT ticker FROM iv_watchlist WHERE is_active=1 ORDER BY added_by='builtin' DESC, added_date ASC" + ).fetchall() + conn.close() + return [r["ticker"] for r in rows] + + +def get_watchlist_entries() -> List[Dict]: + conn = get_conn() + rows = conn.execute( + "SELECT ticker, added_date, added_by, is_active FROM iv_watchlist ORDER BY added_by, ticker" + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def add_watchlist_ticker(ticker: str, added_by: str = "manual") -> bool: + """Add ticker to watchlist. Returns True if newly inserted, False if already existed.""" + conn = get_conn() + existing = conn.execute("SELECT ticker, is_active FROM iv_watchlist WHERE ticker=?", (ticker.upper(),)).fetchone() + if existing: + if not existing["is_active"]: + conn.execute("UPDATE iv_watchlist SET is_active=1, added_by=? WHERE ticker=?", (added_by, ticker.upper())) + conn.commit() + conn.close() + return True + conn.close() + return False + conn.execute( + "INSERT INTO iv_watchlist (ticker, added_date, added_by) VALUES (?, date('now'), ?)", + (ticker.upper(), added_by) + ) + conn.commit() + conn.close() + return True + + +def remove_watchlist_ticker(ticker: str) -> bool: + conn = get_conn() + conn.execute("UPDATE iv_watchlist SET is_active=0 WHERE ticker=?", (ticker.upper(),)) + changed = conn.total_changes > 0 + conn.commit() + conn.close() + return changed + + +# ── System Logs ─────────────────────────────────────────────────────────────── + +def log_system_event( + level: str, + source: str, + message: str, + cycle_id: Optional[str] = None, + ticker: Optional[str] = None, + details: Optional[Dict] = None, +) -> None: + try: + conn = get_conn() + conn.execute( + """INSERT INTO system_logs (level, source, cycle_id, ticker, message, details) + VALUES (?, ?, ?, ?, ?, ?)""", + ( + level.upper(), + source, + cycle_id, + ticker.upper() if ticker else None, + message, + json.dumps(details, default=str) if details else None, + ), + ) + conn.commit() + conn.close() + except Exception: + pass # Never raise from logging + + +def get_system_logs( + level: Optional[str] = None, + source: Optional[str] = None, + cycle_id: Optional[str] = None, + ticker: Optional[str] = None, + date_from: Optional[str] = None, + date_to: Optional[str] = None, + limit: int = 300, +) -> List[Dict]: + conn = get_conn() + clauses = [] + params: List[Any] = [] + if level: + clauses.append("level = ?") + params.append(level.upper()) + if source: + clauses.append("source LIKE ?") + params.append(f"%{source}%") + if cycle_id: + clauses.append("cycle_id = ?") + params.append(cycle_id) + if ticker: + clauses.append("ticker = ?") + params.append(ticker.upper()) + if date_from: + clauses.append("ts >= ?") + params.append(date_from) + if date_to: + clauses.append("ts <= ?") + params.append(date_to + "T23:59:59") + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + rows = conn.execute( + f"SELECT id, ts, level, source, cycle_id, ticker, message, details FROM system_logs {where} ORDER BY ts DESC LIMIT ?", + params + [limit], + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def clear_system_logs(older_than_days: int = 30) -> int: + conn = get_conn() + conn.execute(f"DELETE FROM system_logs WHERE ts < datetime('now', '-{older_than_days} days')") + deleted = conn.total_changes + conn.commit() + conn.close() + return deleted + + # ── Knowledge Base Decay ────────────────────────────────────────────────────── def decay_kb_confidence() -> int: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b1c16fe..8279238 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 23d903b..5c56b99 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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' }, ] diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 525e557..946e09f 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -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), + }) diff --git a/frontend/src/pages/OptionsLab.tsx b/frontend/src/pages/OptionsLab.tsx index 627c224..fa175c0 100644 --- a/frontend/src/pages/OptionsLab.tsx +++ b/frontend/src/pages/OptionsLab.tsx @@ -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 ( +
+
+ + Gestion de la Watchlist IV + {active.length} tickers actifs +
+ + {/* Add ticker */} +
+ setInput(e.target.value.toUpperCase())} + onKeyDown={e => e.key === 'Enter' && handleAdd()} + /> + +
+

Le système bootstrappe automatiquement 1 an d'historique IV pour chaque nouveau ticker ajouté.

+ + {/* Active tickers */} +
+ {active.map(e => ( + + {e.ticker} + {e.added_by !== 'builtin' && ( + + )} + + ))} +
+
+ Builtins   + Auto (cycle)   + Manuel +
+ + {inactive.length > 0 && ( + + )} + {showInactive && inactive.map(e => ( + + {e.ticker} + + ))} +
+ ) +} + // ── Main page ───────────────────────────────────────────────────────────────── export default function OptionsLab() { const { data, isLoading, refetch, isFetching } = useIvWatchlist() @@ -390,6 +491,8 @@ export default function OptionsLab() { )} )} + + ) } diff --git a/frontend/src/pages/SystemLogs.tsx b/frontend/src/pages/SystemLogs.tsx new file mode 100644 index 0000000..51b9588 --- /dev/null +++ b/frontend/src/pages/SystemLogs.tsx @@ -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 = { + INFO: { cls: 'bg-slate-700 text-slate-300', icon: }, + WARNING: { cls: 'bg-amber-900/60 text-amber-300 border border-amber-700/40', icon: }, + ERROR: { cls: 'bg-red-900/60 text-red-300 border border-red-700/40', icon: }, + CRITICAL: { cls: 'bg-red-800 text-white border border-red-600', icon: }, + } + const { cls, icon } = cfg[level] ?? cfg['INFO'] + return ( + + {icon} {level} + + ) +} + +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 ( + <> + + {ts} + + {log.source} + + {log.cycle_id ? log.cycle_id.slice(0, 16) + '…' : '—'} + + {log.ticker ?? '—'} + +
+ {hasDetails && ( + + )} + {log.message} +
+ + + {expanded && hasDetails && ( + + +
+              {JSON.stringify(JSON.parse(log.details), null, 2)}
+            
+ + + )} + + ) +} + +export default function SystemLogs() { + const qc = useQueryClient() + const [filters, setFilters] = useState({ 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 ( +
+ {/* Header */} +
+
+

Logs Système

+

Erreurs, avertissements et événements des cycles IA

+
+
+ + +
+
+ + {/* Summary chips */} +
+ {[ + { 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 }) => ( + + ))} + {logs.length} entrées +
+ + {/* Filters */} +
+
+ + +
+ +
+ + +
+ +
+ + setTickerInput(e.target.value.toUpperCase())} + onBlur={() => applyFilter('ticker', tickerInput)} + onKeyDown={e => e.key === 'Enter' && applyFilter('ticker', tickerInput)} + /> +
+ +
+
+ + applyFilter('date_from', e.target.value)} + /> +
+
+ + applyFilter('date_to', e.target.value)} + /> +
+
+
+ + {/* Log table */} +
+ {isLoading ? ( +
Chargement…
+ ) : logs.length === 0 ? ( +
+ Aucun log trouvé pour ces filtres.
+ Les WARNING et ERROR des cycles apparaissent ici automatiquement. +
+ ) : ( +
+ + + + + + + + + + + + + {logs.map(log => )} + +
HorodatageNiveauSourceCycleTickerMessage
+
+ )} +
+
+ ) +}