Files
AetherForge/server/web/src/pages/ActivityFeedPage.tsx

432 lines
18 KiB
TypeScript

import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
import { formatHashrate } from '../help/fleetFilters';
import './ActivityFeedPage.css';
// ── Event types ───────────────────────────────────────────────────────────
export type ActivityEventKind =
| 'connect'
| 'disconnect'
| 'hashrate'
| 'share'
| 'alert'
| 'command'
| 'ai'
| 'posture'
| 'default';
export interface ActivityEvent {
id: string;
kind: ActivityEventKind;
agentId?: string;
agentName?: string;
message: string;
detail?: string;
ts: Date;
raw?: unknown;
}
let _eid = 0;
function eid() { return String(++_eid); }
// ── Visual config per kind ────────────────────────────────────────────────
const KIND_CONFIG: Record<ActivityEventKind, { icon: string; label: string; color: string }> = {
connect: { icon: '🟢', label: 'ONLINE', color: '#39ff14' },
disconnect: { icon: '🔴', label: 'OFFLINE', color: '#ff4444' },
hashrate: { icon: '⚡', label: 'HASHRATE', color: '#00e8f5' },
share: { icon: '✅', label: 'SHARE', color: '#b24bf3' },
alert: { icon: '⚠️', label: 'ALERT', color: '#ff6b35' },
command: { icon: '📡', label: 'COMMAND', color: '#ffb020' },
ai: { icon: '🤖', label: 'AI', color: '#ff2da6' },
posture: { icon: '🛡️', label: 'POSTURE', color: '#a8ff78' },
default: { icon: '·', label: 'EVENT', color: '#8899aa' },
};
// ALL_KINDS excludes 'default' — that kind is a fallback sentinel and is never
// actually emitted, so it would only create a permanently-zero filter chip.
const ALL_KINDS = (Object.keys(KIND_CONFIG) as ActivityEventKind[]).filter(
(k) => k !== 'default'
);
const MAX_EVENTS = 500;
function fmt(d: Date): string {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
// ── Event row ─────────────────────────────────────────────────────────────
function EventRow({ event }: { event: ActivityEvent }) {
const cfg = KIND_CONFIG[event.kind];
return (
<div className="activity-event">
<div className={`activity-event-accent accent--${event.kind}`} />
<span className="activity-event-icon">{cfg.icon}</span>
<div className="activity-event-body">
<div className="activity-event-main">
<span className={`activity-event-type-badge badge--${event.kind}`}>{cfg.label}</span>
{event.agentName && (
<span className="activity-event-agent" title={event.agentId}>
{event.agentName}
</span>
)}
<span className="activity-event-msg">{event.message}</span>
</div>
{event.detail && (
<div className="activity-event-detail">{event.detail}</div>
)}
</div>
<span className="activity-event-ts">{fmt(event.ts)}</span>
</div>
);
}
// ── Main page ─────────────────────────────────────────────────────────────
export default function ActivityFeedPage() {
const {
isConnected,
agents,
recentShares,
fleetAlerts,
commandResults,
aiActivity,
} = useWebSocket();
const [events, setEvents] = useState<ActivityEvent[]>([]);
const [activeFilters, setActiveFilters] = useState<Set<ActivityEventKind>>(new Set(ALL_KINDS));
const [search, setSearch] = useState('');
const [autoScroll, setAutoScroll] = useState(true);
const streamRef = useRef<HTMLDivElement>(null);
const agentMapRef = useRef<Map<string, string>>(new Map()); // id → name
const prevAgentStatus = useRef<Record<string, string>>({}); // id → status
const prevHashrates = useRef<Record<string, number>>({}); // id → hashrate_15m
const prevPosture = useRef<Record<string, number>>({}); // id → posture_score
// Build agent name lookup
useEffect(() => {
for (const a of agents) agentMapRef.current.set(a.id, a.name);
}, [agents]);
const push = useCallback((ev: ActivityEvent) => {
setEvents((prev) => [ev, ...prev].slice(0, MAX_EVENTS));
}, []);
// ── Agent status change events (online / offline) ──────────────────────
useEffect(() => {
for (const agent of agents) {
const prev = prevAgentStatus.current[agent.id];
if (prev === undefined) {
// First time we see this agent — synthetic "connect" on page load
prevAgentStatus.current[agent.id] = agent.status;
if (agent.status === 'online') {
push({
id: eid(), kind: 'connect',
agentId: agent.id, agentName: agent.name,
message: 'came online',
detail: `${agent.platform ?? 'unknown'} · ${agent.ip ?? '—'} · ${agent.cpu_cores}c`,
ts: new Date(),
});
}
continue;
}
if (prev !== agent.status) {
prevAgentStatus.current[agent.id] = agent.status;
if (agent.status === 'online') {
push({
id: eid(), kind: 'connect',
agentId: agent.id, agentName: agent.name,
message: 'reconnected',
detail: `${agent.platform ?? ''} · ${agent.ip ?? '—'}`,
ts: new Date(),
});
} else {
push({
id: eid(), kind: 'disconnect',
agentId: agent.id, agentName: agent.name,
message: 'went offline',
ts: new Date(),
});
}
}
}
}, [agents, push]);
// ── Hashrate spike events ──────────────────────────────────────────────
useEffect(() => {
for (const agent of agents) {
if (agent.status !== 'online') continue;
const prev = prevHashrates.current[agent.id];
const cur = agent.hashrate_15m ?? 0;
prevHashrates.current[agent.id] = cur;
if (prev === undefined || prev <= 0) continue;
const delta = cur - prev;
// Only emit if ≥20% change AND at least 100 H/s delta
if (Math.abs(delta) >= 100 && Math.abs(delta) / Math.max(prev, 1) >= 0.20) {
push({
id: eid(), kind: 'hashrate',
agentId: agent.id, agentName: agent.name,
message: delta > 0 ? `hashrate up to ${formatHashrate(cur)}` : `hashrate dropped to ${formatHashrate(cur)}`,
detail: `Δ${delta > 0 ? '+' : ''}${formatHashrate(delta)}`,
ts: new Date(),
});
}
}
}, [agents, push]);
// ── Posture score change events ────────────────────────────────────────
useEffect(() => {
for (const agent of agents) {
if (agent.posture_score == null) continue;
const prev = prevPosture.current[agent.id];
const cur = agent.posture_score;
prevPosture.current[agent.id] = cur;
if (prev === undefined) continue;
const delta = cur - prev;
if (Math.abs(delta) >= 10) {
push({
id: eid(), kind: 'posture',
agentId: agent.id, agentName: agent.name,
message: `posture score ${delta > 0 ? 'improved' : 'degraded'} to ${cur}/100`,
detail: `Δ${delta > 0 ? '+' : ''}${delta}`,
ts: new Date(),
});
}
}
}, [agents, push]);
// ── New share events ───────────────────────────────────────────────────
const lastShareId = useRef<string | null>(null);
useEffect(() => {
if (recentShares.length === 0) return;
const top = recentShares[0];
const key = top.id != null ? String(top.id) : `${top.agent_id}-${top.hash}`;
if (key === lastShareId.current) return;
lastShareId.current = key;
const name = agentMapRef.current.get(top.agent_id) ?? top.agent_id?.slice(0, 8);
push({
id: eid(), kind: 'share',
agentId: top.agent_id, agentName: name,
message: top.accepted ? 'share accepted by pool' : 'share rejected',
detail: top.accepted ? undefined : top.error ?? 'pool rejection',
ts: new Date(top.timestamp ?? Date.now()),
});
}, [recentShares, push]);
// ── Fleet alert events ─────────────────────────────────────────────────
const lastAlertId = useRef<string | null>(null);
useEffect(() => {
if (fleetAlerts.length === 0) return;
const top = fleetAlerts[0];
if (top.id === lastAlertId.current) return;
lastAlertId.current = top.id;
push({
id: eid(), kind: 'alert',
agentId: top.agent_id, agentName: top.agent_name,
message: top.message,
detail: top.type,
ts: new Date(top.timestamp ?? Date.now()),
});
}, [fleetAlerts, push]);
// ── Command result events ──────────────────────────────────────────────
const lastCmdSeq = useRef(-1);
useEffect(() => {
if (commandResults.length === 0) return;
const top = commandResults[commandResults.length - 1];
if ((top._seq ?? -1) <= lastCmdSeq.current) return;
lastCmdSeq.current = top._seq ?? -1;
const name = agentMapRef.current.get(top.agent_id ?? '') ?? top.agent_id?.slice(0, 8);
push({
id: eid(), kind: 'command',
agentId: top.agent_id, agentName: name,
message: `${top.action}${top.success ? 'success' : 'failed'}`,
detail: top.success ? undefined : top.message?.slice(0, 80),
ts: new Date(),
});
}, [commandResults, push]);
// ── AI activity events ─────────────────────────────────────────────────
const lastAiAgent = useRef<Record<string, string>>({});
useEffect(() => {
for (const entry of aiActivity) {
const lastAction = lastAiAgent.current[entry.agent_id];
if (entry.last_action && entry.last_action !== lastAction) {
lastAiAgent.current[entry.agent_id] = entry.last_action;
const name = agentMapRef.current.get(entry.agent_id) ?? entry.agent_id?.slice(0, 8);
push({
id: eid(), kind: 'ai',
agentId: entry.agent_id, agentName: name,
message: `AI decided: ${entry.last_action}`,
detail: entry.last_reasoning?.slice(0, 80),
ts: entry.last_decide_at ? new Date(entry.last_decide_at) : new Date(),
});
}
}
}, [aiActivity, push]);
// ── Auto-scroll ────────────────────────────────────────────────────────
useEffect(() => {
if (!autoScroll || !streamRef.current) return;
streamRef.current.scrollTop = 0; // newest is at top
}, [events, autoScroll]);
// ── Filtered view ──────────────────────────────────────────────────────
const filtered = useMemo(() => {
let list = events.filter((e) => activeFilters.has(e.kind));
if (search.trim()) {
const q = search.trim().toLowerCase();
list = list.filter((e) =>
(e.agentName ?? '').toLowerCase().includes(q) ||
e.message.toLowerCase().includes(q) ||
(e.detail ?? '').toLowerCase().includes(q)
);
}
return list;
}, [events, activeFilters, search]);
// ── Stats for pills ────────────────────────────────────────────────────
const onlineCount = agents.filter((a) => a.status === 'online').length;
const totalHashrate = agents.reduce((s, a) => s + (a.hashrate_15m ?? 0), 0);
const alertCount = fleetAlerts.length;
const toggleFilter = (kind: ActivityEventKind) => {
setActiveFilters((prev) => {
const next = new Set(prev);
if (next.has(kind)) { next.delete(kind); } else { next.add(kind); }
if (next.size === 0) return new Set(ALL_KINDS); // prevent empty
return next;
});
};
const countByKind = useMemo(() => {
const m: Record<string, number> = {};
for (const e of events) m[e.kind] = (m[e.kind] ?? 0) + 1;
return m;
}, [events]);
return (
<div className="page fade-in activity-page">
{/* ── Hero ─────────────────────────────────────────────────────────── */}
<header className="activity-hero">
<div className="activity-hero-text">
<p className="activity-eyebrow">REAL-TIME INTELLIGENCE</p>
<h1>Activity Feed</h1>
<p className="page-subtitle">
Live event stream · agent connects · hashrate · shares · commands · AI decisions
</p>
</div>
<div className="activity-live-badge">
<div className={`activity-live-dot ${isConnected ? '' : 'offline'}`} />
{isConnected ? 'LIVE' : 'DISCONNECTED'}
{isConnected && <span style={{ color: 'rgba(57,255,20,0.6)' }}>· {events.length} events</span>}
</div>
</header>
{/* ── Stats pills ──────────────────────────────────────────────────── */}
<div className="activity-stat-pills">
<div className="activity-stat-pill">
<div className="activity-stat-pill-dot" style={{ background: '#39ff14', boxShadow: '0 0 4px #39ff14' }} />
{onlineCount} / {agents.length} online
</div>
{totalHashrate > 0 && (
<div className="activity-stat-pill">
<div className="activity-stat-pill-dot" style={{ background: '#00e8f5' }} />
{formatHashrate(totalHashrate)}
</div>
)}
{alertCount > 0 && (
<div className="activity-stat-pill">
<div className="activity-stat-pill-dot" style={{ background: '#ff6b35' }} />
{alertCount} alert{alertCount !== 1 ? 's' : ''}
</div>
)}
<div className="activity-stat-pill" style={{ marginLeft: 'auto' }}>
<input
type="checkbox"
id="autoscroll-check"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
style={{ cursor: 'pointer', accentColor: '#00e8f5' }}
/>
<label htmlFor="autoscroll-check" style={{ cursor: 'pointer', color: 'var(--text-muted)', fontSize: '0.62rem', fontFamily: 'monospace' }}>
Auto-scroll
</label>
</div>
</div>
{/* ── Filter bar ───────────────────────────────────────────────────── */}
<div className="activity-filters">
<span className="activity-filter-label">FILTER:</span>
{ALL_KINDS.map((kind) => {
const cfg = KIND_CONFIG[kind];
const isActive = activeFilters.has(kind);
const count = countByKind[kind] ?? 0;
return (
<button
key={kind}
type="button"
className={`activity-type-chip ${isActive ? `active--${kind}` : ''}`}
onClick={() => toggleFilter(kind)}
title={`${isActive ? 'Hide' : 'Show'} ${cfg.label} events`}
>
{cfg.icon} {cfg.label}
{count > 0 && <span style={{ opacity: 0.65 }}> {count}</span>}
</button>
);
})}
<input
className="activity-search"
type="text"
placeholder="Search agent, message…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{(events.length > 0 || search) && (
<button
type="button"
className="activity-clear-btn"
onClick={() => { setEvents([]); setSearch(''); }}
>
CLEAR ALL
</button>
)}
</div>
{/* ── Event stream ─────────────────────────────────────────────────── */}
<div className="activity-stream-wrap">
<div className="activity-stream-header">
<span> LIVE EVENT STREAM</span>
<span>sorted by most recent</span>
<span className="activity-stream-count">
{filtered.length} events{search ? ' matching' : ''}
</span>
</div>
<div
ref={streamRef}
className="activity-stream"
onScroll={(e) => {
// Disable auto-scroll when user scrolls away from top
const el = e.currentTarget;
setAutoScroll(el.scrollTop < 60);
}}
>
{filtered.length === 0 ? (
<div className="activity-empty">
<span className="activity-empty-icon">📡</span>
{events.length === 0
? 'Waiting for fleet events…'
: 'No events match your filters.'}
</div>
) : (
filtered.map((ev) => <EventRow key={ev.id} event={ev} />)
)}
</div>
</div>
</div>
);
}