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

@@ -10,6 +10,7 @@ The project now includes both the UI and the first backend/runtime slice:
- generated `3proxy.cfg` from persisted panel state - generated `3proxy.cfg` from persisted panel state
- runtime manager for start/restart/reload - runtime manager for start/restart/reload
- access-log-backed traffic ingestion from a real 3proxy process - access-log-backed traffic ingestion from a real 3proxy process
- websocket-based live sync with top-level snapshot patches
- Docker image that builds the panel and compiles 3proxy in-container - Docker image that builds the panel and compiles 3proxy in-container
- panel views for dashboard, users, and system - panel views for dashboard, users, and system
- edge-case-focused frontend and backend tests - edge-case-focused frontend and backend tests
@@ -23,6 +24,7 @@ npm run dev
`npm run dev` now starts both the Vite client and the Express control-plane server together. `npm run dev` now starts both the Vite client and the Express control-plane server together.
If you only need the backend process, use `npm run dev:server`. If you only need the backend process, use `npm run dev:server`.
The local Vite dev server proxies both `/api` and `/ws` to the backend so the same panel build works with `http/ws` locally and can be promoted behind `https/wss` later.
Default panel credentials: Default panel credentials:
@@ -36,6 +38,7 @@ For Docker runs these values come from `compose.yaml`:
- `PANEL_SESSION_TTL_HOURS` with a default of `24` - `PANEL_SESSION_TTL_HOURS` with a default of `24`
The panel stores the issued session token in `sessionStorage`, so a browser refresh keeps the operator signed in until the token expires. The panel stores the issued session token in `sessionStorage`, so a browser refresh keeps the operator signed in until the token expires.
Panel preferences are stored in `localStorage`, the active tab is tracked in the URL hash, and runtime data now arrives through websocket `snapshot.init` / `snapshot.patch` messages instead of periodic polling.
Once the API is available, dashboard/user traffic values are refreshed from live 3proxy access logs instead of the seeded fallback snapshot. Once the API is available, dashboard/user traffic values are refreshed from live 3proxy access logs instead of the seeded fallback snapshot.
## Docker run ## Docker run

View File

@@ -5,12 +5,13 @@ Updated: 2026-04-02
## Active ## Active
1. Keep replacing remaining seeded fallback signals with runtime-backed 3proxy data and extend the Docker-verified ingestion path beyond access-log-derived usage. 1. Keep replacing remaining seeded fallback signals with runtime-backed 3proxy data and extend the Docker-verified ingestion path beyond access-log-derived usage.
2. Harden websocket-driven live sync and keep forms/edit flows resilient to concurrent runtime updates.
## Next ## Next
1. Wire real counter/log ingestion into dashboard traffic and user status instead of seeded snapshot values. 1. Wire real counter/log ingestion into dashboard traffic and user status instead of seeded snapshot values.
2. Refine Docker/runtime behavior and document real host-access expectations and deployment constraints. 2. Refine Docker/runtime behavior and document real host-access expectations and deployment constraints.
3. Expand validation and tests around service mutations, credential safety, and runtime failure reporting. 3. Expand validation and tests around service mutations, credential safety, websocket reconnect behavior, and runtime failure reporting.
## Done ## Done
@@ -38,3 +39,6 @@ Updated: 2026-04-02
22. Restored editable proxy endpoint in panel settings so copied proxy URLs and displayed user endpoints can be corrected from the UI. 22. Restored editable proxy endpoint in panel settings so copied proxy URLs and displayed user endpoints can be corrected from the UI.
23. Replaced seeded dashboard/user usage with live 3proxy access-log ingestion, derived user traffic/status from runtime logs, and added frontend polling so the panel refreshes runtime state automatically. 23. Replaced seeded dashboard/user usage with live 3proxy access-log ingestion, derived user traffic/status from runtime logs, and added frontend polling so the panel refreshes runtime state automatically.
24. Verified the new runtime-backed snapshot flow in Docker by sending real traffic through the bundled 3proxy services and observing live byte counters in `/api/state`. 24. Verified the new runtime-backed snapshot flow in Docker by sending real traffic through the bundled 3proxy services and observing live byte counters in `/api/state`.
25. Replaced frontend polling with websocket live sync over `/ws`, sending only changed top-level snapshot sections while keeping the current `http/ws` path compatible with future `https/wss` deployment.
26. Stopped incoming runtime sync from overwriting dirty Settings drafts and added hash-based tab navigation so refresh/back/forward stay on the current panel tab.
27. Verified websocket delivery in Docker over plain `ws://127.0.0.1:3000/ws` by authenticating, receiving `snapshot.init`, mutating panel state, and observing a follow-up `snapshot.patch`.

View File

@@ -10,9 +10,9 @@ Updated: 2026-04-02
- `compose.yaml`: Docker Compose entrypoint for the bundled panel + 3proxy runtime, including panel auth env defaults - `compose.yaml`: Docker Compose entrypoint for the bundled panel + 3proxy runtime, including panel auth env defaults
- `Dockerfile`: multi-stage image that builds the panel and compiles 3proxy - `Dockerfile`: multi-stage image that builds the panel and compiles 3proxy
- `README.md`: quick start and current project scope - `README.md`: quick start and current project scope
- `package.json`: frontend/backend scripts and dependencies, including combined local `dev` startup - `package.json`: frontend/backend scripts and dependencies, including combined local `dev` startup and websocket support
- `tsconfig.server.json`: server type-check configuration - `tsconfig.server.json`: server type-check configuration
- `vite.config.ts`: Vite + Vitest configuration plus local `/api` proxy to the control-plane backend - `vite.config.ts`: Vite + Vitest configuration plus local `/api` and `/ws` proxying to the control-plane backend
## Documentation ## Documentation
@@ -23,9 +23,9 @@ Updated: 2026-04-02
## Frontend ## Frontend
- `src/main.tsx`: application bootstrap - `src/main.tsx`: application bootstrap
- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, localized labels, early theme application, protected panel mutations, and periodic runtime snapshot refresh - `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, hash-based tab history, websocket snapshot patch sync, localized labels, early theme application, and protected panel mutations
- `src/SystemTab.tsx`: Settings tab with separate panel-settings and services cards, editable proxy endpoint, unified service type editing, remove confirmation, and generated config preview - `src/SystemTab.tsx`: Settings tab with separate panel-settings and services cards, editable proxy endpoint, dirty-draft protection against incoming live sync, unified service type editing, remove confirmation, and generated config preview
- `src/App.test.tsx`: login-gate, preferences persistence, modal interaction, pause/resume, delete-confirm, and settings-save UI tests - `src/App.test.tsx`: login-gate, preferences persistence, hash-tab restoration, websocket-sync safety, modal interaction, pause/resume, delete-confirm, and settings-save UI tests
- `src/app.css`: full panel styling - `src/app.css`: full panel styling
- `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot - `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot
- `src/lib/3proxy.ts`: formatting and status helpers - `src/lib/3proxy.ts`: formatting and status helpers
@@ -34,16 +34,18 @@ Updated: 2026-04-02
- `src/lib/panelText.ts`: English/Russian UI text catalog for the panel shell and settings flows - `src/lib/panelText.ts`: English/Russian UI text catalog for the panel shell and settings flows
- `src/shared/contracts.ts`: shared panel, service, user, and API data contracts - `src/shared/contracts.ts`: shared panel, service, user, and API data contracts
- `src/shared/validation.ts`: shared validation for user creation, system edits, service type mapping, and quota conversion - `src/shared/validation.ts`: shared validation for user creation, system edits, service type mapping, and quota conversion
- `src/test/setup.ts`: Testing Library matchers - `src/test/setup.ts`: Testing Library matchers plus browser WebSocket test double
## Server ## Server
- `server/index.ts`: backend entrypoint and runtime bootstrap - `server/index.ts`: backend entrypoint, runtime bootstrap, and HTTP server wiring for websocket upgrades
- `server/app.ts`: Express app with login, protected panel state/runtime routes, and writable system configuration API with linked-user cleanup on removed services - `server/app.ts`: Express app with login, protected panel state/runtime routes, live-sync change notifications, and writable system configuration API with linked-user cleanup on removed services
- `server/app.test.ts`: API tests for user management plus system-update safety, cascade delete, and config edge cases - `server/app.test.ts`: API tests for user management plus system-update safety, cascade delete, and config edge cases
- `server/lib/auth.ts`: expiring token issuance and bearer-token verification for the panel - `server/lib/auth.ts`: expiring token issuance and bearer-token verification for the panel
- `server/lib/config.ts`: 3proxy config renderer, validation, and dashboard derivation for SOCKS/HTTP managed services - `server/lib/config.ts`: 3proxy config renderer, validation, and dashboard derivation for SOCKS/HTTP managed services
- `server/lib/config.test.ts`: config-generation regression tests - `server/lib/config.test.ts`: config-generation regression tests
- `server/lib/liveSync.ts`: websocket broadcaster that emits `snapshot.init` and top-level `snapshot.patch` messages from runtime/store changes
- `server/lib/liveSync.test.ts`: regression tests for patch-only websocket payload generation
- `server/lib/snapshot.ts`: runtime-backed dashboard snapshot assembly that combines stored panel state with parsed 3proxy traffic observations - `server/lib/snapshot.ts`: runtime-backed dashboard snapshot assembly that combines stored panel state with parsed 3proxy traffic observations
- `server/lib/traffic.ts`: 3proxy access-log reader that derives current user usage, recent activity, daily totals, and lightweight live-connection estimates - `server/lib/traffic.ts`: 3proxy access-log reader that derives current user usage, recent activity, daily totals, and lightweight live-connection estimates
- `server/lib/traffic.test.ts`: parser and empty-runtime regression tests for log-derived traffic metrics - `server/lib/traffic.test.ts`: parser and empty-runtime regression tests for log-derived traffic metrics

35
package-lock.json generated
View File

@@ -10,7 +10,8 @@
"dependencies": { "dependencies": {
"express": "^5.2.1", "express": "^5.2.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4",
"ws": "^8.20.0"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
@@ -20,6 +21,7 @@
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/supertest": "^7.2.0", "@types/supertest": "^7.2.0",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"esbuild": "^0.27.4", "esbuild": "^0.27.4",
@@ -1347,6 +1349,16 @@
"@types/superagent": "^8.1.0" "@types/superagent": "^8.1.0"
} }
}, },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
@@ -4128,6 +4140,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": { "node_modules/xml-name-validator": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",

View File

@@ -22,6 +22,7 @@
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/supertest": "^7.2.0", "@types/supertest": "^7.2.0",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"esbuild": "^0.27.4", "esbuild": "^0.27.4",
@@ -35,6 +36,7 @@
"dependencies": { "dependencies": {
"express": "^5.2.1", "express": "^5.2.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4",
"ws": "^8.20.0"
} }
} }

View File

@@ -16,6 +16,7 @@ import {
} from './lib/config'; } from './lib/config';
import { getDashboardSnapshot } from './lib/snapshot'; import { getDashboardSnapshot } from './lib/snapshot';
import { AuthService } from './lib/auth'; import { AuthService } from './lib/auth';
import type { LiveSyncPublisher } from './lib/liveSync';
import type { RuntimeController } from './lib/runtime'; import type { RuntimeController } from './lib/runtime';
import { StateStore } from './lib/store'; import { StateStore } from './lib/store';
@@ -24,9 +25,10 @@ export interface AppServices {
runtime: RuntimeController; runtime: RuntimeController;
runtimeRootDir: string; runtimeRootDir: string;
auth: AuthService; auth: AuthService;
liveSync?: LiveSyncPublisher;
} }
export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices) { export function createApp({ store, runtime, runtimeRootDir, auth, liveSync }: AppServices) {
const app = express(); const app = express();
const runtimePaths = buildRuntimePaths(runtimeRootDir); const runtimePaths = buildRuntimePaths(runtimeRootDir);
const distDir = path.resolve('dist'); const distDir = path.resolve('dist');
@@ -106,6 +108,7 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices)
} }
await writeConfigAndState(store, state, runtimePaths); await writeConfigAndState(store, state, runtimePaths);
liveSync?.notifyPotentialChange();
response.json(await getDashboardSnapshot(store, runtime, runtimePaths)); response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
} catch (error) { } catch (error) {
next(error); next(error);
@@ -120,6 +123,7 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices)
state.userRecords.push(record); state.userRecords.push(record);
state.service.lastEvent = `User ${record.username} created from panel`; state.service.lastEvent = `User ${record.username} created from panel`;
await persistRuntimeMutation(store, runtime, state, runtimePaths); await persistRuntimeMutation(store, runtime, state, runtimePaths);
liveSync?.notifyPotentialChange();
response.status(201).json(await getDashboardSnapshot(store, runtime, runtimePaths)); response.status(201).json(await getDashboardSnapshot(store, runtime, runtimePaths));
} catch (error) { } catch (error) {
next(error); next(error);
@@ -147,6 +151,7 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices)
? `System configuration updated from panel and removed ${removedUsers.length} linked users` ? `System configuration updated from panel and removed ${removedUsers.length} linked users`
: 'System configuration updated from panel'; : 'System configuration updated from panel';
await persistRuntimeMutation(store, runtime, state, runtimePaths); await persistRuntimeMutation(store, runtime, state, runtimePaths);
liveSync?.notifyPotentialChange();
response.json(await getDashboardSnapshot(store, runtime, runtimePaths)); response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
} catch (error) { } catch (error) {
next(error); next(error);
@@ -169,6 +174,7 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices)
: `User ${user.username} resumed from panel`; : `User ${user.username} resumed from panel`;
await persistRuntimeMutation(store, runtime, state, runtimePaths); await persistRuntimeMutation(store, runtime, state, runtimePaths);
liveSync?.notifyPotentialChange();
response.json(await getDashboardSnapshot(store, runtime, runtimePaths)); response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
} catch (error) { } catch (error) {
next(error); next(error);
@@ -188,6 +194,7 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices)
const [removed] = state.userRecords.splice(index, 1); const [removed] = state.userRecords.splice(index, 1);
state.service.lastEvent = `User ${removed.username} deleted from panel`; state.service.lastEvent = `User ${removed.username} deleted from panel`;
await persistRuntimeMutation(store, runtime, state, runtimePaths); await persistRuntimeMutation(store, runtime, state, runtimePaths);
liveSync?.notifyPotentialChange();
response.json(await getDashboardSnapshot(store, runtime, runtimePaths)); response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
} catch (error) { } catch (error) {
next(error); next(error);

View File

@@ -1,8 +1,10 @@
import { createServer } from 'node:http';
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { createApp } from './app'; import { createApp } from './app';
import { AuthService } from './lib/auth'; import { AuthService } from './lib/auth';
import { buildRuntimePaths, render3proxyConfig } from './lib/config'; import { buildRuntimePaths, render3proxyConfig } from './lib/config';
import { LiveSyncServer } from './lib/liveSync';
import { ThreeProxyManager } from './lib/runtime'; import { ThreeProxyManager } from './lib/runtime';
import { StateStore } from './lib/store'; import { StateStore } from './lib/store';
@@ -34,8 +36,13 @@ async function main() {
await fs.mkdir(path.dirname(runtimePaths.configPath), { recursive: true }); await fs.mkdir(path.dirname(runtimePaths.configPath), { recursive: true });
await fs.writeFile(runtimePaths.configPath, render3proxyConfig(initialState, runtimePaths), 'utf8'); await fs.writeFile(runtimePaths.configPath, render3proxyConfig(initialState, runtimePaths), 'utf8');
await runtime.initialize(); await runtime.initialize();
const app = createApp({ store, runtime, runtimeRootDir, auth }); const liveSync = new LiveSyncServer({ store, runtime, runtimePaths, auth });
app.listen(port, () => { await liveSync.initialize();
const app = createApp({ store, runtime, runtimeRootDir, auth, liveSync });
const server = createServer(app);
liveSync.attach(server);
server.listen(port, () => {
console.log(`Panel server listening on http://0.0.0.0:${port}`); console.log(`Panel server listening on http://0.0.0.0:${port}`);
}); });
} }

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { fallbackDashboardSnapshot } from '../../src/data/mockDashboard';
import { buildSnapshotPatch } from './liveSync';
describe('buildSnapshotPatch', () => {
it('returns only changed top-level dashboard sections', () => {
const nextSnapshot = {
...fallbackDashboardSnapshot,
traffic: {
...fallbackDashboardSnapshot.traffic,
totalBytes: fallbackDashboardSnapshot.traffic.totalBytes + 512,
},
};
expect(buildSnapshotPatch(fallbackDashboardSnapshot, nextSnapshot)).toEqual({
traffic: nextSnapshot.traffic,
});
});
it('returns a full patch when no previous snapshot exists', () => {
expect(buildSnapshotPatch(null, fallbackDashboardSnapshot)).toEqual({
service: fallbackDashboardSnapshot.service,
traffic: fallbackDashboardSnapshot.traffic,
users: fallbackDashboardSnapshot.users,
attention: fallbackDashboardSnapshot.attention,
userRecords: fallbackDashboardSnapshot.userRecords,
system: fallbackDashboardSnapshot.system,
});
});
});

206
server/lib/liveSync.ts Normal file
View File

@@ -0,0 +1,206 @@
import { watch, type FSWatcher } from 'node:fs';
import fs from 'node:fs/promises';
import type { Server as HttpServer } from 'node:http';
import path from 'node:path';
import type { Socket } from 'node:net';
import { WebSocketServer, type WebSocket } from 'ws';
import type { DashboardSnapshot, DashboardSnapshotPatch, DashboardSyncMessage } from '../../src/shared/contracts';
import { AuthService } from './auth';
import { getDashboardSnapshot } from './snapshot';
import type { RuntimePaths } from './config';
import type { RuntimeController } from './runtime';
import type { StateStore } from './store';
export interface LiveSyncPublisher {
notifyPotentialChange(): void;
}
interface LiveSyncOptions {
auth: AuthService;
runtime: RuntimeController;
runtimePaths: RuntimePaths;
store: StateStore;
}
export class LiveSyncServer implements LiveSyncPublisher {
private readonly wss = new WebSocketServer({ noServer: true });
private readonly watchers: FSWatcher[] = [];
private readonly clients = new Set<WebSocket>();
private lastSnapshot: DashboardSnapshot | null = null;
private refreshTimer: NodeJS.Timeout | null = null;
constructor(private readonly options: LiveSyncOptions) {
this.wss.on('connection', (socket) => {
this.clients.add(socket);
socket.on('close', () => {
this.clients.delete(socket);
});
void this.sendInit(socket);
});
}
attach(server: HttpServer) {
server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
if (url.pathname !== '/ws') {
return;
}
const token = url.searchParams.get('token');
const session = token ? this.options.auth.verify(token) : null;
if (!session) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
this.wss.handleUpgrade(request, socket as Socket, head, (ws) => {
this.wss.emit('connection', ws, request);
this.scheduleSessionExpiry(ws, session.exp);
});
});
}
async initialize() {
const directories = new Set([
this.options.runtimePaths.rootDir,
path.dirname(this.options.runtimePaths.configPath),
path.dirname(this.options.runtimePaths.logPath),
path.dirname(this.options.runtimePaths.counterPath),
this.options.runtimePaths.reportDir,
]);
for (const directory of directories) {
await fs.mkdir(directory, { recursive: true });
this.watchers.push(watch(directory, { recursive: false }, () => this.notifyPotentialChange()));
}
}
notifyPotentialChange() {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
this.refreshTimer = setTimeout(() => {
this.refreshTimer = null;
void this.refreshAndBroadcast();
}, 150);
}
async refreshAndBroadcast() {
if (this.clients.size === 0) {
this.lastSnapshot = await this.readSnapshot();
return;
}
const nextSnapshot = await this.readSnapshot();
const patch = buildSnapshotPatch(this.lastSnapshot, nextSnapshot);
this.lastSnapshot = nextSnapshot;
if (!patch) {
return;
}
const message: DashboardSyncMessage = { type: 'snapshot.patch', patch };
const payload = JSON.stringify(message);
this.clients.forEach((client) => {
if (client.readyState === client.OPEN) {
client.send(payload);
}
});
}
close() {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
this.watchers.forEach((watcher) => watcher.close());
this.watchers.length = 0;
this.clients.forEach((client) => client.close());
this.clients.clear();
this.wss.close();
}
private async sendInit(socket: WebSocket) {
const snapshot = await this.readSnapshot();
this.lastSnapshot = snapshot;
const message: DashboardSyncMessage = {
type: 'snapshot.init',
snapshot,
};
if (socket.readyState === socket.OPEN) {
socket.send(JSON.stringify(message));
}
}
private readSnapshot() {
return getDashboardSnapshot(this.options.store, this.options.runtime, this.options.runtimePaths);
}
private scheduleSessionExpiry(socket: WebSocket, expiresAt: number) {
const timeoutMs = Math.max(expiresAt - Date.now(), 0);
const timer = setTimeout(() => {
if (socket.readyState === socket.OPEN) {
socket.send(
JSON.stringify({
type: 'session.expired',
error: this.options.auth.invalidTokenError().error,
} satisfies DashboardSyncMessage),
);
}
socket.close();
}, timeoutMs);
socket.on('close', () => clearTimeout(timer));
}
}
export function buildSnapshotPatch(
previous: DashboardSnapshot | null,
next: DashboardSnapshot,
): DashboardSnapshotPatch | null {
if (!previous) {
return {
service: next.service,
traffic: next.traffic,
users: next.users,
attention: next.attention,
userRecords: next.userRecords,
system: next.system,
};
}
const patch: DashboardSnapshotPatch = {};
if (!isEqual(previous.service, next.service)) {
patch.service = next.service;
}
if (!isEqual(previous.traffic, next.traffic)) {
patch.traffic = next.traffic;
}
if (!isEqual(previous.users, next.users)) {
patch.users = next.users;
}
if (!isEqual(previous.attention, next.attention)) {
patch.attention = next.attention;
}
if (!isEqual(previous.userRecords, next.userRecords)) {
patch.userRecords = next.userRecords;
}
if (!isEqual(previous.system, next.system)) {
patch.system = next.system;
}
return Object.keys(patch).length > 0 ? patch : null;
}
function isEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}

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 userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
import App from './App'; import App from './App';
import { fallbackDashboardSnapshot } from './data/mockDashboard';
import { MockWebSocket } from './test/setup';
async function loginIntoPanel(user: ReturnType<typeof userEvent.setup>) { async function loginIntoPanel(user: ReturnType<typeof userEvent.setup>) {
await user.type(screen.getByLabelText(/login/i), 'admin'); await user.type(screen.getByLabelText(/login/i), 'admin');
@@ -13,6 +15,7 @@ beforeEach(() => {
document.documentElement.dataset.theme = ''; document.documentElement.dataset.theme = '';
window.sessionStorage.clear(); window.sessionStorage.clear();
window.localStorage.clear(); window.localStorage.clear();
window.history.replaceState(null, '', '/');
}); });
describe('App login gate', () => { describe('App login gate', () => {
@@ -66,7 +69,7 @@ describe('App login gate', () => {
render(<App />); render(<App />);
expect(screen.getByRole('button', { name: /панель/i })).toBeInTheDocument(); 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 () => { 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'); 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 () => { it('opens add-user flow in a modal and closes it on escape', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); render(<App />);
@@ -170,6 +190,33 @@ describe('App login gate', () => {
expect(screen.getAllByText(/gw\.example\.net:1180/i).length).toBeGreaterThan(0); 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 () => { it('warns before deleting a service and removes linked users after confirmation', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); render(<App />);

View File

@@ -19,6 +19,8 @@ import { getPanelText } from './lib/panelText';
import type { import type {
CreateUserInput, CreateUserInput,
DashboardSnapshot, DashboardSnapshot,
DashboardSnapshotPatch,
DashboardSyncMessage,
PanelLoginResponse, PanelLoginResponse,
ProxyServiceRecord, ProxyServiceRecord,
ProxyUserRecord, ProxyUserRecord,
@@ -37,6 +39,7 @@ const tabs: Array<{ id: TabId; textKey: 'dashboard' | 'users' | 'settings' }> =
const SESSION_KEY = '3proxy-ui-panel-session'; const SESSION_KEY = '3proxy-ui-panel-session';
const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000; const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
const LIVE_SYNC_RECONNECT_MS = 2000;
interface StoredSession { interface StoredSession {
token: string; token: string;
@@ -576,7 +579,7 @@ export default function App() {
return loaded; return loaded;
}); });
const [session, setSession] = useState<StoredSession | null>(() => loadStoredSession()); 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 [snapshot, setSnapshot] = useState<DashboardSnapshot>(fallbackDashboardSnapshot);
const text = getPanelText(preferences.language); const text = getPanelText(preferences.language);
@@ -593,6 +596,21 @@ export default function App() {
return observeSystemTheme(() => applyPanelTheme('system')); return observeSystemTheme(() => applyPanelTheme('system'));
}, [preferences.theme]); }, [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 = () => { const resetSession = () => {
clearStoredSession(); clearStoredSession();
setSession(null); setSession(null);
@@ -640,7 +658,8 @@ export default function App() {
} }
let cancelled = false; let cancelled = false;
let intervalId: number | null = null; let reconnectTimer: number | null = null;
let socket: WebSocket | null = null;
const refreshSnapshot = async () => { const refreshSnapshot = async () => {
try { try {
@@ -663,15 +682,61 @@ export default function App() {
}; };
void refreshSnapshot(); void refreshSnapshot();
intervalId = window.setInterval(() => {
void refreshSnapshot(); const liveSyncUrl = getLiveSyncUrl(session.token);
}, 5000); 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 () => { return () => {
cancelled = true; cancelled = true;
if (intervalId !== null) { if (reconnectTimer !== null) {
window.clearInterval(intervalId); window.clearTimeout(reconnectTimer);
} }
socket?.close();
}; };
}, [session]); }, [session]);
@@ -884,7 +949,7 @@ export default function App() {
key={tab.id} key={tab.id}
type="button" type="button"
className={activeTab === tab.id ? 'tab-button active' : 'tab-button'} className={activeTab === tab.id ? 'tab-button active' : 'tab-button'}
onClick={() => setActiveTab(tab.id)} onClick={() => navigateToTab(tab.id)}
> >
{text.tabs[tab.textKey]} {text.tabs[tab.textKey]}
</button> </button>
@@ -913,6 +978,17 @@ export default function App() {
) : null} ) : null}
</main> </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 { function withDerivedSnapshot(snapshot: DashboardSnapshot): DashboardSnapshot {
@@ -1000,12 +1076,40 @@ async function readApiError(response: Response): Promise<string> {
class SessionExpiredError extends Error {} class SessionExpiredError extends Error {}
function applySnapshotPatch(snapshot: DashboardSnapshot, patch: DashboardSnapshotPatch): DashboardSnapshot {
return {
...snapshot,
...patch,
};
}
function buildAuthHeaders(token: string): HeadersInit { function buildAuthHeaders(token: string): HeadersInit {
return { return {
Authorization: `Bearer ${token}`, 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 { function loadStoredSession(): StoredSession | null {
try { try {
const raw = window.sessionStorage.getItem(SESSION_KEY); const raw = window.sessionStorage.getItem(SESSION_KEY);
@@ -1043,3 +1147,27 @@ function createLocalFallbackSession(): StoredSession {
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS).toISOString(), 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 { DashboardSnapshot, ProxyServiceRecord, SystemSettings, UpdateSystemInput } from './shared/contracts';
import type { PanelLanguage, PanelPreferences, PanelTheme } from './lib/panelPreferences'; import type { PanelLanguage, PanelPreferences, PanelTheme } from './lib/panelPreferences';
import { getPanelText, getThemeLabel } from './lib/panelText'; import { getPanelText, getThemeLabel } from './lib/panelText';
@@ -21,12 +21,25 @@ export default function SystemTab({
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [removeServiceId, setRemoveServiceId] = useState<string | null>(null); const [removeServiceId, setRemoveServiceId] = useState<string | null>(null);
const lastAppliedSystemKey = useRef(serializeSystemSettings(cloneSystemSettings(snapshot.system)));
const text = getPanelText(preferences.language); const text = getPanelText(preferences.language);
useEffect(() => { useEffect(() => {
setDraft(cloneSystemSettings(snapshot.system)); const incomingDraft = cloneSystemSettings(snapshot.system);
setError(''); const incomingKey = serializeSystemSettings(incomingDraft);
}, [snapshot.system]); 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 linkedUsersByService = useMemo(() => {
const result = new Map<string, string[]>(); const result = new Map<string, string[]>();
@@ -249,6 +262,7 @@ export default function SystemTab({
className="button-secondary" className="button-secondary"
onClick={() => { onClick={() => {
setDraft(cloneSystemSettings(snapshot.system)); setDraft(cloneSystemSettings(snapshot.system));
lastAppliedSystemKey.current = serializeSystemSettings(cloneSystemSettings(snapshot.system));
setError(''); setError('');
}} }}
> >
@@ -344,3 +358,7 @@ function createServiceDraft(existingServices: ProxyServiceRecord[]): ProxyServic
assignable: true, 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 { export interface CreateUserInput {
username: string; username: string;
password: string; password: string;

View File

@@ -2,6 +2,59 @@ import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react'; import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest'; 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(() => { afterEach(() => {
cleanup(); cleanup();
MockWebSocket.reset();
}); });

View File

@@ -9,6 +9,11 @@ export default defineConfig({
target: process.env.VITE_API_PROXY_TARGET ?? 'http://127.0.0.1:3000', target: process.env.VITE_API_PROXY_TARGET ?? 'http://127.0.0.1:3000',
changeOrigin: true, changeOrigin: true,
}, },
'/ws': {
target: process.env.VITE_API_PROXY_TARGET ?? 'http://127.0.0.1:3000',
changeOrigin: true,
ws: true,
},
}, },
}, },
test: { test: {