166 lines
5.7 KiB
TypeScript
166 lines
5.7 KiB
TypeScript
'use client'
|
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import { useEngineStore } from '@/store/engineStore'
|
|
|
|
interface LogEntry {
|
|
id: number
|
|
time: string
|
|
msg: string
|
|
level: 'info' | 'success' | 'warn' | 'error'
|
|
}
|
|
|
|
let _counter = 1
|
|
|
|
function makeEntry(msg: string, level: LogEntry['level'] = 'info'): LogEntry {
|
|
return { id: _counter++, time: new Date().toLocaleTimeString(), msg, level }
|
|
}
|
|
|
|
const LEVEL_STYLES: Record<LogEntry['level'], string> = {
|
|
info: 'text-g-muted',
|
|
success: 'text-g-green',
|
|
warn: 'text-g-amber',
|
|
error: 'text-g-red',
|
|
}
|
|
|
|
const LEVEL_DOT: Record<LogEntry['level'], string> = {
|
|
info: 'bg-g-faint/40',
|
|
success: 'bg-g-green',
|
|
warn: 'bg-g-amber',
|
|
error: 'bg-g-red',
|
|
}
|
|
|
|
export default function ActivityLog() {
|
|
const [entries, setEntries] = useState<LogEntry[]>(() => [
|
|
makeEntry('Ghost Node dashboard initialised.', 'success'),
|
|
])
|
|
const [filter, setFilter] = useState('')
|
|
const bottomRef = useRef<HTMLDivElement>(null)
|
|
|
|
const { status, last_cycle, total_alerts } = useEngineStore()
|
|
|
|
const prevStatus = useRef<string | null>(null)
|
|
const prevCycle = useRef<string | null>(null)
|
|
const prevAlerts = useRef<number>(-1)
|
|
|
|
const push = useCallback((msg: string, level: LogEntry['level'] = 'info') => {
|
|
setEntries(prev => [...prev.slice(-199), makeEntry(msg, level)])
|
|
}, [])
|
|
|
|
// Track engine status transitions
|
|
useEffect(() => {
|
|
if (prevStatus.current === null) { prevStatus.current = status; return }
|
|
if (prevStatus.current === status) return
|
|
if (status === 'Running') push('Engine started.', 'success')
|
|
else if (status === 'Paused') push('Engine paused.', 'warn')
|
|
else if (status === 'Idle') push('Engine stopped.', 'warn')
|
|
prevStatus.current = status
|
|
}, [status, push])
|
|
|
|
// Track scan cycles
|
|
useEffect(() => {
|
|
if (prevCycle.current === null) { prevCycle.current = last_cycle; return }
|
|
if (prevCycle.current === last_cycle) return
|
|
if (last_cycle && last_cycle !== 'Never') {
|
|
push('Scan cycle completed.', 'info')
|
|
}
|
|
prevCycle.current = last_cycle
|
|
}, [last_cycle, push])
|
|
|
|
// Track new alerts
|
|
useEffect(() => {
|
|
if (prevAlerts.current === -1) { prevAlerts.current = total_alerts; return }
|
|
if (total_alerts > prevAlerts.current) {
|
|
const delta = total_alerts - prevAlerts.current
|
|
push(`${delta} new alert${delta > 1 ? 's' : ''} fired!`, 'success')
|
|
}
|
|
prevAlerts.current = total_alerts
|
|
}, [total_alerts, push])
|
|
|
|
// Auto-scroll to bottom
|
|
useEffect(() => {
|
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}, [entries])
|
|
|
|
const filtered = filter
|
|
? entries.filter(e => e.msg.toLowerCase().includes(filter.toLowerCase()))
|
|
: entries
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 1, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.2, duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
|
|
className="g-card overflow-hidden"
|
|
>
|
|
{/* ── Header ── */}
|
|
<div className="flex items-center gap-3 px-5 py-3.5 border-b border-g-border/40">
|
|
<div className="flex items-center gap-2.5">
|
|
<span className="g-pulse-dot" />
|
|
<span className="text-sm font-bold text-g-text tracking-tight">Activity Log</span>
|
|
</div>
|
|
<span className="text-[10px] text-g-faint font-mono tabular-nums">
|
|
{entries.length} events
|
|
</span>
|
|
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<input
|
|
value={filter}
|
|
onChange={e => setFilter(e.target.value)}
|
|
placeholder="Filter events…"
|
|
className="g-input w-36 h-7 text-[11px] py-0 px-2.5"
|
|
/>
|
|
<button
|
|
onClick={() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' })}
|
|
className="g-btn h-7 px-2 text-xs"
|
|
title="Scroll to bottom"
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/>
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={() => setEntries([])}
|
|
className="g-btn h-7 px-2.5 text-xs hover:!text-g-red hover:!border-g-red/30"
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Body ── */}
|
|
<div className="h-48 overflow-y-auto p-4 font-mono">
|
|
{filtered.length === 0 ? (
|
|
<p className="text-g-faint/30 text-[11px] text-center py-8">No events</p>
|
|
) : (
|
|
<AnimatePresence mode="popLayout" initial={false}>
|
|
{filtered.map(e => (
|
|
<motion.div
|
|
key={e.id}
|
|
initial={{ opacity: 1, x: -6 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="flex items-start gap-3 py-1.5 group"
|
|
>
|
|
<span className="shrink-0 text-[10px] text-g-faint/40 pt-px tabular-nums leading-relaxed">
|
|
{e.time}
|
|
</span>
|
|
<span
|
|
className={`w-1 h-1 rounded-full mt-1.5 shrink-0 ${LEVEL_DOT[e.level]}`}
|
|
/>
|
|
<span
|
|
className={`text-[11px] leading-relaxed transition-opacity group-hover:opacity-100 opacity-80 ${LEVEL_STYLES[e.level]}`}
|
|
>
|
|
{e.msg}
|
|
</span>
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
)}
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
</motion.div>
|
|
)
|
|
}
|