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
|
- 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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
35
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
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 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 />);
|
||||||
|
|||||||
142
src/App.tsx
142
src/App.tsx
@@ -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(() => {
|
|
||||||
|
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();
|
void refreshSnapshot();
|
||||||
}, 5000);
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
const incomingKey = serializeSystemSettings(incomingDraft);
|
||||||
|
const draftKey = serializeSystemSettings(draft);
|
||||||
|
|
||||||
|
if (incomingKey === lastAppliedSystemKey.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draftKey === lastAppliedSystemKey.current || draftKey === incomingKey) {
|
||||||
|
setDraft(incomingDraft);
|
||||||
setError('');
|
setError('');
|
||||||
}, [snapshot.system]);
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user