Ingest live 3proxy traffic from access logs
This commit is contained in:
@@ -9,6 +9,7 @@ The project now includes both the UI and the first backend/runtime slice:
|
|||||||
- Express-based control plane API
|
- Express-based control plane API
|
||||||
- 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
|
||||||
- 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
|
||||||
@@ -35,6 +36,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.
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Updated: 2026-04-02
|
|||||||
|
|
||||||
## Active
|
## Active
|
||||||
|
|
||||||
1. Harden the backend/runtime layer, keep replacing fallback UI behavior with runtime-backed signals, and prepare the next slice of real traffic/counter ingestion.
|
1. Keep replacing remaining seeded fallback signals with runtime-backed 3proxy data and extend the Docker-verified ingestion path beyond access-log-derived usage.
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
@@ -36,3 +36,5 @@ Updated: 2026-04-02
|
|||||||
20. Made `npm run dev` start both the Vite client and Express backend, added a Vite API proxy for local development, and restored `system` as the default panel theme so the login screen follows OS appearance.
|
20. Made `npm run dev` start both the Vite client and Express backend, added a Vite API proxy for local development, and restored `system` as the default panel theme so the login screen follows OS appearance.
|
||||||
21. Re-separated the Settings tab into distinct panel-settings and services cards so panel preferences no longer appear inside the Services section.
|
21. Re-separated the Settings tab into distinct panel-settings and services cards so panel preferences no longer appear inside the Services section.
|
||||||
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.
|
||||||
|
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`.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ 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, and protected panel mutations
|
- `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/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.test.tsx`: login-gate, preferences persistence, modal interaction, pause/resume, delete-confirm, and settings-save UI tests
|
||||||
- `src/app.css`: full panel styling
|
- `src/app.css`: full panel styling
|
||||||
@@ -44,6 +44,9 @@ Updated: 2026-04-02
|
|||||||
- `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/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
|
||||||
- `server/lib/runtime.ts`: managed 3proxy process controller
|
- `server/lib/runtime.ts`: managed 3proxy process controller
|
||||||
- `server/lib/store.ts`: JSON-backed persistent state store with legacy admin-service migration
|
- `server/lib/store.ts`: JSON-backed persistent state store with legacy admin-service migration
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import { validateCreateUserInput, validateSystemInput } from '../src/shared/vali
|
|||||||
import {
|
import {
|
||||||
buildRuntimePaths,
|
buildRuntimePaths,
|
||||||
createUserRecord,
|
createUserRecord,
|
||||||
deriveDashboardSnapshot,
|
|
||||||
render3proxyConfig,
|
render3proxyConfig,
|
||||||
type RuntimePaths,
|
type RuntimePaths,
|
||||||
} from './lib/config';
|
} from './lib/config';
|
||||||
|
import { getDashboardSnapshot } from './lib/snapshot';
|
||||||
import { AuthService } from './lib/auth';
|
import { AuthService } from './lib/auth';
|
||||||
import type { RuntimeController } from './lib/runtime';
|
import type { RuntimeController } from './lib/runtime';
|
||||||
import { StateStore } from './lib/store';
|
import { StateStore } from './lib/store';
|
||||||
@@ -81,7 +81,7 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices)
|
|||||||
|
|
||||||
app.get('/api/state', async (_request, response, next) => {
|
app.get('/api/state', async (_request, response, next) => {
|
||||||
try {
|
try {
|
||||||
const payload = await getSnapshot(store, runtime, runtimePaths);
|
const payload = await getDashboardSnapshot(store, runtime, runtimePaths);
|
||||||
response.json(payload);
|
response.json(payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -106,7 +106,7 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices)
|
|||||||
}
|
}
|
||||||
|
|
||||||
await writeConfigAndState(store, state, runtimePaths);
|
await writeConfigAndState(store, state, runtimePaths);
|
||||||
response.json(await getSnapshot(store, runtime, runtimePaths));
|
response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -120,7 +120,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);
|
||||||
response.status(201).json(await getSnapshot(store, runtime, runtimePaths));
|
response.status(201).json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,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);
|
||||||
response.json(await getSnapshot(store, runtime, runtimePaths));
|
response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -169,7 +169,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);
|
||||||
response.json(await getSnapshot(store, runtime, runtimePaths));
|
response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,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);
|
||||||
response.json(await getSnapshot(store, runtime, runtimePaths));
|
response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -214,15 +214,6 @@ export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices)
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSnapshot(
|
|
||||||
store: StateStore,
|
|
||||||
runtime: RuntimeController,
|
|
||||||
runtimePaths: RuntimePaths,
|
|
||||||
) {
|
|
||||||
const state = await store.read();
|
|
||||||
const previewConfig = render3proxyConfig(state, runtimePaths);
|
|
||||||
return deriveDashboardSnapshot(state, runtime.getSnapshot(), previewConfig);
|
|
||||||
}
|
|
||||||
async function persistRuntimeMutation(
|
async function persistRuntimeMutation(
|
||||||
store: StateStore,
|
store: StateStore,
|
||||||
runtime: RuntimeController,
|
runtime: RuntimeController,
|
||||||
|
|||||||
@@ -35,5 +35,6 @@ describe('render3proxyConfig', () => {
|
|||||||
expect(config).not.toContain('night-shift:CL:kettle!23');
|
expect(config).not.toContain('night-shift:CL:kettle!23');
|
||||||
expect(config).not.toContain('allow night-shift,ops-east');
|
expect(config).not.toContain('allow night-shift,ops-east');
|
||||||
expect(config).toContain('allow ops-east');
|
expect(config).toContain('allow ops-east');
|
||||||
|
expect(config).toContain('countall 1 D 1024 night-shift');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
ControlPlaneState,
|
ControlPlaneState,
|
||||||
CreateUserInput,
|
CreateUserInput,
|
||||||
DashboardSnapshot,
|
DashboardSnapshot,
|
||||||
|
DailyTrafficBucket,
|
||||||
ProxyServiceRecord,
|
ProxyServiceRecord,
|
||||||
ProxyUserRecord,
|
ProxyUserRecord,
|
||||||
} from '../../src/shared/contracts';
|
} from '../../src/shared/contracts';
|
||||||
@@ -19,6 +20,14 @@ export interface RuntimeSnapshot {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ObservedRuntimeState {
|
||||||
|
totalBytes: number;
|
||||||
|
liveConnections: number;
|
||||||
|
activeUsers: number;
|
||||||
|
daily: DailyTrafficBucket[];
|
||||||
|
userRecords: ProxyUserRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface RuntimePaths {
|
export interface RuntimePaths {
|
||||||
rootDir: string;
|
rootDir: string;
|
||||||
configPath: string;
|
configPath: string;
|
||||||
@@ -73,7 +82,7 @@ export function render3proxyConfig(state: ControlPlaneState, paths: RuntimePaths
|
|||||||
lines.push(`users ${activeUsers.map(renderUserCredential).join(' ')}`);
|
lines.push(`users ${activeUsers.map(renderUserCredential).join(' ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const quotaUsers = activeUsers.filter((user) => user.quotaBytes !== null);
|
const quotaUsers = state.userRecords.filter((user) => user.quotaBytes !== null);
|
||||||
if (quotaUsers.length > 0) {
|
if (quotaUsers.length > 0) {
|
||||||
lines.push(
|
lines.push(
|
||||||
`counter ${normalizePath(paths.counterPath)} D ${normalizePath(
|
`counter ${normalizePath(paths.counterPath)} D ${normalizePath(
|
||||||
@@ -112,13 +121,14 @@ export function render3proxyConfig(state: ControlPlaneState, paths: RuntimePaths
|
|||||||
export function deriveDashboardSnapshot(
|
export function deriveDashboardSnapshot(
|
||||||
state: ControlPlaneState,
|
state: ControlPlaneState,
|
||||||
runtime: RuntimeSnapshot,
|
runtime: RuntimeSnapshot,
|
||||||
|
observed: ObservedRuntimeState,
|
||||||
previewConfig: string,
|
previewConfig: string,
|
||||||
): DashboardSnapshot {
|
): DashboardSnapshot {
|
||||||
const liveUsers = state.userRecords.filter((user) => !user.paused && user.status === 'live').length;
|
const liveUsers = observed.userRecords.filter((user) => !user.paused && user.status === 'live').length;
|
||||||
const exceededUsers = state.userRecords.filter(
|
const exceededUsers = observed.userRecords.filter(
|
||||||
(user) => user.quotaBytes !== null && user.usedBytes >= user.quotaBytes,
|
(user) => user.quotaBytes !== null && user.usedBytes >= user.quotaBytes,
|
||||||
).length;
|
).length;
|
||||||
const nearQuotaUsers = state.userRecords.filter((user) => {
|
const nearQuotaUsers = observed.userRecords.filter((user) => {
|
||||||
if (user.paused || user.quotaBytes === null || user.usedBytes >= user.quotaBytes) {
|
if (user.paused || user.quotaBytes === null || user.usedBytes >= user.quotaBytes) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -175,17 +185,19 @@ export function deriveDashboardSnapshot(
|
|||||||
lastEvent: state.service.lastEvent,
|
lastEvent: state.service.lastEvent,
|
||||||
},
|
},
|
||||||
traffic: {
|
traffic: {
|
||||||
...state.traffic,
|
totalBytes: observed.totalBytes,
|
||||||
activeUsers: state.userRecords.filter((user) => !user.paused).length,
|
liveConnections: observed.liveConnections,
|
||||||
|
activeUsers: observed.activeUsers,
|
||||||
|
daily: observed.daily,
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
total: state.userRecords.length,
|
total: observed.userRecords.length,
|
||||||
live: liveUsers,
|
live: liveUsers,
|
||||||
nearQuota: nearQuotaUsers,
|
nearQuota: nearQuotaUsers,
|
||||||
exceeded: exceededUsers,
|
exceeded: exceededUsers,
|
||||||
},
|
},
|
||||||
attention,
|
attention,
|
||||||
userRecords: state.userRecords,
|
userRecords: observed.userRecords,
|
||||||
system: {
|
system: {
|
||||||
...state.system,
|
...state.system,
|
||||||
previewConfig,
|
previewConfig,
|
||||||
|
|||||||
64
server/lib/snapshot.ts
Normal file
64
server/lib/snapshot.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { ProxyUserRecord } from '../../src/shared/contracts';
|
||||||
|
import { deriveDashboardSnapshot, render3proxyConfig, type ObservedRuntimeState, type RuntimePaths, type RuntimeSnapshot } from './config';
|
||||||
|
import type { StateStore } from './store';
|
||||||
|
import { readObservedTraffic } from './traffic';
|
||||||
|
|
||||||
|
export async function getDashboardSnapshot(
|
||||||
|
store: StateStore,
|
||||||
|
runtime: { getSnapshot(): RuntimeSnapshot },
|
||||||
|
runtimePaths: RuntimePaths,
|
||||||
|
) {
|
||||||
|
const state = await store.read();
|
||||||
|
const previewConfig = render3proxyConfig(state, runtimePaths);
|
||||||
|
const traffic = await readObservedTraffic(runtimePaths, state.userRecords);
|
||||||
|
const observedUsers = deriveObservedUsers(state.userRecords, traffic.userBytesByName, traffic.recentUsers);
|
||||||
|
const observed: ObservedRuntimeState = {
|
||||||
|
totalBytes: traffic.totalBytes,
|
||||||
|
liveConnections: traffic.liveConnections,
|
||||||
|
activeUsers: traffic.activeUsers,
|
||||||
|
daily: traffic.daily,
|
||||||
|
userRecords: observedUsers,
|
||||||
|
};
|
||||||
|
|
||||||
|
return deriveDashboardSnapshot(state, runtime.getSnapshot(), observed, previewConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveObservedUsers(
|
||||||
|
users: ProxyUserRecord[],
|
||||||
|
userBytesByName: Map<string, number>,
|
||||||
|
recentUsers: Set<string>,
|
||||||
|
): ProxyUserRecord[] {
|
||||||
|
return users.map((user) => {
|
||||||
|
const usedBytes = userBytesByName.get(user.username) ?? 0;
|
||||||
|
|
||||||
|
if (user.paused) {
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
usedBytes,
|
||||||
|
status: 'idle',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.quotaBytes !== null && usedBytes >= user.quotaBytes) {
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
usedBytes,
|
||||||
|
status: 'fail',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.quotaBytes !== null && user.quotaBytes > 0 && usedBytes / user.quotaBytes >= 0.8) {
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
usedBytes,
|
||||||
|
status: recentUsers.has(user.username) ? 'live' : 'warn',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
usedBytes,
|
||||||
|
status: recentUsers.has(user.username) ? 'live' : 'idle',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
104
server/lib/traffic.test.ts
Normal file
104
server/lib/traffic.test.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import type { ProxyUserRecord } from '../../src/shared/contracts';
|
||||||
|
import { readObservedTraffic } from './traffic';
|
||||||
|
|
||||||
|
const cleanupDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(cleanupDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readObservedTraffic', () => {
|
||||||
|
it('derives current user usage, recent activity, and daily totals from 3proxy access logs', async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), '3proxy-traffic-'));
|
||||||
|
cleanupDirs.push(dir);
|
||||||
|
|
||||||
|
const logDir = path.join(dir, 'logs');
|
||||||
|
await fs.mkdir(logDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(logDir, '3proxy.log.2026.04.02'),
|
||||||
|
[
|
||||||
|
'260402111500.000 1080 00000 night-shift 172.19.0.1:54402 104.18.27.120:80 100 900 0 GET http://example.com/ HTTP/1.1',
|
||||||
|
'260402115900.000 2080 00000 lab-unlimited 172.19.0.1:53490 8.6.112.0:80 75 842 0 CONNECT example.com:80',
|
||||||
|
'260402115930.000 1080 00000 - 0.0.0.0:1080 0.0.0.0:0 0 0 0 Accepting connections [18/1]',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(logDir, '3proxy.log.2026.04.01'),
|
||||||
|
'260401230000.000 1080 00000 night-shift 172.19.0.1:50000 104.18.27.120:80 50 150 0 GET http://example.com/ HTTP/1.1\n',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const users: ProxyUserRecord[] = [
|
||||||
|
{
|
||||||
|
id: 'u-1',
|
||||||
|
username: 'night-shift',
|
||||||
|
password: 'secret',
|
||||||
|
serviceId: 'socks-main',
|
||||||
|
status: 'idle',
|
||||||
|
usedBytes: 0,
|
||||||
|
quotaBytes: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'u-2',
|
||||||
|
username: 'lab-unlimited',
|
||||||
|
password: 'secret',
|
||||||
|
serviceId: 'socks-lab',
|
||||||
|
status: 'idle',
|
||||||
|
usedBytes: 0,
|
||||||
|
quotaBytes: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const observed = await readObservedTraffic(
|
||||||
|
{
|
||||||
|
rootDir: dir,
|
||||||
|
configPath: path.join(dir, 'generated', '3proxy.cfg'),
|
||||||
|
counterPath: path.join(dir, 'state', 'counters.3cf'),
|
||||||
|
reportDir: path.join(dir, 'state', 'reports'),
|
||||||
|
logPath: path.join(logDir, '3proxy.log'),
|
||||||
|
pidPath: path.join(dir, '3proxy.pid'),
|
||||||
|
},
|
||||||
|
users,
|
||||||
|
new Date(2026, 3, 2, 12, 0, 0, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(observed.totalBytes).toBe(1917);
|
||||||
|
expect(observed.liveConnections).toBe(1);
|
||||||
|
expect(observed.activeUsers).toBe(2);
|
||||||
|
expect(observed.userBytesByName.get('night-shift')).toBe(1000);
|
||||||
|
expect(observed.userBytesByName.get('lab-unlimited')).toBe(917);
|
||||||
|
expect(observed.recentUsers.has('night-shift')).toBe(false);
|
||||||
|
expect(observed.recentUsers.has('lab-unlimited')).toBe(true);
|
||||||
|
expect(observed.daily[observed.daily.length - 2].bytes).toBe(200);
|
||||||
|
expect(observed.daily[observed.daily.length - 1].bytes).toBe(1917);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns zeroed metrics when no runtime logs exist yet', async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), '3proxy-traffic-empty-'));
|
||||||
|
cleanupDirs.push(dir);
|
||||||
|
|
||||||
|
const observed = await readObservedTraffic(
|
||||||
|
{
|
||||||
|
rootDir: dir,
|
||||||
|
configPath: path.join(dir, 'generated', '3proxy.cfg'),
|
||||||
|
counterPath: path.join(dir, 'state', 'counters.3cf'),
|
||||||
|
reportDir: path.join(dir, 'state', 'reports'),
|
||||||
|
logPath: path.join(dir, 'logs', '3proxy.log'),
|
||||||
|
pidPath: path.join(dir, '3proxy.pid'),
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
new Date(2026, 3, 2, 12, 0, 0, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(observed.totalBytes).toBe(0);
|
||||||
|
expect(observed.liveConnections).toBe(0);
|
||||||
|
expect(observed.activeUsers).toBe(0);
|
||||||
|
expect(observed.daily).toHaveLength(5);
|
||||||
|
expect(observed.daily.every((entry) => entry.bytes === 0 && entry.share === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
184
server/lib/traffic.ts
Normal file
184
server/lib/traffic.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { DailyTrafficBucket, ProxyUserRecord } from '../../src/shared/contracts';
|
||||||
|
import type { RuntimePaths } from './config';
|
||||||
|
|
||||||
|
const LOG_FILE_PREFIX = '3proxy.log';
|
||||||
|
const RECENT_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
const CONNECTION_WINDOW_MS = 5 * 60 * 1000;
|
||||||
|
const HISTORY_DAYS = 5;
|
||||||
|
|
||||||
|
export interface ObservedTraffic {
|
||||||
|
totalBytes: number;
|
||||||
|
liveConnections: number;
|
||||||
|
activeUsers: number;
|
||||||
|
daily: DailyTrafficBucket[];
|
||||||
|
userBytesByName: Map<string, number>;
|
||||||
|
recentUsers: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedLogRecord {
|
||||||
|
timestamp: Date;
|
||||||
|
username: string;
|
||||||
|
bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readObservedTraffic(
|
||||||
|
runtimePaths: RuntimePaths,
|
||||||
|
users: ProxyUserRecord[],
|
||||||
|
now = new Date(),
|
||||||
|
): Promise<ObservedTraffic> {
|
||||||
|
const records = await readParsedRecords(runtimePaths.logPath);
|
||||||
|
const currentDayKey = toDayKey(now);
|
||||||
|
const historyStart = startOfDay(now);
|
||||||
|
const history = buildHistoryDays(historyStart, HISTORY_DAYS);
|
||||||
|
const dailyTotals = new Map(history.map((entry) => [entry.key, 0]));
|
||||||
|
const userBytesByName = new Map(users.map((user) => [user.username, 0]));
|
||||||
|
const recentUsers = new Set<string>();
|
||||||
|
let liveConnections = 0;
|
||||||
|
|
||||||
|
records.forEach((record) => {
|
||||||
|
const dayKey = toDayKey(record.timestamp);
|
||||||
|
if (dailyTotals.has(dayKey)) {
|
||||||
|
dailyTotals.set(dayKey, (dailyTotals.get(dayKey) ?? 0) + record.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dayKey === currentDayKey && userBytesByName.has(record.username)) {
|
||||||
|
userBytesByName.set(record.username, (userBytesByName.get(record.username) ?? 0) + record.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ageMs = now.getTime() - record.timestamp.getTime();
|
||||||
|
if (ageMs >= 0 && ageMs <= RECENT_WINDOW_MS) {
|
||||||
|
recentUsers.add(record.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ageMs >= 0 && ageMs <= CONNECTION_WINDOW_MS) {
|
||||||
|
liveConnections += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeUsers = Array.from(userBytesByName.values()).filter((value) => value > 0).length;
|
||||||
|
const totalBytes = Array.from(userBytesByName.values()).reduce((sum, value) => sum + value, 0);
|
||||||
|
const daily = history.map((entry) => ({
|
||||||
|
day: entry.label,
|
||||||
|
bytes: dailyTotals.get(entry.key) ?? 0,
|
||||||
|
share: 0,
|
||||||
|
}));
|
||||||
|
const peak = Math.max(...daily.map((entry) => entry.bytes), 0);
|
||||||
|
|
||||||
|
daily.forEach((entry) => {
|
||||||
|
entry.share = peak > 0 ? entry.bytes / peak : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalBytes,
|
||||||
|
liveConnections,
|
||||||
|
activeUsers,
|
||||||
|
daily,
|
||||||
|
userBytesByName,
|
||||||
|
recentUsers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readParsedRecords(logPath: string): Promise<ParsedLogRecord[]> {
|
||||||
|
const logDir = path.dirname(logPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(logDir);
|
||||||
|
const logFiles = files
|
||||||
|
.filter((file) => file === LOG_FILE_PREFIX || file.startsWith(`${LOG_FILE_PREFIX}.`))
|
||||||
|
.sort()
|
||||||
|
.slice(-HISTORY_DAYS - 2);
|
||||||
|
|
||||||
|
const parsed = await Promise.all(logFiles.map(async (file) => parseLogFile(path.join(logDir, file))));
|
||||||
|
return parsed.flat();
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseLogFile(filePath: string): Promise<ParsedLogRecord[]> {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(filePath, 'utf8');
|
||||||
|
return content
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(parseLogLine)
|
||||||
|
.filter((record): record is ParsedLogRecord => record !== null);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLogLine(line: string): ParsedLogRecord | null {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (!trimmed || trimmed.includes('Accepting connections') || trimmed.includes('Exiting thread')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = trimmed.match(
|
||||||
|
/^(\d{12}\.\d{3})\s+\d+\s+\d+\s+(\S+)\s+\S+\s+\S+\s+(\d+)\s+(\d+)\s+\d+\s+/,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, rawTimestamp, username, bytesIn, bytesOut] = match;
|
||||||
|
if (username === '-') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = parse3proxyTimestamp(rawTimestamp);
|
||||||
|
if (!timestamp) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp,
|
||||||
|
username,
|
||||||
|
bytes: Number(bytesIn) + Number(bytesOut),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse3proxyTimestamp(value: string): Date | null {
|
||||||
|
const match = value.match(/^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.(\d{3})$/);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, yy, mm, dd, hh, min, ss, ms] = match;
|
||||||
|
const year = 2000 + Number(yy);
|
||||||
|
const date = new Date(year, Number(mm) - 1, Number(dd), Number(hh), Number(min), Number(ss), Number(ms));
|
||||||
|
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHistoryDays(today: Date, count: number) {
|
||||||
|
return Array.from({ length: count }, (_value, index) => {
|
||||||
|
const date = new Date(today);
|
||||||
|
date.setDate(today.getDate() - (count - index - 1));
|
||||||
|
return {
|
||||||
|
key: toDayKey(date),
|
||||||
|
label: date.toLocaleDateString('en-US', { weekday: 'short' }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfDay(date: Date): Date {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDayKey(date: Date): string {
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${date.getFullYear()}-${month}-${day}`;
|
||||||
|
}
|
||||||
30
src/App.tsx
30
src/App.tsx
@@ -640,30 +640,38 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let intervalId: number | null = null;
|
||||||
|
|
||||||
|
const refreshSnapshot = async () => {
|
||||||
|
try {
|
||||||
|
const payload = await requestSnapshot(() =>
|
||||||
|
fetch('/api/state', {
|
||||||
|
headers: buildAuthHeaders(session.token),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
void requestSnapshot(() =>
|
|
||||||
fetch('/api/state', {
|
|
||||||
headers: buildAuthHeaders(session.token),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.then((payload) => {
|
|
||||||
if (!cancelled && payload) {
|
if (!cancelled && payload) {
|
||||||
setSnapshot(payload);
|
setSnapshot(payload);
|
||||||
}
|
}
|
||||||
})
|
} catch (error) {
|
||||||
.catch((error) => {
|
|
||||||
if (error instanceof SessionExpiredError) {
|
if (error instanceof SessionExpiredError) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
resetSession();
|
resetSession();
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Keep fallback snapshot for local UI and tests when backend is not running.
|
void refreshSnapshot();
|
||||||
});
|
intervalId = window.setInterval(() => {
|
||||||
|
void refreshSnapshot();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
if (intervalId !== null) {
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user