Refactor VPN proxy client implementation
Build and Deploy Gateway / build-and-push (push) Failing after 2s
Build and Deploy Gateway / deploy (push) Has been skipped

This commit is contained in:
2026-08-09 00:41:52 +03:00
parent 32be4380a3
commit 34d8b681ad
190 changed files with 20064 additions and 9761 deletions
@@ -0,0 +1,557 @@
import {
useEffect,
useRef,
useState,
type ReactNode,
type RefObject,
} from 'react';
import { ERROR_DEFINITIONS } from '../../../shared/errors.js';
import { operationBlocked } from '../../state/operations.js';
import {
isSubscriptionUrlValid,
subscriptionDaysLeft,
subscriptionDomain,
subscriptionUsage,
} from '../../utils/clientControls.js';
import { formatBytes } from '../../utils/format.js';
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
import { normalizeRequestError, type RequestError } from './requestError.js';
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
interface SubscriptionState {
status?: string;
host?: string | null;
userInfo?: Record<string, unknown> | null;
}
interface SubscriptionFeatureOptions {
subscription?: SubscriptionState | null;
subscriptionUrl: string;
setSubscriptionUrl: (value: string) => void;
operations: Record<string, { status?: string } | undefined>;
error?: RequestError | null;
serverCount: number;
isGateway: boolean;
gatewayDirect: boolean;
validateSubscription: (url: string, options: { signal: AbortSignal }) => Promise<unknown>;
onImport: () => Promise<unknown>;
onRefresh: () => Promise<unknown>;
onForget: () => Promise<unknown>;
onDismissError: () => void;
}
interface SubscriptionValidation {
url: string;
status: 'idle' | 'checking' | 'valid' | 'invalid';
error: RequestError | null;
}
function CloudTooltip({ children }: { children: ReactNode }) {
return <span className="client-tooltip" role="tooltip">{children}</span>;
}
export function useSubscriptionFeature({
subscription,
subscriptionUrl,
setSubscriptionUrl,
operations,
error,
serverCount,
isGateway,
gatewayDirect,
validateSubscription,
onImport,
onRefresh,
onForget,
onDismissError,
}: SubscriptionFeatureOptions) {
const hasSubscription = subscription?.status === 'ready';
const [editing, setEditing] = useState(!hasSubscription);
const [contentReady, setContentReady] = useState(hasSubscription);
const [validation, setValidation] = useState<SubscriptionValidation>({
url: '',
status: 'idle',
error: null,
});
const [validationAttempt, setValidationAttempt] = useState(0);
const [refreshing, setRefreshing] = useState(false);
const [usageUpdated, setUsageUpdated] = useState(false);
const [serverRevealVersion, setServerRevealVersion] = useState(0);
const [serversLeaving, setServersLeaving] = useState(false);
const [open, setOpen] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const subscriptionRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const confirmingDeleteRef = useRef(confirmingDelete);
const previousHasSubscriptionRef = useRef(hasSubscription);
confirmingDeleteRef.current = confirmingDelete;
const usage = subscriptionUsage(subscription?.userInfo || undefined);
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
const hasUsage = Boolean(
subscription?.userInfo
&& ['upload', 'download', 'total', 'expire'].some((key) => key in subscription.userInfo!),
);
const normalizedUrl = subscriptionUrl.trim();
const currentValidation = validation.url === normalizedUrl ? validation : null;
const localError = normalizedUrl && !isSubscriptionUrlValid(normalizedUrl)
? { context: 'subscription', message: ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message }
: null;
const subscriptionError = currentValidation?.error
|| localError
|| (error?.context === 'subscription' ? error : null);
const validationStatus = !normalizedUrl
? 'idle'
: subscriptionError || !isSubscriptionUrlValid(normalizedUrl)
? 'invalid'
: currentValidation?.status || 'checking';
const waiting = hasSubscription && !contentReady;
const importBlocked = operationBlocked(operations, 'subscriptionImport');
const refreshBlocked = operationBlocked(operations, 'subscriptionRefresh');
const deleteBlocked = operationBlocked(operations, 'subscriptionDelete');
useEffect(() => {
if (editing) inputRef.current?.focus();
}, [editing]);
useEffect(() => {
if (!normalizedUrl || !isSubscriptionUrlValid(normalizedUrl)) return undefined;
const controller = new AbortController();
setValidation({ url: normalizedUrl, status: 'checking', error: null });
const timer = setTimeout(async () => {
try {
await validateSubscription(normalizedUrl, { signal: controller.signal });
setValidation({ url: normalizedUrl, status: 'valid', error: null });
} catch (caught) {
const requestError = normalizeRequestError(caught);
if (requestError.name === 'AbortError') return;
setValidation({
url: normalizedUrl,
status: 'invalid',
error: {
context: 'subscription',
message: requestError.message,
correlationId: requestError.correlationId,
retry: requestError.retryable
? () => setValidationAttempt((attempt) => attempt + 1)
: null,
},
});
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [normalizedUrl, validationAttempt, validateSubscription]);
useEffect(() => {
const previouslyHadSubscription = previousHasSubscriptionRef.current;
previousHasSubscriptionRef.current = hasSubscription;
if (!hasSubscription) {
setContentReady(false);
return undefined;
}
if (previouslyHadSubscription) {
setContentReady(true);
return undefined;
}
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setContentReady(true);
return undefined;
}
const timer = setTimeout(() => setContentReady(true), SUBSCRIPTION_REVEAL_DELAY_MS);
return () => clearTimeout(timer);
}, [hasSubscription]);
useEffect(() => {
if (!hasSubscription) setEditing(true);
}, [hasSubscription]);
useEffect(() => {
if (!editing || !hasSubscription || subscriptionUrl) return undefined;
const timer = setTimeout(() => setEditing(false), 5000);
return () => clearTimeout(timer);
}, [editing, hasSubscription, subscriptionUrl]);
useEffect(() => {
if (!editing || !hasSubscription) return undefined;
const closeOnOutsideClick = (event: PointerEvent) => {
if (subscriptionRef.current?.contains(event.target as Node | null)) return;
setSubscriptionUrl('');
setEditing(false);
};
document.addEventListener('pointerdown', closeOnOutsideClick);
return () => document.removeEventListener('pointerdown', closeOnOutsideClick);
}, [editing, hasSubscription, setSubscriptionUrl]);
useEffect(() => {
if (!hasSubscription) return undefined;
onRefresh();
return undefined;
}, [hasSubscription]);
useEffect(() => {
const from = displayedUsed;
const to = usage.used;
if (from === to) return undefined;
const startedAt = performance.now();
let frame: number;
const tick = (now: number) => {
const progress = Math.min(1, (now - startedAt) / 900);
const eased = 1 - Math.pow(1 - progress, 4);
setDisplayedUsed(from + (to - from) * eased);
if (progress < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [usage.used]);
useEffect(() => {
if (!open) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus());
const closeSubscription = (event: PointerEvent | KeyboardEvent) => {
if (confirmingDeleteRef.current) return;
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
const target = event.target as Node | null;
if (event.type !== 'keydown' && (
panelRef.current?.contains(target) || toggleRef.current?.contains(target)
)) return;
setOpen(false);
};
document.addEventListener('pointerdown', closeSubscription);
document.addEventListener('keydown', closeSubscription);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('pointerdown', closeSubscription);
document.removeEventListener('keydown', closeSubscription);
requestAnimationFrame(() => {
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
});
};
}, [open]);
async function submit() {
if (validationStatus !== 'valid') return;
if (!await onImport()) return;
setSubscriptionUrl('');
setEditing(false);
}
async function refresh() {
const startedAt = performance.now();
setRefreshing(true);
try {
if (!await onRefresh()) return;
setUsageUpdated(false);
requestAnimationFrame(() => setUsageUpdated(true));
setTimeout(() => setUsageUpdated(false), 900);
setServersLeaving(true);
await new Promise((resolve) => setTimeout(
resolve,
420 + Math.min(7, Math.max(0, serverCount - 1)) * 90,
));
setServerRevealVersion((version) => version + 1);
setServersLeaving(false);
} finally {
const elapsed = performance.now() - startedAt;
const completeCyclesAt = Math.max(900, Math.ceil(elapsed / 900) * 900);
await new Promise((resolve) => setTimeout(resolve, completeCyclesAt - elapsed));
setRefreshing(false);
}
}
async function forget() {
if (!await onForget()) return;
setConfirmingDelete(false);
}
function changeUrl(value: string) {
if (error?.context === 'subscription') onDismissError();
setValidation({ url: '', status: 'idle', error: null });
setSubscriptionUrl(value);
}
function cancelEditing() {
if (!hasSubscription) return;
setSubscriptionUrl('');
setEditing(false);
}
return {
subscription,
subscriptionUrl,
hasSubscription,
hasUsage,
isGateway,
gatewayDirect,
editing,
contentReady,
waiting,
open,
confirmingDelete,
refreshing,
usageUpdated,
usage,
displayedUsed,
validationStatus,
normalizedUrl,
error: subscriptionError,
importBlocked,
refreshBlocked,
deleteBlocked,
serversLeaving,
serverRevealVersion,
inputRef,
subscriptionRef,
panelRef,
toggleRef,
closeRef,
toggle: () => setOpen((current) => !current),
close: () => setOpen(false),
edit: () => setEditing(true),
changeUrl,
cancelEditing,
submit,
refresh,
requestDelete: () => setConfirmingDelete(true),
cancelDelete: () => setConfirmingDelete(false),
forget,
};
}
type SubscriptionFeatureController = ReturnType<typeof useSubscriptionFeature>;
interface SubscriptionToggleProps {
feature: SubscriptionFeatureController;
onToggle: () => void;
}
export function SubscriptionToggle({ feature, onToggle }: SubscriptionToggleProps) {
return <button
ref={feature.toggleRef}
className={`client-instructions-toggle client-subscription-toggle${feature.open ? ' is-open' : ''}`}
type="button"
aria-expanded={feature.open}
aria-controls="client-subscription-drawer"
aria-label={feature.open ? 'Закрыть подписку' : 'Управление подпиской'}
onClick={onToggle}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 5h14v14H5zM8 9h8M8 13h5" />
</svg>
<span>Подписка</span>
</button>;
}
interface SubscriptionPanelProps {
feature: SubscriptionFeatureController;
statusSlot?: ReactNode;
serverSlot?: ReactNode;
}
export function SubscriptionPanel({ feature, statusSlot, serverSlot }: SubscriptionPanelProps) {
const {
subscription,
subscriptionUrl,
hasSubscription,
hasUsage,
isGateway,
gatewayDirect,
editing,
contentReady,
waiting,
open,
refreshing,
usageUpdated,
usage,
displayedUsed,
validationStatus,
normalizedUrl,
error,
importBlocked,
refreshBlocked,
deleteBlocked,
inputRef,
subscriptionRef,
panelRef,
closeRef,
} = feature;
return <div
ref={isGateway ? panelRef : undefined}
id={isGateway ? 'client-subscription-drawer' : undefined}
className={isGateway
? `client-drawer client-subscription-drawer${open ? ' is-open' : ''}`
: `client-form${waiting ? ' is-waiting' : ''}`}
aria-label={isGateway ? 'Управление подпиской' : undefined}
aria-hidden={isGateway ? !open : waiting}
aria-disabled={gatewayDirect}
inert={(isGateway && !open) || waiting || gatewayDirect ? true : undefined}
>
{isGateway && <button
ref={closeRef}
className="client-drawer-close"
type="button"
aria-label="Закрыть подписку"
onClick={feature.close}
>×</button>}
<div className={`client-form-content${isGateway ? ' client-drawer-sheet client-subscription-sheet' : ''}`}>
<div
ref={subscriptionRef}
className={`client-subscription ${editing ? 'is-editing' : ''}${editing && hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
>
<div
className="client-subscription-summary"
aria-hidden={editing}
inert={editing ? true : undefined}
>
<div className="client-subscription-heading">
<span className="client-subscription-label">Ваша подписка</span>
<span className="client-icon-tooltip client-tooltip-anchor">
<button
className="client-subscription-refresh"
type="button"
aria-label="Обновить подписку"
disabled={refreshing || refreshBlocked}
onClick={feature.refresh}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
</svg>
</button>
<CloudTooltip>Обновить подписку</CloudTooltip>
</span>
<span className="client-icon-tooltip client-tooltip-anchor">
<button
className="client-subscription-delete"
type="button"
aria-label="Удалить подписку"
disabled={deleteBlocked}
onClick={feature.requestDelete}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path className="client-trash-lid" d="M4 7h16M9 7V4h6v3" />
<path d="m6 7 1 13h10l1-13M10 11v5M14 11v5" />
</svg>
</button>
<CloudTooltip>Удалить подписку</CloudTooltip>
</span>
</div>
<button
className="client-subscription-domain-button"
type="button"
tabIndex={editing ? -1 : 0}
onClick={feature.edit}
>
<strong>{subscriptionDomain(subscription?.host)}</strong>
</button>
</div>
<form
className={`client-subscription-edit is-${validationStatus}`}
autoComplete="off"
aria-hidden={!editing}
inert={!editing ? true : undefined}
onSubmit={(event) => {
event.preventDefault();
feature.submit();
}}
>
<input
ref={inputRef}
id="subscription-url"
type="url"
inputMode="url"
autoComplete="off"
tabIndex={editing ? 0 : -1}
aria-label="Ссылка подписки"
placeholder="Вставьте ссылку подписки"
className={subscriptionUrl ? 'has-value' : ''}
value={subscriptionUrl}
onChange={(event) => feature.changeUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') feature.cancelEditing();
}}
/>
{subscriptionUrl && (
<span className="client-subscription-domain">
{subscriptionDomain(subscriptionUrl)}
</span>
)}
{normalizedUrl && (
<button
className="client-subscription-submit"
type="submit"
aria-live="polite"
aria-label={validationStatus === 'valid'
? 'Сохранить подписку'
: validationStatus === 'checking'
? 'Проверяем подписку'
: error?.message || 'Ссылка подписки не распознана'}
disabled={importBlocked || validationStatus !== 'valid'}
>
{validationStatus === 'valid'
? '✓'
: validationStatus === 'checking' ? '…' : '×'}
</button>
)}
</form>
{statusSlot}
</div>
{hasSubscription && contentReady && hasUsage && (
<section className={`client-usage${usageUpdated ? ' is-updated' : ''}`} aria-label="Статистика подписки">
<span>Использовано</span>
<strong>
{formatBytes(displayedUsed)}
<small> / {usage.total ? formatBytes(usage.total) : 'без лимита'}</small>
</strong>
{usage.percent !== null && (
<div
className="client-usage-bar"
role="progressbar"
aria-label="Использованный трафик"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(usage.percent)}
>
<i style={{ width: `${usage.percent}%` }} />
</div>
)}
<div className="client-usage-details">
{usage.expiresAt && !Number.isNaN(usage.expiresAt.getTime()) && (
<span>
до {usage.expiresAt.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })}
{' · '}{subscriptionDaysLeft(usage.expiresAt)}
</span>
)}
</div>
</section>
)}
{serverSlot}
</div>
</div>;
}
export function SubscriptionDeleteDialog({ feature }: { feature: SubscriptionFeatureController }) {
return <ConfirmationDialog
open={feature.confirmingDelete}
id="delete-subscription"
kicker="Необратимое действие"
title="Удалить подписку?"
description="Harbor остановит VPN и удалит сохранённую подписку. Приложения с локальным прокси потеряют соединение до добавления новой подписки."
cancelLabel="Отмена"
confirmLabel="Удалить"
busy={feature.deleteBlocked}
onCancel={feature.cancelDelete}
onConfirm={feature.forget}
/>;
}
+6
View File
@@ -0,0 +1,6 @@
export {
SubscriptionDeleteDialog,
SubscriptionPanel,
SubscriptionToggle,
useSubscriptionFeature,
} from './SubscriptionFeature.js';
@@ -0,0 +1,32 @@
import { ERROR_DEFINITIONS } from '../../../shared/errors.js';
export interface RequestError {
name?: string;
context?: string;
message?: string;
correlationId?: string;
retryable?: boolean;
retry?: (() => unknown) | null;
}
export function normalizeRequestError(value: unknown): RequestError & { message: string } {
const candidate = value && (typeof value === 'object' || typeof value === 'function')
? value
: null;
const property = (key: string): unknown => candidate ? Reflect.get(candidate, key) : undefined;
const name = property('name');
const context = property('context');
const message = property('message');
const correlationId = property('correlationId');
const retry = property('retry');
return {
name: typeof name === 'string' ? name : undefined,
context: typeof context === 'string' ? context : undefined,
message: typeof message === 'string' && message
? message
: ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message,
correlationId: typeof correlationId === 'string' ? correlationId : undefined,
retryable: property('retryable') === true,
retry: typeof retry === 'function' ? retry as () => unknown : null,
};
}