298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
'use client'
|
|
import { useEffect, useState, useRef, useCallback } from 'react'
|
|
import { motion } from 'framer-motion'
|
|
import { cn, formatUptime, timeAgo } from '@/lib/utils'
|
|
import { useEngineStore } from '@/store/engineStore'
|
|
import {
|
|
pauseEngine,
|
|
resumeEngine,
|
|
restartEngine,
|
|
killEngine,
|
|
} from '@/lib/api/engine'
|
|
import type { TargetSite } from '@/lib/types'
|
|
|
|
const BASE = 'http://localhost:8000'
|
|
const SITE_POLL_MS = 25_000
|
|
|
|
function StatusOrb({ status }: { status: 'Running' | 'Paused' | 'Idle' }) {
|
|
const map = {
|
|
Running: { color: '#00e87b', label: 'Running', shadow: 'rgba(0,232,123,0.5)' },
|
|
Paused: { color: '#fbbf24', label: 'Paused', shadow: 'rgba(251,191,36,0.5)' },
|
|
Idle: { color: '#3d4f78', label: 'Idle', shadow: 'rgba(61,79,120,0.3)' },
|
|
}
|
|
const m = map[status] ?? map.Idle
|
|
|
|
return (
|
|
<div className="flex items-center gap-2.5">
|
|
{/* Pulsing dot + aura */}
|
|
<div className="relative flex items-center justify-center">
|
|
{status === 'Running' && (
|
|
<span
|
|
className="absolute inline-flex rounded-full opacity-60"
|
|
style={{
|
|
width: 20, height: 20,
|
|
background: m.color,
|
|
filter: 'blur(6px)',
|
|
animation: 'glow-pulse 2s ease-in-out infinite',
|
|
}}
|
|
/>
|
|
)}
|
|
<span
|
|
className="relative inline-flex rounded-full"
|
|
style={{
|
|
width: 10, height: 10,
|
|
background: m.color,
|
|
boxShadow: `0 0 10px ${m.shadow}`,
|
|
animation: status === 'Running' ? 'pulse-ring 2s ease-out infinite' : undefined,
|
|
}}
|
|
/>
|
|
</div>
|
|
<span
|
|
className="text-[13px] font-bold tracking-tight"
|
|
style={{ color: m.color }}
|
|
>
|
|
{m.label}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface CtrlBtn {
|
|
label: string
|
|
icon: React.ReactNode
|
|
onClick: () => void
|
|
variant: 'default' | 'danger'
|
|
disabled?: boolean
|
|
}
|
|
|
|
function ControlButton({ label, icon, onClick, variant, disabled }: CtrlBtn) {
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
const handle = async () => {
|
|
if (busy || disabled) return
|
|
setBusy(true)
|
|
try { await onClick() } finally {
|
|
setTimeout(() => setBusy(false), 1500)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<motion.button
|
|
whileHover={!disabled ? { scale: 1.03, y: -1 } : undefined}
|
|
whileTap={!disabled ? { scale: 0.96 } : undefined}
|
|
onClick={handle}
|
|
disabled={disabled || busy}
|
|
className={cn(
|
|
'flex items-center justify-center gap-1.5 rounded-xl text-[11px] font-semibold px-3 py-2.5 transition-all duration-200',
|
|
'border disabled:opacity-40 disabled:cursor-not-allowed',
|
|
variant === 'danger'
|
|
? 'border-g-red/25 bg-g-red/8 text-g-red hover:bg-g-red/15 hover:border-g-red/40 hover:shadow-[0_0_20px_rgba(244,63,94,0.12)]'
|
|
: 'border-g-border/60 bg-g-raised/60 text-g-muted hover:text-g-text hover:border-g-line hover:bg-g-raised hover:shadow-[0_4px_16px_rgba(0,0,0,0.4)]',
|
|
)}
|
|
>
|
|
{busy ? (
|
|
<svg
|
|
className="animate-spin shrink-0"
|
|
width="11" height="11" viewBox="0 0 24 24"
|
|
fill="none" stroke="currentColor" strokeWidth="2.5"
|
|
>
|
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
|
</svg>
|
|
) : icon}
|
|
{label}
|
|
</motion.button>
|
|
)
|
|
}
|
|
|
|
function SiteRow({ site }: { site: TargetSite }) {
|
|
const isEnabled = site.enabled === 1
|
|
const isCooling = site.cooldown_until ? new Date(site.cooldown_until) > new Date() : false
|
|
const hasError = site.consecutive_failures > 0
|
|
|
|
let statusColor = '#3d4f78'
|
|
let statusLabel = 'Disabled'
|
|
if (isEnabled && isCooling) { statusColor = '#fbbf24'; statusLabel = 'Cooldown' }
|
|
else if (isEnabled && hasError) { statusColor = '#f43f5e'; statusLabel = `${site.consecutive_failures}x fail` }
|
|
else if (isEnabled) { statusColor = '#00e87b'; statusLabel = 'Active' }
|
|
|
|
return (
|
|
<div className="flex items-center gap-2.5 py-2 px-4 border-b border-g-border/15 last:border-0 group">
|
|
<span
|
|
className="w-1.5 h-1.5 rounded-full shrink-0 transition-all"
|
|
style={{
|
|
background: statusColor,
|
|
boxShadow: isEnabled && !isCooling && !hasError ? `0 0 5px ${statusColor}80` : undefined,
|
|
}}
|
|
/>
|
|
<span className="flex-1 text-[11px] text-g-muted group-hover:text-g-text transition-colors truncate font-medium">
|
|
{site.name}
|
|
</span>
|
|
<span
|
|
className="text-[10px] font-semibold shrink-0 tabular-nums"
|
|
style={{ color: statusColor }}
|
|
>
|
|
{statusLabel}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function EngineConsole() {
|
|
const { status, uptime_seconds, total_scanned, last_cycle, isOffline } = useEngineStore()
|
|
const [sites, setSites] = useState<TargetSite[]>([])
|
|
const [cycleAgo, setCycleAgo] = useState('—')
|
|
const aliveRef = useRef(true)
|
|
|
|
const fetchSites = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`${BASE}/api/sites`)
|
|
const data: TargetSite[] = await res.json()
|
|
if (aliveRef.current) setSites(Array.isArray(data) ? data : [])
|
|
} catch { /* silent */ }
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
aliveRef.current = true
|
|
fetchSites()
|
|
const id = setInterval(fetchSites, SITE_POLL_MS)
|
|
return () => { aliveRef.current = false; clearInterval(id) }
|
|
}, [fetchSites])
|
|
|
|
// Live relative timestamp
|
|
useEffect(() => {
|
|
const tick = () => setCycleAgo(timeAgo(last_cycle === 'Never' ? null : last_cycle))
|
|
tick()
|
|
const id = setInterval(tick, 5000)
|
|
return () => clearInterval(id)
|
|
}, [last_cycle])
|
|
|
|
const isRunning = status === 'Running'
|
|
const isPaused = status === 'Paused'
|
|
const enabledCount = sites.filter(s => s.enabled === 1).length
|
|
|
|
const controls: CtrlBtn[] = [
|
|
{
|
|
label: 'Pause',
|
|
variant: 'default',
|
|
disabled: !isRunning,
|
|
onClick: () => pauseEngine(),
|
|
icon: (
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
label: 'Resume',
|
|
variant: 'default',
|
|
disabled: !isPaused,
|
|
onClick: () => resumeEngine(),
|
|
icon: (
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<polygon points="5 3 19 12 5 21 5 3"/>
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
label: 'Restart',
|
|
variant: 'default',
|
|
disabled: false,
|
|
onClick: () => restartEngine(),
|
|
icon: (
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-3.51"/>
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
label: 'Kill',
|
|
variant: 'danger',
|
|
disabled: false,
|
|
onClick: () => killEngine(),
|
|
icon: (
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
),
|
|
},
|
|
]
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 1, x: 12 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ delay: 0.2, duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
|
className="g-card flex flex-col overflow-hidden"
|
|
>
|
|
{/* ── Status Hero ── */}
|
|
<div className="px-5 pt-5 pb-4 border-b border-g-border/40 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] font-bold uppercase tracking-[0.14em] text-g-faint">
|
|
Engine Status
|
|
</span>
|
|
{isOffline && (
|
|
<span className="g-badge g-badge-red text-[9px]">OFFLINE</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Big status */}
|
|
<StatusOrb status={status} />
|
|
|
|
{/* Telemetry strip */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="bg-g-raised/50 rounded-xl p-3 border border-g-border/30">
|
|
<p className="text-[9px] uppercase tracking-widest text-g-faint font-bold mb-1">Uptime</p>
|
|
<p className="text-sm font-bold text-g-text tabular-nums font-mono">
|
|
{formatUptime(uptime_seconds)}
|
|
</p>
|
|
</div>
|
|
<div className="bg-g-raised/50 rounded-xl p-3 border border-g-border/30">
|
|
<p className="text-[9px] uppercase tracking-widest text-g-faint font-bold mb-1">Last Scan</p>
|
|
<p className="text-sm font-bold text-g-text tabular-nums font-mono">
|
|
{cycleAgo}
|
|
</p>
|
|
</div>
|
|
<div className="bg-g-raised/50 rounded-xl p-3 border border-g-border/30 col-span-2">
|
|
<p className="text-[9px] uppercase tracking-widest text-g-faint font-bold mb-1">Lots Scanned</p>
|
|
<p className="text-sm font-bold text-g-text tabular-nums font-mono">
|
|
{total_scanned.toLocaleString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Controls ── */}
|
|
<div className="px-5 py-4 border-b border-g-border/40 space-y-2.5">
|
|
<span className="text-[10px] font-bold uppercase tracking-[0.14em] text-g-faint block">
|
|
Controls
|
|
</span>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{controls.map(btn => (
|
|
<ControlButton key={btn.label} {...btn} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Site Health ── */}
|
|
<div className="flex flex-col flex-1 min-h-0">
|
|
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-g-border/25">
|
|
<span className="text-[10px] font-bold uppercase tracking-[0.14em] text-g-faint">
|
|
Sites
|
|
</span>
|
|
<span className="text-[10px] text-g-faint/50 font-mono ml-auto">
|
|
{enabledCount}/{sites.length} active
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto" style={{ maxHeight: 200 }}>
|
|
{sites.length === 0 ? (
|
|
<p className="text-[11px] text-g-faint/40 text-center py-6">No sites configured</p>
|
|
) : (
|
|
sites.map(s => <SiteRow key={s.id} site={s} />)
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)
|
|
}
|