Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Operators can remove machines from Crucible and dashboard rosters with honest messaging that deletion is registry-only; bulk select, oath ledger entries, and Go/Vitest coverage included.
340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
|
import { WS_LATEST_MESSAGE_TYPES } from '../help/wsStatsCoalesce';
|
|
import { applyStatsUpdates } from '../help/applyStatsUpdate';
|
|
import type {
|
|
WSDashboardInit,
|
|
WSAgentOffline,
|
|
WSStatsUpdate,
|
|
WSStatsBatch,
|
|
WSCommandResult,
|
|
WSAgentLog,
|
|
WSPolicyAck,
|
|
} from '../types/ws';
|
|
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
|
|
import { WebSocketContext } from './WebSocketContext';
|
|
import type { SeqCommandResult } from './WebSocketContext';
|
|
import type { SeqPolicyAck } from './WebSocketContext';
|
|
import { authHeaders, getStoredAuth } from '../api/auth';
|
|
|
|
/**
|
|
* WebSocketProvider mounts a SINGLE WebSocket connection for the whole app.
|
|
* All components call useWebSocket() and receive data from this one connection
|
|
* — fixes M12 (duplicate connections when multiple components called the hook).
|
|
*/
|
|
export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const unmounted = useRef(false);
|
|
// Guard: true while an openSocket() invocation is in-flight (awaiting ticket fetch).
|
|
// A second connect() call while one is already opening is a no-op — prevents duplicate
|
|
// sockets when 'aetherforge-auth' fires during the async fetch.
|
|
const openingRef = useRef(false);
|
|
// AbortController for the current in-flight ws-ticket fetch; replaced on each open attempt.
|
|
const ticketAbortRef = useRef<AbortController | null>(null);
|
|
const [isConnected, setIsConnected] = useState(false);
|
|
const [agents, setAgents] = useState<Agent[]>([]);
|
|
const [recentShares, setRecentShares] = useState<Share[]>([]);
|
|
const [fleetAlerts, setFleetAlerts] = useState<FleetAlert[]>([]);
|
|
const [poolStatus, setPoolStatus] = useState<PoolStatus[]>([]);
|
|
const [aiActivity, setAiActivity] = useState<AIActivityEntry[]>([]);
|
|
const [agentLogs, setAgentLogs] = useState<Record<string, string>>({});
|
|
const [commandResults, setCommandResults] = useState<SeqCommandResult[]>([]);
|
|
const [policyAcks, setPolicyAcks] = useState<SeqPolicyAck[]>([]);
|
|
const [latestMessage, setLatestMessage] = useState<WSMessage | null>(null);
|
|
// Monotonic counter so consumers can detect new entries even after the ring buffer trims old ones
|
|
const cmdSeqRef = useRef(0);
|
|
const policyAckSeqRef = useRef(0);
|
|
|
|
const sendDashboardMessage = useCallback((type: string, payload: Record<string, unknown>) => {
|
|
const ws = wsRef.current;
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
ws.send(JSON.stringify({ type, payload }));
|
|
}, []);
|
|
|
|
const connect = useCallback(() => {
|
|
if (unmounted.current) return;
|
|
|
|
if (reconnectTimer.current) {
|
|
clearTimeout(reconnectTimer.current);
|
|
reconnectTimer.current = null;
|
|
}
|
|
|
|
const token = getStoredAuth();
|
|
if (!token) {
|
|
const existing = wsRef.current;
|
|
if (existing) {
|
|
existing.onclose = null;
|
|
existing.close();
|
|
wsRef.current = null;
|
|
}
|
|
setIsConnected(false);
|
|
return;
|
|
}
|
|
|
|
const existing = wsRef.current;
|
|
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
|
|
existing.onclose = null;
|
|
existing.close();
|
|
}
|
|
|
|
const openSocket = async () => {
|
|
if (openingRef.current) return;
|
|
openingRef.current = true;
|
|
|
|
// Cancel any previously in-flight ticket fetch before starting a new one.
|
|
if (ticketAbortRef.current) {
|
|
ticketAbortRef.current.abort();
|
|
}
|
|
const abortCtrl = new AbortController();
|
|
ticketAbortRef.current = abortCtrl;
|
|
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
let wsQuery = `token=${encodeURIComponent(token)}`;
|
|
try {
|
|
const resp = await fetch('/api/v1/auth/ws-ticket', {
|
|
method: 'POST',
|
|
headers: authHeaders(),
|
|
signal: abortCtrl.signal,
|
|
});
|
|
if (resp.ok) {
|
|
const data = (await resp.json()) as { ticket?: string };
|
|
if (data.ticket) {
|
|
wsQuery = `ticket=${encodeURIComponent(data.ticket)}`;
|
|
}
|
|
}
|
|
} catch {
|
|
/* fall back to legacy token query param — also catches AbortError */
|
|
} finally {
|
|
if (ticketAbortRef.current === abortCtrl) {
|
|
ticketAbortRef.current = null;
|
|
}
|
|
openingRef.current = false;
|
|
}
|
|
if (unmounted.current) return;
|
|
// If this invocation was aborted by a newer call, bail out — the newer
|
|
// call will (or already has) opened its own socket.
|
|
if (abortCtrl.signal.aborted) return;
|
|
|
|
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard?${wsQuery}`;
|
|
const ws = new WebSocket(wsUrl);
|
|
wsRef.current = ws;
|
|
|
|
ws.onopen = () => { if (!unmounted.current) setIsConnected(true); };
|
|
|
|
ws.onclose = () => {
|
|
if (unmounted.current) return;
|
|
setIsConnected(false);
|
|
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
|
if (!getStoredAuth()) return;
|
|
reconnectTimer.current = setTimeout(connect, 3000);
|
|
};
|
|
|
|
ws.onerror = () => { ws.close(); };
|
|
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data) as WSMessage;
|
|
if (WS_LATEST_MESSAGE_TYPES.has(msg.type)) {
|
|
setLatestMessage(msg);
|
|
}
|
|
|
|
switch (msg.type) {
|
|
case 'init': {
|
|
const data = msg.payload as WSDashboardInit;
|
|
if (data.agents) setAgents(data.agents);
|
|
break;
|
|
}
|
|
case 'agent_online': {
|
|
const agent = msg.payload as Agent;
|
|
setAgents((prev) => {
|
|
const idx = prev.findIndex((a) => a.id === agent.id);
|
|
if (idx >= 0) {
|
|
const updated = [...prev];
|
|
updated[idx] = { ...updated[idx], ...agent };
|
|
return updated;
|
|
}
|
|
return [...prev, agent];
|
|
});
|
|
break;
|
|
}
|
|
case 'agent_offline': {
|
|
const { agent_id } = msg.payload as WSAgentOffline;
|
|
setAgents((prev) =>
|
|
prev.map((a) => a.id === agent_id ? { ...a, status: 'offline' as const } : a)
|
|
);
|
|
break;
|
|
}
|
|
case 'agent_removed':
|
|
case 'agent_deleted': {
|
|
const { agent_id } = msg.payload as { agent_id: string };
|
|
setAgents((prev) => prev.filter((a) => a.id !== agent_id));
|
|
// Also purge any cached alerts for this agent so the dashboard
|
|
// alert banner stops showing errors for machines that no longer exist.
|
|
setFleetAlerts((prev) => prev.filter((a) => a.agent_id !== agent_id));
|
|
break;
|
|
}
|
|
case 'stats_update': {
|
|
const update = msg.payload as WSStatsUpdate;
|
|
setAgents((prev) => applyStatsUpdates(prev, [update]));
|
|
break;
|
|
}
|
|
case 'stats_batch': {
|
|
const batch = msg.payload as WSStatsBatch;
|
|
if (Array.isArray(batch?.updates) && batch.updates.length > 0) {
|
|
setAgents((prev) => applyStatsUpdates(prev, batch.updates));
|
|
}
|
|
break;
|
|
}
|
|
case 'clearance_elevated': {
|
|
const p = msg.payload as {
|
|
agent_id: string;
|
|
from_level: number;
|
|
to_level: number;
|
|
reason?: string;
|
|
source?: string;
|
|
};
|
|
setAgents((prev) =>
|
|
prev.map((a) =>
|
|
a.id === p.agent_id ? { ...a, clearance_level: p.to_level } : a,
|
|
),
|
|
);
|
|
break;
|
|
}
|
|
case 'new_share': {
|
|
const share = msg.payload as Share;
|
|
setRecentShares((prev) => [share, ...prev].slice(0, 50));
|
|
break;
|
|
}
|
|
case 'fleet_alert': {
|
|
const alert = msg.payload as FleetAlert;
|
|
setFleetAlerts((prev) => [alert, ...prev].slice(0, 20));
|
|
break;
|
|
}
|
|
case 'pool_status': {
|
|
const pools = msg.payload as PoolStatus[];
|
|
if (Array.isArray(pools)) setPoolStatus(pools);
|
|
break;
|
|
}
|
|
case 'ai_activity': {
|
|
const entry = msg.payload as AIActivityEntry;
|
|
setAiActivity((prev) => {
|
|
const idx = prev.findIndex((a) => a.agent_id === entry.agent_id);
|
|
if (idx >= 0) {
|
|
const next = [...prev];
|
|
next[idx] = entry;
|
|
return next;
|
|
}
|
|
return [...prev, entry];
|
|
});
|
|
break;
|
|
}
|
|
case 'command_result': {
|
|
// Payload must be an object — a double-encoded string would spread to
|
|
// char indices and drop agent_id, breaking Crucible terminal routing.
|
|
let p = msg.payload as WSCommandResult | string;
|
|
if (typeof p === 'string') {
|
|
try {
|
|
p = JSON.parse(p) as WSCommandResult;
|
|
} catch {
|
|
break;
|
|
}
|
|
}
|
|
const seq = ++cmdSeqRef.current;
|
|
// Cap at 2000; command results are rare (operator-triggered) so this is plenty.
|
|
// Consumers MUST use _seq for change detection — NOT array index — because the
|
|
// slice trims old entries and makes absolute indices stale.
|
|
setCommandResults((prev) => [...prev, { ...p, _seq: seq }].slice(-2000));
|
|
if (p.agent_id && p.action === 'get_log' && p.success && p.message) {
|
|
setAgentLogs((prev) => ({ ...prev, [p.agent_id!]: p.message! }));
|
|
}
|
|
break;
|
|
}
|
|
case 'policy_ack': {
|
|
const ack = msg.payload as WSPolicyAck;
|
|
policyAckSeqRef.current += 1;
|
|
setPolicyAcks((prev) => [...prev.slice(-49), { ...ack, _seq: policyAckSeqRef.current }]);
|
|
break;
|
|
}
|
|
case 'agent_log': {
|
|
const { agent_id, content } = msg.payload as WSAgentLog;
|
|
if (agent_id) setAgentLogs((prev) => ({ ...prev, [agent_id]: content }));
|
|
break;
|
|
}
|
|
case 'agent_capabilities': {
|
|
const { agent_id, capabilities } = msg.payload as {
|
|
agent_id: string;
|
|
capabilities: Agent['capabilities'];
|
|
};
|
|
if (!agent_id || !capabilities) break;
|
|
setAgents((prev) =>
|
|
prev.map((a) =>
|
|
a.id === agent_id
|
|
? {
|
|
...a,
|
|
capabilities: { ...a.capabilities, ...capabilities },
|
|
}
|
|
: a
|
|
)
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to parse WebSocket message:', err);
|
|
}
|
|
};
|
|
};
|
|
|
|
void openSocket();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
unmounted.current = false;
|
|
connect();
|
|
const onAuthChange = () => connect();
|
|
window.addEventListener('aetherforge-auth', onAuthChange);
|
|
return () => {
|
|
unmounted.current = true;
|
|
window.removeEventListener('aetherforge-auth', onAuthChange);
|
|
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
|
const ws = wsRef.current;
|
|
if (ws) { ws.onclose = null; ws.close(); }
|
|
};
|
|
}, [connect]);
|
|
|
|
const value = useMemo(
|
|
() => ({
|
|
isConnected,
|
|
agents,
|
|
recentShares,
|
|
fleetAlerts,
|
|
poolStatus,
|
|
aiActivity,
|
|
agentLogs,
|
|
commandResults,
|
|
policyAcks,
|
|
latestMessage,
|
|
sendDashboardMessage,
|
|
}),
|
|
[
|
|
isConnected,
|
|
agents,
|
|
recentShares,
|
|
fleetAlerts,
|
|
poolStatus,
|
|
aiActivity,
|
|
agentLogs,
|
|
commandResults,
|
|
policyAcks,
|
|
latestMessage,
|
|
sendDashboardMessage,
|
|
],
|
|
);
|
|
|
|
return (
|
|
<WebSocketContext.Provider value={value}>
|
|
{children}
|
|
</WebSocketContext.Provider>
|
|
);
|
|
}
|