Files
harbor-net/src/web/components/ClientOverviewPage.jsx
T
dokril c26d4cb43b
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s
Reposition Gateway traffic summary below power controls
2026-08-08 01:13:48 +03:00

1781 lines
74 KiB
React
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 React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { api } from '../api.js';
import {
connectionAction,
connectionDurationParts,
copyText,
isSubscriptionUrlValid,
localProxyUrls,
subscriptionDomain,
subscriptionDaysLeft,
subscriptionUsage,
} from '../utils/clientControls.js';
import { formatBytes, formatByteString, formatLastSeen } from '../utils/format.js';
import { instructionBlocks } from '../instructions.js';
import { operationBlocked } from '../state/operations.js';
import { ConfirmationPopup } from './ConfirmationPopup.jsx';
import { DevicesPanel } from './DevicesPanel.jsx';
import { ConnectivityDiagnosticsPanel } from './ConnectivityDiagnosticsPanel.jsx';
import { ServerPicker } from './ServerPicker.jsx';
import { TrafficChart } from './TrafficChart.jsx';
import { ERROR_DEFINITIONS } from '../../shared/errors.js';
import { canAppendRouteRule } from '../../shared/routingRules.js';
import {
HARBOR_VERSIONS,
parseVersion,
versionCompatibility,
} from '../../shared/versions.js';
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
const DEVICE_AUTO_REFRESH_MS = 15_000;
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
function CloudTooltip({ children, id }) {
return <span className="client-tooltip" id={id} role="tooltip">{children}</span>;
}
const VERSION_PARTS = [
['major', 'Major'],
['minor', 'Minor'],
['hotfix', 'Hotfix'],
];
function VersionBadge({ code, component, componentKey, version, runtime, incompatible = false }) {
const parsed = parseVersion(version);
const values = parsed ? VERSION_PARTS.map(([key]) => parsed[key]) : ['', '', ''];
function description(key) {
if (key === 'major') {
return 'Уровень совместимости всей экосистемы. Все совместно работающие компоненты и клиенты должны иметь одинаковый major.';
}
if (key === 'minor') {
return 'Уровень конкретного компонента и тесно связанной с ним части системы. Остальные клиенты с тем же major могут обновляться отдельно.';
}
return 'Локальное совместимое исправление этой версии. Не требует обновлять связанные компоненты или другие клиенты.';
}
return (
<div className={`harbor-version${incompatible ? ' is-incompatible' : ''}`}>
<span className="harbor-version-code" aria-hidden="true">{code}</span>
<span className="harbor-version-number" aria-label={`${component} ${version || 'не определена'}`}>
{VERSION_PARTS.map(([key, label], index) => {
const tooltipId = `harbor-version-${componentKey}-${key}`;
return <React.Fragment key={key}>
{index > 0 && <span className="harbor-version-dot" aria-hidden="true">.</span>}
<span
className="harbor-version-part"
tabIndex="0"
aria-describedby={tooltipId}
>
{values[index]}
<span className="harbor-version-tooltip" id={tooltipId} role="tooltip">
<strong>{component} · {label} {values[index]}</strong>
<span>{description(key)}</span>
{runtime && <small>{runtime}</small>}
{incompatible && <small className="is-warning">Версии Gateway несовместимы.</small>}
</span>
</span>
</React.Fragment>;
})}
</span>
</div>
);
}
function VersionDisplay({ isGateway, versionInfo }) {
const runtimeSingBox = versionInfo?.runtime?.singBox;
if (!isGateway) {
return <aside className="harbor-versions" aria-label="Версия Harbor">
<VersionBadge
code="M"
component="Mac client"
componentKey="macClient"
version={versionInfo?.components?.macClient || HARBOR_VERSIONS.macClient}
runtime={runtimeSingBox ? `Runtime: sing-box ${runtimeSingBox}` : null}
/>
</aside>;
}
const backendVersion = versionInfo?.components?.gatewayBackend;
const dataplaneVersion = versionInfo?.runtime?.dataplaneVersion;
const compatibility = backendVersion && versionCompatibility({
...HARBOR_VERSIONS,
gatewayBackend: backendVersion,
});
const incompatible = compatibility && !compatibility.compatible;
return <aside className="harbor-versions" aria-label="Версии Harbor Gateway">
<VersionBadge
code="C"
component="Gateway client UI"
componentKey="gatewayClient"
version={HARBOR_VERSIONS.gatewayClient}
incompatible={incompatible}
/>
<VersionBadge
code="B"
component="Gateway control backend"
componentKey="gatewayBackend"
version={backendVersion}
incompatible={incompatible}
/>
<VersionBadge
code="D"
component="Gateway dataplane"
componentKey="gatewayDataplane"
version={dataplaneVersion}
runtime={runtimeSingBox ? `Runtime: sing-box ${runtimeSingBox}` : null}
/>
</aside>;
}
function InlineError({ error, context }) {
if (!error || error.context !== context) return null;
return (
<div className={`client-inline-error is-${context}`} role="alert">
<span>{error.message}</span>
{error.retry && <button type="button" onClick={error.retry}>Повторить</button>}
{error.correlationId && (
<small title={error.correlationId}>Код: {error.correlationId.slice(0, 8)}</small>
)}
</div>
);
}
const operationProgress = {
connection: ['connection', 'Меняем состояние подключения…'],
serverApply: ['connection', 'Применяем сервер…'],
subscriptionImport: ['subscription', 'Загружаем подписку…'],
subscriptionDelete: ['subscription', 'Удаляем подписку…'],
routeRules: ['routing', 'Применяем локальные правила…'],
};
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 (
<>
{step.before}
<a href={step.link[1]} target="_blank" rel="noreferrer">{step.link[0]}</a>
{step.after}
</>
);
}
function InstructionBlock({ block, open, onToggle }) {
return (
<section
className={`client-instruction-block${open ? ' is-open' : ''}`}
style={{ viewTransitionName: `instruction-${block.id}` }}
>
<button
className="client-instruction-summary"
type="button"
aria-expanded={open}
onClick={onToggle}
>
<span>{block.label}</span>
<strong>{block.title}</strong>
<small>{block.summary}</small>
<i aria-hidden="true" />
</button>
<div className="client-instruction-reveal" aria-hidden={!open} inert={!open ? true : undefined}>
<div className="client-instruction-body">
{block.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
{block.steps && (
<ol>
{block.steps.map((step) => (
<li key={typeof step === 'string' ? step : step.link[1]}>
<InstructionStep step={step} />
</li>
))}
</ol>
)}
{block.code && <code>{block.code}</code>}
{block.note && <p className="client-instruction-note">{block.note}</p>}
</div>
</div>
</section>
);
}
const ROUTE_RULE_OPTIONS = [
['domain', 'Точный домен'],
['domain_suffix', 'Суффикс'],
['domain_keyword', 'Содержит'],
];
const ROUTE_RULE_PLACEHOLDERS = {
domain: 'example.com или полный URL',
domain_suffix: 'example.org',
domain_keyword: 'cdn',
};
let localRuleDraftId = 0;
const createLocalRuleDraft = (rule) => ({
...rule,
enabled: rule?.enabled !== false,
_key: `route-rule-${localRuleDraftId += 1}`,
});
const localRuleValues = (rules) => rules
.filter((rule) => !rule.removing)
.map(({ type, value, enabled }) => ({ type, value, enabled }));
const localRulesSignature = (rules) => JSON.stringify(localRuleValues(rules));
const localRuleKey = ({ type, value, enabled }) => `${type}:${String(value || '').trim().toLowerCase()}:${enabled}`;
function localRuleStatus(rule, savedRules, activeRules, runtimeActive) {
const key = localRuleKey(rule);
if (!savedRules.some((saved) => localRuleKey(saved) === key)) return ['unsaved', 'Не сохранено'];
if (!rule.enabled) return ['disabled', 'Выключено'];
if (!runtimeActive) return ['saved', 'Сохранено'];
if (activeRules.some((active) => localRuleKey(active) === key)) return ['active', 'Активно'];
return ['pending', 'Ждёт перезапуска'];
}
function RuleTypePicker({ value, ruleKey, index, disabled, onChange }) {
const [open, setOpen] = useState(false);
const rootRef = useRef(null);
const triggerRef = useRef(null);
const optionRefs = useRef([]);
const listId = `${ruleKey}-types`;
const selectedIndex = Math.max(0, ROUTE_RULE_OPTIONS.findIndex(([type]) => type === value));
useEffect(() => {
if (!open) return undefined;
optionRefs.current[selectedIndex]?.focus();
const close = (event) => {
if (event.type === 'keydown' && event.key !== 'Escape') return;
if (rootRef.current?.contains(event.target)) return;
setOpen(false);
};
document.addEventListener('pointerdown', close);
document.addEventListener('keydown', close);
return () => {
document.removeEventListener('pointerdown', close);
document.removeEventListener('keydown', close);
};
}, [open, selectedIndex]);
function choose(type) {
onChange(type);
setOpen(false);
triggerRef.current?.focus();
}
function moveOption(event, offset) {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End', 'Escape'].includes(event.key)) return;
event.preventDefault();
if (event.key === 'Escape') {
setOpen(false);
triggerRef.current?.focus();
return;
}
const current = optionRefs.current.indexOf(document.activeElement);
const next = event.key === 'Home'
? 0
: event.key === 'End'
? ROUTE_RULE_OPTIONS.length - 1
: (current + offset + ROUTE_RULE_OPTIONS.length) % ROUTE_RULE_OPTIONS.length;
optionRefs.current[next]?.focus();
}
return (
<div className={`client-rule-type${open ? ' is-open' : ''}`} ref={rootRef}>
<button
ref={triggerRef}
className="client-rule-type-trigger"
type="button"
aria-label={`Тип правила ${index + 1}`}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
onKeyDown={(event) => {
if (event.key === 'Escape' && open) {
event.preventDefault();
setOpen(false);
return;
}
if (!['ArrowDown', 'ArrowUp'].includes(event.key)) return;
event.preventDefault();
setOpen(true);
}}
>
<span>{ROUTE_RULE_OPTIONS[selectedIndex][1]}</span>
<svg viewBox="0 0 12 8" aria-hidden="true"><path d="m1 1 5 5 5-5" /></svg>
</button>
<div className="client-rule-type-list" id={listId} role="listbox" aria-hidden={!open}>
{ROUTE_RULE_OPTIONS.map(([type, label], optionIndex) => (
<button
ref={(node) => { optionRefs.current[optionIndex] = node; }}
type="button"
role="option"
aria-selected={type === value}
tabIndex={open ? 0 : -1}
key={type}
onClick={() => choose(type)}
onKeyDown={(event) => moveOption(event, event.key === 'ArrowUp' ? -1 : 1)}
>
{label}
</button>
))}
</div>
</div>
);
}
function LocalRulesPanel({
open,
rules,
savedRules,
activeRules,
runtimeActive,
blocked,
dirty,
restartPending,
error,
operations,
panelRef,
closeRef,
onAdd,
onChange,
onRemove,
onRemoveComplete,
onClose,
onSave,
}) {
const draftRules = rules.filter((rule) => !rule.removing);
const canAdd = canAppendRouteRule(draftRules) && !blocked;
const incomplete = draftRules.some((rule) => !String(rule.value || '').trim());
return (
<aside
ref={panelRef}
id="client-local-rules"
className={`client-drawer client-local-rules${open ? ' is-open' : ''}`}
aria-labelledby="local-rules-title"
aria-hidden={!open}
inert={!open ? true : undefined}
>
<div className="client-drawer-sheet client-local-rules-sheet">
<button
ref={closeRef}
className="client-drawer-close"
type="button"
aria-label="Закрыть локальные правила"
onClick={onClose}
>×</button>
<header className="client-local-rules-header">
<span>Маршрутизация</span>
<button
className="client-local-rules-save"
type="submit"
form="client-local-rules-form"
disabled={blocked || !dirty}
>
Сохранить
</button>
<h2 id="local-rules-title">Локальные правила</h2>
<p>Эти домены идут напрямую. Остальной трафик через выбранный VPN.</p>
{restartPending && (
<p className="client-local-rules-runtime" role="status">
Правила сохранены, но начнут работать после запуска или перезапуска sing-box.
</p>
)}
</header>
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={onSave}>
<section className="client-local-rules-group" aria-labelledby="local-rules-list-title">
<span id="local-rules-list-title">Правила</span>
<div className="client-local-rules-list">
{rules.map((rule, index) => {
const [status, statusLabel] = localRuleStatus(
rule,
savedRules,
activeRules,
runtimeActive,
);
return (
<div
className={`client-local-rule client-deletable-row is-${status}${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}`}
key={rule._key}
style={{ viewTransitionName: rule.removing ? 'none' : rule._key }}
inert={rule.removing ? true : undefined}
>
<button
className="client-local-rule-enabled"
type="button"
role="switch"
aria-checked={rule.enabled}
aria-label={`${rule.enabled ? 'Выключить' : 'Включить'} правило ${index + 1}`}
onClick={() => onChange(index, 'enabled', !rule.enabled)}
>
<svg viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="6" />
<path className="client-rule-check" d="m6.8 10.1 2.1 2.2 4.5-5" />
</svg>
</button>
<RuleTypePicker
value={rule.type}
ruleKey={rule._key}
index={index}
disabled={rule.removing}
onChange={(type) => onChange(index, 'type', type)}
/>
<input
type="text"
inputMode="url"
autoComplete="off"
spellCheck={false}
required
aria-label={`Значение правила ${index + 1}`}
placeholder={ROUTE_RULE_PLACEHOLDERS[rule.type]}
value={rule.value}
onChange={(event) => onChange(index, 'value', event.target.value)}
/>
<span className="client-local-rule-status" role="status">{statusLabel}</span>
<button
className="client-local-rule-delete"
type="button"
aria-label={`Удалить правило ${index + 1}`}
onClick={() => onRemove(index)}
>
×
</button>
<span
className="client-delete-strike"
aria-hidden="true"
onAnimationEnd={() => onRemoveComplete(rule._key)}
/>
</div>
);
})}
{!rules.length && <p className="client-local-rules-empty">Правил пока нет. Весь трафик идёт через VPN.</p>}
</div>
<div className="client-local-rule-add-slot">
<button className="client-local-rule-add" type="button" disabled={!canAdd} onClick={onAdd}>
+ Добавить правило
</button>
<span className={incomplete ? 'is-visible' : ''}>Сначала заполните текущее правило</span>
</div>
</section>
<p className="client-local-rules-note">
Можно вставить полный URL: Harbor сохранит только домен. Путь и параметры HTTPS недоступны для маршрутизации.
</p>
<InlineError error={error} context="routing" />
<InlineProgress operations={operations} context="routing" />
<div className="client-local-rules-actions">
<button type="button" onClick={onClose}>Отмена</button>
</div>
</form>
</div>
</aside>
);
}
function DurationPart({ name, children }) {
return (
<span
className={`client-duration-part client-duration-${name}${name.endsWith('-value') ? ' is-value' : ' is-label'}`}
>
{children}
</span>
);
}
function AnimatedSeconds({ value, padded = true }) {
return String(value).padStart(padded ? 2 : 1, '0').split('').map((digit, index) => (
<span className="client-duration-second-digit" key={`${index}-${digit}`}>{digit}</span>
));
}
function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, blocked, onSetGatewayAuto }) {
const [modeAnimating, setModeAnimating] = useState(false);
const [arrowTurns, setArrowTurns] = useState(gatewayDirect ? 0.5 : 0);
const stopModeAnimationRef = useRef(false);
const previousGatewayDirectRef = useRef(gatewayDirect);
const product = isGateway ? 'Gateway' : 'Connect';
const switchable = !isGateway && gatewayAvailable;
const label = gatewayDirect
? 'Игнорировать Harbor Gateway и использовать локальный VPN'
: 'Использовать обнаруженный Harbor Gateway';
useLayoutEffect(() => {
if (previousGatewayDirectRef.current === gatewayDirect) return;
previousGatewayDirectRef.current = gatewayDirect;
setArrowTurns((turns) => turns + 0.5);
}, [gatewayDirect]);
function startModeAnimation() {
stopModeAnimationRef.current = false;
setModeAnimating(true);
}
function finishModeAnimation() {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setModeAnimating(false);
return;
}
stopModeAnimationRef.current = true;
}
const content = <div className="harbor-brand-content">
<svg viewBox="0 0 32 32" aria-hidden="true">
<circle cx="16" cy="6" r="3" />
<path d="M16 9v15M10 14h12" />
<path className="harbor-anchor-left" d="M16 28C11 28 8.4 25.2 6.3 22v-3.3M3.8 21.4l2.5-2.7 2.5 2.7" />
<path className="harbor-anchor-right" d="M16 28C21 28 23.6 25.2 25.7 22v-3.3M23.2 21.4l2.5-2.7 2.5 2.7" />
</svg>
<span className="harbor-brand-name">
<strong>Harbor</strong>
{switchable ? <span className="harbor-mode-control">
<span className="harbor-mode-stack" aria-hidden="true">
<em className="harbor-mode-connect">Connect</em>
<em className="harbor-mode-gateway"><span>Gateway</span></em>
</span>
<svg
className="harbor-mode-swap"
viewBox="0 0 18 18"
aria-hidden="true"
style={{ '--harbor-arrow-turn': `${arrowTurns}turn` }}
>
<g className="is-connect"><path d="M3 6h10m-3-3 3 3-3 3" /></g>
<g className="is-gateway"><path d="M15 12H5m3 3-3-3 3-3" /></g>
</svg>
<span id="harbor-mode-tooltip" className="harbor-mode-tooltip" role="tooltip">
<strong>{gatewayDirect ? 'Harbor Gateway активен' : 'Harbor Gateway доступен'}</strong>
<span>{gatewayDirect
? 'Трафик идёт через Gateway в этой сети. Нажмите, чтобы использовать локальный VPN.'
: 'Сейчас используется локальный VPN. Нажмите, чтобы направить трафик через Gateway.'}</span>
</span>
</span> : <em>{product}</em>}
</span>
</div>;
return (
<div className={`harbor-brand is-${product.toLowerCase()}${switchable ? ` is-switchable${gatewayDirect ? ' is-gateway-active' : ''}` : ''}`}>
{switchable ? <button
className={`harbor-brand-control${modeAnimating ? ' is-mode-animating' : ''}`}
type="button"
aria-label={label}
aria-describedby="harbor-mode-tooltip"
aria-pressed={gatewayDirect}
disabled={blocked}
onPointerEnter={startModeAnimation}
onPointerLeave={finishModeAnimation}
onFocus={startModeAnimation}
onBlur={finishModeAnimation}
onAnimationIteration={(event) => {
if (event.animationName === 'harbor-mode-float-front' && stopModeAnimationRef.current) {
stopModeAnimationRef.current = false;
setModeAnimating(false);
}
}}
onClick={() => onSetGatewayAuto(!gatewayDirect)}
>
{content}
</button> : <div aria-label={`Harbor ${product}`}>{content}</div>}
</div>
);
}
export function ClientOverviewPage({
state,
versionInfo,
operations = {},
error,
subscriptionUrl,
setSubscriptionUrl,
servers,
pendingServerId,
setPendingServerId,
onFetchSubscription,
onRefreshSubscription,
onForgetSubscription,
onApply,
onRestart,
onStop,
onSetGatewayAuto,
onSaveRouteRules,
onDismissError,
}) {
const isGateway = state?.mode === 'gateway';
const gatewayDirect = !isGateway && state?.gatewayAuto?.mode === 'gateway-direct';
const gatewayAvailable = !isGateway && Boolean(state?.gatewayAuto?.available);
const connected = Boolean(state?.singboxRunning);
const hasSubscription = Boolean(state?.hasSubscription);
const selectedServerId = pendingServerId || state?.selection?.desiredServerId || '';
const appliedServerId = state?.selection?.appliedServerId || '';
const appliedServer = servers.find(({ id }) => id === appliedServerId);
const desiredServer = servers.find(({ id }) => id === selectedServerId);
const showPower = isGateway || (hasSubscription && Boolean(selectedServerId));
const canStart = Boolean(selectedServerId || state?.configExists);
const powerUnavailable = isGateway && !connected && !canStart;
const [now, setNow] = useState(Date.now());
const [durationMode, setDurationMode] = useState(() => {
try {
return localStorage.getItem(DURATION_MODE_STORAGE_KEY) === 'words' ? 'words' : 'digital';
} catch {
return 'digital';
}
});
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
const [showIntro, setShowIntro] = useState(true);
const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription);
const [copyFeedback, setCopyFeedback] = useState(null);
const [subscriptionValidation, setSubscriptionValidation] = useState({
url: '',
status: 'idle',
error: null,
});
const [subscriptionValidationAttempt, setSubscriptionValidationAttempt] = useState(0);
const [refreshingInfo, setRefreshingInfo] = useState(false);
const [usageUpdated, setUsageUpdated] = useState(false);
const [serverRevealVersion, setServerRevealVersion] = useState(0);
const [serversLeaving, setServersLeaving] = useState(false);
const [instructionsOpen, setInstructionsOpen] = useState(false);
const [subscriptionOpen, setSubscriptionOpen] = useState(false);
const [localRulesOpen, setLocalRulesOpen] = useState(false);
const [devicesOpen, setDevicesOpen] = useState(false);
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
const [deviceSnapshot, setDeviceSnapshot] = useState(null);
const [deviceStatus, setDeviceStatus] = useState('idle');
const [deviceError, setDeviceError] = useState(null);
const [devicesRefreshing, setDevicesRefreshing] = useState(false);
const [deviceRefreshCycle, setDeviceRefreshCycle] = useState(0);
const [localRulesDraft, setLocalRulesDraft] = useState([]);
const [localRulesRevision, setLocalRulesRevision] = useState(state?.route?.localRulesRevision || 0);
const [confirmingLocalRulesClose, setConfirmingLocalRulesClose] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [confirmingStop, setConfirmingStop] = useState(false);
const [openInstructionId, setOpenInstructionId] = useState('');
const subscriptionInputRef = useRef(null);
const subscriptionRef = useRef(null);
const copyTimerRef = useRef(null);
const instructionsPanelRef = useRef(null);
const instructionsToggleRef = useRef(null);
const instructionsCloseRef = useRef(null);
const subscriptionPanelRef = useRef(null);
const subscriptionToggleRef = useRef(null);
const subscriptionCloseRef = useRef(null);
const localRulesPanelRef = useRef(null);
const localRulesToggleRef = useRef(null);
const localRulesCloseRef = useRef(null);
const devicesPanelRef = useRef(null);
const devicesToggleRef = useRef(null);
const devicesCloseRef = useRef(null);
const diagnosticsPanelRef = useRef(null);
const diagnosticsToggleRef = useRef(null);
const diagnosticsCloseRef = useRef(null);
const localRulesBaselineRef = useRef('[]');
const confirmingDeleteRef = useRef(confirmingDelete);
const previousHasSubscriptionRef = useRef(hasSubscription);
confirmingDeleteRef.current = confirmingDelete;
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
const usage = subscriptionUsage(state?.userInfo);
const duration = connectionDurationParts(state?.singboxStartedAt, now);
const wordClockDuration = [
['hours', duration.hours],
['minutes', duration.minutes],
['seconds', duration.seconds],
].filter(([name, part]) => duration.days.value || part.value || name === 'seconds');
const connectionTitle = connected
? gatewayDirect ? 'Gateway подключён' : 'VPN включён'
: 'Подключение выключено';
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
const hasUsage = Boolean(
state?.userInfo && ['upload', 'download', 'total', 'expire'].some((key) => key in state.userInfo),
);
const instructions = instructionBlocks({
isGateway,
host: gatewayAddress,
port: state?.proxyPort || (isGateway ? 8080 : 8082),
});
const [instructionsIntro, ...instructionGuides] = instructions;
const openInstruction = instructionGuides.find((block) => block.id === openInstructionId);
const orderedInstructionGuides = openInstruction
? [openInstruction, ...instructionGuides.filter((block) => block.id !== openInstructionId)]
: instructionGuides;
const normalizedSubscriptionUrl = subscriptionUrl.trim();
const currentSubscriptionValidation = subscriptionValidation.url === normalizedSubscriptionUrl
? subscriptionValidation
: null;
const localSubscriptionError = normalizedSubscriptionUrl
&& !isSubscriptionUrlValid(normalizedSubscriptionUrl)
? { context: 'subscription', message: ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message }
: null;
const subscriptionError = currentSubscriptionValidation?.error
|| localSubscriptionError
|| (error?.context === 'subscription' ? error : null);
const subscriptionValidationStatus = !normalizedSubscriptionUrl
? 'idle'
: subscriptionError || !isSubscriptionUrlValid(normalizedSubscriptionUrl)
? 'invalid'
: currentSubscriptionValidation?.status || 'checking';
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');
const routeRulesBlocked = operationBlocked(operations, 'routeRules');
const localRulesDirty = localRulesSignature(localRulesDraft) !== localRulesBaselineRef.current;
const localRulesPendingRestart = connected && state?.route?.localRulesPendingRestart === true;
const pendingLocalRulesCount = localRulesPendingRestart
? (state?.route?.localRules || []).filter((rule) => (
rule.enabled && !(state?.route?.activeLocalRules || []).some((active) => localRuleKey(active) === localRuleKey(rule))
)).length
: 0;
const globalTraffic = deviceSnapshot?.traffic;
const trafficSourceError = deviceSnapshot?.source?.traffic?.error
|| deviceSnapshot?.source?.traffic?.proxy?.error
|| (deviceStatus === 'error' ? deviceError : null);
const trafficFreshness = globalTraffic?.observedAt
? formatLastSeen(globalTraffic.observedAt, new Date(now)).relative
: 'Нет данных';
const switchingServer = Boolean(
selectedServerId && selectedServerId !== appliedServerId && desiredServer,
);
async function loadDevices(quiet = false, discover = false) {
if (!isGateway) return;
if (!quiet) setDeviceStatus(deviceSnapshot ? 'refreshing' : 'loading');
setDevicesRefreshing(true);
try {
const next = await (discover ? api.devices.refresh() : api.devices.list());
setDeviceSnapshot((current) => !current || next.revision > current.revision ? next : current);
setDeviceError(null);
setDeviceStatus('ready');
} catch (requestError) {
setDeviceError(requestError);
setDeviceStatus('error');
} finally {
setDevicesRefreshing(false);
setDeviceRefreshCycle((cycle) => cycle + 1);
}
}
useEffect(() => {
setNow(Date.now());
if (!isGateway && (!connected || !state?.singboxStartedAt)) return undefined;
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [isGateway, connected, state?.singboxStartedAt]);
useEffect(() => {
if (!isGateway) return undefined;
loadDevices();
return undefined;
}, [isGateway]);
useEffect(() => {
if (!isGateway || devicesRefreshing || deviceStatus === 'loading') return undefined;
const timer = setTimeout(() => loadDevices(true), DEVICE_AUTO_REFRESH_MS);
return () => clearTimeout(timer);
}, [isGateway, deviceRefreshCycle, devicesRefreshing, deviceStatus]);
useEffect(() => {
if (editingSubscription) subscriptionInputRef.current?.focus();
}, [editingSubscription]);
useEffect(() => {
if (!normalizedSubscriptionUrl || !isSubscriptionUrlValid(normalizedSubscriptionUrl)) return undefined;
const controller = new AbortController();
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'checking', error: null });
const timer = setTimeout(async () => {
try {
await api.subscription.validate(normalizedSubscriptionUrl, { signal: controller.signal });
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'valid', error: null });
} catch (requestError) {
if (requestError?.name === 'AbortError') return;
setSubscriptionValidation({
url: normalizedSubscriptionUrl,
status: 'invalid',
error: {
context: 'subscription',
message: requestError.message,
correlationId: requestError.correlationId,
retry: requestError.retryable
? () => setSubscriptionValidationAttempt((attempt) => attempt + 1)
: null,
},
});
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [normalizedSubscriptionUrl, subscriptionValidationAttempt]);
useEffect(() => {
if (!showIntro) return undefined;
const timer = setTimeout(() => setShowIntro(false), 1200);
return () => clearTimeout(timer);
}, [showIntro]);
useEffect(() => {
const previouslyHadSubscription = previousHasSubscriptionRef.current;
previousHasSubscriptionRef.current = hasSubscription;
if (!hasSubscription) {
setSubscriptionContentReady(false);
return undefined;
}
if (previouslyHadSubscription) {
setSubscriptionContentReady(true);
return undefined;
}
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setSubscriptionContentReady(true);
return undefined;
}
const timer = setTimeout(() => setSubscriptionContentReady(true), SUBSCRIPTION_REVEAL_DELAY_MS);
return () => clearTimeout(timer);
}, [hasSubscription]);
useEffect(() => {
if (!hasSubscription) {
setEditingSubscription(true);
setLocalRulesOpen(false);
if (!isGateway) {
setInstructionsOpen(false);
setDevicesOpen(false);
setDiagnosticsOpen(false);
}
}
}, [hasSubscription, isGateway]);
useEffect(() => {
if (!editingSubscription || !state?.hasSubscription || subscriptionUrl) return undefined;
const timer = setTimeout(() => setEditingSubscription(false), 5000);
return () => clearTimeout(timer);
}, [editingSubscription, state?.hasSubscription, subscriptionUrl]);
useEffect(() => {
if (!editingSubscription || !hasSubscription) return undefined;
const closeOnOutsideClick = (event) => {
if (subscriptionRef.current?.contains(event.target)) return;
setSubscriptionUrl('');
setEditingSubscription(false);
};
document.addEventListener('pointerdown', closeOnOutsideClick);
return () => document.removeEventListener('pointerdown', closeOnOutsideClick);
}, [editingSubscription, hasSubscription, setSubscriptionUrl]);
useEffect(() => {
if (!state?.hasSubscription) return undefined;
onRefreshSubscription();
return undefined;
}, [state?.hasSubscription]);
useEffect(() => {
const from = displayedUsed;
const to = usage.used;
if (from === to) return undefined;
const startedAt = performance.now();
let frame;
const tick = (now) => {
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(() => () => clearTimeout(copyTimerRef.current), []);
useEffect(() => {
if (!subscriptionOpen) return undefined;
const frame = requestAnimationFrame(() => subscriptionCloseRef.current?.focus());
const closeSubscription = (event) => {
if (confirmingDeleteRef.current) return;
if (event.type === 'keydown' && event.key !== 'Escape') return;
if (event.type !== 'keydown' && (
subscriptionPanelRef.current?.contains(event.target) || subscriptionToggleRef.current?.contains(event.target)
)) return;
setSubscriptionOpen(false);
};
document.addEventListener('pointerdown', closeSubscription);
document.addEventListener('keydown', closeSubscription);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('pointerdown', closeSubscription);
document.removeEventListener('keydown', closeSubscription);
requestAnimationFrame(() => {
if (subscriptionPanelRef.current?.contains(document.activeElement)) subscriptionToggleRef.current?.focus();
});
};
}, [subscriptionOpen]);
useEffect(() => {
if (!instructionsOpen) return undefined;
const frame = requestAnimationFrame(() => instructionsCloseRef.current?.focus());
const closeOnEscape = (event) => {
if (event.key === 'Escape') setInstructionsOpen(false);
};
document.addEventListener('keydown', closeOnEscape);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('keydown', closeOnEscape);
requestAnimationFrame(() => {
if (instructionsPanelRef.current?.contains(document.activeElement)) instructionsToggleRef.current?.focus();
});
};
}, [instructionsOpen]);
useEffect(() => {
if (!devicesOpen) return undefined;
const frame = requestAnimationFrame(() => devicesCloseRef.current?.focus());
const closeDevices = (event) => {
if (event.type === 'keydown' && event.key !== 'Escape') return;
if (event.type !== 'keydown' && (
devicesPanelRef.current?.contains(event.target) || devicesToggleRef.current?.contains(event.target)
)) return;
setDevicesOpen(false);
};
document.addEventListener('pointerdown', closeDevices);
document.addEventListener('keydown', closeDevices);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('pointerdown', closeDevices);
document.removeEventListener('keydown', closeDevices);
requestAnimationFrame(() => {
if (devicesPanelRef.current?.contains(document.activeElement)) devicesToggleRef.current?.focus();
});
};
}, [devicesOpen]);
useEffect(() => {
if (!diagnosticsOpen) return undefined;
const frame = requestAnimationFrame(() => diagnosticsCloseRef.current?.focus());
const closeDiagnostics = (event) => {
if (event.type === 'keydown' && event.key !== 'Escape') return;
if (event.type !== 'keydown' && (
diagnosticsPanelRef.current?.contains(event.target) || diagnosticsToggleRef.current?.contains(event.target)
)) return;
setDiagnosticsOpen(false);
};
document.addEventListener('pointerdown', closeDiagnostics);
document.addEventListener('keydown', closeDiagnostics);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('pointerdown', closeDiagnostics);
document.removeEventListener('keydown', closeDiagnostics);
requestAnimationFrame(() => {
if (diagnosticsPanelRef.current?.contains(document.activeElement)) diagnosticsToggleRef.current?.focus();
});
};
}, [diagnosticsOpen]);
useEffect(() => {
if (!localRulesOpen) return undefined;
const frame = requestAnimationFrame(() => localRulesCloseRef.current?.focus());
return () => {
cancelAnimationFrame(frame);
requestAnimationFrame(() => {
if (localRulesPanelRef.current?.contains(document.activeElement)) localRulesToggleRef.current?.focus();
});
};
}, [localRulesOpen]);
useEffect(() => {
if (!localRulesOpen) return undefined;
const closeLocalRules = (event) => {
if (event.type === 'keydown') {
if (event.key !== 'Escape' || event.defaultPrevented) return;
} else {
if (localRulesPanelRef.current?.contains(event.target)) return;
if (localRulesToggleRef.current?.contains(event.target)) return;
}
requestCloseLocalRules();
};
document.addEventListener('pointerdown', closeLocalRules);
document.addEventListener('keydown', closeLocalRules);
return () => {
document.removeEventListener('pointerdown', closeLocalRules);
document.removeEventListener('keydown', closeLocalRules);
};
}, [localRulesOpen, localRulesDirty]);
useEffect(() => {
if (!localRulesOpen || !localRulesDirty) return undefined;
const warnBeforeUnload = (event) => {
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', warnBeforeUnload);
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
}, [localRulesOpen, localRulesDirty]);
useEffect(() => {
if (!instructionsOpen) return undefined;
const closeInstructionsOutside = (event) => {
if (instructionsPanelRef.current?.contains(event.target)) return;
if (instructionsToggleRef.current?.contains(event.target)) return;
setInstructionsOpen(false);
};
document.addEventListener('pointerdown', closeInstructionsOutside);
return () => document.removeEventListener('pointerdown', closeInstructionsOutside);
}, [instructionsOpen]);
function toggleConnection() {
const action = connectionAction({ connected, selectedServerId, configExists: state?.configExists });
if (action?.type === 'stop') {
setConfirmingStop(true);
return;
}
if (action?.type === 'apply') return onApply(action.serverId);
if (action?.type === 'restart') return onRestart();
}
async function stopConnection() {
if (!await onStop()) return;
setConfirmingStop(false);
}
function selectServer(serverId) {
setPendingServerId(serverId);
if (connected && serverId) onApply(serverId);
}
async function submitSubscription(event) {
event.preventDefault();
if (subscriptionValidationStatus !== 'valid') return;
if (!await onFetchSubscription()) return;
setSubscriptionUrl('');
setEditingSubscription(false);
}
async function copyProxy(kind) {
const value = kind === 'gateway' ? gatewayAddress : proxyUrls[kind];
clearTimeout(copyTimerRef.current);
try {
await copyText(value);
setCopyFeedback({ kind, failed: false });
} catch {
setCopyFeedback({ kind, failed: true });
}
copyTimerRef.current = setTimeout(() => setCopyFeedback(null), 800);
}
async function refreshSubscription() {
const startedAt = performance.now();
setRefreshingInfo(true);
try {
if (!await onRefreshSubscription()) 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, servers.length - 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));
setRefreshingInfo(false);
}
}
async function forgetSubscription() {
if (!await onForgetSubscription()) return;
setConfirmingDelete(false);
}
function toggleInstruction(id) {
const update = () => flushSync(() => {
setOpenInstructionId((current) => current === id ? '' : id);
});
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
update();
return;
}
document.startViewTransition(update);
}
function toggleDurationMode() {
setDurationMode((mode) => {
const nextMode = mode === 'digital' ? 'words' : 'digital';
try {
localStorage.setItem(DURATION_MODE_STORAGE_KEY, nextMode);
} catch {
// The visual preference still works for this session.
}
return nextMode;
});
}
function openLocalRules() {
const rules = state?.route?.localRules || [];
setSubscriptionOpen(false);
setInstructionsOpen(false);
setDevicesOpen(false);
setDiagnosticsOpen(false);
localRulesBaselineRef.current = localRulesSignature(rules);
setLocalRulesDraft(rules.map(createLocalRuleDraft));
setLocalRulesRevision(state?.route?.localRulesRevision || 0);
setConfirmingLocalRulesClose(false);
onDismissError();
setLocalRulesOpen(true);
}
function changeLocalRule(index, field, value) {
setLocalRulesDraft((rules) => rules.map((rule, ruleIndex) => (
ruleIndex === index ? { ...rule, [field]: value } : rule
)));
}
async function saveLocalRules(event) {
event.preventDefault();
const rules = localRuleValues(localRulesDraft);
const result = await onSaveRouteRules(rules, localRulesRevision);
if (!result) return;
localRulesBaselineRef.current = JSON.stringify(rules);
setLocalRulesRevision(result.state.route.localRulesRevision);
setConfirmingLocalRulesClose(false);
if (!connected || !result.state.route.localRulesPendingRestart) setLocalRulesOpen(false);
}
function requestCloseLocalRules() {
if (localRulesDirty) {
setConfirmingLocalRulesClose(true);
return false;
}
setLocalRulesOpen(false);
return true;
}
function discardLocalRules() {
setConfirmingLocalRulesClose(false);
setLocalRulesOpen(false);
}
function removeLocalRule(ruleKey) {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setLocalRulesDraft((rules) => rules.filter((rule) => rule._key !== ruleKey));
return;
}
setLocalRulesDraft((rules) => rules.map((rule) => (
rule._key === ruleKey ? { ...rule, removing: true } : rule
)));
}
function finishRemoveLocalRule(ruleKey) {
const update = () => flushSync(() => {
setLocalRulesDraft((rules) => rules.filter((rule) => rule._key !== ruleKey));
});
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
update();
return;
}
document.startViewTransition(update);
}
const powerButton = <button
className="client-power"
type="button"
role="switch"
aria-checked={connected}
aria-label={isGateway
? connected ? 'Остановить VPN' : 'Запустить VPN'
: connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
disabled={connectionBlocked || (!connected && !canStart)}
onClick={toggleConnection}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
</svg>
</button>;
return (
<div
className={`client-shell${!isGateway && !hasSubscription ? ' is-first-run' : ''}${showIntro ? ' is-intro' : ''}`}
>
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
</div>
<HarborBrand
isGateway={isGateway}
gatewayAvailable={gatewayAvailable}
gatewayDirect={gatewayDirect}
blocked={gatewayAutoBlocked}
onSetGatewayAuto={onSetGatewayAuto}
/>
{(isGateway || (hasSubscription && subscriptionContentReady)) && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
{isGateway && <button
ref={subscriptionToggleRef}
className={`client-instructions-toggle client-subscription-toggle${subscriptionOpen ? ' is-open' : ''}`}
type="button"
aria-expanded={subscriptionOpen}
aria-controls="client-subscription-drawer"
aria-label={subscriptionOpen ? 'Закрыть подписку' : 'Управление подпиской'}
onClick={() => {
if (localRulesOpen && !requestCloseLocalRules()) return;
setInstructionsOpen(false);
setDevicesOpen(false);
setDiagnosticsOpen(false);
setSubscriptionOpen((open) => !open);
}}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 5h14v14H5zM8 9h8M8 13h5" />
</svg>
<span>Подписка</span>
</button>}
<button
ref={instructionsToggleRef}
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
type="button"
aria-expanded={instructionsOpen}
aria-controls="client-instructions"
aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'}
onClick={() => {
if (localRulesOpen && !requestCloseLocalRules()) return;
setSubscriptionOpen(false);
setDevicesOpen(false);
setDiagnosticsOpen(false);
setInstructionsOpen((open) => !open);
}}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle className="client-rail-info-ring" cx="12" cy="12" r="8.5" pathLength="1" />
<path d="M12 11v5M12 8h.01" />
</svg>
<span>Как использовать</span>
</button>
{isGateway && <button
ref={devicesToggleRef}
className={`client-instructions-toggle client-devices-toggle${devicesOpen ? ' is-open' : ''}`}
type="button"
aria-expanded={devicesOpen}
aria-controls="client-devices"
aria-label={devicesOpen ? 'Закрыть устройства' : 'Устройства Gateway'}
onClick={() => {
if (localRulesOpen && !requestCloseLocalRules()) return;
setSubscriptionOpen(false);
setInstructionsOpen(false);
setDiagnosticsOpen(false);
setDevicesOpen((open) => !open);
}}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect className="client-rail-device-primary" x="3.5" y="5" width="7" height="10" rx="1.5" />
<rect className="client-rail-device-secondary" x="13.5" y="8" width="7" height="7" rx="1.5" />
<path className="client-rail-device-link" d="M6 19h12M7 15v4M17 15v4" />
</svg>
<span>Устройства</span>
</button>}
<button
ref={diagnosticsToggleRef}
className={`client-instructions-toggle client-diagnostics-toggle${diagnosticsOpen ? ' is-open' : ''}`}
type="button"
aria-expanded={diagnosticsOpen}
aria-controls="client-diagnostics"
aria-label={diagnosticsOpen ? 'Закрыть диагностику' : 'Проверить маршруты'}
onClick={() => {
if (localRulesOpen && !requestCloseLocalRules()) return;
setSubscriptionOpen(false);
setInstructionsOpen(false);
setDevicesOpen(false);
setDiagnosticsOpen((open) => !open);
}}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path className="client-rail-diagnostics-base" d="M3 12h4l2.2-5 4.2 10 2.1-5H21" />
<path className="client-rail-diagnostics-pulse" pathLength="1" d="M3 12h4l2.2-5 4.2 10 2.1-5H21" />
</svg>
<span>Диагностика</span>
</button>
<button
ref={localRulesToggleRef}
className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}${localRulesPendingRestart ? ' has-pending' : ''}`}
type="button"
disabled={gatewayDirect || (isGateway && !hasSubscription)}
aria-expanded={localRulesOpen}
aria-controls="client-local-rules"
aria-label={gatewayDirect || (isGateway && !hasSubscription)
? hasSubscription
? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway'
: 'Локальные правила недоступны: сначала добавьте подписку'
: localRulesOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
onClick={() => localRulesOpen ? requestCloseLocalRules() : openLocalRules()}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h9M17 7h3M4 17h3M11 17h9" />
<circle className="client-rail-rule-knob is-top" cx="15" cy="7" r="2" />
<circle className="client-rail-rule-knob is-bottom" cx="9" cy="17" r="2" />
</svg>
<span>{gatewayDirect || (isGateway && !hasSubscription)
? hasSubscription
? 'Локальные правила недоступны: сейчас работают правила Gateway'
: 'Сначала добавьте подписку'
: localRulesPendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}</span>
</button>
</nav>}
<main className={`client-panel${showPower ? '' : ' is-setup'}${!isGateway && hasSubscription ? ' has-subscription' : ''}${isGateway ? ' is-gateway-home' : ''}`}>
{showPower && (
<section className="client-power-section" aria-labelledby="connection-title">
{isGateway ? <span
className="client-power-control client-tooltip-anchor"
tabIndex={powerUnavailable ? 0 : undefined}
aria-label={powerUnavailable ? 'VPN недоступен' : undefined}
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
>
{powerButton}
{powerUnavailable && <CloudTooltip id="gateway-power-unavailable">
Сначала добавьте подписку и выберите сервер
</CloudTooltip>}
</span> : powerButton}
<div className={`client-route-rules-pending${pendingLocalRulesCount ? ' is-visible' : ''}`} role="status" aria-live="polite">
{pendingLocalRulesCount > 0 && (
<>
<span>{pendingLocalRulesCount} {pendingLocalRulesCount === 1 ? 'правило не применено' : 'правила не применены'}</span>
<button type="button" disabled={connectionBlocked} onClick={onRestart}>Перезапустить VPN</button>
</>
)}
</div>
<div className="client-state-copy" aria-live="polite">
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
<span className={!connected ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
<span className={connected && !gatewayDirect ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
<span className={connected && gatewayDirect ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
</h2>
{isGateway && <div className="client-gateway-route-summary" aria-labelledby="gateway-summary-title">
<span className="client-gateway-summary-kicker">Сейчас</span>
<strong id="gateway-summary-title">
{appliedServer?.label || 'VPN-сервер не используется'}
</strong>
<div className="client-gateway-route-slot">
{switchingServer && <span>Переключаем на {desiredServer.label}</span>}
</div>
</div>}
<div className="client-state-detail">
{connected ? (
<button
className={`client-duration-toggle client-tooltip-anchor${durationMode === 'words' ? ' is-words' : ''}`}
type="button"
key="duration"
aria-label={durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
onClick={toggleDurationMode}
>
<span className="client-duration-stack">
<time
className={`client-duration${durationMode === 'digital' ? ' is-active' : ''}`}
aria-hidden={durationMode !== 'digital'}
>
<DurationPart name="hours-value">{String(duration.totalHours).padStart(2, '0')}</DurationPart>
:<DurationPart name="minutes-value">{String(duration.minutes.value).padStart(2, '0')}</DurationPart>
:<DurationPart name="seconds-value"><AnimatedSeconds value={duration.seconds.value} /></DurationPart>
</time>
<time
className={`client-duration client-duration-words${durationMode === 'words' ? ' is-active' : ''}`}
aria-hidden={durationMode !== 'words'}
>
{duration.days.value > 0 && (
<span className="client-duration-word-row is-calendar">
<span className="client-duration-unit" data-unit="days">
<DurationPart name="days-value">{duration.days.value}</DurationPart>{' '}
<DurationPart name="days-label">{duration.days.label}</DurationPart>
</span>
</span>
)}
<span className="client-duration-word-row is-clock">
{wordClockDuration.map(([name, part]) => (
<span className="client-duration-unit" data-unit={name} key={name}>
<DurationPart name={`${name}-value`}>{name === 'seconds'
? <AnimatedSeconds value={part.value} padded={false} />
: part.value}</DurationPart>{' '}
<DurationPart name={`${name}-label`}>{part.label}</DurationPart>
</span>
))}
</span>
</time>
</span>
<CloudTooltip>{durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}</CloudTooltip>
</button>
) : (
<p key="hint">
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
</p>
)}
</div>
</div>
<section className={`client-proxies${isGateway ? ' is-gateway' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}>
<div className="client-access-point">
{!isGateway && (
<span className={`client-proxy-label${gatewayDirect ? ' is-gateway' : ''}`}>
<span className={!gatewayDirect ? 'is-active' : ''}>Локальный VPN</span>
<span className={gatewayDirect ? 'is-active' : ''}>
Через <a href={state.gatewayAuto.uiOrigin || `http://${state.gatewayAuto.address}:3456`}>Harbor Gateway</a> · {state.gatewayAuto.address}
</span>
</span>
)}
<strong className="client-proxy-address">
{isGateway ? gatewayAddress : proxyUrls.http.replace(/^https?:\/\//, '')}
</strong>
<div className="client-proxy-actions">
{(isGateway ? [
['gateway', 'GATEWAY'],
['socks5', 'SOCKS5'],
['http', 'HTTP'],
] : [
['socks5', 'SOCKS5'],
['http', 'HTTP'],
]).map(([kind, label]) => (
<button
className={`client-copy-button${copyFeedback?.kind === kind ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
key={kind}
aria-label={`Скопировать ${label}: ${kind === 'gateway' ? gatewayAddress : proxyUrls[kind]}`}
onClick={() => copyProxy(kind)}
>
<span className="client-copy-label">{label}</span>
{copyFeedback?.kind === kind && <span className="client-copy-feedback" aria-hidden="true">{copyFeedback.failed ? 'Ошибка' : 'Скопировано'}</span>}
</button>
))}
</div>
</div>
</section>
<InlineError error={error} context="connection" />
<InlineProgress operations={operations} context="connection" />
</section>
)}
{isGateway && <section className="client-gateway-summary" aria-label="Общий трафик Harbor">
<div className="client-gateway-traffic-heading">
<span>Учтено Harbor</span>
<strong>{formatByteString(globalTraffic?.totalBytes || '0')}</strong>
</div>
<div className="client-gateway-traffic-chart">
<TrafficChart
samples={globalTraffic?.history || []}
capacity={deviceSnapshot?.trafficHistoryCapacity || 120}
routeLabel="Gateway"
/>
</div>
<div className={`client-gateway-traffic-freshness${trafficSourceError ? ' is-stale' : ''}`} role="status" aria-live="polite">
{trafficSourceError
? `Трафик не обновляется · последние данные ${trafficFreshness}`
: globalTraffic?.observedAt ? `Обновлено ${trafficFreshness}` : 'Ожидаем первые данные трафика'}
</div>
</section>}
<div
ref={isGateway ? subscriptionPanelRef : undefined}
id={isGateway ? 'client-subscription-drawer' : undefined}
className={isGateway
? `client-drawer client-subscription-drawer${subscriptionOpen ? ' is-open' : ''}`
: `client-form${subscriptionWaiting ? ' is-waiting' : ''}`}
aria-label={isGateway ? 'Управление подпиской' : undefined}
aria-hidden={isGateway ? !subscriptionOpen : subscriptionWaiting}
aria-disabled={gatewayDirect}
inert={(isGateway && !subscriptionOpen) || subscriptionWaiting || gatewayDirect ? true : undefined}
>
{isGateway && <button
ref={subscriptionCloseRef}
className="client-drawer-close"
type="button"
aria-label="Закрыть подписку"
onClick={() => setSubscriptionOpen(false)}
>×</button>}
<div className={`client-form-content${isGateway ? ' client-drawer-sheet client-subscription-sheet' : ''}`}>
<div
ref={subscriptionRef}
className={`client-subscription ${editingSubscription ? 'is-editing' : ''}${editingSubscription && state?.hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
>
<div
className="client-subscription-summary"
aria-hidden={editingSubscription}
inert={editingSubscription ? 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={refreshingInfo || subscriptionRefreshBlocked}
onClick={refreshSubscription}
>
<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={subscriptionDeleteBlocked}
onClick={() => setConfirmingDelete(true)}
>
<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={editingSubscription ? -1 : 0}
onClick={() => setEditingSubscription(true)}
>
<strong>{subscriptionDomain(state?.subscriptionHost)}</strong>
</button>
</div>
<form
className={`client-subscription-edit is-${subscriptionValidationStatus}`}
autoComplete="off"
aria-hidden={!editingSubscription}
inert={!editingSubscription ? true : undefined}
onSubmit={submitSubscription}
>
<input
ref={subscriptionInputRef}
id="subscription-url"
type="url"
inputMode="url"
autoComplete="off"
tabIndex={editingSubscription ? 0 : -1}
aria-label="Ссылка подписки"
placeholder="Вставьте ссылку подписки"
className={subscriptionUrl ? 'has-value' : ''}
value={subscriptionUrl}
onChange={(event) => {
if (error?.context === 'subscription') onDismissError();
setSubscriptionValidation({ url: '', status: 'idle', error: null });
setSubscriptionUrl(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === 'Escape' && state?.hasSubscription) {
setSubscriptionUrl('');
setEditingSubscription(false);
}
}}
/>
{subscriptionUrl && (
<span className="client-subscription-domain">
{subscriptionDomain(subscriptionUrl)}
</span>
)}
{normalizedSubscriptionUrl && (
<button
className="client-subscription-submit"
type="submit"
aria-live="polite"
aria-label={subscriptionValidationStatus === 'valid'
? 'Сохранить подписку'
: subscriptionValidationStatus === 'checking'
? 'Проверяем подписку'
: subscriptionError?.message || 'Ссылка подписки не распознана'}
disabled={subscriptionImportBlocked || subscriptionValidationStatus !== 'valid'}
>
{subscriptionValidationStatus === 'valid'
? '✓'
: subscriptionValidationStatus === 'checking' ? '…' : '×'}
</button>
)}
</form>
<InlineError error={subscriptionError || error} context="subscription" />
<InlineProgress operations={operations} context="subscription" />
</div>
{hasSubscription && subscriptionContentReady && 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>
)}
{hasSubscription && subscriptionContentReady && (
<ServerPicker
servers={servers}
selectedServerId={selectedServerId}
disabled={serverApplyBlocked}
prompt={!showPower}
leaving={serversLeaving}
revealVersion={serverRevealVersion}
onSelect={selectServer}
/>
)}
</div>
</div>
</main>
{(isGateway || (hasSubscription && subscriptionContentReady)) && <aside
ref={instructionsPanelRef}
id="client-instructions"
className={`client-drawer client-instructions${instructionsOpen ? ' is-open' : ''}`}
aria-labelledby="instructions-title"
aria-hidden={!instructionsOpen}
inert={!instructionsOpen ? true : undefined}
>
<div className="client-drawer-sheet client-instructions-sheet">
<button
ref={instructionsCloseRef}
className="client-drawer-close"
type="button"
aria-label="Закрыть инструкции"
onClick={() => setInstructionsOpen(false)}
>×</button>
<header className="client-instructions-header">
<span>Подключение</span>
<h2 id="instructions-title">Как использовать {isGateway ? 'Gateway' : 'прокси'}</h2>
<div className="client-instructions-intro">
{instructionsIntro.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
</div>
</header>
<div className="client-instruction-list">
{orderedInstructionGuides.map((block) => (
<InstructionBlock
block={block}
key={block.id}
open={block.id === openInstructionId}
onToggle={() => toggleInstruction(block.id)}
/>
))}
</div>
</div>
</aside>}
{isGateway && <DevicesPanel
open={devicesOpen}
panelRef={devicesPanelRef}
closeRef={devicesCloseRef}
onClose={() => setDevicesOpen(false)}
snapshot={deviceSnapshot}
status={deviceStatus}
error={deviceError}
refreshing={devicesRefreshing}
refreshCycle={deviceRefreshCycle}
onLoad={loadDevices}
onSnapshot={setDeviceSnapshot}
onError={setDeviceError}
/>}
{(isGateway || (hasSubscription && subscriptionContentReady)) && <ConnectivityDiagnosticsPanel
isGateway={isGateway}
open={diagnosticsOpen}
panelRef={diagnosticsPanelRef}
closeRef={diagnosticsCloseRef}
onClose={() => setDiagnosticsOpen(false)}
/>}
{hasSubscription && subscriptionContentReady && <LocalRulesPanel
open={localRulesOpen}
rules={localRulesDraft}
savedRules={state?.route?.localRules || []}
activeRules={state?.route?.activeLocalRules || []}
runtimeActive={connected}
blocked={routeRulesBlocked || localRulesDraft.some((rule) => rule.removing)}
dirty={localRulesDirty}
restartPending={localRulesPendingRestart}
error={error}
operations={operations}
panelRef={localRulesPanelRef}
closeRef={localRulesCloseRef}
onAdd={() => setLocalRulesDraft((rules) => [
...rules,
createLocalRuleDraft({ type: 'domain', value: '', enabled: true }),
])}
onChange={changeLocalRule}
onRemove={(index) => removeLocalRule(localRulesDraft[index]._key)}
onRemoveComplete={finishRemoveLocalRule}
onClose={requestCloseLocalRules}
onSave={saveLocalRules}
/>}
<ConfirmationPopup
open={confirmingStop}
id="stop-connection"
kicker="Защита от случайного отключения"
title="Отключить VPN?"
description="Harbor остановит текущее VPN-подключение. Локальный прокси перестанет передавать трафик до повторного включения."
cancelLabel="Оставить включённым"
confirmLabel="Отключить VPN"
busy={connectionBlocked}
onCancel={() => setConfirmingStop(false)}
onConfirm={stopConnection}
/>
<ConfirmationPopup
open={confirmingLocalRulesClose}
id="discard-local-rules"
title="Есть несохранённые настройки"
description="Закрыть редактор и потерять изменения?"
cancelLabel="Остаться"
confirmLabel="Закрыть без сохранения"
onCancel={() => setConfirmingLocalRulesClose(false)}
onConfirm={discardLocalRules}
/>
<ConfirmationPopup
open={confirmingDelete}
id="delete-subscription"
kicker="Необратимое действие"
title="Удалить подписку?"
description="Harbor остановит VPN и удалит сохранённую подписку. Приложения с локальным прокси потеряют соединение до добавления новой подписки."
cancelLabel="Отмена"
confirmLabel="Удалить"
busy={subscriptionDeleteBlocked}
onCancel={() => setConfirmingDelete(false)}
onConfirm={forgetSubscription}
/>
</div>
);
}