Files
harbor-net/src/web/features/routing/RoutingFeature.tsx
T
dokril 79ffff194f
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 6s
Update VPN client and gateway behavior
2026-08-11 08:50:44 +03:00

579 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
useEffect,
useRef,
useState,
type FormEvent,
type ReactNode,
} from 'react';
import { flushSync } from 'react-dom';
import { canAppendRouteRule } from '../../../shared/routingRules.js';
import type { RouteRule } from '../../../shared/contracts/state.js';
import { operationBlocked } from '../../state/operations.js';
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
import { Drawer } from '../../ui/Drawer.js';
import { RailAction } from '../../ui/RailAction.js';
const ROUTE_RULE_OPTIONS: Array<[RouteRule['type'], string]> = [
['domain', 'Точный домен'],
['domain_suffix', 'Суффикс'],
['domain_keyword', 'Содержит'],
];
const ROUTE_RULE_PLACEHOLDERS: Record<RouteRule['type'], string> = {
domain: 'example.com или полный URL',
domain_suffix: 'example.org',
domain_keyword: 'cdn',
};
interface DraftRule extends RouteRule {
_key: string;
removing?: boolean;
}
interface RoutingState {
localRules?: RouteRule[];
activeLocalRules?: RouteRule[];
localRulesRevision?: number;
localRulesPendingRestart?: boolean;
}
interface RoutingFeatureOptions {
route?: RoutingState | null;
connected: boolean;
operations: Record<string, { status?: string } | undefined>;
onSave: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
onDismissError: () => void;
}
interface RoutingSaveState {
localRulesRevision: number;
localRulesPendingRestart: boolean;
}
let localRuleDraftId = 0;
const createLocalRuleDraft = (rule: RouteRule): DraftRule => ({
...rule,
enabled: rule?.enabled !== false,
_key: `route-rule-${localRuleDraftId += 1}`,
});
const localRuleValues = (rules: DraftRule[]): RouteRule[] => rules
.filter((rule) => !rule.removing)
.map(({ type, value, enabled }) => ({ type, value, enabled }));
const localRulesSignature = (rules: Array<RouteRule & { removing?: boolean }>) => JSON.stringify(
rules
.filter((rule) => !rule.removing)
.map(({ type, value, enabled }) => ({ type, value, enabled })),
);
const localRuleKey = ({ type, value, enabled }: RouteRule) => (
`${type}:${String(value || '').trim().toLowerCase()}:${enabled}`
);
function localRuleStatus(
rule: DraftRule,
savedRules: RouteRule[],
activeRules: RouteRule[],
runtimeActive: boolean,
) {
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 routingSaveState(result: unknown): RoutingSaveState | null {
if (!result) return null;
if (!result || typeof result !== 'object' || Array.isArray(result)) {
throw new TypeError('Harbor route mutation returned invalid state');
}
const state = (result as Record<string, unknown>).state;
if (!state || typeof state !== 'object' || Array.isArray(state)) {
throw new TypeError('Harbor route mutation returned invalid state');
}
const route = (state as Record<string, unknown>).route;
if (!route || typeof route !== 'object' || Array.isArray(route)) {
throw new TypeError('Harbor route mutation returned invalid state');
}
const { localRulesRevision, localRulesPendingRestart } = route as Record<string, unknown>;
if (
!Number.isSafeInteger(localRulesRevision)
|| (localRulesRevision as number) < 0
|| typeof localRulesPendingRestart !== 'boolean'
) {
throw new TypeError('Harbor route mutation returned invalid state');
}
return { localRulesRevision: localRulesRevision as number, localRulesPendingRestart };
}
export function useRoutingFeature({
route,
connected,
operations,
onSave,
onDismissError,
}: RoutingFeatureOptions) {
const [isOpen, setIsOpen] = useState(false);
const [rules, setRules] = useState<DraftRule[]>([]);
const [revision, setRevision] = useState(route?.localRulesRevision || 0);
const [confirmingClose, setConfirmingClose] = useState(false);
const panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const baselineRef = useRef('[]');
const savedRules = route?.localRules || [];
const activeRules = route?.activeLocalRules || [];
const dirty = localRulesSignature(rules) !== baselineRef.current;
const pendingRestart = connected && route?.localRulesPendingRestart === true;
const pendingCount = pendingRestart
? savedRules.filter((rule) => (
rule.enabled && !activeRules.some((active) => localRuleKey(active) === localRuleKey(rule))
)).length
: 0;
const blocked = operationBlocked(operations, 'routeRules') || rules.some((rule) => rule.removing);
useEffect(() => {
if (!isOpen) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus());
return () => {
cancelAnimationFrame(frame);
requestAnimationFrame(() => {
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
});
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return undefined;
const closeRouting = (event: PointerEvent | KeyboardEvent) => {
if (event.type === 'keydown') {
const keyboardEvent = event as KeyboardEvent;
if (keyboardEvent.key !== 'Escape' || keyboardEvent.defaultPrevented) return;
} else {
if (panelRef.current?.contains(event.target as Node)) return;
if (toggleRef.current?.contains(event.target as Node)) return;
}
requestClose();
};
document.addEventListener('pointerdown', closeRouting);
document.addEventListener('keydown', closeRouting);
return () => {
document.removeEventListener('pointerdown', closeRouting);
document.removeEventListener('keydown', closeRouting);
};
}, [isOpen, dirty]);
useEffect(() => {
if (!isOpen || !dirty) return undefined;
const warnBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', warnBeforeUnload);
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
}, [isOpen, dirty]);
function open() {
baselineRef.current = JSON.stringify(savedRules.map(({ type, value, enabled }) => ({ type, value, enabled })));
setRules(savedRules.map(createLocalRuleDraft));
setRevision(route?.localRulesRevision || 0);
setConfirmingClose(false);
onDismissError();
setIsOpen(true);
}
function forceClose() {
setIsOpen(false);
}
function requestClose() {
if (dirty) {
setConfirmingClose(true);
return false;
}
setIsOpen(false);
return true;
}
function discard() {
setConfirmingClose(false);
setIsOpen(false);
}
function change(index: number, field: keyof Pick<RouteRule, 'type' | 'value' | 'enabled'>, value: unknown) {
setRules((current) => current.map((rule, ruleIndex) => (
ruleIndex === index ? { ...rule, [field]: value } as DraftRule : rule
)));
}
function add() {
setRules((current) => [
...current,
createLocalRuleDraft({ type: 'domain', value: '', enabled: true }),
]);
}
function remove(ruleKey: string) {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setRules((current) => current.filter((rule) => rule._key !== ruleKey));
return;
}
setRules((current) => current.map((rule) => (
rule._key === ruleKey ? { ...rule, removing: true } : rule
)));
}
function finishRemove(ruleKey: string) {
const update = () => flushSync(() => {
setRules((current) => current.filter((rule) => rule._key !== ruleKey));
});
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
update();
return;
}
document.startViewTransition(update);
}
async function save(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const values = localRuleValues(rules);
const result = routingSaveState(await onSave(values, revision));
if (!result) return;
baselineRef.current = JSON.stringify(values);
setRevision(result.localRulesRevision);
setConfirmingClose(false);
if (!connected || !result.localRulesPendingRestart) setIsOpen(false);
}
return {
isOpen,
rules,
savedRules,
activeRules,
connected,
dirty,
pendingRestart,
pendingCount,
blocked,
confirmingClose,
panelRef,
toggleRef,
closeRef,
open,
forceClose,
requestClose,
setConfirmingClose,
discard,
change,
add,
remove,
finishRemove,
save,
};
}
type RoutingFeature = ReturnType<typeof useRoutingFeature>;
interface RuleTypePickerProps {
value: RouteRule['type'];
ruleKey: string;
index: number;
disabled?: boolean;
onChange: (value: RouteRule['type']) => void;
}
function RuleTypePicker({ value, ruleKey, index, disabled, onChange }: RuleTypePickerProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
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: PointerEvent | KeyboardEvent) => {
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
if (rootRef.current?.contains(event.target as Node)) 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: RouteRule['type']) {
onChange(type);
setOpen(false);
triggerRef.current?.focus();
}
function moveOption(event: React.KeyboardEvent<HTMLButtonElement>, offset: number) {
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 as HTMLButtonElement);
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>
);
}
export function RoutingToggle({
feature,
gatewayDirect,
isGateway,
hasSubscription,
onOpen,
}: {
feature: RoutingFeature;
gatewayDirect: boolean;
isGateway: boolean;
hasSubscription: boolean;
onOpen: () => void;
}) {
const disabled = gatewayDirect || (isGateway && !hasSubscription);
return (
<RailAction
buttonRef={feature.toggleRef}
className={`client-local-rules-toggle${feature.pendingRestart ? ' has-pending' : ''}`}
open={feature.isOpen}
controls="client-local-rules"
disabled={disabled}
ariaLabel={disabled
? hasSubscription
? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway'
: 'Локальные правила недоступны: сначала добавьте подписку'
: feature.isOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
label={disabled
? hasSubscription
? 'Локальные правила недоступны: сейчас работают правила Gateway'
: 'Сначала добавьте подписку'
: feature.pendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}
onClick={() => feature.isOpen ? feature.requestClose() : onOpen()}
>
<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>
</RailAction>
);
}
export function RoutingPendingStatus({
feature,
blocked,
onRestart,
}: {
feature: RoutingFeature;
blocked: boolean;
onRestart: () => unknown;
}) {
return <div className={`client-route-rules-pending${feature.pendingCount ? ' is-visible' : ''}`} role="status" aria-live="polite">
{feature.pendingCount > 0 && (
<>
<span>{feature.pendingCount} {feature.pendingCount === 1 ? 'правило не применено' : 'правила не применены'}</span>
<button type="button" disabled={blocked} onClick={onRestart}>Перезапустить VPN</button>
</>
)}
</div>;
}
export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; statusSlot?: ReactNode }) {
const draftRules = feature.rules.filter((rule) => !rule.removing);
const canAdd = canAppendRouteRule(draftRules) && !feature.blocked;
const incomplete = draftRules.some((rule) => !String(rule.value || '').trim());
return (
<Drawer
panelRef={feature.panelRef}
closeRef={feature.closeRef}
id="client-local-rules"
className="client-local-rules"
sheetClassName="client-local-rules-sheet"
open={feature.isOpen}
labelledBy="local-rules-title"
closeLabel="Закрыть локальные правила"
onClose={feature.requestClose}
>
<header className="client-local-rules-header">
<span>Маршрутизация</span>
<button
className="client-local-rules-save"
type="submit"
form="client-local-rules-form"
disabled={feature.blocked || !feature.dirty}
>
Сохранить
</button>
<h2 id="local-rules-title">Локальные правила</h2>
<p>Эти домены идут напрямую. Остальной трафик через выбранный VPN.</p>
{feature.pendingRestart && (
<p className="client-local-rules-runtime" role="status">
Правила сохранены, но начнут работать после запуска или перезапуска sing-box.
</p>
)}
</header>
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={feature.save}>
<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">
{feature.rules.map((rule, index) => {
const [status, statusLabel] = localRuleStatus(
rule,
feature.savedRules,
feature.activeRules,
feature.connected,
);
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={() => feature.change(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) => feature.change(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) => feature.change(index, 'value', event.target.value)}
/>
<span className="client-local-rule-status" role="status">{statusLabel}</span>
<button
className="client-row-delete"
type="button"
aria-label={`Удалить правило ${index + 1}`}
onClick={() => feature.remove(rule._key)}
>
×
</button>
<span
className="client-delete-strike"
aria-hidden="true"
onAnimationEnd={() => feature.finishRemove(rule._key)}
/>
</div>
);
})}
{!feature.rules.length && <p className="client-local-rules-empty">Правил пока нет. Весь трафик идёт через VPN.</p>}
</div>
<div className="client-row-add-slot">
<button className="client-row-add" type="button" disabled={!canAdd} onClick={feature.add}>
+ Добавить правило
</button>
<span className={incomplete ? 'is-visible' : ''}>Сначала заполните текущее правило</span>
</div>
</section>
<p className="client-local-rules-note">
Можно вставить полный URL: Harbor сохранит только домен. Путь и параметры HTTPS недоступны для маршрутизации.
</p>
{statusSlot}
<div className="client-local-rules-actions">
<button type="button" onClick={feature.requestClose}>Отмена</button>
</div>
</form>
</Drawer>
);
}
export function RoutingDiscardDialog({ feature }: { feature: RoutingFeature }) {
return <ConfirmationDialog
open={feature.confirmingClose}
id="discard-local-rules"
title="Есть несохранённые настройки"
description="Закрыть редактор и потерять изменения?"
cancelLabel="Остаться"
confirmLabel="Закрыть без сохранения"
onCancel={() => feature.setConfirmingClose(false)}
onConfirm={feature.discard}
/>;
}