From 74660d915f36835258ff3bcb52910c15602e8e5b Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sat, 11 Jul 2026 20:55:20 +0300 Subject: [PATCH] Track client operations and show inline progress --- docs/product/frontend-operations.md | 16 +++ src/web/App.jsx | 58 ++++++----- src/web/components/ClientOverviewPage.jsx | 114 ++++++++++++++-------- src/web/state/operations.js | 65 ++++++++++++ src/web/styles.css | 19 ++++ test/web/operations.test.js | 93 ++++++++++++++++++ 6 files changed, 299 insertions(+), 66 deletions(-) create mode 100644 docs/product/frontend-operations.md create mode 100644 src/web/state/operations.js create mode 100644 test/web/operations.test.js diff --git a/docs/product/frontend-operations.md b/docs/product/frontend-operations.md new file mode 100644 index 0000000..6158070 --- /dev/null +++ b/docs/product/frontend-operations.md @@ -0,0 +1,16 @@ +# Frontend operation registry + +Harbor tracks active browser mutations by operation key instead of one global `busy` flag: + +- `connection`: start, stop and restart; +- `serverApply`: apply the selected server; +- `subscriptionImport`, `subscriptionRefresh`, `subscriptionDelete`; +- `gatewayAuto`: change the active route preference. + +Each entry is `{ status: "running", startedAt }`. A repeated operation key receives the same in-flight Promise, so a double click sends one request. A conflicting key resolves to `false` without starting its action. The symmetric conflict matrix lives in `src/web/state/operations.js`. + +The registry only disables controls that can mutate the same domain state. Copy actions, instruction navigation and local tabs remain available during subscription refresh. Progress is announced with `role="status"`; the structured error from TASK-004 remains `role="alert"` after failure. + +Subscription URL validation uses a latest-request runner. Starting a new validation aborts the previous signal and ignores its result even if the underlying request resolves late. + +The registry is local transport/UI state. It does not replace backend `snapshot.operation`, change revisions or persist data. Rollback is frontend-only. A `diagnostics` key is intentionally deferred until TASK-016 adds a diagnostics operation to run. diff --git a/src/web/App.jsx b/src/web/App.jsx index dbf61af..c4f0b5d 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -9,6 +9,7 @@ import { harborReducer, initialHarborState, } from './state/harborReducer.js'; +import { createOperationRegistry } from './state/operations.js'; function App() { const previewReady = new URLSearchParams(window.location.search).has('preview-ready'); @@ -17,9 +18,13 @@ function App() { initialHarborState, ); const [subscriptionUrl, setSubscriptionUrl] = useState(''); - const [busy, setBusy] = useState(false); + const [operations, setOperations] = useState({}); const [error, setError] = useState(null); const pollGeneration = useRef(0); + const operationRegistry = useRef(null); + if (!operationRegistry.current) { + operationRegistry.current = createOperationRegistry(setOperations); + } function setPendingTag(serverId) { dispatch({ type: 'select-server', serverId }); @@ -60,24 +65,25 @@ function App() { : '/harbor-connect.svg?v=2'; }, [state?.mode]); - async function run(action, context) { - setBusy(true); + function run(key, action, context) { setError(null); - try { - return await applyMutation(action); - } catch (err) { - const safeError = err instanceof HarborApiError ? err : new HarborApiError(); - setError({ - context, - message: safeError.message, - code: safeError.code, - correlationId: safeError.correlationId, - retry: safeError.retryable ? () => run(action, context) : null, - }); - return false; - } finally { - setBusy(false); - } + return operationRegistry.current.run(key, async () => { + try { + return await applyMutation(action); + } catch (err) { + const safeError = err instanceof HarborApiError + ? err + : new HarborApiError({ code: err?.code }, err?.status); + setError({ + context, + message: safeError.message, + code: safeError.code, + correlationId: safeError.correlationId, + retry: safeError.retryable ? () => run(key, action, context) : null, + }); + return false; + } + }); } async function applyMutation(action) { @@ -94,7 +100,7 @@ function App() { } async function fetchSubscription() { - return run(async () => { + return run('subscriptionImport', async () => { const data = await api.subscription.fetch(subscriptionUrl); dispatch({ type: 'clear-pending-server' }); return data; @@ -102,11 +108,11 @@ function App() { } async function refreshSubscription() { - return run(api.subscription.refresh, 'subscription'); + return run('subscriptionRefresh', api.subscription.refresh, 'subscription'); } async function forgetSubscription() { - return run(async () => { + return run('subscriptionDelete', async () => { const data = await api.subscription.forget(); setSubscriptionUrl(''); dispatch({ type: 'clear-pending-server' }); @@ -123,7 +129,7 @@ function App() {
run(() => api.apply(tag), 'connection')} - onRestart={() => run(api.singbox.restart, 'connection')} - onStop={() => run(api.singbox.stop, 'connection')} - onSetGatewayAuto={(enabled) => run(() => api.gatewayAuto.setEnabled(enabled), 'connection')} + onApply={(tag) => run('serverApply', () => api.apply(tag), 'connection')} + onRestart={() => run('connection', api.singbox.restart, 'connection')} + onStop={() => run('connection', api.singbox.stop, 'connection')} + onSetGatewayAuto={(enabled) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')} />
diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 928253b..af3890b 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -12,6 +12,7 @@ import { } from '../utils/clientControls.js'; import { formatBytes } from '../utils/format.js'; import { instructionBlocks } from '../instructions.js'; +import { createLatestRequest, operationBlocked } from '../state/operations.js'; const SUBSCRIPTION_REVEAL_DELAY_MS = 1350; const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode'; @@ -33,6 +34,27 @@ function InlineError({ error, context }) { ); } +const operationProgress = { + connection: ['connection', 'Меняем состояние подключения…'], + serverApply: ['connection', 'Применяем сервер…'], + gatewayAuto: ['connection', 'Переключаем маршрут…'], + subscriptionImport: ['subscription', 'Загружаем подписку…'], + subscriptionRefresh: ['subscription', 'Обновляем подписку…'], + subscriptionDelete: ['subscription', 'Удаляем подписку…'], +}; + +function InlineProgress({ operations, context }) { + const active = Object.entries(operationProgress).find(([key, [operationContext]]) => ( + operationContext === context && operations[key]?.status === 'running' + )); + if (!active) return null; + return ( +
+ {active[1][1]} +
+ ); +} + function InstructionStep({ step }) { if (typeof step === 'string') return step; return ( @@ -97,7 +119,7 @@ function AnimatedSeconds({ value, padded = true }) { )); } -function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, busy, onSetGatewayAuto }) { +function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, blocked, onSetGatewayAuto }) { const [modeAnimating, setModeAnimating] = useState(false); const stopModeAnimationRef = useRef(false); const product = isGateway ? 'Gateway' : 'Connect'; @@ -155,7 +177,7 @@ function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, busy, onSetGa aria-label={label} aria-describedby="harbor-mode-tooltip" aria-pressed={gatewayDirect} - disabled={busy} + disabled={blocked} onPointerEnter={startModeAnimation} onPointerLeave={finishModeAnimation} onFocus={startModeAnimation} @@ -176,7 +198,7 @@ function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, busy, onSetGa export function ClientOverviewPage({ state, - busy, + operations = {}, error, subscriptionUrl, setSubscriptionUrl, @@ -228,6 +250,8 @@ export function ClientOverviewPage({ const instructionsPanelRef = useRef(null); const instructionsToggleRef = useRef(null); const previousHasSubscriptionRef = useRef(hasSubscription); + const validationRequests = useRef(null); + if (!validationRequests.current) validationRequests.current = createLatestRequest(); const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|'); const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1'; const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress); @@ -258,6 +282,12 @@ export function ClientOverviewPage({ ? subscriptionValidation.error : null; const subscriptionWaiting = hasSubscription && !subscriptionContentReady; + const connectionBlocked = operationBlocked(operations, 'connection'); + const serverApplyBlocked = operationBlocked(operations, 'serverApply'); + const subscriptionImportBlocked = operationBlocked(operations, 'subscriptionImport'); + const subscriptionRefreshBlocked = operationBlocked(operations, 'subscriptionRefresh'); + const subscriptionDeleteBlocked = operationBlocked(operations, 'subscriptionDelete'); + const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto'); useEffect(() => { setNow(Date.now()); @@ -324,48 +354,50 @@ export function ClientOverviewPage({ }, [hasSubscription]); useEffect(() => { + validationRequests.current.cancel(); if (!normalizedSubscriptionUrl) { setSubscriptionValidation({ url: '', status: 'idle' }); return undefined; } - const controller = new AbortController(); setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'checking' }); const timer = setTimeout(() => { - api.subscription.validate(normalizedSubscriptionUrl, controller.signal) - .then(() => setSubscriptionValidation({ - url: normalizedSubscriptionUrl, - status: 'valid', - error: null, - })) + validationRequests.current + .run((signal) => api.subscription.validate(normalizedSubscriptionUrl, signal)) + .then((result) => { + if (!result) return; + setSubscriptionValidation({ + url: normalizedSubscriptionUrl, + status: 'valid', + error: null, + }); + }) .catch((validationError) => { - if (!controller.signal.aborted) { - setSubscriptionValidation({ - url: normalizedSubscriptionUrl, - status: validationStatusForError(validationError), - error: { - context: 'subscription', - message: validationError.message, - correlationId: validationError.correlationId, - retry: validationError.retryable - ? () => { - setSubscriptionValidation({ - url: normalizedSubscriptionUrl, - status: 'checking', - error: null, - }); - setValidationAttempt((attempt) => attempt + 1); - } - : null, - }, - }); - } + setSubscriptionValidation({ + url: normalizedSubscriptionUrl, + status: validationStatusForError(validationError), + error: { + context: 'subscription', + message: validationError.message, + correlationId: validationError.correlationId, + retry: validationError.retryable + ? () => { + setSubscriptionValidation({ + url: normalizedSubscriptionUrl, + status: 'checking', + error: null, + }); + setValidationAttempt((attempt) => attempt + 1); + } + : null, + }, + }); }); }, 350); return () => { clearTimeout(timer); - controller.abort(); + validationRequests.current.cancel(); }; }, [normalizedSubscriptionUrl, validationAttempt]); @@ -525,7 +557,7 @@ export function ClientOverviewPage({ isGateway={isGateway} gatewayAvailable={gatewayAvailable} gatewayDirect={gatewayDirect} - busy={busy} + blocked={gatewayAutoBlocked} onSetGatewayAuto={onSetGatewayAuto} /> {hasSubscription && subscriptionContentReady && - + + }
@@ -723,7 +756,7 @@ export function ClientOverviewPage({ className="client-subscription-refresh" type="button" aria-label="Обновить подписку" - disabled={refreshingInfo} + disabled={refreshingInfo || subscriptionRefreshBlocked} onClick={refreshSubscription} >
{hasSubscription && subscriptionContentReady && hasUsage && ( @@ -865,7 +899,7 @@ export function ClientOverviewPage({ className={`client-server ${selected ? 'is-selected' : ''}`} type="button" key={server.tag} - disabled={busy} + disabled={serverApplyBlocked} aria-pressed={selected} style={{ '--server-index': index }} onClick={() => selectServer(server.tag)} diff --git a/src/web/state/operations.js b/src/web/state/operations.js new file mode 100644 index 0000000..3f61819 --- /dev/null +++ b/src/web/state/operations.js @@ -0,0 +1,65 @@ +export const OPERATION_CONFLICTS = Object.freeze({ + connection: ['serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto'], + serverApply: ['connection', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto'], + subscriptionImport: ['connection', 'serverApply', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto'], + subscriptionRefresh: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionDelete', 'gatewayAuto'], + subscriptionDelete: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'gatewayAuto'], + gatewayAuto: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete'], +}); + +export function operationBlocked(operations, key) { + if (operations[key]?.status === 'running') return true; + return (OPERATION_CONFLICTS[key] || []).some( + (conflict) => operations[conflict]?.status === 'running', + ); +} + +export function createOperationRegistry(onChange = () => {}, now = () => new Date().toISOString()) { + let operations = {}; + const inFlight = new Map(); + + function run(key, action) { + if (inFlight.has(key)) return inFlight.get(key); + if (operationBlocked(operations, key)) return Promise.resolve(false); + + operations = { ...operations, [key]: { status: 'running', startedAt: now() } }; + onChange(operations); + + const promise = Promise.resolve() + .then(action) + .finally(() => { + const { [key]: completed, ...remaining } = operations; + operations = remaining; + inFlight.delete(key); + onChange(operations); + }); + inFlight.set(key, promise); + return promise; + } + + return { run, getSnapshot: () => operations }; +} + +export function createLatestRequest() { + let controller = null; + + return { + run(action) { + controller?.abort(); + controller = new AbortController(); + const current = controller; + return Promise.resolve() + .then(() => action(current.signal)) + .then( + (value) => current.signal.aborted ? undefined : value, + (error) => { + if (current.signal.aborted) return undefined; + throw error; + }, + ); + }, + cancel() { + controller?.abort(); + }, + }; +} diff --git a/src/web/styles.css b/src/web/styles.css index a63c072..ec4bceb 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -1944,6 +1944,24 @@ p { outline-offset: 2px; } +.client-operation-progress { + color: var(--client-accent); +} + +.client-operation-progress::before { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 8px currentColor; + content: ''; + animation: client-operation-pulse 900ms ease-in-out infinite alternate; +} + +@keyframes client-operation-pulse { + to { opacity: 0.35; transform: scale(0.72); } +} + .client-copy-button { position: relative; width: 86px; @@ -2092,6 +2110,7 @@ p { } @media (prefers-reduced-motion: reduce) { + .client-operation-progress::before, .client-power, .client-power::before, .client-power::after, diff --git a/test/web/operations.test.js b/test/web/operations.test.js new file mode 100644 index 0000000..49a3d5e --- /dev/null +++ b/test/web/operations.test.js @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import test from 'node:test'; + +import { + createLatestRequest, + createOperationRegistry, + OPERATION_CONFLICTS, + operationBlocked, +} from '../../src/web/state/operations.js'; + +const deferred = () => { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +}; + +test('operation conflicts block domain controls but leave copy and navigation alone', () => { + for (const [key, conflicts] of Object.entries(OPERATION_CONFLICTS)) { + for (const conflict of conflicts) { + assert.ok(OPERATION_CONFLICTS[conflict].includes(key), `${key} -> ${conflict} is not symmetric`); + } + } + + const refreshing = { subscriptionRefresh: { status: 'running' } }; + assert.equal(operationBlocked(refreshing, 'connection'), true); + assert.equal(operationBlocked(refreshing, 'serverApply'), true); + assert.equal(operationBlocked(refreshing, 'subscriptionDelete'), true); + assert.equal(operationBlocked(refreshing, 'copy'), false); + assert.equal(operationBlocked(refreshing, 'navigation'), false); + + const applying = { serverApply: { status: 'running' } }; + assert.equal(operationBlocked(applying, 'connection'), true); + assert.equal(operationBlocked(applying, 'subscriptionRefresh'), true); + assert.equal(operationBlocked(applying, 'copy'), false); +}); + +test('double click shares one in-flight request end to end', async (t) => { + let requests = 0; + const server = http.createServer((request, response) => { + requests += 1; + setTimeout(() => { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end('{"success":true}'); + }, 20); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + + const registry = createOperationRegistry(); + const action = () => fetch(`http://127.0.0.1:${server.address().port}/apply`).then((response) => response.json()); + const first = registry.run('serverApply', action); + const second = registry.run('serverApply', action); + + assert.equal(second, first); + assert.equal(registry.getSnapshot().serverApply.status, 'running'); + assert.deepEqual(await first, { success: true }); + assert.equal(requests, 1); + assert.deepEqual(registry.getSnapshot(), {}); +}); + +test('a conflicting operation is rejected before its action starts', async () => { + const connection = deferred(); + const registry = createOperationRegistry(); + const running = registry.run('connection', () => connection.promise); + let applyCalls = 0; + + const result = registry.run('serverApply', () => { applyCalls += 1; }); + assert.equal(await result, false); + assert.equal(applyCalls, 0); + + connection.resolve(true); + await running; +}); + +test('latest request cancels and ignores a stale validation result', async () => { + const latest = createLatestRequest(); + const oldResult = deferred(); + const newResult = deferred(); + let oldSignal; + + const oldRequest = latest.run((signal) => { + oldSignal = signal; + return oldResult.promise; + }); + const newRequest = latest.run(() => newResult.promise); + oldResult.resolve('old'); + newResult.resolve('new'); + + assert.equal(await oldRequest, undefined); + assert.equal(await newRequest, 'new'); + assert.equal(oldSignal.aborted, true); +});