Track client operations and show inline progress
This commit is contained in:
16
docs/product/frontend-operations.md
Normal file
16
docs/product/frontend-operations.md
Normal 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.
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
harborReducer,
|
harborReducer,
|
||||||
initialHarborState,
|
initialHarborState,
|
||||||
} from './state/harborReducer.js';
|
} from './state/harborReducer.js';
|
||||||
|
import { createOperationRegistry } from './state/operations.js';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
|
const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
|
||||||
@@ -17,9 +18,13 @@ function App() {
|
|||||||
initialHarborState,
|
initialHarborState,
|
||||||
);
|
);
|
||||||
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [operations, setOperations] = useState({});
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const pollGeneration = useRef(0);
|
const pollGeneration = useRef(0);
|
||||||
|
const operationRegistry = useRef(null);
|
||||||
|
if (!operationRegistry.current) {
|
||||||
|
operationRegistry.current = createOperationRegistry(setOperations);
|
||||||
|
}
|
||||||
|
|
||||||
function setPendingTag(serverId) {
|
function setPendingTag(serverId) {
|
||||||
dispatch({ type: 'select-server', serverId });
|
dispatch({ type: 'select-server', serverId });
|
||||||
@@ -60,24 +65,25 @@ function App() {
|
|||||||
: '/harbor-connect.svg?v=2';
|
: '/harbor-connect.svg?v=2';
|
||||||
}, [state?.mode]);
|
}, [state?.mode]);
|
||||||
|
|
||||||
async function run(action, context) {
|
function run(key, action, context) {
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
return operationRegistry.current.run(key, async () => {
|
||||||
try {
|
try {
|
||||||
return await applyMutation(action);
|
return await applyMutation(action);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const safeError = err instanceof HarborApiError ? err : new HarborApiError();
|
const safeError = err instanceof HarborApiError
|
||||||
|
? err
|
||||||
|
: new HarborApiError({ code: err?.code }, err?.status);
|
||||||
setError({
|
setError({
|
||||||
context,
|
context,
|
||||||
message: safeError.message,
|
message: safeError.message,
|
||||||
code: safeError.code,
|
code: safeError.code,
|
||||||
correlationId: safeError.correlationId,
|
correlationId: safeError.correlationId,
|
||||||
retry: safeError.retryable ? () => run(action, context) : null,
|
retry: safeError.retryable ? () => run(key, action, context) : null,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyMutation(action) {
|
async function applyMutation(action) {
|
||||||
@@ -94,7 +100,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSubscription() {
|
async function fetchSubscription() {
|
||||||
return run(async () => {
|
return run('subscriptionImport', async () => {
|
||||||
const data = await api.subscription.fetch(subscriptionUrl);
|
const data = await api.subscription.fetch(subscriptionUrl);
|
||||||
dispatch({ type: 'clear-pending-server' });
|
dispatch({ type: 'clear-pending-server' });
|
||||||
return data;
|
return data;
|
||||||
@@ -102,11 +108,11 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshSubscription() {
|
async function refreshSubscription() {
|
||||||
return run(api.subscription.refresh, 'subscription');
|
return run('subscriptionRefresh', api.subscription.refresh, 'subscription');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function forgetSubscription() {
|
async function forgetSubscription() {
|
||||||
return run(async () => {
|
return run('subscriptionDelete', async () => {
|
||||||
const data = await api.subscription.forget();
|
const data = await api.subscription.forget();
|
||||||
setSubscriptionUrl('');
|
setSubscriptionUrl('');
|
||||||
dispatch({ type: 'clear-pending-server' });
|
dispatch({ type: 'clear-pending-server' });
|
||||||
@@ -123,7 +129,7 @@ function App() {
|
|||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<ClientOverviewPage
|
<ClientOverviewPage
|
||||||
state={previewReady ? { ...state, mode: 'client', hasSubscription: true, subscriptionHost: 'harbor.example', selectedTag: 'Amsterdam', proxyPort: 8082 } : state}
|
state={previewReady ? { ...state, mode: 'client', hasSubscription: true, subscriptionHost: 'harbor.example', selectedTag: 'Amsterdam', proxyPort: 8082 } : state}
|
||||||
busy={busy}
|
operations={operations}
|
||||||
error={error}
|
error={error}
|
||||||
subscriptionUrl={subscriptionUrl}
|
subscriptionUrl={subscriptionUrl}
|
||||||
setSubscriptionUrl={setSubscriptionUrl}
|
setSubscriptionUrl={setSubscriptionUrl}
|
||||||
@@ -133,10 +139,10 @@ function App() {
|
|||||||
onFetchSubscription={fetchSubscription}
|
onFetchSubscription={fetchSubscription}
|
||||||
onRefreshSubscription={refreshSubscription}
|
onRefreshSubscription={refreshSubscription}
|
||||||
onForgetSubscription={forgetSubscription}
|
onForgetSubscription={forgetSubscription}
|
||||||
onApply={(tag) => run(() => api.apply(tag), 'connection')}
|
onApply={(tag) => run('serverApply', () => api.apply(tag), 'connection')}
|
||||||
onRestart={() => run(api.singbox.restart, 'connection')}
|
onRestart={() => run('connection', api.singbox.restart, 'connection')}
|
||||||
onStop={() => run(api.singbox.stop, 'connection')}
|
onStop={() => run('connection', api.singbox.stop, 'connection')}
|
||||||
onSetGatewayAuto={(enabled) => run(() => api.gatewayAuto.setEnabled(enabled), 'connection')}
|
onSetGatewayAuto={(enabled) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '../utils/clientControls.js';
|
} from '../utils/clientControls.js';
|
||||||
import { formatBytes } from '../utils/format.js';
|
import { formatBytes } from '../utils/format.js';
|
||||||
import { instructionBlocks } from '../instructions.js';
|
import { instructionBlocks } from '../instructions.js';
|
||||||
|
import { createLatestRequest, operationBlocked } from '../state/operations.js';
|
||||||
|
|
||||||
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
|
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
|
||||||
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
|
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 }) {
|
function InstructionStep({ step }) {
|
||||||
if (typeof step === 'string') return step;
|
if (typeof step === 'string') return step;
|
||||||
return (
|
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 [modeAnimating, setModeAnimating] = useState(false);
|
||||||
const stopModeAnimationRef = useRef(false);
|
const stopModeAnimationRef = useRef(false);
|
||||||
const product = isGateway ? 'Gateway' : 'Connect';
|
const product = isGateway ? 'Gateway' : 'Connect';
|
||||||
@@ -155,7 +177,7 @@ function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, busy, onSetGa
|
|||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-describedby="harbor-mode-tooltip"
|
aria-describedby="harbor-mode-tooltip"
|
||||||
aria-pressed={gatewayDirect}
|
aria-pressed={gatewayDirect}
|
||||||
disabled={busy}
|
disabled={blocked}
|
||||||
onPointerEnter={startModeAnimation}
|
onPointerEnter={startModeAnimation}
|
||||||
onPointerLeave={finishModeAnimation}
|
onPointerLeave={finishModeAnimation}
|
||||||
onFocus={startModeAnimation}
|
onFocus={startModeAnimation}
|
||||||
@@ -176,7 +198,7 @@ function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, busy, onSetGa
|
|||||||
|
|
||||||
export function ClientOverviewPage({
|
export function ClientOverviewPage({
|
||||||
state,
|
state,
|
||||||
busy,
|
operations = {},
|
||||||
error,
|
error,
|
||||||
subscriptionUrl,
|
subscriptionUrl,
|
||||||
setSubscriptionUrl,
|
setSubscriptionUrl,
|
||||||
@@ -228,6 +250,8 @@ export function ClientOverviewPage({
|
|||||||
const instructionsPanelRef = useRef(null);
|
const instructionsPanelRef = useRef(null);
|
||||||
const instructionsToggleRef = useRef(null);
|
const instructionsToggleRef = useRef(null);
|
||||||
const previousHasSubscriptionRef = useRef(hasSubscription);
|
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 serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
|
||||||
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
||||||
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
||||||
@@ -258,6 +282,12 @@ export function ClientOverviewPage({
|
|||||||
? subscriptionValidation.error
|
? subscriptionValidation.error
|
||||||
: null;
|
: null;
|
||||||
const subscriptionWaiting = hasSubscription && !subscriptionContentReady;
|
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(() => {
|
useEffect(() => {
|
||||||
setNow(Date.now());
|
setNow(Date.now());
|
||||||
@@ -324,22 +354,25 @@ export function ClientOverviewPage({
|
|||||||
}, [hasSubscription]);
|
}, [hasSubscription]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
validationRequests.current.cancel();
|
||||||
if (!normalizedSubscriptionUrl) {
|
if (!normalizedSubscriptionUrl) {
|
||||||
setSubscriptionValidation({ url: '', status: 'idle' });
|
setSubscriptionValidation({ url: '', status: 'idle' });
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'checking' });
|
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'checking' });
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
api.subscription.validate(normalizedSubscriptionUrl, controller.signal)
|
validationRequests.current
|
||||||
.then(() => setSubscriptionValidation({
|
.run((signal) => api.subscription.validate(normalizedSubscriptionUrl, signal))
|
||||||
|
.then((result) => {
|
||||||
|
if (!result) return;
|
||||||
|
setSubscriptionValidation({
|
||||||
url: normalizedSubscriptionUrl,
|
url: normalizedSubscriptionUrl,
|
||||||
status: 'valid',
|
status: 'valid',
|
||||||
error: null,
|
error: null,
|
||||||
}))
|
});
|
||||||
|
})
|
||||||
.catch((validationError) => {
|
.catch((validationError) => {
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
setSubscriptionValidation({
|
setSubscriptionValidation({
|
||||||
url: normalizedSubscriptionUrl,
|
url: normalizedSubscriptionUrl,
|
||||||
status: validationStatusForError(validationError),
|
status: validationStatusForError(validationError),
|
||||||
@@ -359,13 +392,12 @@ export function ClientOverviewPage({
|
|||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}, 350);
|
}, 350);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
controller.abort();
|
validationRequests.current.cancel();
|
||||||
};
|
};
|
||||||
}, [normalizedSubscriptionUrl, validationAttempt]);
|
}, [normalizedSubscriptionUrl, validationAttempt]);
|
||||||
|
|
||||||
@@ -525,7 +557,7 @@ export function ClientOverviewPage({
|
|||||||
isGateway={isGateway}
|
isGateway={isGateway}
|
||||||
gatewayAvailable={gatewayAvailable}
|
gatewayAvailable={gatewayAvailable}
|
||||||
gatewayDirect={gatewayDirect}
|
gatewayDirect={gatewayDirect}
|
||||||
busy={busy}
|
blocked={gatewayAutoBlocked}
|
||||||
onSetGatewayAuto={onSetGatewayAuto}
|
onSetGatewayAuto={onSetGatewayAuto}
|
||||||
/>
|
/>
|
||||||
{hasSubscription && subscriptionContentReady && <button
|
{hasSubscription && subscriptionContentReady && <button
|
||||||
@@ -553,7 +585,7 @@ export function ClientOverviewPage({
|
|||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={connected}
|
aria-checked={connected}
|
||||||
aria-label={connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
|
aria-label={connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
|
||||||
disabled={busy || (!connected && !canStart)}
|
disabled={connectionBlocked || (!connected && !canStart)}
|
||||||
onClick={toggleConnection}
|
onClick={toggleConnection}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
@@ -681,6 +713,7 @@ export function ClientOverviewPage({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
<InlineError error={error} context="connection" />
|
<InlineError error={error} context="connection" />
|
||||||
|
<InlineProgress operations={operations} context="connection" />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -702,8 +735,8 @@ export function ClientOverviewPage({
|
|||||||
Harbor остановит VPN и удалит сохранённую подписку. Приложения, настроенные на локальный прокси, не смогут выходить в сеть до добавления новой подписки.
|
Harbor остановит VPN и удалит сохранённую подписку. Приложения, настроенные на локальный прокси, не смогут выходить в сеть до добавления новой подписки.
|
||||||
</p>
|
</p>
|
||||||
<div className="client-delete-actions">
|
<div className="client-delete-actions">
|
||||||
<button type="button" disabled={busy} onClick={() => setConfirmingDelete(false)}>Отмена</button>
|
<button type="button" onClick={() => setConfirmingDelete(false)}>Отмена</button>
|
||||||
<button className="is-danger" type="button" disabled={busy} onClick={forgetSubscription}>Удалить</button>
|
<button className="is-danger" type="button" disabled={subscriptionDeleteBlocked} onClick={forgetSubscription}>Удалить</button>
|
||||||
</div>
|
</div>
|
||||||
</section>}
|
</section>}
|
||||||
<div className="client-form-content" inert={confirmingDelete ? true : undefined}>
|
<div className="client-form-content" inert={confirmingDelete ? true : undefined}>
|
||||||
@@ -723,7 +756,7 @@ export function ClientOverviewPage({
|
|||||||
className="client-subscription-refresh"
|
className="client-subscription-refresh"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Обновить подписку"
|
aria-label="Обновить подписку"
|
||||||
disabled={refreshingInfo}
|
disabled={refreshingInfo || subscriptionRefreshBlocked}
|
||||||
onClick={refreshSubscription}
|
onClick={refreshSubscription}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
@@ -737,7 +770,7 @@ export function ClientOverviewPage({
|
|||||||
className="client-subscription-delete"
|
className="client-subscription-delete"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Удалить подписку"
|
aria-label="Удалить подписку"
|
||||||
disabled={busy}
|
disabled={subscriptionDeleteBlocked}
|
||||||
onClick={() => setConfirmingDelete(true)}
|
onClick={() => setConfirmingDelete(true)}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
@@ -800,7 +833,7 @@ export function ClientOverviewPage({
|
|||||||
: subscriptionValidationStatus === 'unavailable'
|
: subscriptionValidationStatus === 'unavailable'
|
||||||
? 'Проверка подписки временно недоступна'
|
? 'Проверка подписки временно недоступна'
|
||||||
: 'Проверяем ссылку подписки'}
|
: 'Проверяем ссылку подписки'}
|
||||||
disabled={busy || subscriptionValidationStatus !== 'valid'}
|
disabled={subscriptionImportBlocked || subscriptionValidationStatus !== 'valid'}
|
||||||
>
|
>
|
||||||
{subscriptionValidationStatus === 'valid'
|
{subscriptionValidationStatus === 'valid'
|
||||||
? '✓'
|
? '✓'
|
||||||
@@ -811,6 +844,7 @@ export function ClientOverviewPage({
|
|||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
<InlineError error={subscriptionError || error} context="subscription" />
|
<InlineError error={subscriptionError || error} context="subscription" />
|
||||||
|
<InlineProgress operations={operations} context="subscription" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasSubscription && subscriptionContentReady && hasUsage && (
|
{hasSubscription && subscriptionContentReady && hasUsage && (
|
||||||
@@ -865,7 +899,7 @@ export function ClientOverviewPage({
|
|||||||
className={`client-server ${selected ? 'is-selected' : ''}`}
|
className={`client-server ${selected ? 'is-selected' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
key={server.tag}
|
key={server.tag}
|
||||||
disabled={busy}
|
disabled={serverApplyBlocked}
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
style={{ '--server-index': index }}
|
style={{ '--server-index': index }}
|
||||||
onClick={() => selectServer(server.tag)}
|
onClick={() => selectServer(server.tag)}
|
||||||
|
|||||||
65
src/web/state/operations.js
Normal file
65
src/web/state/operations.js
Normal 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1944,6 +1944,24 @@ p {
|
|||||||
outline-offset: 2px;
|
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 {
|
.client-copy-button {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 86px;
|
width: 86px;
|
||||||
@@ -2092,6 +2110,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.client-operation-progress::before,
|
||||||
.client-power,
|
.client-power,
|
||||||
.client-power::before,
|
.client-power::before,
|
||||||
.client-power::after,
|
.client-power::after,
|
||||||
|
|||||||
93
test/web/operations.test.js
Normal file
93
test/web/operations.test.js
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user