diff --git a/README.md b/README.md index 03d5928..b394c30 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/PLAN.md b/docs/PLAN.md index ccc00af..b0a9063 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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`. diff --git a/docs/PROJECT_INDEX.md b/docs/PROJECT_INDEX.md index ed427cb..b961f29 100644 --- a/docs/PROJECT_INDEX.md +++ b/docs/PROJECT_INDEX.md @@ -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 diff --git a/package-lock.json b/package-lock.json index b30aad5..3c34376 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 41fdcbf..d9934df 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/server/app.ts b/server/app.ts index b8d34ab..9432c5c 100644 --- a/server/app.ts +++ b/server/app.ts @@ -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); diff --git a/server/index.ts b/server/index.ts index 3b3feb7..750f19c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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}`); }); } diff --git a/server/lib/liveSync.test.ts b/server/lib/liveSync.test.ts new file mode 100644 index 0000000..de2412c --- /dev/null +++ b/server/lib/liveSync.test.ts @@ -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, + }); + }); +}); diff --git a/server/lib/liveSync.ts b/server/lib/liveSync.ts new file mode 100644 index 0000000..7049b1f --- /dev/null +++ b/server/lib/liveSync.ts @@ -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(); + 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); +} diff --git a/src/App.test.tsx b/src/App.test.tsx index 77089ca..0afd7fb 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -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) { 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(); 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(); + + 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(); + + 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(); @@ -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(); + + 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(); diff --git a/src/App.tsx b/src/App.tsx index 2c25264..176d982 100644 --- a/src/App.tsx +++ b/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(() => loadStoredSession()); - const [activeTab, setActiveTab] = useState('dashboard'); + const [activeTab, setActiveTab] = useState(() => readTabFromHash(window.location.hash)); const [snapshot, setSnapshot] = useState(fallbackDashboardSnapshot); const text = getPanelText(preferences.language); @@ -593,6 +596,21 @@ export default function App() { return observeSystemTheme(() => applyPanelTheme('system')); }, [preferences.theme]); + useEffect(() => { + const syncFromHash = () => { + setActiveTab(readTabFromHash(window.location.hash)); + }; + + if (!window.location.hash) { + window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}${getHashForTab('dashboard')}`); + } else { + syncFromHash(); + } + + window.addEventListener('hashchange', syncFromHash); + return () => window.removeEventListener('hashchange', syncFromHash); + }, []); + const resetSession = () => { clearStoredSession(); setSession(null); @@ -640,7 +658,8 @@ export default function App() { } let cancelled = false; - let intervalId: number | null = null; + let reconnectTimer: number | null = null; + let socket: WebSocket | null = null; const refreshSnapshot = async () => { try { @@ -663,15 +682,61 @@ export default function App() { }; void refreshSnapshot(); - intervalId = window.setInterval(() => { - void refreshSnapshot(); - }, 5000); + + const liveSyncUrl = getLiveSyncUrl(session.token); + if (typeof window.WebSocket !== 'undefined' && liveSyncUrl) { + const connect = () => { + if (cancelled) { + return; + } + + socket = new window.WebSocket(liveSyncUrl); + + socket.addEventListener('message', (event) => { + const message = parseDashboardSyncMessage(event.data); + if (!message || cancelled) { + return; + } + + if (message.type === 'snapshot.init') { + setSnapshot(message.snapshot); + return; + } + + if (message.type === 'snapshot.patch') { + setSnapshot((current) => applySnapshotPatch(current, message.patch)); + return; + } + + resetSession(); + }); + + socket.addEventListener('error', () => { + socket?.close(); + }); + + socket.addEventListener('close', () => { + if (cancelled || reconnectTimer !== null) { + return; + } + + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null; + void refreshSnapshot(); + connect(); + }, LIVE_SYNC_RECONNECT_MS); + }); + }; + + connect(); + } return () => { cancelled = true; - if (intervalId !== null) { - window.clearInterval(intervalId); + if (reconnectTimer !== null) { + window.clearTimeout(reconnectTimer); } + socket?.close(); }; }, [session]); @@ -884,7 +949,7 @@ export default function App() { key={tab.id} type="button" className={activeTab === tab.id ? 'tab-button active' : 'tab-button'} - onClick={() => setActiveTab(tab.id)} + onClick={() => navigateToTab(tab.id)} > {text.tabs[tab.textKey]} @@ -913,6 +978,17 @@ export default function App() { ) : null} ); + + 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 { 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'; + } +} diff --git a/src/SystemTab.tsx b/src/SystemTab.tsx index 77ee063..6043f5d 100644 --- a/src/SystemTab.tsx +++ b/src/SystemTab.tsx @@ -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(null); + const lastAppliedSystemKey = useRef(serializeSystemSettings(cloneSystemSettings(snapshot.system))); const text = getPanelText(preferences.language); useEffect(() => { - setDraft(cloneSystemSettings(snapshot.system)); - setError(''); - }, [snapshot.system]); + const incomingDraft = cloneSystemSettings(snapshot.system); + const incomingKey = serializeSystemSettings(incomingDraft); + const draftKey = serializeSystemSettings(draft); + + if (incomingKey === lastAppliedSystemKey.current) { + return; + } + + if (draftKey === lastAppliedSystemKey.current || draftKey === incomingKey) { + setDraft(incomingDraft); + setError(''); + } + + lastAppliedSystemKey.current = incomingKey; + }, [draft, snapshot.system]); const linkedUsersByService = useMemo(() => { const result = new Map(); @@ -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); +} diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index b76d025..b510559 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -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; diff --git a/src/test/setup.ts b/src/test/setup.ts index 0d74b73..47c17cc 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -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(); }); diff --git a/vite.config.ts b/vite.config.ts index 0045f36..116c631 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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: {