Files
harbor-net/src/web/features/subscription/SubscriptionFeature.tsx
T
dokril aa9c959368
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 13s
Refactor VPN proxy components and update related behavior
2026-08-11 01:27:46 +03:00

603 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
useEffect,
useRef,
useState,
type ReactNode,
} from 'react';
import { ERROR_DEFINITIONS } from '../../../shared/errors.js';
import type { ProfileSnapshot, StateSnapshot } from '../../../shared/contracts/state.js';
import {
operationBlocked,
type OperationKey,
type OperationRegistrySnapshot,
} 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 type { RequestError } from './requestError.js';
interface ProfileError extends RequestError {
profileId?: string;
}
interface ServerPickerRenderState {
disabled: boolean;
leaving: boolean;
revealVersion: number;
}
interface SubscriptionFeatureOptions {
profiles: ProfileSnapshot[];
selection: StateSnapshot['selection'];
connected: boolean;
isGateway: boolean;
gatewayDirect: boolean;
operations: OperationRegistrySnapshot;
error?: ProfileError | null;
onAdd: (label: string, url: string) => Promise<unknown>;
onRename: (profileId: string, label: string) => Promise<unknown>;
onRefresh: (profileId: string) => Promise<unknown>;
onForget: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise<unknown>;
onActivate: (profileId: string) => Promise<unknown>;
onDismissError: () => void;
}
const foldLabel = (value: string) => value.trim().toLocaleLowerCase('ru-RU');
function operationText(key: OperationKey) {
return ({
profileAdd: 'Добавляем подписку…',
profileRename: 'Переименовываем подписку…',
profileSelect: 'Сохраняем выбор сервера…',
profileActivate: 'Переключаем подписку…',
profileRefresh: 'Обновляем подписку…',
profileDelete: 'Удаляем подписку…',
serverApply: 'Применяем сервер…',
} as Partial<Record<OperationKey, string>>)[key] || '';
}
function profileServer(profile: ProfileSnapshot | undefined, serverId: string) {
return profile?.servers.find((server) => server.id === serverId) || null;
}
export function useSubscriptionFeature({
profiles,
selection,
connected,
isGateway,
gatewayDirect,
operations,
error,
onAdd,
onRename,
onRefresh,
onForget,
onActivate,
onDismissError,
}: SubscriptionFeatureOptions) {
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState<string[]>([]);
const [visited, setVisited] = useState<string[]>([]);
const [adding, setAdding] = useState(profiles.length === 0);
const [label, setLabel] = useState('');
const [url, setUrl] = useState('');
const [renamingId, setRenamingId] = useState('');
const [renameLabel, setRenameLabel] = useState('');
const [menuId, setMenuId] = useState('');
const [deleteId, setDeleteId] = useState('');
const [refreshingIds, setRefreshingIds] = useState<string[]>([]);
const [revealVersions, setRevealVersions] = useState<Record<string, number>>({});
const panelRef = useRef<HTMLDivElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const labelRef = useRef<HTMLInputElement>(null);
const addInvokerRef = useRef<HTMLElement | null>(null);
const deleteIdRef = useRef(deleteId);
const previousProfileCountRef = useRef(profiles.length);
deleteIdRef.current = deleteId;
const normalizedUrl = url.trim();
const normalizedLabel = label.trim();
const duplicateLabel = Boolean(normalizedLabel && profiles.some(
(profile) => foldLabel(profile.label) === foldLabel(normalizedLabel),
));
const addError = !error?.profileId && error?.context === 'subscription' ? error : null;
const validationStatus = !normalizedUrl
? 'idle'
: isSubscriptionUrlValid(normalizedUrl) ? 'valid' : 'invalid';
const renameDuplicate = Boolean(renameLabel.trim() && profiles.some((profile) => (
profile.id !== renamingId && foldLabel(profile.label) === foldLabel(renameLabel)
)));
const deleteProfile = profiles.find((profile) => profile.id === deleteId) || null;
const deleteStopsVpn = connected && selection.appliedProfileId === deleteId;
const activeOperation = (Object.entries(operations) as Array<[
OperationKey,
OperationRegistrySnapshot[OperationKey],
]>).find(([, operation]) => operation?.status === 'running');
useEffect(() => {
const ids = new Set(profiles.map((profile) => profile.id));
setExpanded((current) => current.filter((id) => ids.has(id)));
setVisited((current) => current.filter((id) => ids.has(id)));
if (profiles.length === 0) {
setAdding(true);
if (previousProfileCountRef.current > 0) setOpen(false);
}
if (previousProfileCountRef.current === 0 && profiles.length > 0) {
const first = profiles[0].id;
setOpen(true);
setAdding(false);
setExpanded((current) => current.includes(first) ? current : [...current, first]);
setVisited((current) => current.includes(first) ? current : [...current, first]);
}
previousProfileCountRef.current = profiles.length;
}, [profiles, isGateway]);
useEffect(() => {
if (!adding || (profiles.length > 0 && !open)) return undefined;
const frame = requestAnimationFrame(() => labelRef.current?.focus());
return () => cancelAnimationFrame(frame);
}, [adding, open, profiles.length, isGateway]);
useEffect(() => {
if (!open) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus());
const closeDrawer = (event: PointerEvent | KeyboardEvent) => {
if (deleteIdRef.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);
setMenuId('');
};
document.addEventListener('pointerdown', closeDrawer);
document.addEventListener('keydown', closeDrawer);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('pointerdown', closeDrawer);
document.removeEventListener('keydown', closeDrawer);
requestAnimationFrame(() => {
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
});
};
}, [open]);
function resetAdd() {
setLabel('');
setUrl('');
if (profiles.length) setAdding(false);
const invoker = addInvokerRef.current;
addInvokerRef.current = null;
if (invoker) requestAnimationFrame(() => invoker.focus());
}
async function addProfile() {
if (!normalizedLabel || duplicateLabel || validationStatus !== 'valid') return;
if (!await onAdd(normalizedLabel, normalizedUrl)) return;
resetAdd();
}
function startRename(profile: ProfileSnapshot) {
setMenuId('');
setRenamingId(profile.id);
setRenameLabel(profile.label);
onDismissError();
}
function cancelRename() {
const profileId = renamingId;
setRenamingId('');
requestAnimationFrame(() => document.getElementById(`client-profile-menu-${profileId}`)?.focus());
}
async function saveRename() {
const next = renameLabel.trim();
if (!next || renameDuplicate) return;
if (!await onRename(renamingId, next)) return;
cancelRename();
}
async function refresh(profileId: string) {
const startedAt = performance.now();
setRefreshingIds((current) => current.includes(profileId) ? current : [...current, profileId]);
try {
if (!await onRefresh(profileId)) return;
setRevealVersions((current) => ({
...current,
[profileId]: (current[profileId] || 0) + 1,
}));
} finally {
const elapsed = performance.now() - startedAt;
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const completeAt = reduced ? elapsed : Math.max(900, Math.ceil(elapsed / 900) * 900);
await new Promise((resolve) => setTimeout(resolve, Math.max(0, completeAt - elapsed)));
setRefreshingIds((current) => current.filter((id) => id !== profileId));
}
}
async function confirmDelete() {
if (!deleteProfile) return;
if (!await onForget(deleteProfile.id, deleteStopsVpn ? 'stop-and-delete' : 'delete')) return;
setDeleteId('');
setMenuId('');
}
function toggleProfile(profileId: string) {
setVisited((current) => current.includes(profileId) ? current : [...current, profileId]);
setExpanded((current) => current.includes(profileId)
? current.filter((id) => id !== profileId)
: [...current, profileId]);
}
return {
profiles,
selection,
connected,
isGateway,
gatewayDirect,
operations,
error,
open,
expanded,
visited,
adding,
label,
url,
validationStatus,
duplicateLabel,
addError,
renamingId,
renameLabel,
renameDuplicate,
menuId,
deleteProfile,
deleteStopsVpn,
activeOperation,
refreshingIds,
revealVersions,
panelRef,
toggleRef,
closeRef,
labelRef,
addBlocked: operationBlocked(operations, 'profileAdd'),
renameBlocked: operationBlocked(operations, 'profileRename'),
refreshBlocked: operationBlocked(operations, 'profileRefresh'),
deleteBlocked: operationBlocked(operations, 'profileDelete'),
activateBlocked: operationBlocked(operations, 'profileActivate'),
toggle: () => setOpen((current) => !current),
close: () => { setOpen(false); setMenuId(''); },
showAdd: () => {
addInvokerRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
setAdding(true);
setMenuId('');
onDismissError();
},
cancelAdd: resetAdd,
setLabel: (value: string) => { setLabel(value); onDismissError(); },
setUrl: (value: string) => {
setUrl(value);
onDismissError();
},
addProfile,
toggleProfile,
startRename,
setRenameLabel,
cancelRename,
saveRename,
toggleMenu: (profileId: string) => setMenuId((current) => current === profileId ? '' : profileId),
requestDelete: (profileId: string) => setDeleteId(profileId),
cancelDelete: () => setDeleteId(''),
confirmDelete,
refresh,
onActivate,
};
}
type SubscriptionFeatureController = ReturnType<typeof useSubscriptionFeature>;
export function SubscriptionToggle({
feature,
onToggle,
}: {
feature: SubscriptionFeatureController;
onToggle: () => void;
}) {
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>;
}
function AddProfileForm({ feature }: { feature: SubscriptionFeatureController }) {
const message = feature.duplicateLabel
? 'Такое имя уже используется.'
: feature.validationStatus === 'invalid'
? ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message
: feature.addError?.message || '';
return <form
className="client-profile-add"
autoComplete="off"
onSubmit={(event) => {
event.preventDefault();
feature.addProfile();
}}
>
<label>
<span>Название</span>
<input
ref={feature.labelRef}
value={feature.label}
maxLength={48}
placeholder="Например, Личный"
aria-invalid={feature.duplicateLabel}
onChange={(event) => feature.setLabel(event.target.value)}
/>
</label>
<label>
<span>Ссылка подписки</span>
<input
type="url"
inputMode="url"
value={feature.url}
placeholder="https://…"
aria-invalid={feature.validationStatus === 'invalid'}
onChange={(event) => feature.setUrl(event.target.value)}
/>
</label>
<div className="client-profile-form-status" role="status">
{message || '\u00a0'}
</div>
<div className="client-profile-form-actions">
{feature.profiles.length > 0 && <button type="button" onClick={feature.cancelAdd}>Отмена</button>}
<button
className="is-primary"
type="submit"
disabled={feature.addBlocked || !feature.label.trim() || feature.duplicateLabel || feature.validationStatus !== 'valid'}
>Добавить</button>
</div>
</form>;
}
function ProfileUsage({ profile }: { profile: ProfileSnapshot }) {
const usage = subscriptionUsage(profile.subscription.userInfo);
const expires = usage.expiresAt && !Number.isNaN(usage.expiresAt.getTime())
? subscriptionDaysLeft(usage.expiresAt)
: '';
if (!usage.used && !usage.total && !expires) return null;
return <span className="client-profile-usage">
{formatBytes(usage.used)}{usage.total ? ` / ${formatBytes(usage.total)}` : ''}{expires ? ` · ${expires}` : ''}
</span>;
}
function ProfileGroup({
feature,
profile,
renderServerPicker,
}: {
feature: SubscriptionFeatureController;
profile: ProfileSnapshot;
renderServerPicker: (profile: ProfileSnapshot, state: ServerPickerRenderState) => ReactNode;
}) {
const expanded = feature.expanded.includes(profile.id);
const visited = feature.visited.includes(profile.id);
const applied = feature.connected
&& !feature.gatewayDirect
&& feature.selection.appliedProfileId === profile.id;
const desired = !feature.connected
&& !feature.gatewayDirect
&& feature.selection.desiredProfileId === profile.id;
const profileError = feature.error?.profileId === profile.id ? feature.error : null;
const refreshing = feature.refreshingIds.includes(profile.id)
|| feature.operations.profileRefresh?.target === profile.id;
const controlsBlocked = operationBlocked(feature.operations, 'profileSelect');
const selectedServer = profileServer(profile, profile.desiredServerId);
return <section className={`client-profile-group${expanded ? ' is-expanded' : ''}${applied ? ' is-active' : ''}`}>
<div className="client-profile-header">
<button
className="client-profile-disclosure"
type="button"
aria-expanded={expanded}
aria-controls={`client-profile-${profile.id}`}
onClick={() => feature.toggleProfile(profile.id)}
>
<span className="client-profile-chevron" aria-hidden="true"></span>
<span className="client-profile-title">
<span>
<strong>{profile.label}</strong>
{(applied || desired) && <em>{applied ? 'АКТИВНА' : 'ВЫБРАНА'}</em>}
</span>
<small>{subscriptionDomain(profile.subscription.host)}</small>
</span>
</button>
<ProfileUsage profile={profile} />
<button
className={`client-profile-refresh${refreshing ? ' is-refreshing' : ''}`}
type="button"
aria-label={`Обновить подписку ${profile.label}`}
disabled={feature.refreshBlocked || refreshing}
onClick={() => feature.refresh(profile.id)}
></button>
<div className="client-profile-menu-wrap">
<button
id={`client-profile-menu-${profile.id}`}
className="client-profile-menu-toggle"
type="button"
aria-label={`Действия с подпиской ${profile.label}`}
aria-expanded={feature.menuId === profile.id}
onClick={() => feature.toggleMenu(profile.id)}
></button>
{feature.menuId === profile.id && <div className="client-profile-menu">
<button type="button" onClick={() => feature.startRename(profile)}>Переименовать</button>
<button type="button" className="is-danger" onClick={() => feature.requestDelete(profile.id)}>Удалить</button>
</div>}
</div>
</div>
{feature.renamingId === profile.id && <form
className="client-profile-rename"
onSubmit={(event) => { event.preventDefault(); feature.saveRename(); }}
>
<input
autoFocus
value={feature.renameLabel}
maxLength={48}
aria-label="Новое название подписки"
aria-invalid={feature.renameDuplicate}
onChange={(event) => feature.setRenameLabel(event.target.value)}
onKeyDown={(event) => {
if (event.key !== 'Escape') return;
event.stopPropagation();
feature.cancelRename();
}}
/>
<button type="submit" disabled={feature.renameBlocked || !feature.renameLabel.trim() || feature.renameDuplicate}>Сохранить</button>
<button type="button" onClick={feature.cancelRename}>Отмена</button>
<span role="status">{feature.renameDuplicate ? 'Такое имя уже используется.' : ''}</span>
</form>}
<div className="client-profile-local-status" role="status">
{profileError?.message || (profile.subscription.status === 'stale'
? `Последнее обновление не удалось. Данные от ${profile.subscription.fetchedAt
? new Date(profile.subscription.fetchedAt).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' })
: 'предыдущей загрузки'}.`
: '\u00a0')}
</div>
{visited && <div
id={`client-profile-${profile.id}`}
className="client-profile-body"
aria-hidden={!expanded}
inert={!expanded ? true : undefined}
>
{!applied && feature.selection.desiredProfileId !== profile.id && <button
className="client-profile-activate"
type="button"
disabled={feature.activateBlocked || !selectedServer}
onClick={() => feature.onActivate(profile.id)}
>Сделать активной</button>}
{!profile.desiredServerId && <p className="client-profile-server-hint">Выберите сервер этой подписки</p>}
{renderServerPicker(profile, {
disabled: controlsBlocked,
leaving: false,
revealVersion: feature.revealVersions[profile.id] || 0,
})}
</div>}
</section>;
}
export function SubscriptionPanel({
feature,
statusSlot,
renderServerPicker,
}: {
feature: SubscriptionFeatureController;
statusSlot?: ReactNode;
renderServerPicker: (profile: ProfileSnapshot, state: ServerPickerRenderState) => ReactNode;
}) {
const currentProfileId = feature.connected
? feature.selection.appliedProfileId
: feature.selection.desiredProfileId;
const currentProfile = feature.profiles.find((profile) => profile.id === currentProfileId);
const currentServerId = feature.connected
? feature.selection.appliedServerId
: currentProfile?.desiredServerId || '';
const currentServer = profileServer(currentProfile, currentServerId)
|| (feature.connected && feature.selection.appliedServerSnapshot?.id === currentServerId
? feature.selection.appliedServerSnapshot
: null);
const currentLabel = feature.gatewayDirect
? 'Gateway · сервер не определён'
: currentProfile && currentServer
? `${currentProfile.label} · ${currentServer.label}`
: 'сервер не выбран';
const drawerOpen = feature.open && feature.profiles.length > 0;
return <>
{feature.profiles.length === 0 && <div className="client-form client-subscription-first-run">
<div className="client-first-run-copy">
<span>Подписки</span>
<strong>Добавьте первую подписку</strong>
</div>
<AddProfileForm feature={feature} />
</div>}
<div
ref={feature.panelRef}
id="client-subscription-drawer"
className={`client-drawer client-subscription-drawer${drawerOpen ? ' is-open' : ''}`}
aria-label="Управление подписками"
aria-hidden={!drawerOpen}
inert={!drawerOpen ? true : undefined}
>
<div className="client-drawer-sheet client-subscription-sheet">
<header className="client-profiles-header">
<h2>ПОДПИСКИ</h2>
<button type="button" aria-label="Добавить подписку" onClick={feature.showAdd}></button>
<button
ref={feature.closeRef}
className="client-drawer-close"
type="button"
aria-label="Закрыть подписки"
onClick={feature.close}
>×</button>
</header>
<div className={`client-profiles-operation${feature.activeOperation ? ' is-active' : ''}`} role="status">
{feature.activeOperation ? operationText(feature.activeOperation[0]) : '\u00a0'}
</div>
<div className="client-profiles-current">
<span>{feature.connected || feature.gatewayDirect ? 'Сейчас работает:' : 'Выбрано:'}</span>
<strong>{currentLabel}</strong>
</div>
{statusSlot}
{feature.adding && feature.profiles.length > 0 && <AddProfileForm feature={feature} />}
<div className="client-profile-list">
{feature.profiles.map((profile) => <ProfileGroup
key={profile.id}
feature={feature}
profile={profile}
renderServerPicker={renderServerPicker}
/>)}
</div>
<button className="client-profile-add-trigger" type="button" onClick={feature.showAdd}>
Добавить подписку
</button>
</div>
</div>
</>;
}
export function SubscriptionDeleteDialog({ feature }: { feature: SubscriptionFeatureController }) {
return <ConfirmationDialog
open={Boolean(feature.deleteProfile)}
id="delete-subscription"
kicker="Необратимое действие"
title={`Удалить «${feature.deleteProfile?.label || ''}»?`}
description={feature.deleteStopsVpn
? 'Эта подписка сейчас работает. Harbor остановит VPN и удалит её одной операцией.'
: 'Подписка и её локальные настройки будут удалены. Работающий VPN другой подписки не изменится.'}
cancelLabel="Отмена"
confirmLabel={feature.deleteStopsVpn ? 'Остановить VPN и удалить' : 'Удалить'}
busy={feature.deleteBlocked}
onCancel={feature.cancelDelete}
onConfirm={feature.confirmDelete}
/>;
}