Refine users table and reconnect handling
This commit is contained in:
@@ -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 />);
|
||||
|
||||
254
src/App.tsx
254
src/App.tsx
@@ -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>
|
||||
<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}
|
||||
>
|
||||
{copiedId === user.id ? <CheckIcon /> : <CopyIcon />}
|
||||
</ActionIconButton>
|
||||
</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 || isPendingRow}
|
||||
>
|
||||
{copiedId === user.id ? <CheckIcon /> : <CopyIcon />}
|
||||
</ActionIconButton>
|
||||
<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,66 +912,138 @@ 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;
|
||||
}
|
||||
|
||||
if (typeof window.WebSocket === 'undefined' || !liveSyncUrl) {
|
||||
void runHttpFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
nextSocket.addEventListener('error', () => {
|
||||
nextSocket.close();
|
||||
});
|
||||
|
||||
nextSocket.addEventListener('close', () => {
|
||||
if (socket === nextSocket) {
|
||||
socket = null;
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket = new window.WebSocket(liveSyncUrl);
|
||||
scheduleWebSocketReconnect();
|
||||
});
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
14
src/app.css
14
src/app.css
@@ -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);
|
||||
|
||||
@@ -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: 'Настройки панели',
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface ProxyUserRecord {
|
||||
status: Exclude<ServiceState, 'paused'>;
|
||||
usedBytes: number;
|
||||
quotaBytes: number | null;
|
||||
lastSeenAt?: string | null;
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user