Replace polling with websocket live sync
This commit is contained in:
@@ -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 />);
|
||||
|
||||
144
src/App.tsx
144
src/App.tsx
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user