Replace polling with websocket live sync

This commit is contained in:
2026-04-02 02:31:59 +03:00
parent 9a3785deb9
commit c04847b21c
15 changed files with 596 additions and 28 deletions

View File

@@ -1,7 +1,9 @@
import { render, screen, within } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it } from 'vitest';
import App from './App';
import { fallbackDashboardSnapshot } from './data/mockDashboard';
import { MockWebSocket } from './test/setup';
async function loginIntoPanel(user: ReturnType<typeof userEvent.setup>) {
await user.type(screen.getByLabelText(/login/i), 'admin');
@@ -13,6 +15,7 @@ beforeEach(() => {
document.documentElement.dataset.theme = '';
window.sessionStorage.clear();
window.localStorage.clear();
window.history.replaceState(null, '', '/');
});
describe('App login gate', () => {
@@ -66,7 +69,7 @@ describe('App login gate', () => {
render(<App />);
expect(screen.getByRole('button', { name: /панель/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /настройки/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^настройки$/i })).toBeInTheDocument();
});
it('stores panel theme in localStorage and restores it after a remount', async () => {
@@ -85,6 +88,23 @@ describe('App login gate', () => {
expect(document.documentElement.dataset.theme).toBe('dark');
});
it('keeps tab navigation in the hash and restores the active tab after remount', async () => {
const user = userEvent.setup();
const firstRender = render(<App />);
await loginIntoPanel(user);
await user.click(screen.getByRole('button', { name: /users/i }));
expect(window.location.hash).toBe('#users');
expect(screen.getByRole('button', { name: /new user/i })).toBeInTheDocument();
firstRender.unmount();
render(<App />);
expect(window.location.hash).toBe('#users');
expect(screen.getByRole('button', { name: /new user/i })).toBeInTheDocument();
});
it('opens add-user flow in a modal and closes it on escape', async () => {
const user = userEvent.setup();
render(<App />);
@@ -170,6 +190,33 @@ describe('App login gate', () => {
expect(screen.getAllByText(/gw\.example\.net:1180/i).length).toBeGreaterThan(0);
});
it('does not overwrite dirty system settings when a websocket patch arrives', async () => {
const user = userEvent.setup();
render(<App />);
await loginIntoPanel(user);
await waitFor(() => expect(MockWebSocket.instances.length).toBeGreaterThan(0));
const socket = MockWebSocket.instances[0];
await user.click(screen.getByRole('button', { name: /settings/i }));
const endpointInput = screen.getByLabelText(/proxy endpoint/i);
await user.clear(endpointInput);
await user.type(endpointInput, 'draft.example.net');
socket.emitMessage({
type: 'snapshot.patch',
patch: {
system: {
...fallbackDashboardSnapshot.system,
publicHost: 'server-sync.example.net',
},
},
});
expect(screen.getByLabelText(/proxy endpoint/i)).toHaveValue('draft.example.net');
});
it('warns before deleting a service and removes linked users after confirmation', async () => {
const user = userEvent.setup();
render(<App />);

View File

@@ -19,6 +19,8 @@ import { getPanelText } from './lib/panelText';
import type {
CreateUserInput,
DashboardSnapshot,
DashboardSnapshotPatch,
DashboardSyncMessage,
PanelLoginResponse,
ProxyServiceRecord,
ProxyUserRecord,
@@ -37,6 +39,7 @@ const tabs: Array<{ id: TabId; textKey: 'dashboard' | 'users' | 'settings' }> =
const SESSION_KEY = '3proxy-ui-panel-session';
const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
const LIVE_SYNC_RECONNECT_MS = 2000;
interface StoredSession {
token: string;
@@ -576,7 +579,7 @@ export default function App() {
return loaded;
});
const [session, setSession] = useState<StoredSession | null>(() => loadStoredSession());
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
const [activeTab, setActiveTab] = useState<TabId>(() => readTabFromHash(window.location.hash));
const [snapshot, setSnapshot] = useState<DashboardSnapshot>(fallbackDashboardSnapshot);
const text = getPanelText(preferences.language);
@@ -593,6 +596,21 @@ export default function App() {
return observeSystemTheme(() => applyPanelTheme('system'));
}, [preferences.theme]);
useEffect(() => {
const syncFromHash = () => {
setActiveTab(readTabFromHash(window.location.hash));
};
if (!window.location.hash) {
window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}${getHashForTab('dashboard')}`);
} else {
syncFromHash();
}
window.addEventListener('hashchange', syncFromHash);
return () => window.removeEventListener('hashchange', syncFromHash);
}, []);
const resetSession = () => {
clearStoredSession();
setSession(null);
@@ -640,7 +658,8 @@ export default function App() {
}
let cancelled = false;
let intervalId: number | null = null;
let reconnectTimer: number | null = null;
let socket: WebSocket | null = null;
const refreshSnapshot = async () => {
try {
@@ -663,15 +682,61 @@ export default function App() {
};
void refreshSnapshot();
intervalId = window.setInterval(() => {
void refreshSnapshot();
}, 5000);
const liveSyncUrl = getLiveSyncUrl(session.token);
if (typeof window.WebSocket !== 'undefined' && liveSyncUrl) {
const connect = () => {
if (cancelled) {
return;
}
socket = new window.WebSocket(liveSyncUrl);
socket.addEventListener('message', (event) => {
const message = parseDashboardSyncMessage(event.data);
if (!message || cancelled) {
return;
}
if (message.type === 'snapshot.init') {
setSnapshot(message.snapshot);
return;
}
if (message.type === 'snapshot.patch') {
setSnapshot((current) => applySnapshotPatch(current, message.patch));
return;
}
resetSession();
});
socket.addEventListener('error', () => {
socket?.close();
});
socket.addEventListener('close', () => {
if (cancelled || reconnectTimer !== null) {
return;
}
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
void refreshSnapshot();
connect();
}, LIVE_SYNC_RECONNECT_MS);
});
};
connect();
}
return () => {
cancelled = true;
if (intervalId !== null) {
window.clearInterval(intervalId);
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
}
socket?.close();
};
}, [session]);
@@ -884,7 +949,7 @@ export default function App() {
key={tab.id}
type="button"
className={activeTab === tab.id ? 'tab-button active' : 'tab-button'}
onClick={() => setActiveTab(tab.id)}
onClick={() => navigateToTab(tab.id)}
>
{text.tabs[tab.textKey]}
</button>
@@ -913,6 +978,17 @@ export default function App() {
) : null}
</main>
);
function navigateToTab(tab: TabId) {
const nextHash = getHashForTab(tab);
if (window.location.hash === nextHash) {
setActiveTab(tab);
return;
}
window.location.hash = nextHash;
}
}
function withDerivedSnapshot(snapshot: DashboardSnapshot): DashboardSnapshot {
@@ -1000,12 +1076,40 @@ async function readApiError(response: Response): Promise<string> {
class SessionExpiredError extends Error {}
function applySnapshotPatch(snapshot: DashboardSnapshot, patch: DashboardSnapshotPatch): DashboardSnapshot {
return {
...snapshot,
...patch,
};
}
function buildAuthHeaders(token: string): HeadersInit {
return {
Authorization: `Bearer ${token}`,
};
}
function parseDashboardSyncMessage(data: unknown): DashboardSyncMessage | null {
if (typeof data !== 'string') {
return null;
}
try {
return JSON.parse(data) as DashboardSyncMessage;
} catch {
return null;
}
}
function getLiveSyncUrl(token: string): string | null {
if (!token) {
return null;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
}
function loadStoredSession(): StoredSession | null {
try {
const raw = window.sessionStorage.getItem(SESSION_KEY);
@@ -1043,3 +1147,27 @@ function createLocalFallbackSession(): StoredSession {
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS).toISOString(),
};
}
function readTabFromHash(hash: string): TabId {
switch (hash.toLowerCase()) {
case '#users':
return 'users';
case '#settings':
return 'system';
case '#dashboard':
default:
return 'dashboard';
}
}
function getHashForTab(tab: TabId): string {
switch (tab) {
case 'users':
return '#users';
case 'system':
return '#settings';
case 'dashboard':
default:
return '#dashboard';
}
}

View File

@@ -1,4 +1,4 @@
import { FormEvent, useEffect, useMemo, useState } from 'react';
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
import type { DashboardSnapshot, ProxyServiceRecord, SystemSettings, UpdateSystemInput } from './shared/contracts';
import type { PanelLanguage, PanelPreferences, PanelTheme } from './lib/panelPreferences';
import { getPanelText, getThemeLabel } from './lib/panelText';
@@ -21,12 +21,25 @@ export default function SystemTab({
const [error, setError] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [removeServiceId, setRemoveServiceId] = useState<string | null>(null);
const lastAppliedSystemKey = useRef(serializeSystemSettings(cloneSystemSettings(snapshot.system)));
const text = getPanelText(preferences.language);
useEffect(() => {
setDraft(cloneSystemSettings(snapshot.system));
setError('');
}, [snapshot.system]);
const incomingDraft = cloneSystemSettings(snapshot.system);
const incomingKey = serializeSystemSettings(incomingDraft);
const draftKey = serializeSystemSettings(draft);
if (incomingKey === lastAppliedSystemKey.current) {
return;
}
if (draftKey === lastAppliedSystemKey.current || draftKey === incomingKey) {
setDraft(incomingDraft);
setError('');
}
lastAppliedSystemKey.current = incomingKey;
}, [draft, snapshot.system]);
const linkedUsersByService = useMemo(() => {
const result = new Map<string, string[]>();
@@ -249,6 +262,7 @@ export default function SystemTab({
className="button-secondary"
onClick={() => {
setDraft(cloneSystemSettings(snapshot.system));
lastAppliedSystemKey.current = serializeSystemSettings(cloneSystemSettings(snapshot.system));
setError('');
}}
>
@@ -344,3 +358,7 @@ function createServiceDraft(existingServices: ProxyServiceRecord[]): ProxyServic
assignable: true,
};
}
function serializeSystemSettings(system: UpdateSystemInput): string {
return JSON.stringify(system);
}

View File

@@ -81,6 +81,29 @@ export interface DashboardSnapshot {
};
}
export interface DashboardSnapshotPatch {
service?: DashboardSnapshot['service'];
traffic?: DashboardSnapshot['traffic'];
users?: DashboardSnapshot['users'];
attention?: DashboardSnapshot['attention'];
userRecords?: DashboardSnapshot['userRecords'];
system?: DashboardSnapshot['system'];
}
export type DashboardSyncMessage =
| {
type: 'snapshot.init';
snapshot: DashboardSnapshot;
}
| {
type: 'snapshot.patch';
patch: DashboardSnapshotPatch;
}
| {
type: 'session.expired';
error: string;
};
export interface CreateUserInput {
username: string;
password: string;

View File

@@ -2,6 +2,59 @@ import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
export class MockWebSocket extends EventTarget {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
static instances: MockWebSocket[] = [];
readonly CONNECTING = MockWebSocket.CONNECTING;
readonly OPEN = MockWebSocket.OPEN;
readonly CLOSING = MockWebSocket.CLOSING;
readonly CLOSED = MockWebSocket.CLOSED;
readonly url: string;
readyState = MockWebSocket.OPEN;
constructor(url: string | URL) {
super();
this.url = String(url);
MockWebSocket.instances.push(this);
queueMicrotask(() => this.dispatchEvent(new Event('open')));
}
send(_data?: string | ArrayBufferLike | Blob | ArrayBufferView): void {}
close(): void {
if (this.readyState === MockWebSocket.CLOSED) {
return;
}
this.readyState = MockWebSocket.CLOSED;
this.dispatchEvent(new CloseEvent('close'));
}
emitMessage(data: unknown): void {
const payload = typeof data === 'string' ? data : JSON.stringify(data);
this.dispatchEvent(new MessageEvent('message', { data: payload }));
}
emitError(): void {
this.dispatchEvent(new Event('error'));
}
static reset(): void {
this.instances = [];
}
}
Object.defineProperty(globalThis, 'WebSocket', {
configurable: true,
writable: true,
value: MockWebSocket,
});
afterEach(() => {
cleanup();
MockWebSocket.reset();
});