Refine users table and reconnect handling
This commit is contained in:
@@ -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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -23,16 +23,16 @@ 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, 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/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.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 and busy-state treatment
|
- `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/data/mockDashboard.ts`: default panel state and frontend fallback snapshot
|
||||||
- `src/lib/3proxy.ts`: formatting and status helpers
|
- `src/lib/3proxy.ts`: formatting and status helpers
|
||||||
- `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules
|
- `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/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/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
|
- `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/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
|
- `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/auth.ts`: expiring token issuance and bearer-token verification for the panel
|
||||||
- `server/lib/config.ts`: 3proxy config renderer, validation, and dashboard derivation for SOCKS/HTTP managed services
|
- `server/lib/config.ts`: 3proxy config renderer, validation, and dashboard derivation for SOCKS/HTTP managed services
|
||||||
- `server/lib/config.test.ts`: config-generation regression tests
|
- `server/lib/config.test.ts`: config-generation regression tests
|
||||||
- `server/lib/liveSync.ts`: websocket broadcaster that emits `snapshot.init` and top-level `snapshot.patch` messages from runtime/store changes 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/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/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, daily totals, and lightweight live-connection estimates
|
- `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/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/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
|
- `server/lib/store.ts`: JSON-backed persistent state store with legacy admin-service migration
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ export async function getDashboardSnapshot(
|
|||||||
const state = await store.read();
|
const state = await store.read();
|
||||||
const previewConfig = render3proxyConfig(state, runtimePaths);
|
const previewConfig = render3proxyConfig(state, runtimePaths);
|
||||||
const traffic = await readObservedTraffic(runtimePaths, state.userRecords);
|
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 = {
|
const observed: ObservedRuntimeState = {
|
||||||
totalBytes: traffic.totalBytes,
|
totalBytes: traffic.totalBytes,
|
||||||
liveConnections: traffic.liveConnections,
|
liveConnections: traffic.liveConnections,
|
||||||
@@ -26,14 +31,17 @@ export async function getDashboardSnapshot(
|
|||||||
function deriveObservedUsers(
|
function deriveObservedUsers(
|
||||||
users: ProxyUserRecord[],
|
users: ProxyUserRecord[],
|
||||||
userBytesByName: Map<string, number>,
|
userBytesByName: Map<string, number>,
|
||||||
|
lastSeenByName: Map<string, string | null>,
|
||||||
recentUsers: Set<string>,
|
recentUsers: Set<string>,
|
||||||
): ProxyUserRecord[] {
|
): ProxyUserRecord[] {
|
||||||
return users.map((user) => {
|
return users.map((user) => {
|
||||||
const usedBytes = userBytesByName.get(user.username) ?? 0;
|
const usedBytes = userBytesByName.get(user.username) ?? 0;
|
||||||
|
const lastSeenAt = lastSeenByName.get(user.username) ?? null;
|
||||||
|
|
||||||
if (user.paused) {
|
if (user.paused) {
|
||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
|
lastSeenAt,
|
||||||
usedBytes,
|
usedBytes,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
};
|
};
|
||||||
@@ -42,6 +50,7 @@ function deriveObservedUsers(
|
|||||||
if (user.quotaBytes !== null && usedBytes >= user.quotaBytes) {
|
if (user.quotaBytes !== null && usedBytes >= user.quotaBytes) {
|
||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
|
lastSeenAt,
|
||||||
usedBytes,
|
usedBytes,
|
||||||
status: 'fail',
|
status: 'fail',
|
||||||
};
|
};
|
||||||
@@ -50,6 +59,7 @@ function deriveObservedUsers(
|
|||||||
if (user.quotaBytes !== null && user.quotaBytes > 0 && usedBytes / user.quotaBytes >= 0.8) {
|
if (user.quotaBytes !== null && user.quotaBytes > 0 && usedBytes / user.quotaBytes >= 0.8) {
|
||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
|
lastSeenAt,
|
||||||
usedBytes,
|
usedBytes,
|
||||||
status: recentUsers.has(user.username) ? 'live' : 'warn',
|
status: recentUsers.has(user.username) ? 'live' : 'warn',
|
||||||
};
|
};
|
||||||
@@ -57,6 +67,7 @@ function deriveObservedUsers(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
|
lastSeenAt,
|
||||||
usedBytes,
|
usedBytes,
|
||||||
status: recentUsers.has(user.username) ? 'live' : 'idle',
|
status: recentUsers.has(user.username) ? 'live' : 'idle',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface ObservedTraffic {
|
|||||||
activeUsers: number;
|
activeUsers: number;
|
||||||
daily: DailyTrafficBucket[];
|
daily: DailyTrafficBucket[];
|
||||||
userBytesByName: Map<string, number>;
|
userBytesByName: Map<string, number>;
|
||||||
|
lastSeenByName: Map<string, string | null>;
|
||||||
recentUsers: Set<string>;
|
recentUsers: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ export async function readObservedTraffic(
|
|||||||
const history = buildHistoryDays(historyStart, HISTORY_DAYS);
|
const history = buildHistoryDays(historyStart, HISTORY_DAYS);
|
||||||
const dailyTotals = new Map(history.map((entry) => [entry.key, 0]));
|
const dailyTotals = new Map(history.map((entry) => [entry.key, 0]));
|
||||||
const userBytesByName = new Map(users.map((user) => [user.username, 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>();
|
const recentUsers = new Set<string>();
|
||||||
let liveConnections = 0;
|
let liveConnections = 0;
|
||||||
|
|
||||||
@@ -47,6 +49,13 @@ export async function readObservedTraffic(
|
|||||||
userBytesByName.set(record.username, (userBytesByName.get(record.username) ?? 0) + record.bytes);
|
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();
|
const ageMs = now.getTime() - record.timestamp.getTime();
|
||||||
if (ageMs >= 0 && ageMs <= RECENT_WINDOW_MS) {
|
if (ageMs >= 0 && ageMs <= RECENT_WINDOW_MS) {
|
||||||
recentUsers.add(record.username);
|
recentUsers.add(record.username);
|
||||||
@@ -76,6 +85,7 @@ export async function readObservedTraffic(
|
|||||||
activeUsers,
|
activeUsers,
|
||||||
daily,
|
daily,
|
||||||
userBytesByName,
|
userBytesByName,
|
||||||
|
lastSeenByName,
|
||||||
recentUsers,
|
recentUsers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { beforeEach, describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { fallbackDashboardSnapshot } from './data/mockDashboard';
|
import { fallbackDashboardSnapshot } from './data/mockDashboard';
|
||||||
import { MockWebSocket } from './test/setup';
|
import { MockWebSocket } from './test/setup';
|
||||||
@@ -115,6 +115,19 @@ describe('App login gate', () => {
|
|||||||
expect(screen.getAllByText(/^idle$/i).length).toBeGreaterThan(0);
|
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 () => {
|
it('opens add-user flow in a modal and closes it on escape', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />);
|
||||||
@@ -249,6 +262,27 @@ describe('App login gate', () => {
|
|||||||
expect(screen.getByLabelText(/proxy endpoint/i)).toHaveValue('draft.example.net');
|
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 () => {
|
it('warns before deleting a service and removes linked users after confirmation', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|||||||
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 SESSION_KEY = '3proxy-ui-panel-session';
|
||||||
const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
const LIVE_SYNC_RECONNECT_MS = 2000;
|
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 {
|
interface StoredSession {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -547,8 +550,8 @@ function UsersTab({
|
|||||||
<th>{text.common.status}</th>
|
<th>{text.common.status}</th>
|
||||||
<th>{text.users.used}</th>
|
<th>{text.users.used}</th>
|
||||||
<th>{text.users.remaining}</th>
|
<th>{text.users.remaining}</th>
|
||||||
|
<th>{text.users.online}</th>
|
||||||
<th>{text.users.share}</th>
|
<th>{text.users.share}</th>
|
||||||
<th>{text.users.proxy}</th>
|
|
||||||
<th>{text.users.actions}</th>
|
<th>{text.users.actions}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -593,20 +596,19 @@ function UsersTab({
|
|||||||
</td>
|
</td>
|
||||||
<td>{formatBytes(user.usedBytes)}</td>
|
<td>{formatBytes(user.usedBytes)}</td>
|
||||||
<td>{formatQuotaStateLabel(user.usedBytes, user.quotaBytes, preferences.language)}</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>{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>
|
<td>
|
||||||
<div className="row-actions">
|
<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
|
<ActionIconButton
|
||||||
ariaLabel={text.users.editAction}
|
ariaLabel={text.users.editAction}
|
||||||
title={text.users.editAction}
|
title={text.users.editAction}
|
||||||
@@ -799,6 +801,7 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
const [session, setSession] = useState<StoredSession | null>(() => loadStoredSession());
|
const [session, setSession] = useState<StoredSession | null>(() => loadStoredSession());
|
||||||
const [activeTab, setActiveTab] = useState<TabId>(() => readTabFromHash(window.location.hash));
|
const [activeTab, setActiveTab] = useState<TabId>(() => readTabFromHash(window.location.hash));
|
||||||
|
const [connectionNotice, setConnectionNotice] = useState<string | null>(null);
|
||||||
const [snapshot, setSnapshot] = useState<DashboardSnapshot>(fallbackDashboardSnapshot);
|
const [snapshot, setSnapshot] = useState<DashboardSnapshot>(fallbackDashboardSnapshot);
|
||||||
const text = getPanelText(preferences.language);
|
const text = getPanelText(preferences.language);
|
||||||
|
|
||||||
@@ -832,6 +835,7 @@ export default function App() {
|
|||||||
|
|
||||||
const resetSession = () => {
|
const resetSession = () => {
|
||||||
clearStoredSession();
|
clearStoredSession();
|
||||||
|
setConnectionNotice(null);
|
||||||
setSession(null);
|
setSession(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -879,6 +883,15 @@ export default function App() {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let reconnectTimer: number | null = null;
|
let reconnectTimer: number | null = null;
|
||||||
let socket: WebSocket | 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 () => {
|
const refreshSnapshot = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -890,6 +903,7 @@ export default function App() {
|
|||||||
|
|
||||||
if (!cancelled && payload) {
|
if (!cancelled && payload) {
|
||||||
setSnapshot(payload);
|
setSnapshot(payload);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SessionExpiredError) {
|
if (error instanceof SessionExpiredError) {
|
||||||
@@ -898,66 +912,138 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
void refreshSnapshot();
|
|
||||||
|
|
||||||
const liveSyncUrl = getLiveSyncUrl(session.token);
|
const liveSyncUrl = getLiveSyncUrl(session.token);
|
||||||
if (typeof window.WebSocket !== 'undefined' && liveSyncUrl) {
|
const scheduleBackgroundReconnect = () => {
|
||||||
const connect = () => {
|
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) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
socket = new window.WebSocket(liveSyncUrl);
|
scheduleWebSocketReconnect();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
socket.addEventListener('message', (event) => {
|
void refreshSnapshot();
|
||||||
const message = parseDashboardSyncMessage(event.data);
|
connectWebSocket();
|
||||||
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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (reconnectTimer !== null) {
|
clearReconnectTimer();
|
||||||
window.clearTimeout(reconnectTimer);
|
|
||||||
}
|
|
||||||
socket?.close();
|
socket?.close();
|
||||||
};
|
};
|
||||||
}, [session]);
|
}, [session, text.connection.attempt, text.connection.httpRetry, text.connection.sessionClosed, text.connection.websocketRetry]);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return <LoginGate onUnlock={handleUnlock} preferences={preferences} />;
|
return <LoginGate onUnlock={handleUnlock} preferences={preferences} />;
|
||||||
@@ -1262,6 +1348,7 @@ export default function App() {
|
|||||||
onSaveSystem={handleSaveSystem}
|
onSaveSystem={handleSaveSystem}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{connectionNotice ? <div className="connection-banner">{connectionNotice}</div> : null}
|
||||||
</main>
|
</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 {
|
function toUserEditorValues(user: ProxyUserRecord): CreateUserInput {
|
||||||
return {
|
return {
|
||||||
username: user.username,
|
username: user.username,
|
||||||
@@ -1474,3 +1604,9 @@ function generateUsername(): string {
|
|||||||
function generatePassword(): string {
|
function generatePassword(): string {
|
||||||
return `pw-${Math.random().toString(36).slice(2, 8)}-${Math.random().toString(36).slice(2, 6)}`;
|
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;
|
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) {
|
@media (max-width: 960px) {
|
||||||
.shell {
|
.shell {
|
||||||
width: calc(100vw - 24px);
|
width: calc(100vw - 24px);
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const text = {
|
|||||||
light: 'Light',
|
light: 'Light',
|
||||||
dark: 'Dark',
|
dark: 'Dark',
|
||||||
system: 'System',
|
system: 'System',
|
||||||
|
disconnected: 'Disconnected',
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
service: 'Service',
|
service: 'Service',
|
||||||
@@ -79,8 +80,8 @@ const text = {
|
|||||||
endpoint: 'Endpoint',
|
endpoint: 'Endpoint',
|
||||||
used: 'Used',
|
used: 'Used',
|
||||||
remaining: 'Remaining',
|
remaining: 'Remaining',
|
||||||
|
online: 'Online',
|
||||||
share: 'Share',
|
share: 'Share',
|
||||||
proxy: 'Proxy',
|
|
||||||
actions: 'Actions',
|
actions: 'Actions',
|
||||||
addUser: 'Add user',
|
addUser: 'Add user',
|
||||||
editUser: 'Edit user',
|
editUser: 'Edit user',
|
||||||
@@ -99,6 +100,14 @@ const text = {
|
|||||||
deletePrompt: 'Remove profile',
|
deletePrompt: 'Remove profile',
|
||||||
deletePromptSuffix: '? This action will delete the user entry from the current panel state.',
|
deletePromptSuffix: '? This action will delete the user entry from the current panel state.',
|
||||||
deleteAction: 'Delete user',
|
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: {
|
settings: {
|
||||||
panelTitle: 'Panel settings',
|
panelTitle: 'Panel settings',
|
||||||
@@ -183,6 +192,7 @@ const text = {
|
|||||||
light: 'Светлый',
|
light: 'Светлый',
|
||||||
dark: 'Темный',
|
dark: 'Темный',
|
||||||
system: 'Системный',
|
system: 'Системный',
|
||||||
|
disconnected: 'Нет связи',
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
service: 'Сервис',
|
service: 'Сервис',
|
||||||
@@ -205,8 +215,8 @@ const text = {
|
|||||||
endpoint: 'Точка входа',
|
endpoint: 'Точка входа',
|
||||||
used: 'Использовано',
|
used: 'Использовано',
|
||||||
remaining: 'Остаток',
|
remaining: 'Остаток',
|
||||||
|
online: 'В сети',
|
||||||
share: 'Доля',
|
share: 'Доля',
|
||||||
proxy: 'Прокси',
|
|
||||||
actions: 'Действия',
|
actions: 'Действия',
|
||||||
addUser: 'Добавить пользователя',
|
addUser: 'Добавить пользователя',
|
||||||
editUser: 'Редактирование пользователя',
|
editUser: 'Редактирование пользователя',
|
||||||
@@ -225,6 +235,14 @@ const text = {
|
|||||||
deletePrompt: 'Удалить профиль',
|
deletePrompt: 'Удалить профиль',
|
||||||
deletePromptSuffix: '? Это действие удалит пользователя из текущего состояния панели.',
|
deletePromptSuffix: '? Это действие удалит пользователя из текущего состояния панели.',
|
||||||
deleteAction: 'Удалить пользователя',
|
deleteAction: 'Удалить пользователя',
|
||||||
|
onlineNow: 'Сейчас',
|
||||||
|
neverOnline: 'Никогда',
|
||||||
|
},
|
||||||
|
connection: {
|
||||||
|
websocketRetry: 'Связь live sync потеряна. Переподключаем WebSocket',
|
||||||
|
httpRetry: 'Live sync недоступен. Повторяем синхронизацию через API',
|
||||||
|
attempt: 'попытка',
|
||||||
|
sessionClosed: 'Связь с панелью потеряна. Войдите снова.',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
panelTitle: 'Настройки панели',
|
panelTitle: 'Настройки панели',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface ProxyUserRecord {
|
|||||||
status: Exclude<ServiceState, 'paused'>;
|
status: Exclude<ServiceState, 'paused'>;
|
||||||
usedBytes: number;
|
usedBytes: number;
|
||||||
quotaBytes: number | null;
|
quotaBytes: number | null;
|
||||||
|
lastSeenAt?: string | null;
|
||||||
paused?: boolean;
|
paused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
import { cleanup } from '@testing-library/react';
|
import { cleanup } from '@testing-library/react';
|
||||||
import { afterEach } from 'vitest';
|
import { afterEach, vi } from 'vitest';
|
||||||
|
|
||||||
export class MockWebSocket extends EventTarget {
|
export class MockWebSocket extends EventTarget {
|
||||||
static readonly CONNECTING = 0;
|
static readonly CONNECTING = 0;
|
||||||
@@ -8,6 +8,7 @@ export class MockWebSocket extends EventTarget {
|
|||||||
static readonly CLOSING = 2;
|
static readonly CLOSING = 2;
|
||||||
static readonly CLOSED = 3;
|
static readonly CLOSED = 3;
|
||||||
static instances: MockWebSocket[] = [];
|
static instances: MockWebSocket[] = [];
|
||||||
|
static connectMode: 'open' | 'close' = 'open';
|
||||||
|
|
||||||
readonly CONNECTING = MockWebSocket.CONNECTING;
|
readonly CONNECTING = MockWebSocket.CONNECTING;
|
||||||
readonly OPEN = MockWebSocket.OPEN;
|
readonly OPEN = MockWebSocket.OPEN;
|
||||||
@@ -20,7 +21,14 @@ export class MockWebSocket extends EventTarget {
|
|||||||
super();
|
super();
|
||||||
this.url = String(url);
|
this.url = String(url);
|
||||||
MockWebSocket.instances.push(this);
|
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 {}
|
send(_data?: string | ArrayBufferLike | Blob | ArrayBufferView): void {}
|
||||||
@@ -45,6 +53,7 @@ export class MockWebSocket extends EventTarget {
|
|||||||
|
|
||||||
static reset(): void {
|
static reset(): void {
|
||||||
this.instances = [];
|
this.instances = [];
|
||||||
|
this.connectMode = 'open';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,5 +65,6 @@ Object.defineProperty(globalThis, 'WebSocket', {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
MockWebSocket.reset();
|
MockWebSocket.reset();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user