Track client operations and show inline progress
This commit is contained in:
@@ -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() {
|
||||
<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>
|
||||
|
||||
@@ -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,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 && <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)}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.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,
|
||||
|
||||
Reference in New Issue
Block a user