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);
+});