Route guards, WS lifecycle, ticket stats. Refs EventHub/EventHubFrontAdmin#3

This commit is contained in:
2026-07-07 21:03:46 +03:00
parent 896e3bcd35
commit ecfa1a66bb
18 changed files with 1331 additions and 955 deletions
+143 -100
View File
@@ -1,100 +1,143 @@
import { useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../store/authStore';
type WsMessage = {
type: 'report_created' | 'ticket_created';
data?: {
report_id?: string;
ticket_id?: string;
target_type?: string;
target_id?: string;
reason?: string;
};
status?: string;
channel?: string;
timestamp?: number;
};
export const useAdminWebSocket = () => {
const queryClient = useQueryClient();
const accessToken = useAuthStore((s) => s.accessToken);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const lastMessageTimestamp = useRef<number>(0);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
if (!isAuthenticated || !accessToken) return;
let isMounted = true;
const connect = () => {
const wsUrl = import.meta.env.DEV
? `ws://localhost:5173/admin/ws?token=${accessToken}`
: `wss://${window.location.host}/admin/ws?token=${accessToken}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
if (!isMounted) return;
console.log('[WS] Connected');
ws.send(JSON.stringify({ action: 'subscribe', channel: 'reports' }));
ws.send(JSON.stringify({ action: 'subscribe', channel: 'tickets' }));
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: 'ping' }));
}
}, 30_000);
};
ws.onmessage = (event) => {
if (!isMounted) return;
try {
const msg: WsMessage = JSON.parse(event.data);
console.log('[WS] Message:', msg);
if (msg.timestamp && msg.timestamp === lastMessageTimestamp.current) {
return;
}
if (msg.timestamp) {
lastMessageTimestamp.current = msg.timestamp;
}
if (msg.type === 'report_created' || msg.type === 'ticket_created') {
window.dispatchEvent(
new CustomEvent('admin-ws-message', { detail: msg })
);
if (msg.type === 'report_created') {
queryClient.invalidateQueries({ queryKey: ['reports'] });
} else if (msg.type === 'ticket_created') {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
queryClient.invalidateQueries({ queryKey: ['ticket-stats'] });
}
}
} catch (e) {
console.warn('[WS] Failed to parse message:', e);
}
};
ws.onclose = () => {
if (!isMounted) return;
console.log('[WS] Disconnected, will reconnect in 5s');
if (pingIntervalRef.current) clearInterval(pingIntervalRef.current);
reconnectTimeoutRef.current = setTimeout(connect, 5000);
};
// Не вызываем ws.close() при ошибке, браузер закроет сокет сам
};
connect();
return () => {
isMounted = false;
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
if (pingIntervalRef.current) clearInterval(pingIntervalRef.current);
// Никакого принудительного закрытия wsRef.current – браузер сам управится
};
}, [isAuthenticated, accessToken, queryClient]);
};
import { useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../store/authStore';
import { useMetricsStore } from '../store/metricsStore';
type WsMessage = {
type: 'report_created' | 'ticket_created' | 'node_metric';
data?: any;
status?: string;
channel?: string;
timestamp?: number;
};
export const useAdminWebSocket = () => {
const queryClient = useQueryClient();
const accessToken = useAuthStore((s) => s.accessToken);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const setCurrentMetric = useMetricsStore((s) => s.setCurrent);
const addToAllHistory = useMetricsStore((s) => s.addToAllHistory);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const lastMessageTimestamp = useRef<number>(0);
const shouldReconnectRef = useRef(true);
const clearTimers = () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
};
const closeSocket = () => {
clearTimers();
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.onerror = null;
wsRef.current.onmessage = null;
wsRef.current.onopen = null;
if (wsRef.current.readyState === WebSocket.OPEN ||
wsRef.current.readyState === WebSocket.CONNECTING) {
wsRef.current.close();
}
wsRef.current = null;
}
};
useEffect(() => {
if (!isAuthenticated || !accessToken) {
shouldReconnectRef.current = false;
closeSocket();
return;
}
shouldReconnectRef.current = true;
let isMounted = true;
const connect = () => {
if (!isMounted || !shouldReconnectRef.current) return;
closeSocket();
const wsUrl = import.meta.env.DEV
? `ws://localhost:5173/admin/ws?token=${accessToken}`
: `wss://${window.location.host}/admin/ws?token=${accessToken}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
if (!isMounted || !shouldReconnectRef.current) return;
console.log('[WS] Connected');
ws.send(JSON.stringify({ action: 'subscribe', channel: 'reports' }));
ws.send(JSON.stringify({ action: 'subscribe', channel: 'tickets' }));
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: 'ping' }));
}
}, 30_000);
};
ws.onmessage = (event) => {
if (!isMounted) return;
try {
const msg: WsMessage = JSON.parse(event.data);
console.log('[WS] Message:', msg);
if (msg.timestamp && msg.timestamp === lastMessageTimestamp.current) {
return;
}
if (msg.timestamp) {
lastMessageTimestamp.current = msg.timestamp;
}
if (msg.type === 'report_created' || msg.type === 'ticket_created') {
window.dispatchEvent(
new CustomEvent('admin-ws-message', { detail: msg })
);
if (msg.type === 'report_created') {
queryClient.invalidateQueries({ queryKey: ['reports'] });
} else if (msg.type === 'ticket_created') {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
queryClient.invalidateQueries({ queryKey: ['ticket-stats'] });
}
} else if (msg.type === 'node_metric') {
setCurrentMetric(msg.data);
addToAllHistory(msg.data);
}
} catch (e) {
console.warn('[WS] Failed to parse message:', e);
}
};
ws.onclose = (event) => {
if (!isMounted || !shouldReconnectRef.current) return;
console.log('[WS] Disconnected:', event.reason);
clearTimers();
wsRef.current = null;
reconnectTimeoutRef.current = setTimeout(connect, 5000);
};
ws.onerror = (error) => {
if (!isMounted) return;
console.error('[WS] Error:', error);
ws.close();
};
};
connect();
return () => {
isMounted = false;
shouldReconnectRef.current = false;
closeSocket();
};
}, [isAuthenticated, accessToken, queryClient, setCurrentMetric, addToAllHistory]);
};
View File