409 lines
16 KiB
TypeScript
409 lines
16 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { Play, CheckSquare, Clock, AlertCircle, ChevronDown, ChevronUp, Loader2, Database } from 'lucide-react'
|
|
|
|
const API = ''
|
|
|
|
interface ActionDef {
|
|
id: string
|
|
label: string
|
|
description: string
|
|
status: 'idle' | 'running'
|
|
implemented: boolean
|
|
group: string
|
|
}
|
|
|
|
interface CreatedEvent {
|
|
name: string
|
|
category: string
|
|
date: string
|
|
source: string
|
|
}
|
|
|
|
interface ActionResult {
|
|
action: string
|
|
status: string
|
|
started_at: string
|
|
finished_at: string
|
|
duration_s: number
|
|
total_created: number
|
|
detail: {
|
|
news: CreatedEvent[]
|
|
eco: CreatedEvent[]
|
|
technical: CreatedEvent[]
|
|
reports: CreatedEvent[]
|
|
}
|
|
}
|
|
|
|
const SOURCE_LABELS: Record<string, string> = {
|
|
news: 'News',
|
|
eco: 'Éco. Calendar',
|
|
technical: 'Technique',
|
|
reports: 'Reports',
|
|
}
|
|
|
|
const CAT_COLORS: Record<string, string> = {
|
|
event_calendar: 'text-amber-400',
|
|
geopolitical: 'text-red-400',
|
|
fundamental: 'text-emerald-400',
|
|
report: 'text-blue-400',
|
|
sentiment: 'text-purple-400',
|
|
technical: 'text-cyan-400',
|
|
}
|
|
|
|
// ── Simple bootstrap action button ───────────────────────────────────────────
|
|
|
|
function BootstrapAction({ action }: { action: ActionDef }) {
|
|
const [running, setRunning] = useState(false)
|
|
const [result, setResult] = useState<{ inserted?: number; updated?: number; skipped?: number } | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [force, setForce] = useState(false)
|
|
|
|
const run = async () => {
|
|
setRunning(true)
|
|
setResult(null)
|
|
setError(null)
|
|
try {
|
|
const res = await fetch(`${API}/api/actions/${action.id}?force=${force}`, { method: 'POST' })
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
|
throw new Error(err.detail || res.statusText)
|
|
}
|
|
const data = await res.json()
|
|
setResult(data)
|
|
} catch (e: any) {
|
|
setError(e.message)
|
|
} finally {
|
|
setRunning(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="bg-dark-800/60 border border-slate-700/40 rounded-xl px-5 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<Database size={14} className="text-blue-400 shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-slate-100 text-sm font-medium">{action.label}</div>
|
|
<div className="text-slate-500 text-xs mt-0.5">{action.description}</div>
|
|
</div>
|
|
<label className="flex items-center gap-1.5 text-xs text-slate-500 cursor-pointer shrink-0">
|
|
<input type="checkbox" checked={force} onChange={e => setForce(e.target.checked)}
|
|
className="w-3 h-3 accent-blue-500" />
|
|
Force
|
|
</label>
|
|
<button
|
|
disabled={running}
|
|
onClick={run}
|
|
className="flex items-center gap-2 bg-blue-700 hover:bg-blue-600 disabled:bg-slate-700
|
|
disabled:text-slate-500 text-white text-xs font-medium rounded-lg px-3 py-2 transition-colors shrink-0"
|
|
>
|
|
{running ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />}
|
|
{running ? 'En cours...' : 'Lancer'}
|
|
</button>
|
|
</div>
|
|
{error && (
|
|
<div className="mt-2 flex items-center gap-2 text-red-400 text-xs bg-red-900/20 rounded px-3 py-1.5 border border-red-800/30">
|
|
<AlertCircle size={12} /> {error}
|
|
</div>
|
|
)}
|
|
{result && !error && (
|
|
<div className="mt-2 text-xs text-emerald-300 bg-emerald-900/20 rounded px-3 py-1.5 border border-emerald-800/30">
|
|
✓ Terminé —
|
|
{result.inserted != null && ` ${result.inserted} insérés`}
|
|
{result.updated != null && ` · ${result.updated} mis à jour`}
|
|
{result.skipped != null && ` · ${result.skipped} ignorés`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Check Market Events config panel ────────────────────────────────────────
|
|
|
|
interface CheckEventsConfig {
|
|
sources: string[]
|
|
news_impact_min: number
|
|
eco_z_threshold: number
|
|
technical_lookback_days: number
|
|
report_days: number
|
|
report_min_importance: number
|
|
date_from: string
|
|
date_to: string
|
|
}
|
|
|
|
const SCAN_PRESETS = [
|
|
{ label: '6h', hours: 6 },
|
|
{ label: '12h', hours: 12 },
|
|
{ label: '24h', hours: 24 },
|
|
{ label: '48h', hours: 48 },
|
|
{ label: '72h', hours: 72 },
|
|
{ label: '7j', hours: 168 },
|
|
]
|
|
|
|
function presetDates(hours: number): { date_from: string; date_to: string } {
|
|
const now = new Date()
|
|
const from = new Date(now.getTime() - hours * 3_600_000)
|
|
const fmt = (d: Date) => d.toISOString().slice(0, 10)
|
|
return { date_from: fmt(from), date_to: fmt(now) }
|
|
}
|
|
|
|
function makeDefaultConfig(): CheckEventsConfig {
|
|
return {
|
|
sources: ['news', 'eco', 'technical', 'reports'],
|
|
news_impact_min: 0.55,
|
|
eco_z_threshold: 1.5,
|
|
technical_lookback_days: 7,
|
|
report_days: 7,
|
|
report_min_importance: 3,
|
|
...presetDates(48),
|
|
}
|
|
}
|
|
|
|
function CheckEventsPanel() {
|
|
const [config, setConfig] = useState<CheckEventsConfig>(makeDefaultConfig)
|
|
const [running, setRunning] = useState(false)
|
|
const [result, setResult] = useState<ActionResult | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [expanded, setExpanded] = useState(false)
|
|
const [showConfig, setShowConfig] = useState(false)
|
|
|
|
const toggleSource = (src: string) => {
|
|
setConfig(c => ({
|
|
...c,
|
|
sources: c.sources.includes(src)
|
|
? c.sources.filter(s => s !== src)
|
|
: [...c.sources, src],
|
|
}))
|
|
}
|
|
|
|
const run = async () => {
|
|
setRunning(true)
|
|
setResult(null)
|
|
setError(null)
|
|
try {
|
|
const res = await fetch(`${API}/api/actions/check-market-events`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(config),
|
|
})
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
|
throw new Error(err.detail || res.statusText)
|
|
}
|
|
const data: ActionResult = await res.json()
|
|
setResult(data)
|
|
setExpanded(true)
|
|
} catch (e: any) {
|
|
setError(e.message)
|
|
} finally {
|
|
setRunning(false)
|
|
}
|
|
}
|
|
|
|
const allEvents = result
|
|
? [...(result.detail.news || []), ...(result.detail.eco || []),
|
|
...(result.detail.technical || []), ...(result.detail.reports || [])]
|
|
: []
|
|
|
|
return (
|
|
<div className="bg-dark-800/60 border border-slate-700/40 rounded-xl overflow-hidden">
|
|
<div className="flex items-center gap-3 px-5 py-4">
|
|
<div className="flex items-center gap-2 flex-1">
|
|
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
|
<span className="text-slate-100 font-semibold text-sm">Check New Market Events</span>
|
|
<span className="text-xs text-slate-500 ml-1">
|
|
Scans news · FRED · MA crossovers · rapports institutionnels
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowConfig(x => !x)}
|
|
className="text-xs text-slate-400 hover:text-slate-200 border border-slate-700 rounded px-2 py-1"
|
|
>
|
|
{showConfig ? 'Masquer config' : 'Config'}
|
|
</button>
|
|
<button
|
|
disabled={running || config.sources.length === 0}
|
|
onClick={run}
|
|
className="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-700
|
|
disabled:text-slate-500 text-white text-sm font-medium rounded-lg px-4 py-2 transition-colors"
|
|
>
|
|
{running ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
|
|
{running ? 'En cours...' : 'Lancer'}
|
|
</button>
|
|
</div>
|
|
|
|
{showConfig && (
|
|
<div className="px-5 pb-5 border-t border-slate-700/30 pt-4 space-y-4 text-sm">
|
|
|
|
{/* Fenêtre de scan */}
|
|
<div>
|
|
<div className="text-slate-400 mb-2 text-xs uppercase tracking-wide">Fenêtre de scan</div>
|
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
|
{SCAN_PRESETS.map(p => {
|
|
const { date_from, date_to } = presetDates(p.hours)
|
|
const active = config.date_from === date_from && config.date_to === date_to
|
|
return (
|
|
<button key={p.label}
|
|
onClick={() => setConfig(c => ({ ...c, ...presetDates(p.hours) }))}
|
|
className={`px-3 py-1 rounded text-xs border transition-colors ${
|
|
active
|
|
? 'bg-blue-600/30 border-blue-500/50 text-blue-300'
|
|
: 'border-slate-700 text-slate-500 hover:border-slate-500 hover:text-slate-300'
|
|
}`}
|
|
>
|
|
{p.label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
<div className="flex gap-2 items-center">
|
|
<input type="date" value={config.date_from}
|
|
onChange={e => setConfig(c => ({ ...c, date_from: e.target.value }))}
|
|
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-xs flex-1" />
|
|
<span className="text-slate-600 text-xs">→</span>
|
|
<input type="date" value={config.date_to}
|
|
onChange={e => setConfig(c => ({ ...c, date_to: e.target.value }))}
|
|
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-xs flex-1" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sources */}
|
|
<div>
|
|
<div className="text-slate-400 mb-2 text-xs uppercase tracking-wide">Sources actives</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{['news', 'eco', 'technical', 'reports'].map(s => (
|
|
<button key={s} onClick={() => toggleSource(s)}
|
|
className={`px-3 py-1 rounded-full text-xs border transition-colors ${
|
|
config.sources.includes(s)
|
|
? 'bg-emerald-600/30 border-emerald-500/50 text-emerald-300'
|
|
: 'border-slate-700 text-slate-500 hover:border-slate-500'
|
|
}`}
|
|
>
|
|
{SOURCE_LABELS[s]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="mx-5 mb-4 flex items-center gap-2 text-red-400 text-sm bg-red-900/20 rounded-lg px-3 py-2 border border-red-800/30">
|
|
<AlertCircle size={14} /> {error}
|
|
</div>
|
|
)}
|
|
|
|
{result && (
|
|
<div className="border-t border-slate-700/30">
|
|
<div
|
|
className="flex items-center gap-4 px-5 py-3 cursor-pointer hover:bg-slate-800/30"
|
|
onClick={() => setExpanded(x => !x)}
|
|
>
|
|
<CheckSquare size={14} className="text-emerald-400" />
|
|
<span className="text-emerald-400 font-semibold text-sm">
|
|
{result.total_created} événement{result.total_created !== 1 ? 's' : ''} créé{result.total_created !== 1 ? 's' : ''}
|
|
</span>
|
|
<div className="flex gap-3 text-xs text-slate-400">
|
|
{(['news', 'eco', 'technical', 'reports'] as const).map(src => {
|
|
const count = result.detail[src]?.length ?? 0
|
|
return count > 0 ? (
|
|
<span key={src}>{SOURCE_LABELS[src]}: <span className="text-slate-200">{count}</span></span>
|
|
) : null
|
|
})}
|
|
</div>
|
|
<span className="ml-auto text-xs text-slate-500">{result.duration_s}s</span>
|
|
{expanded ? <ChevronUp size={14} className="text-slate-400" /> : <ChevronDown size={14} className="text-slate-400" />}
|
|
</div>
|
|
{expanded && allEvents.length > 0 && (
|
|
<div className="px-5 pb-4 space-y-1">
|
|
{allEvents.map((ev, i) => (
|
|
<div key={i} className="flex items-start gap-3 py-1.5 border-b border-slate-800/60 last:border-0">
|
|
<span className="text-xs text-slate-500 w-20 shrink-0">{ev.date}</span>
|
|
<span className={`text-xs font-mono w-20 shrink-0 ${CAT_COLORS[ev.category] ?? 'text-slate-400'}`}>
|
|
{ev.category?.replace('_', ' ')}
|
|
</span>
|
|
<span className="text-sm text-slate-200 flex-1">{ev.name}</span>
|
|
<span className="text-xs text-slate-600 shrink-0">{SOURCE_LABELS[ev.source] ?? ev.source}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{expanded && allEvents.length === 0 && (
|
|
<div className="px-5 pb-4 text-sm text-slate-500 italic">
|
|
Aucun nouvel événement — tout était déjà enregistré ou sous le seuil.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Planned action card ───────────────────────────────────────────────────────
|
|
|
|
function PlannedActionCard({ action }: { action: ActionDef }) {
|
|
return (
|
|
<div className="bg-dark-800/40 border border-slate-800/50 rounded-xl px-5 py-4 flex items-center gap-3 opacity-50">
|
|
<Clock size={14} className="text-slate-600 shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-slate-400 text-sm font-medium">{action.label}</div>
|
|
<div className="text-slate-600 text-xs mt-0.5 truncate">{action.description}</div>
|
|
</div>
|
|
<span className="text-xs text-slate-700 border border-slate-800 rounded px-2 py-0.5 shrink-0">Planifié</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
|
|
|
const GROUP_LABELS: Record<string, string> = {
|
|
detection: 'Détection',
|
|
bootstrap: 'Bootstrap (données initiales)',
|
|
data: 'Données & Indicateurs',
|
|
ai: 'Analyse IA',
|
|
portfolio: 'Portfolio & Rapport',
|
|
}
|
|
|
|
export default function CycleActions() {
|
|
const [actions, setActions] = useState<ActionDef[]>([])
|
|
|
|
useEffect(() => {
|
|
fetch(`${API}/api/actions`)
|
|
.then(r => r.json())
|
|
.then(d => setActions(d.actions || []))
|
|
.catch(() => {})
|
|
}, [])
|
|
|
|
const groups = ['detection', 'bootstrap', 'data', 'ai', 'portfolio']
|
|
|
|
return (
|
|
<div className="p-6 space-y-8 max-w-4xl">
|
|
<div>
|
|
<h1 className="text-xl font-bold text-slate-100">Actions Cycle</h1>
|
|
<p className="text-slate-400 text-sm mt-1">
|
|
Décomposition du cycle en actions isolées — lancez-les indépendamment pour tester ou rejouer une étape.
|
|
Les bootstraps auto au démarrage ont été désactivés.
|
|
</p>
|
|
</div>
|
|
|
|
{groups.map(group => {
|
|
const groupActions = actions.filter(a => a.group === group)
|
|
if (groupActions.length === 0) return null
|
|
const hasAny = groupActions.some(a => a.implemented)
|
|
return (
|
|
<div key={group} className="space-y-3">
|
|
<div className="text-xs uppercase tracking-widest text-slate-500 font-semibold border-b border-slate-800/60 pb-1.5">
|
|
{GROUP_LABELS[group] ?? group}
|
|
</div>
|
|
{groupActions.map(action => {
|
|
if (!action.implemented) return <PlannedActionCard key={action.id} action={action} />
|
|
if (action.id === 'check-market-events') return <CheckEventsPanel key={action.id} />
|
|
return <BootstrapAction key={action.id} action={action} />
|
|
})}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|