432 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|