Track client operations and show inline progress
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-07-11 20:55:20 +03:00
parent 9da4fef1f0
commit 74660d915f
6 changed files with 299 additions and 66 deletions

View File

@@ -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.

View File

@@ -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);
return operationRegistry.current.run(key, async () => {
try {
return await applyMutation(action);
} catch (err) {
const safeError = err instanceof HarborApiError ? err : new HarborApiError();
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(action, context) : null,
retry: safeError.retryable ? () => run(key, action, context) : null,
});
return false;
} finally {
setBusy(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() {
<main className="app-main">
<ClientOverviewPage
state={previewReady ? { ...state, mode: 'client', hasSubscription: true, subscriptionHost: 'harbor.example', selectedTag: 'Amsterdam', proxyPort: 8082 } : state}
busy={busy}
operations={operations}
error={error}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
@@ -133,10 +139,10 @@ function App() {
onFetchSubscription={fetchSubscription}
onRefreshSubscription={refreshSubscription}
onForgetSubscription={forgetSubscription}
onApply={(tag) => 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')}
/>
</main>
</div>

View File

@@ -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 (
<div className={`client-inline-error client-operation-progress is-${context}`} role="status">
<span>{active[1][1]}</span>
</div>
);
}
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,22 +354,25 @@ 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({
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),
@@ -359,13 +392,12 @@ export function ClientOverviewPage({
: 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 && <button
@@ -553,7 +585,7 @@ export function ClientOverviewPage({
role="switch"
aria-checked={connected}
aria-label={connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
disabled={busy || (!connected && !canStart)}
disabled={connectionBlocked || (!connected && !canStart)}
onClick={toggleConnection}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
@@ -681,6 +713,7 @@ export function ClientOverviewPage({
)}
</section>
<InlineError error={error} context="connection" />
<InlineProgress operations={operations} context="connection" />
</section>
)}
@@ -702,8 +735,8 @@ export function ClientOverviewPage({
Harbor остановит VPN и удалит сохранённую подписку. Приложения, настроенные на локальный прокси, не смогут выходить в сеть до добавления новой подписки.
</p>
<div className="client-delete-actions">
<button type="button" disabled={busy} onClick={() => setConfirmingDelete(false)}>Отмена</button>
<button className="is-danger" type="button" disabled={busy} onClick={forgetSubscription}>Удалить</button>
<button type="button" onClick={() => setConfirmingDelete(false)}>Отмена</button>
<button className="is-danger" type="button" disabled={subscriptionDeleteBlocked} onClick={forgetSubscription}>Удалить</button>
</div>
</section>}
<div className="client-form-content" inert={confirmingDelete ? true : undefined}>
@@ -723,7 +756,7 @@ export function ClientOverviewPage({
className="client-subscription-refresh"
type="button"
aria-label="Обновить подписку"
disabled={refreshingInfo}
disabled={refreshingInfo || subscriptionRefreshBlocked}
onClick={refreshSubscription}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
@@ -737,7 +770,7 @@ export function ClientOverviewPage({
className="client-subscription-delete"
type="button"
aria-label="Удалить подписку"
disabled={busy}
disabled={subscriptionDeleteBlocked}
onClick={() => setConfirmingDelete(true)}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
@@ -800,7 +833,7 @@ export function ClientOverviewPage({
: subscriptionValidationStatus === 'unavailable'
? 'Проверка подписки временно недоступна'
: 'Проверяем ссылку подписки'}
disabled={busy || subscriptionValidationStatus !== 'valid'}
disabled={subscriptionImportBlocked || subscriptionValidationStatus !== 'valid'}
>
{subscriptionValidationStatus === 'valid'
? '✓'
@@ -811,6 +844,7 @@ export function ClientOverviewPage({
)}
</form>
<InlineError error={subscriptionError || error} context="subscription" />
<InlineProgress operations={operations} context="subscription" />
</div>
{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)}

View File

@@ -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();
},
};
}

View File

@@ -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,

View File

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