import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { api } from '../api.js';
import {
accessTabForKey,
connectionAction,
connectionDurationParts,
copyText,
isSubscriptionUrlValid,
localProxyUrls,
subscriptionDomain,
subscriptionDaysLeft,
subscriptionUsage,
} from '../utils/clientControls.js';
import { formatBytes } from '../utils/format.js';
import { instructionBlocks } from '../instructions.js';
import { operationBlocked } from '../state/operations.js';
import { ConfirmationPopup } from './ConfirmationPopup.jsx';
import { canAppendRouteRule } from '../../shared/routingRules.js';
import {
HARBOR_VERSIONS,
parseVersion,
versionCompatibility,
} from '../../shared/versions.js';
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
const ACCESS_TABS = [
['gateway', 'Gateway'],
['proxy', 'Gateway Proxy'],
];
function CloudTooltip({ children }) {
return {children} ;
}
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 (
{code}
{VERSION_PARTS.map(([key, label], index) => {
const tooltipId = `harbor-version-${componentKey}-${key}`;
return
{index > 0 && . }
{values[index]}
{component} · {label} {values[index]}
{description(key)}
{runtime && {runtime} }
{incompatible && Версии Gateway несовместимы. }
;
})}
);
}
function VersionDisplay({ isGateway, versionInfo }) {
const runtimeSingBox = versionInfo?.runtime?.singBox;
if (!isGateway) {
return ;
}
const backendVersion = versionInfo?.components?.gatewayBackend;
const dataplaneVersion = versionInfo?.runtime?.dataplaneVersion;
const compatibility = backendVersion && versionCompatibility({
...HARBOR_VERSIONS,
gatewayBackend: backendVersion,
});
const incompatible = compatibility && !compatibility.compatible;
return ;
}
function InlineError({ error, context }) {
if (!error || error.context !== context) return null;
return (
{error.message}
{error.correlationId && (
Код: {error.correlationId.slice(0, 8)}
)}
{error.retry && Повторить }
);
}
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 (
{active[1][1]}
);
}
function InstructionStep({ step }) {
if (typeof step === 'string') return step;
return (
<>
{step.before}
{step.link[0]}
{step.after}
>
);
}
function InstructionBlock({ block, open, onToggle }) {
return (
{block.label}
{block.title}
{block.summary}
{block.paragraphs?.map((paragraph) =>
{paragraph}
)}
{block.steps && (
{block.steps.map((step) => (
))}
)}
{block.code &&
{block.code}}
{block.note &&
{block.note}
}
);
}
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) {
const key = localRuleKey(rule);
if (!savedRules.some((saved) => localRuleKey(saved) === key)) return ['unsaved', 'Не сохранено'];
if (!rule.enabled) return ['disabled', 'Выключено'];
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 (
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);
}}
>
{ROUTE_RULE_OPTIONS[selectedIndex][1]}
{ROUTE_RULE_OPTIONS.map(([type, label], optionIndex) => (
{ 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}
))}
);
}
function LocalRulesPanel({
open,
rules,
savedRules,
activeRules,
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 (
×
Маршрутизация
Сохранить
Локальные правила
Эти домены идут напрямую. Остальной трафик — через выбранный VPN.
{restartPending && (
Правила сохранены, но начнут работать после запуска или перезапуска sing-box.
)}
);
}
function DurationPart({ name, children }) {
return (
{children}
);
}
function AnimatedSeconds({ value, padded = true }) {
return String(value).padStart(padded ? 2 : 1, '0').split('').map((digit, index) => (
{digit}
));
}
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 =
Harbor
{switchable ?
Connect
Gateway
{gatewayDirect ? 'Harbor Gateway активен' : 'Harbor Gateway доступен'}
{gatewayDirect
? 'Трафик идёт через Gateway в этой сети. Нажмите, чтобы использовать локальный VPN.'
: 'Сейчас используется локальный VPN. Нажмите, чтобы направить трафик через Gateway.'}
: {product} }
;
return (
{switchable ?
{
if (event.animationName === 'harbor-mode-float-front' && stopModeAnimationRef.current) {
stopModeAnimationRef.current = false;
setModeAnimating(false);
}
}}
onClick={() => onSetGatewayAuto(!gatewayDirect)}
>
{content}
:
{content}
}
);
}
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 showPower = hasSubscription && Boolean(selectedServerId);
const canStart = Boolean(selectedServerId || 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 [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 [localRulesOpen, setLocalRulesOpen] = useState(false);
const [localRulesDraft, setLocalRulesDraft] = useState([]);
const [localRulesRevision, setLocalRulesRevision] = useState(state?.route?.localRulesRevision || 0);
const [confirmingLocalRulesClose, setConfirmingLocalRulesClose] = 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 instructionsCloseRef = useRef(null);
const localRulesPanelRef = useRef(null);
const localRulesToggleRef = useRef(null);
const localRulesCloseRef = useRef(null);
const localRulesBaselineRef = useRef('[]');
const previousHasSubscriptionRef = useRef(hasSubscription);
const serverKey = servers.map((server) => server.id).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 = !normalizedSubscriptionUrl
? 'idle'
: isSubscriptionUrlValid(normalizedSubscriptionUrl) ? 'valid' : 'invalid';
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 pendingLocalRulesCount = (state?.route?.localRules || []).filter((rule) => (
rule.enabled && !(state?.route?.activeLocalRules || []).some((active) => localRuleKey(active) === localRuleKey(rule))
)).length;
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.id, { checking: true }])));
api.servers.pingAll()
.then((data) => {
if (cancelled) return;
setPings(Object.fromEntries((data.results || []).map((ping) => [
ping.id || ping.tag,
ping,
])));
})
.catch(() => {
if (!cancelled) {
setPings(Object.fromEntries(servers.map((server) => [server.id, { 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(() => {
if (!hasSubscription) {
setEditingSubscription(true);
setInstructionsOpen(false);
setLocalRulesOpen(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 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 (!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]);
async function toggleConnection() {
const action = connectionAction({ connected, selectedServerId, configExists: state?.configExists });
if (action?.type === 'stop') return onStop();
if (action?.type === 'apply') return onApply(action.serverId);
if (action?.type === 'restart') return onRestart();
}
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);
}
function navigateAccessTabs(event, current) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const next = accessTabForKey(ACCESS_TABS.map(([tab]) => tab), current, event.key);
setAccessTab(next);
requestAnimationFrame(() => document.getElementById(`client-access-tab-${next}`)?.focus());
}
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;
});
}
function openLocalRules() {
const rules = state?.route?.localRules || [];
setInstructionsOpen(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 (!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);
}
return (
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
{hasSubscription && subscriptionContentReady &&
{
if (localRulesOpen && !requestCloseLocalRules()) return;
setInstructionsOpen((open) => !open);
}}
>
Как использовать
}
{hasSubscription && subscriptionContentReady &&
localRulesOpen ? requestCloseLocalRules() : openLocalRules()}
>
{gatewayDirect
? 'Сейчас работают правила Gateway'
: state?.route?.localRulesPendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}
}
{showPower && (
{pendingLocalRulesCount > 0 && (
<>
{pendingLocalRulesCount} {pendingLocalRulesCount === 1 ? 'правило не применено' : 'правила не применены'}
Перезапустить VPN
>
)}
Подключение выключено
VPN включён
Gateway подключён
{connected ? (
{String(duration.totalHours).padStart(2, '0')}
:{String(duration.minutes.value).padStart(2, '0')}
:
{[
['days', duration.days],
['hours', duration.hours],
['minutes', duration.minutes],
['seconds', duration.seconds],
]
.filter(([name, part]) => part.value || name === 'seconds')
.map(([name, part]) => (
{name === 'seconds'
?
: part.value} {' '}
{part.label}
))}
{durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
) : (
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
)}
{isGateway && (
{ACCESS_TABS.map(([tab, label]) => (
setAccessTab(tab)}
onKeyDown={(event) => navigateAccessTabs(event, tab)}
>
{label}
))}
)}
{isGateway && (
{gatewayAddress}
copyProxy('gateway')}
>
КОПИРОВАТЬ
{copyFeedback?.kind === 'gateway' && {copyFeedback.failed ? 'ОШИБКА' : 'ГОТОВО'} }
)}
{!isGateway && (
Локальный VPN
Через Harbor Gateway · {state.gatewayAuto.address}
)}
{proxyUrls.http.replace(/^https?:\/\//, '')}
{[
['socks5', 'SOCKS5'],
['http', 'HTTP'],
].map(([kind, label]) => (
copyProxy(kind)}
>
{label}
{copyFeedback?.kind === kind && {copyFeedback.failed ? 'ОШИБКА' : 'ГОТОВО'} }
))}
)}
Ваша подписка
Обновить подписку
setConfirmingDelete(true)}
>
Удалить подписку
setEditingSubscription(true)}
>
{subscriptionDomain(state?.subscriptionHost)}
{hasSubscription && subscriptionContentReady && hasUsage && (
Использовано
{formatBytes(displayedUsed)}
/ {usage.total ? formatBytes(usage.total) : 'без лимита'}
{usage.percent !== null && (
)}
{usage.expiresAt && !Number.isNaN(usage.expiresAt.getTime()) && (
до {usage.expiresAt.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })}
{' · '}{subscriptionDaysLeft(usage.expiresAt)}
)}
)}
{hasSubscription && subscriptionContentReady && (
{!showPower && Выберите сервер }
{servers.map((server, index) => {
const ping = pings[server.id];
const selected = server.id === selectedServerId;
const pingText = ping?.checking
? 'Проверка…'
: ping?.ok ? `${ping.latency} ms` : 'Недоступен';
const pingClass = ping?.ok
? ping.latency < 100 ? 'good' : ping.latency < 250 ? 'medium' : 'slow'
: '';
return (
selectServer(server.id)}
>
{server.label}
{pingText}
);
})}
)}
{hasSubscription && subscriptionContentReady &&
}
{hasSubscription && subscriptionContentReady &&
rule.removing)}
dirty={localRulesDirty}
restartPending={state?.route?.localRulesPendingRestart === true}
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}
/>}
setConfirmingLocalRulesClose(false)}
onConfirm={discardLocalRules}
/>
setConfirmingDelete(false)}
onConfirm={forgetSubscription}
/>
);
}