Replace polling with websocket live sync
This commit is contained in:
@@ -10,6 +10,7 @@ The project now includes both the UI and the first backend/runtime slice:
|
||||
- generated `3proxy.cfg` from persisted panel state
|
||||
- runtime manager for start/restart/reload
|
||||
- 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
|
||||
- panel views for dashboard, users, and system
|
||||
- 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.
|
||||
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:
|
||||
|
||||
@@ -36,6 +38,7 @@ For Docker runs these values come from `compose.yaml`:
|
||||
- `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.
|
||||
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.
|
||||
|
||||
## Docker run
|
||||
|
||||
@@ -5,12 +5,13 @@ Updated: 2026-04-02
|
||||
## 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.
|
||||
2. Harden websocket-driven live sync and keep forms/edit flows resilient to concurrent runtime updates.
|
||||
|
||||
## Next
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
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`.
|
||||
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`.
|
||||
|
||||
@@ -10,9 +10,9 @@ Updated: 2026-04-02
|
||||
- `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
|
||||
- `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
|
||||
- `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
|
||||
|
||||
@@ -23,9 +23,9 @@ Updated: 2026-04-02
|
||||
## Frontend
|
||||
|
||||
- `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/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/App.test.tsx`: login-gate, preferences persistence, modal interaction, pause/resume, delete-confirm, and settings-save UI tests
|
||||
- `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, 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, hash-tab restoration, websocket-sync safety, modal interaction, pause/resume, delete-confirm, and settings-save UI tests
|
||||
- `src/app.css`: full panel styling
|
||||
- `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot
|
||||
- `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/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/test/setup.ts`: Testing Library matchers
|
||||
- `src/test/setup.ts`: Testing Library matchers plus browser WebSocket test double
|
||||
|
||||
## Server
|
||||
|
||||
- `server/index.ts`: backend entrypoint and runtime bootstrap
|
||||
- `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/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, 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/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.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/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
|
||||
|
||||
35
package-lock.json
generated
35
package-lock.json
generated
@@ -10,7 +10,8 @@
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
"react-dom": "^19.2.4",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -20,6 +21,7 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"esbuild": "^0.27.4",
|
||||
@@ -1347,6 +1349,16 @@
|
||||
"@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": {
|
||||
"version": "6.0.1",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"esbuild": "^0.27.4",
|
||||
@@ -35,6 +36,7 @@
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
"react-dom": "^19.2.4",
|
||||
"ws": "^8.20.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from './lib/config';
|
||||
import { getDashboardSnapshot } from './lib/snapshot';
|
||||
import { AuthService } from './lib/auth';
|
||||
import type { LiveSyncPublisher } from './lib/liveSync';
|
||||
import type { RuntimeController } from './lib/runtime';
|
||||
import { StateStore } from './lib/store';
|
||||
|
||||
@@ -24,9 +25,10 @@ export interface AppServices {
|
||||
runtime: RuntimeController;
|
||||
runtimeRootDir: string;
|
||||
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 runtimePaths = buildRuntimePaths(runtimeRootDir);
|
||||
const distDir = path.resolve('dist');
|
||||
@@ -106,6 +108,7 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices)
|
||||
}
|
||||
|
||||
await writeConfigAndState(store, state, runtimePaths);
|
||||
liveSync?.notifyPotentialChange();
|
||||
response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -120,6 +123,7 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices)
|
||||
state.userRecords.push(record);
|
||||
state.service.lastEvent = `User ${record.username} created from panel`;
|
||||
await persistRuntimeMutation(store, runtime, state, runtimePaths);
|
||||
liveSync?.notifyPotentialChange();
|
||||
response.status(201).json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
||||
} catch (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';
|
||||
await persistRuntimeMutation(store, runtime, state, runtimePaths);
|
||||
liveSync?.notifyPotentialChange();
|
||||
response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -169,6 +174,7 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices)
|
||||
: `User ${user.username} resumed from panel`;
|
||||
|
||||
await persistRuntimeMutation(store, runtime, state, runtimePaths);
|
||||
liveSync?.notifyPotentialChange();
|
||||
response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -188,6 +194,7 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices)
|
||||
const [removed] = state.userRecords.splice(index, 1);
|
||||
state.service.lastEvent = `User ${removed.username} deleted from panel`;
|
||||
await persistRuntimeMutation(store, runtime, state, runtimePaths);
|
||||
liveSync?.notifyPotentialChange();
|
||||
response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createServer } from 'node:http';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { createApp } from './app';
|
||||
import { AuthService } from './lib/auth';
|
||||
import { buildRuntimePaths, render3proxyConfig } from './lib/config';
|
||||
import { LiveSyncServer } from './lib/liveSync';
|
||||
import { ThreeProxyManager } from './lib/runtime';
|
||||
import { StateStore } from './lib/store';
|
||||
|
||||
@@ -34,8 +36,13 @@ async function main() {
|
||||
await fs.mkdir(path.dirname(runtimePaths.configPath), { recursive: true });
|
||||
await fs.writeFile(runtimePaths.configPath, render3proxyConfig(initialState, runtimePaths), 'utf8');
|
||||
await runtime.initialize();
|
||||
const app = createApp({ store, runtime, runtimeRootDir, auth });
|
||||
app.listen(port, () => {
|
||||
const liveSync = new LiveSyncServer({ store, runtime, runtimePaths, auth });
|
||||
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}`);
|
||||
});
|
||||
}
|
||||
|
||||
30
server/lib/liveSync.test.ts
Normal file
30
server/lib/liveSync.test.ts
Normal 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
206
server/lib/liveSync.ts
Normal 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);
|
||||
}
|
||||
@@ -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 />);
|
||||
|
||||
142
src/App.tsx
142
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(() => {
|
||||
|
||||
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();
|
||||
}, 5000);
|
||||
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));
|
||||
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('');
|
||||
}, [snapshot.system]);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -9,6 +9,11 @@ export default defineConfig({
|
||||
target: process.env.VITE_API_PROXY_TARGET ?? 'http://127.0.0.1:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: process.env.VITE_API_PROXY_TARGET ?? 'http://127.0.0.1:3000',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
||||
Reference in New Issue
Block a user