Release validation: tests green, USB pack, fleet UX and API hardening.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Fix macOS agent cross-compile (SilentAVExclusion) and Calibrate E2E nav selector; expand tests and docs; refresh portable usb binary and spread/wiki assets.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
||||
import { agentStatsUnchanged, WS_LATEST_MESSAGE_TYPES } from '../help/wsStatsCoalesce';
|
||||
import type {
|
||||
WSDashboardInit,
|
||||
WSAgentOffline,
|
||||
@@ -22,6 +23,12 @@ 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[]>([]);
|
||||
@@ -69,12 +76,23 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -83,9 +101,17 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* fall back to legacy token query param */
|
||||
/* 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);
|
||||
@@ -106,7 +132,9 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as WSMessage;
|
||||
setLatestMessage(msg);
|
||||
if (WS_LATEST_MESSAGE_TYPES.has(msg.type)) {
|
||||
setLatestMessage(msg);
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case 'init': {
|
||||
@@ -144,8 +172,11 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
case 'stats_update': {
|
||||
const update = msg.payload as WSStatsUpdate;
|
||||
setAgents((prev) =>
|
||||
prev.map((a) =>
|
||||
setAgents((prev) => {
|
||||
const idx = prev.findIndex((a) => a.id === update.agent_id);
|
||||
if (idx < 0) return prev;
|
||||
if (agentStatsUnchanged(prev[idx], update)) return prev;
|
||||
return prev.map((a) =>
|
||||
a.id === update.agent_id
|
||||
? {
|
||||
...a,
|
||||
@@ -196,9 +227,9 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
...(update.services !== undefined ? { services: update.services } : {}),
|
||||
...(update.latency_ms !== undefined ? { latency_ms: update.latency_ms } : {}),
|
||||
}
|
||||
: a
|
||||
)
|
||||
);
|
||||
: a,
|
||||
);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'new_share': {
|
||||
@@ -294,11 +325,37 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
}, [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={{
|
||||
isConnected, agents, recentShares, fleetAlerts, poolStatus,
|
||||
aiActivity, agentLogs, commandResults, policyAcks, latestMessage, sendDashboardMessage,
|
||||
}}>
|
||||
<WebSocketContext.Provider value={value}>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user