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 &&