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

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