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:
@@ -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("/")
|
||||
|
||||
75
backend/routers/logs.py
Normal file
75
backend/routers/logs.py
Normal file
@@ -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}
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
<span className="text-blue-700 border-b border-blue-700">■</span> Auto (cycle)
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
256
frontend/src/pages/SystemLogs.tsx
Normal file
256
frontend/src/pages/SystemLogs.tsx
Normal 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 >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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user