Refine users table and reconnect handling

This commit is contained in:
2026-04-02 03:14:49 +03:00
parent 2602fab6a7
commit 49b41edcb0
10 changed files with 308 additions and 73 deletions

View File

@@ -45,3 +45,4 @@ Updated: 2026-04-02
28. Reworked Users actions into fixed-width icon buttons, added edit-in-modal flow, generated credentials only for new users, and blocked action buttons while commands are in flight.
29. Added backend user-update support plus runtime stop control, then verified both in Docker by updating `u-1` and stopping the real bundled 3proxy process through the API.
30. Added a websocket heartbeat so time-based status transitions such as `live -> idle/warn` are recalculated predictably even when no new proxy events arrive.
31. Moved proxy-copy into the Users actions column, added a last-seen/online column from parsed 3proxy logs, and introduced bounded websocket/API reconnect attempts with a visible connection banner and forced logout after full recovery failure.

View File

@@ -23,16 +23,16 @@ Updated: 2026-04-02
## Frontend
- `src/main.tsx`: application bootstrap
- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, hash-based tab history, websocket snapshot patch sync, icon-based user actions, create/edit user modal flows, runtime stop control, localized labels, early theme application, and protected panel mutations
- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, hash-based tab history, websocket snapshot patch sync, bounded reconnect/API fallback policy with connection notices, log-derived last-seen user labels, icon-based user actions, create/edit user modal flows, runtime stop control, 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, generated-credential/create-edit modal flows, runtime stop, pause/resume, delete-confirm, and settings-save UI tests
- `src/app.css`: full panel styling including fixed-width icon action buttons and busy-state treatment
- `src/App.test.tsx`: login-gate, preferences persistence, hash-tab restoration, websocket-sync safety, reconnect/logout fallback handling, generated-credential/create-edit modal flows, runtime stop, pause/resume, delete-confirm, and settings-save UI tests
- `src/app.css`: full panel styling including fixed-width icon action buttons, busy-state treatment, and connection banner styling
- `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot
- `src/lib/3proxy.ts`: formatting and status helpers
- `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules
- `src/lib/panelPreferences.ts`: `localStorage`-backed panel language/theme preferences plus theme application helpers with `system` as the default theme
- `src/lib/panelText.ts`: English/Russian UI text catalog for the panel shell, user-edit actions, and runtime controls
- `src/shared/contracts.ts`: shared panel, service, user, and API data contracts
- `src/lib/panelText.ts`: English/Russian UI text catalog for the panel shell, user-edit actions, runtime controls, last-seen labels, and connection recovery notices
- `src/shared/contracts.ts`: shared panel, service, user, and API data contracts including per-user last-seen metadata
- `src/shared/validation.ts`: shared validation for user creation, system edits, service type mapping, and quota conversion
- `src/test/setup.ts`: Testing Library matchers plus browser WebSocket test double
@@ -44,10 +44,10 @@ Updated: 2026-04-02
- `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 plus a heartbeat for time-based status decay
- `server/lib/liveSync.ts`: websocket broadcaster that emits `snapshot.init` and top-level `snapshot.patch` messages from runtime/store changes plus heartbeat-driven status decay and bounded reconnect-friendly behavior
- `server/lib/liveSync.test.ts`: regression tests for patch-only websocket payload generation and heartbeat-driven refreshes
- `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/snapshot.ts`: runtime-backed dashboard snapshot assembly that combines stored panel state with parsed 3proxy traffic observations and derives user last-seen/status output
- `server/lib/traffic.ts`: 3proxy access-log reader that derives current user usage, recent activity, last-seen timestamps, 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 with start/stop/restart/reload operations
- `server/lib/store.ts`: JSON-backed persistent state store with legacy admin-service migration

View File

@@ -11,7 +11,12 @@ export async function getDashboardSnapshot(
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 observedUsers = deriveObservedUsers(
state.userRecords,
traffic.userBytesByName,
traffic.lastSeenByName,
traffic.recentUsers,
);
const observed: ObservedRuntimeState = {
totalBytes: traffic.totalBytes,
liveConnections: traffic.liveConnections,
@@ -26,14 +31,17 @@ export async function getDashboardSnapshot(
function deriveObservedUsers(
users: ProxyUserRecord[],
userBytesByName: Map<string, number>,
lastSeenByName: Map<string, string | null>,
recentUsers: Set<string>,
): ProxyUserRecord[] {
return users.map((user) => {
const usedBytes = userBytesByName.get(user.username) ?? 0;
const lastSeenAt = lastSeenByName.get(user.username) ?? null;
if (user.paused) {
return {
...user,
lastSeenAt,
usedBytes,
status: 'idle',
};
@@ -42,6 +50,7 @@ function deriveObservedUsers(
if (user.quotaBytes !== null && usedBytes >= user.quotaBytes) {
return {
...user,
lastSeenAt,
usedBytes,
status: 'fail',
};
@@ -50,6 +59,7 @@ function deriveObservedUsers(
if (user.quotaBytes !== null && user.quotaBytes > 0 && usedBytes / user.quotaBytes >= 0.8) {
return {
...user,
lastSeenAt,
usedBytes,
status: recentUsers.has(user.username) ? 'live' : 'warn',
};
@@ -57,6 +67,7 @@ function deriveObservedUsers(
return {
...user,
lastSeenAt,
usedBytes,
status: recentUsers.has(user.username) ? 'live' : 'idle',
};

View File

@@ -14,6 +14,7 @@ export interface ObservedTraffic {
activeUsers: number;
daily: DailyTrafficBucket[];
userBytesByName: Map<string, number>;
lastSeenByName: Map<string, string | null>;
recentUsers: Set<string>;
}
@@ -34,6 +35,7 @@ export async function readObservedTraffic(
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 lastSeenByName = new Map<string, string | null>(users.map((user) => [user.username, null]));
const recentUsers = new Set<string>();
let liveConnections = 0;
@@ -47,6 +49,13 @@ export async function readObservedTraffic(
userBytesByName.set(record.username, (userBytesByName.get(record.username) ?? 0) + record.bytes);
}
if (lastSeenByName.has(record.username)) {
const currentLastSeen = lastSeenByName.get(record.username);
if (!currentLastSeen || record.timestamp.toISOString() > currentLastSeen) {
lastSeenByName.set(record.username, record.timestamp.toISOString());
}
}
const ageMs = now.getTime() - record.timestamp.getTime();
if (ageMs >= 0 && ageMs <= RECENT_WINDOW_MS) {
recentUsers.add(record.username);
@@ -76,6 +85,7 @@ export async function readObservedTraffic(
activeUsers,
daily,
userBytesByName,
lastSeenByName,
recentUsers,
};
}

View File

@@ -1,6 +1,6 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import App from './App';
import { fallbackDashboardSnapshot } from './data/mockDashboard';
import { MockWebSocket } from './test/setup';
@@ -115,6 +115,19 @@ describe('App login gate', () => {
expect(screen.getAllByText(/^idle$/i).length).toBeGreaterThan(0);
});
it('shows online data in the users table and keeps proxy copy inside actions', async () => {
const user = userEvent.setup();
render(<App />);
await loginIntoPanel(user);
await user.click(screen.getByRole('button', { name: /users/i }));
expect(screen.getByRole('columnheader', { name: /online/i })).toBeInTheDocument();
expect(screen.queryByRole('columnheader', { name: /^proxy$/i })).not.toBeInTheDocument();
expect(screen.getAllByRole('button', { name: /copy proxy link/i }).length).toBeGreaterThan(0);
expect(screen.getAllByText(/^now$/i).length).toBeGreaterThan(0);
});
it('opens add-user flow in a modal and closes it on escape', async () => {
const user = userEvent.setup();
render(<App />);
@@ -249,6 +262,27 @@ describe('App login gate', () => {
expect(screen.getByLabelText(/proxy endpoint/i)).toHaveValue('draft.example.net');
});
it('shows a connection notice and exits the panel after websocket and api retries are exhausted', async () => {
vi.useFakeTimers();
MockWebSocket.connectMode = 'close';
window.sessionStorage.setItem(
'3proxy-ui-panel-session',
JSON.stringify({
token: 'local-ui-fallback',
expiresAt: new Date(Date.now() + 60_000).toISOString(),
}),
);
render(<App />);
await vi.advanceTimersByTimeAsync(2_100);
expect(screen.getByText(/reconnecting websocket|переподключаем websocket/i)).toBeInTheDocument();
await vi.advanceTimersByTimeAsync(25_000);
expect(screen.getByRole('button', { name: /open panel/i })).toBeInTheDocument();
}, 10_000);
it('warns before deleting a service and removes linked users after confirmation', async () => {
const user = userEvent.setup();
render(<App />);

View File

@@ -40,6 +40,9 @@ 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;
const LIVE_SYNC_RESTORE_MS = 30 * 1000;
const MAX_HTTP_RETRIES = 5;
const MAX_WS_RETRIES = 5;
interface StoredSession {
token: string;
@@ -547,8 +550,8 @@ function UsersTab({
<th>{text.common.status}</th>
<th>{text.users.used}</th>
<th>{text.users.remaining}</th>
<th>{text.users.online}</th>
<th>{text.users.share}</th>
<th>{text.users.proxy}</th>
<th>{text.users.actions}</th>
</tr>
</thead>
@@ -593,20 +596,19 @@ function UsersTab({
</td>
<td>{formatBytes(user.usedBytes)}</td>
<td>{formatQuotaStateLabel(user.usedBytes, user.quotaBytes, preferences.language)}</td>
<td>{formatLastSeenLabel(user, preferences.language)}</td>
<td>{formatTrafficShare(user.usedBytes, snapshot.traffic.totalBytes)}</td>
<td>
<div className="row-actions">
<ActionIconButton
ariaLabel={copiedId === user.id ? text.common.copied : text.users.copyProxyLink}
title={copiedId === user.id ? text.common.copied : text.users.copyProxyLink}
tone={exhausted ? 'danger' : 'secondary'}
onClick={() => handleCopy(user.id, proxyLink)}
disabled={!service}
disabled={!service || isPendingRow}
>
{copiedId === user.id ? <CheckIcon /> : <CopyIcon />}
</ActionIconButton>
</td>
<td>
<div className="row-actions">
<ActionIconButton
ariaLabel={text.users.editAction}
title={text.users.editAction}
@@ -799,6 +801,7 @@ export default function App() {
});
const [session, setSession] = useState<StoredSession | null>(() => loadStoredSession());
const [activeTab, setActiveTab] = useState<TabId>(() => readTabFromHash(window.location.hash));
const [connectionNotice, setConnectionNotice] = useState<string | null>(null);
const [snapshot, setSnapshot] = useState<DashboardSnapshot>(fallbackDashboardSnapshot);
const text = getPanelText(preferences.language);
@@ -832,6 +835,7 @@ export default function App() {
const resetSession = () => {
clearStoredSession();
setConnectionNotice(null);
setSession(null);
};
@@ -879,6 +883,15 @@ export default function App() {
let cancelled = false;
let reconnectTimer: number | null = null;
let socket: WebSocket | null = null;
let isHttpFallbackRunning = false;
let wsAttempts = 0;
const clearReconnectTimer = () => {
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
};
const refreshSnapshot = async () => {
try {
@@ -890,6 +903,7 @@ export default function App() {
if (!cancelled && payload) {
setSnapshot(payload);
return true;
}
} catch (error) {
if (error instanceof SessionExpiredError) {
@@ -898,20 +912,92 @@ export default function App() {
}
}
}
return false;
};
void refreshSnapshot();
const liveSyncUrl = getLiveSyncUrl(session.token);
if (typeof window.WebSocket !== 'undefined' && liveSyncUrl) {
const connect = () => {
const scheduleBackgroundReconnect = () => {
if (cancelled || reconnectTimer !== null) {
return;
}
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
wsAttempts = 0;
connectWebSocket();
}, LIVE_SYNC_RESTORE_MS);
};
const runHttpFallback = async () => {
if (cancelled || isHttpFallbackRunning) {
return;
}
isHttpFallbackRunning = true;
for (let attempt = 1; attempt <= MAX_HTTP_RETRIES && !cancelled; attempt += 1) {
setConnectionNotice(buildConnectionNotice(text.connection.httpRetry, text.connection.attempt, attempt, MAX_HTTP_RETRIES));
const success = await refreshSnapshot();
if (success) {
setConnectionNotice(null);
isHttpFallbackRunning = false;
scheduleBackgroundReconnect();
return;
}
if (attempt < MAX_HTTP_RETRIES) {
await waitForDelay(LIVE_SYNC_RECONNECT_MS);
}
}
isHttpFallbackRunning = false;
if (!cancelled) {
setConnectionNotice(text.connection.sessionClosed);
resetSession();
}
};
const scheduleWebSocketReconnect = () => {
if (cancelled || isHttpFallbackRunning || reconnectTimer !== null) {
return;
}
if (wsAttempts >= MAX_WS_RETRIES) {
void runHttpFallback();
return;
}
wsAttempts += 1;
setConnectionNotice(
buildConnectionNotice(text.connection.websocketRetry, text.connection.attempt, wsAttempts, MAX_WS_RETRIES),
);
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
connectWebSocket();
}, LIVE_SYNC_RECONNECT_MS);
};
const connectWebSocket = () => {
if (cancelled) {
return;
}
socket = new window.WebSocket(liveSyncUrl);
if (typeof window.WebSocket === 'undefined' || !liveSyncUrl) {
void runHttpFallback();
return;
}
socket.addEventListener('message', (event) => {
const nextSocket = new window.WebSocket(liveSyncUrl);
socket = nextSocket;
nextSocket.addEventListener('open', () => {
wsAttempts = 0;
setConnectionNotice(null);
});
nextSocket.addEventListener('message', (event) => {
const message = parseDashboardSyncMessage(event.data);
if (!message || cancelled) {
return;
@@ -919,45 +1005,45 @@ export default function App() {
if (message.type === 'snapshot.init') {
setSnapshot(message.snapshot);
setConnectionNotice(null);
return;
}
if (message.type === 'snapshot.patch') {
setSnapshot((current) => applySnapshotPatch(current, message.patch));
setConnectionNotice(null);
return;
}
resetSession();
});
socket.addEventListener('error', () => {
socket?.close();
nextSocket.addEventListener('error', () => {
nextSocket.close();
});
socket.addEventListener('close', () => {
if (cancelled || reconnectTimer !== null) {
nextSocket.addEventListener('close', () => {
if (socket === nextSocket) {
socket = null;
}
if (cancelled) {
return;
}
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
void refreshSnapshot();
connect();
}, LIVE_SYNC_RECONNECT_MS);
scheduleWebSocketReconnect();
});
};
connect();
}
void refreshSnapshot();
connectWebSocket();
return () => {
cancelled = true;
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
}
clearReconnectTimer();
socket?.close();
};
}, [session]);
}, [session, text.connection.attempt, text.connection.httpRetry, text.connection.sessionClosed, text.connection.websocketRetry]);
if (!session) {
return <LoginGate onUnlock={handleUnlock} preferences={preferences} />;
@@ -1262,6 +1348,7 @@ export default function App() {
onSaveSystem={handleSaveSystem}
/>
) : null}
{connectionNotice ? <div className="connection-banner">{connectionNotice}</div> : null}
</main>
);
@@ -1458,6 +1545,49 @@ function getHashForTab(tab: TabId): string {
}
}
function formatLastSeenLabel(user: ProxyUserRecord, language: PanelPreferences['language']): string {
const text = getPanelText(language);
if (user.status === 'live') {
return text.users.onlineNow;
}
if (!user.lastSeenAt) {
return text.users.neverOnline;
}
const lastSeen = new Date(user.lastSeenAt).getTime();
if (!Number.isFinite(lastSeen)) {
return text.users.neverOnline;
}
const diffMs = Date.now() - lastSeen;
if (diffMs < 60_000) {
return text.users.onlineNow;
}
const formatter = new Intl.RelativeTimeFormat(language === 'ru' ? 'ru-RU' : 'en-US', {
numeric: 'auto',
});
const minutes = Math.round(diffMs / 60_000);
if (minutes < 60) {
return formatter.format(-minutes, 'minute');
}
const hours = Math.round(minutes / 60);
if (hours < 24) {
return formatter.format(-hours, 'hour');
}
const days = Math.round(hours / 24);
return formatter.format(-days, 'day');
}
function buildConnectionNotice(prefix: string, attemptLabel: string, attempt: number, maxAttempts: number): string {
return `${prefix}: ${attemptLabel} ${attempt}/${maxAttempts}.`;
}
function toUserEditorValues(user: ProxyUserRecord): CreateUserInput {
return {
username: user.username,
@@ -1474,3 +1604,9 @@ function generateUsername(): string {
function generatePassword(): string {
return `pw-${Math.random().toString(36).slice(2, 8)}-${Math.random().toString(36).slice(2, 6)}`;
}
function waitForDelay(delayMs: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, delayMs);
});
}

View File

@@ -735,6 +735,20 @@ pre {
justify-content: flex-end;
}
.connection-banner {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 40;
max-width: min(360px, calc(100vw - 32px));
padding: 12px 14px;
border: 1px solid color-mix(in srgb, var(--warning) 45%, var(--border-strong));
border-radius: 10px;
background: color-mix(in srgb, var(--surface) 82%, var(--warning) 18%);
color: var(--text);
box-shadow: 0 10px 24px rgba(17, 24, 39, 0.14);
}
@media (max-width: 960px) {
.shell {
width: calc(100vw - 24px);

View File

@@ -57,6 +57,7 @@ const text = {
light: 'Light',
dark: 'Dark',
system: 'System',
disconnected: 'Disconnected',
},
dashboard: {
service: 'Service',
@@ -79,8 +80,8 @@ const text = {
endpoint: 'Endpoint',
used: 'Used',
remaining: 'Remaining',
online: 'Online',
share: 'Share',
proxy: 'Proxy',
actions: 'Actions',
addUser: 'Add user',
editUser: 'Edit user',
@@ -99,6 +100,14 @@ const text = {
deletePrompt: 'Remove profile',
deletePromptSuffix: '? This action will delete the user entry from the current panel state.',
deleteAction: 'Delete user',
onlineNow: 'Now',
neverOnline: 'Never',
},
connection: {
websocketRetry: 'Live sync disconnected. Reconnecting websocket',
httpRetry: 'Live sync unavailable. Retrying API sync',
attempt: 'attempt',
sessionClosed: 'Connection to the panel was lost. Sign in again.',
},
settings: {
panelTitle: 'Panel settings',
@@ -183,6 +192,7 @@ const text = {
light: 'Светлый',
dark: 'Темный',
system: 'Системный',
disconnected: 'Нет связи',
},
dashboard: {
service: 'Сервис',
@@ -205,8 +215,8 @@ const text = {
endpoint: 'Точка входа',
used: 'Использовано',
remaining: 'Остаток',
online: 'В сети',
share: 'Доля',
proxy: 'Прокси',
actions: 'Действия',
addUser: 'Добавить пользователя',
editUser: 'Редактирование пользователя',
@@ -225,6 +235,14 @@ const text = {
deletePrompt: 'Удалить профиль',
deletePromptSuffix: '? Это действие удалит пользователя из текущего состояния панели.',
deleteAction: 'Удалить пользователя',
onlineNow: 'Сейчас',
neverOnline: 'Никогда',
},
connection: {
websocketRetry: 'Связь live sync потеряна. Переподключаем WebSocket',
httpRetry: 'Live sync недоступен. Повторяем синхронизацию через API',
attempt: 'попытка',
sessionClosed: 'Связь с панелью потеряна. Войдите снова.',
},
settings: {
panelTitle: 'Настройки панели',

View File

@@ -28,6 +28,7 @@ export interface ProxyUserRecord {
status: Exclude<ServiceState, 'paused'>;
usedBytes: number;
quotaBytes: number | null;
lastSeenAt?: string | null;
paused?: boolean;
}

View File

@@ -1,6 +1,6 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
import { afterEach, vi } from 'vitest';
export class MockWebSocket extends EventTarget {
static readonly CONNECTING = 0;
@@ -8,6 +8,7 @@ export class MockWebSocket extends EventTarget {
static readonly CLOSING = 2;
static readonly CLOSED = 3;
static instances: MockWebSocket[] = [];
static connectMode: 'open' | 'close' = 'open';
readonly CONNECTING = MockWebSocket.CONNECTING;
readonly OPEN = MockWebSocket.OPEN;
@@ -20,7 +21,14 @@ export class MockWebSocket extends EventTarget {
super();
this.url = String(url);
MockWebSocket.instances.push(this);
queueMicrotask(() => this.dispatchEvent(new Event('open')));
queueMicrotask(() => {
if (MockWebSocket.connectMode === 'close') {
this.close();
return;
}
this.dispatchEvent(new Event('open'));
});
}
send(_data?: string | ArrayBufferLike | Blob | ArrayBufferView): void {}
@@ -45,6 +53,7 @@ export class MockWebSocket extends EventTarget {
static reset(): void {
this.instances = [];
this.connectMode = 'open';
}
}
@@ -56,5 +65,6 @@ Object.defineProperty(globalThis, 'WebSocket', {
afterEach(() => {
cleanup();
vi.useRealTimers();
MockWebSocket.reset();
});