Files
harbor-net/src/web/components/ClientOverviewPage.jsx
Dmitriy Petrov 7a6f9a26ac
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 12s
Add runtime version reporting and display
2026-07-11 21:24:18 +03:00

1049 lines
43 KiB
JavaScript
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, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { api, validationStatusForError } from '../api.js';
import {
connectionAction,
connectionDurationParts,
copyText,
localProxyUrls,
subscriptionDomain,
subscriptionDaysLeft,
subscriptionUsage,
} from '../utils/clientControls.js';
import { formatBytes } from '../utils/format.js';
import { instructionBlocks } from '../instructions.js';
import { createLatestRequest, operationBlocked } from '../state/operations.js';
import {
HARBOR_VERSIONS,
parseVersion,
versionCompatibility,
} from '../../shared/versions.js';
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
function CloudTooltip({ children }) {
return <span className="client-tooltip" role="tooltip">{children}</span>;
}
const VERSION_PARTS = [
['major', 'Major'],
['minor', 'Minor'],
['hotfix', 'Hotfix'],
];
function VersionBadge({ code, component, componentKey, version, singBox, incompatible = false }) {
const parsed = parseVersion(version);
const values = parsed ? VERSION_PARTS.map(([key]) => parsed[key]) : ['', '', ''];
function description(key) {
if (key === 'major') {
return 'Общий уровень совместимости Harbor. При его изменении обновляются Mac и вся Gateway-инфраструктура.';
}
if (key === 'minor' && componentKey === 'macClient') {
return 'Линия Mac-клиента. Gateway может менять minor без обязательного обновления Mac.';
}
if (key === 'minor') {
return 'Линия Gateway. Gateway client и backend должны совпадать по major.minor.';
}
return `Совместимое исправление только компонента ${component}; hotfix может обновляться независимо.`;
}
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>
{singBox && <small>Runtime: sing-box {singBox}</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}
singBox={runtimeSingBox}
/>
</aside>;
}
const backendVersion = versionInfo?.components?.gatewayBackend;
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"
componentKey="gatewayClient"
version={HARBOR_VERSIONS.gatewayClient}
incompatible={incompatible}
/>
<VersionBadge
code="B"
component="Gateway backend"
componentKey="gatewayBackend"
version={backendVersion}
singBox={runtimeSingBox}
incompatible={incompatible}
/>
</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.correlationId && (
<small title={error.correlationId}>Код: {error.correlationId.slice(0, 8)}</small>
)}
{error.retry && <button type="button" onClick={error.retry}>Повторить</button>}
</div>
);
}
const operationProgress = {
connection: ['connection', 'Меняем состояние подключения…'],
serverApply: ['connection', 'Применяем сервер…'],
gatewayAuto: ['connection', 'Переключаем маршрут…'],
subscriptionImport: ['subscription', 'Загружаем подписку…'],
subscriptionRefresh: ['subscription', 'Обновляем подписку…'],
subscriptionDelete: ['subscription', 'Удаляем подписку…'],
};
function InlineProgress({ operations, context }) {
const active = Object.entries(operationProgress).find(([key, [operationContext]]) => (
operationContext === context && operations[key]?.status === 'running'
));
if (!active) return null;
return (
<div className={`client-inline-error client-operation-progress is-${context}`} role="status">
<span>{active[1][1]}</span>
</div>
);
}
function InstructionStep({ step }) {
if (typeof step === 'string') return step;
return (
<>
{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>
);
}
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 stopModeAnimationRef = useRef(false);
const product = isGateway ? 'Gateway' : 'Connect';
const switchable = !isGateway && gatewayAvailable;
const label = gatewayDirect
? 'Игнорировать Harbor Gateway и использовать локальный VPN'
: 'Использовать обнаруженный Harbor Gateway';
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">Gateway</em>
</span>
<svg className="harbor-mode-swap" viewBox="0 0 18 18" aria-hidden="true">
<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,
pendingTag,
setPendingTag,
onFetchSubscription,
onRefreshSubscription,
onForgetSubscription,
onApply,
onRestart,
onStop,
onSetGatewayAuto,
}) {
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 selectedTag = pendingTag || state?.selectedTag || '';
const showPower = hasSubscription && Boolean(selectedTag);
const canStart = Boolean(selectedTag || state?.configExists);
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 [subscriptionValidation, setSubscriptionValidation] = useState({ url: '', status: 'idle' });
const [validationAttempt, setValidationAttempt] = useState(0);
const [showIntro, setShowIntro] = useState(!hasSubscription);
const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription);
const [pings, setPings] = useState({});
const [accessTab, setAccessTab] = useState('gateway');
const [copyFeedback, setCopyFeedback] = useState(null);
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 [confirmingDelete, setConfirmingDelete] = 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 previousHasSubscriptionRef = useRef(hasSubscription);
const validationRequests = useRef(null);
if (!validationRequests.current) validationRequests.current = createLatestRequest();
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
const usage = subscriptionUsage(state?.userInfo);
const duration = connectionDurationParts(state?.singboxStartedAt, now);
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 subscriptionValidationStatus = subscriptionValidation.url === normalizedSubscriptionUrl
? subscriptionValidation.status
: normalizedSubscriptionUrl ? 'checking' : 'idle';
const subscriptionError = subscriptionValidation.url === normalizedSubscriptionUrl
? subscriptionValidation.error
: null;
const subscriptionWaiting = hasSubscription && !subscriptionContentReady;
const connectionBlocked = operationBlocked(operations, 'connection');
const serverApplyBlocked = operationBlocked(operations, 'serverApply');
const subscriptionImportBlocked = operationBlocked(operations, 'subscriptionImport');
const subscriptionRefreshBlocked = operationBlocked(operations, 'subscriptionRefresh');
const subscriptionDeleteBlocked = operationBlocked(operations, 'subscriptionDelete');
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
useEffect(() => {
setNow(Date.now());
if (!connected || !state?.singboxStartedAt) return undefined;
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [connected, state?.singboxStartedAt]);
useEffect(() => {
if (!servers.length) {
setPings({});
return undefined;
}
let cancelled = false;
setPings(Object.fromEntries(servers.map((server) => [server.tag, { checking: true }])));
api.servers.pingAll()
.then((data) => {
if (cancelled) return;
setPings(Object.fromEntries((data.results || []).map((ping) => [
String(ping.tag || '').trim(),
ping,
])));
})
.catch(() => {
if (!cancelled) {
setPings(Object.fromEntries(servers.map((server) => [server.tag, { ok: false }])));
}
});
return () => { cancelled = true; };
}, [serverKey]);
useEffect(() => {
if (editingSubscription) subscriptionInputRef.current?.focus();
}, [editingSubscription]);
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(() => {
validationRequests.current.cancel();
if (!normalizedSubscriptionUrl) {
setSubscriptionValidation({ url: '', status: 'idle' });
return undefined;
}
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'checking' });
const timer = setTimeout(() => {
validationRequests.current
.run((signal) => api.subscription.validate(normalizedSubscriptionUrl, signal))
.then((result) => {
if (!result) return;
setSubscriptionValidation({
url: normalizedSubscriptionUrl,
status: 'valid',
error: null,
});
})
.catch((validationError) => {
setSubscriptionValidation({
url: normalizedSubscriptionUrl,
status: validationStatusForError(validationError),
error: {
context: 'subscription',
message: validationError.message,
correlationId: validationError.correlationId,
retry: validationError.retryable
? () => {
setSubscriptionValidation({
url: normalizedSubscriptionUrl,
status: 'checking',
error: null,
});
setValidationAttempt((attempt) => attempt + 1);
}
: null,
},
});
});
}, 350);
return () => {
clearTimeout(timer);
validationRequests.current.cancel();
};
}, [normalizedSubscriptionUrl, validationAttempt]);
useEffect(() => {
if (!hasSubscription) {
setEditingSubscription(true);
setInstructionsOpen(false);
}
}, [hasSubscription]);
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 (!instructionsOpen) return undefined;
const closeOnEscape = (event) => {
if (event.key === 'Escape') setInstructionsOpen(false);
};
document.addEventListener('keydown', closeOnEscape);
return () => document.removeEventListener('keydown', closeOnEscape);
}, [instructionsOpen]);
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]);
async function toggleConnection() {
const action = connectionAction({ connected, selectedTag, configExists: state?.configExists });
if (action?.type === 'stop') return onStop();
if (action?.type === 'apply') return onApply(action.selectedTag);
if (action?.type === 'restart') return onRestart();
}
function selectServer(tag) {
setPendingTag(tag);
if (connected && tag) onApply(tag);
}
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);
setCopyFeedback({ kind, failed: false });
copyTimerRef.current = setTimeout(() => setCopyFeedback(null), 800);
try {
await copyText(value);
} catch {
setCopyFeedback({ kind, failed: true });
}
}
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.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;
});
}
return (
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
<HarborBrand
isGateway={isGateway}
gatewayAvailable={gatewayAvailable}
gatewayDirect={gatewayDirect}
blocked={gatewayAutoBlocked}
onSetGatewayAuto={onSetGatewayAuto}
/>
{hasSubscription && subscriptionContentReady && <button
ref={instructionsToggleRef}
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
type="button"
aria-expanded={instructionsOpen}
aria-controls="client-instructions"
aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'}
onClick={() => setInstructionsOpen((open) => !open)}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m3 5 7 7-7 7" />
<path d="m7 5 7 7-7 7" />
<path d="m11 5 7 7-7 7" />
</svg>
<span>Как использовать</span>
</button>}
<main className={`client-panel${showPower ? '' : ' is-setup'}`}>
{showPower && (
<section className="client-power-section" aria-labelledby="connection-title">
<button
className="client-power"
type="button"
role="switch"
aria-checked={connected}
aria-label={connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
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>
<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>
<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${durationMode === 'words' ? ' is-active' : ''}`}
aria-hidden={durationMode !== 'words'}
>
{[
['days', duration.days],
['hours', duration.hours],
['minutes', duration.minutes],
['seconds', duration.seconds],
]
.filter(([name, part]) => part.value || name === 'seconds')
.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>
))}
</time>
</span>
<CloudTooltip>{durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}</CloudTooltip>
</button>
) : (
<p key="hint">
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
</p>
)}
</div>
</div>
<section className={`client-proxies${isGateway ? ' has-tabs' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}>
{isGateway && (
<div className="client-access-tabs" role="tablist" aria-label="Способ подключения">
{[
['gateway', 'Gateway'],
['proxy', 'Gateway Proxy'],
].map(([tab, label]) => (
<button
className={`client-access-tab${accessTab === tab ? ' is-active' : ''}`}
type="button"
role="tab"
key={tab}
aria-selected={accessTab === tab}
onClick={() => setAccessTab(tab)}
>
{label}
</button>
))}
</div>
)}
{(isGateway ? accessTab === 'gateway' : false) ? (
<div className="client-access-point" role="tabpanel" key="gateway">
<strong className="client-proxy-address">{gatewayAddress}</strong>
<button
className={`client-copy-button${copyFeedback?.kind === 'gateway' ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
aria-label={`Скопировать Gateway: ${gatewayAddress}`}
onClick={() => copyProxy('gateway')}
>
<span className="client-copy-label">КОПИРОВАТЬ</span>
{copyFeedback?.kind === 'gateway' && <span className="client-copy-feedback">{copyFeedback.failed ? 'Error' : 'Copied'}</span>}
</button>
</div>
) : (
<div className="client-access-point" role={isGateway ? 'tabpanel' : undefined} key="proxy">
{!isGateway && (
<span className={`client-proxy-label${gatewayDirect ? ' is-gateway' : ''}`}>
{gatewayDirect
? `Через Harbor Gateway · ${state.gatewayAuto.address}`
: 'Локальный VPN'}
</span>
)}
<strong className="client-proxy-address">
{proxyUrls.http.replace(/^https?:\/\//, '')}
</strong>
<div className="client-proxy-actions">
{[
['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}: ${proxyUrls[kind]}`}
onClick={() => copyProxy(kind)}
>
<span className="client-copy-label">{label}</span>
{copyFeedback?.kind === kind && <span className="client-copy-feedback">{copyFeedback.failed ? 'Error' : 'Copied'}</span>}
</button>
))}
</div>
</div>
)}
</section>
<InlineError error={error} context="connection" />
<InlineProgress operations={operations} context="connection" />
</section>
)}
<div
className={`client-form${subscriptionWaiting ? ' is-waiting' : ''}${confirmingDelete ? ' is-confirming-delete' : ''}`}
aria-hidden={subscriptionWaiting}
aria-disabled={gatewayDirect}
inert={subscriptionWaiting || gatewayDirect ? true : undefined}
>
{confirmingDelete && <section
className="client-delete-confirmation"
role="group"
aria-labelledby="delete-subscription-title"
aria-describedby="delete-subscription-description"
>
<span>Необратимое действие</span>
<h2 id="delete-subscription-title">Удалить подписку?</h2>
<p id="delete-subscription-description">
Harbor остановит VPN и удалит сохранённую подписку. Приложения, настроенные на локальный прокси, не смогут выходить в сеть до добавления новой подписки.
</p>
<div className="client-delete-actions">
<button type="button" onClick={() => setConfirmingDelete(false)}>Отмена</button>
<button className="is-danger" type="button" disabled={subscriptionDeleteBlocked} onClick={forgetSubscription}>Удалить</button>
</div>
</section>}
<div className="client-form-content" inert={confirmingDelete ? true : undefined}>
<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) => 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-label={subscriptionValidationStatus === 'valid'
? 'Сохранить подписку'
: subscriptionValidationStatus === 'invalid'
? 'Ссылка подписки не распознана'
: subscriptionValidationStatus === 'unavailable'
? 'Проверка подписки временно недоступна'
: 'Проверяем ссылку подписки'}
disabled={subscriptionImportBlocked || subscriptionValidationStatus !== 'valid'}
>
{subscriptionValidationStatus === 'valid'
? '✓'
: subscriptionValidationStatus === 'invalid'
? '×'
: subscriptionValidationStatus === 'unavailable' ? '!' : '…'}
</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 && (
<section className="client-servers" aria-label="Выберите сервер">
{!showPower && <span className="client-server-prompt">Выберите сервер</span>}
<div
className={`client-server-grid${serversLeaving ? ' is-leaving' : ''}`}
key={`${serverKey}:${serverRevealVersion}`}
>
{servers.map((server, index) => {
const ping = pings[server.tag];
const selected = server.tag === selectedTag;
const pingText = ping?.checking
? 'Проверка…'
: ping?.ok ? `${ping.latency} ms` : 'Недоступен';
const pingClass = ping?.ok
? ping.latency < 100 ? 'good' : ping.latency < 250 ? 'medium' : 'slow'
: '';
return (
<button
className={`client-server ${selected ? 'is-selected' : ''}`}
type="button"
key={server.tag}
disabled={serverApplyBlocked}
aria-pressed={selected}
style={{ '--server-index': index }}
onClick={() => selectServer(server.tag)}
>
<strong>{server.tag}</strong>
<small className={pingClass}>{pingText}</small>
</button>
);
})}
</div>
</section>
)}
</div>
</div>
</main>
{hasSubscription && subscriptionContentReady && <aside
ref={instructionsPanelRef}
id="client-instructions"
className={`client-instructions${instructionsOpen ? ' is-open' : ''}`}
aria-labelledby="instructions-title"
aria-hidden={!instructionsOpen}
inert={!instructionsOpen ? true : undefined}
>
<div className="client-instructions-sheet">
<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>}
</div>
);
}