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