Files
harbor-net/src/web/features/routing/RoutingFeature.tsx
T
dokril 0a0a932057
Build and Deploy Gateway / build-and-push (push) Successful in 35s
Build and Deploy Gateway / deploy (push) Successful in 6s
Refine secondary rail icon animations
2026-08-17 17:07:25 +03:00

1097 lines
41 KiB
TypeScript

import {
useEffect,
useRef,
useState,
type FocusEvent as ReactFocusEvent,
type FormEvent,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from 'react';
import { flushSync } from 'react-dom';
import {
canAppendRouteRule,
normalizeRouteRules,
ROUTE_RULES_CONTRACT_VERSION,
} 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';
import { Tooltip } from '../../ui/Tooltip.js';
import {
beginRuleReorder,
crossedRuleIndex,
edgeScrollDelta,
endRuleReorder,
keyboardRuleIndex,
moveRule,
RULE_DROP_DURATION_MS,
RULE_REORDER_DURATION_MS,
restoreRuleOrder,
shouldLiftRule,
type RuleReorderEndReason,
type RuleReorderLifecycle,
} from './ruleReorderModel.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 {
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
mode?: string;
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;
}
interface RuleDragSession extends RuleReorderLifecycle {
originRules: DraftRule[];
pointerId: number | null;
startY: number;
latestY: number;
pointerOffsetY: number;
translateY: number;
raf: number | null;
capture: HTMLElement;
handle: HTMLButtonElement;
}
let localRuleDraftId = 0;
const createLocalRuleDraft = (rule: RouteRule): DraftRule => ({
...rule,
enabled: rule?.enabled !== false,
outbound: rule?.outbound === 'vpn' ? 'vpn' : 'direct',
_key: `route-rule-${localRuleDraftId += 1}`,
});
const localRuleValues = (rules: DraftRule[]): RouteRule[] => rules
.filter((rule) => !rule.removing)
.map(({ type, value, enabled, outbound }) => ({ type, value, enabled, outbound }));
const localRulesSignature = (rules: Array<RouteRule & { removing?: boolean }>) => JSON.stringify(
rules
.filter((rule) => !rule.removing)
.map(({ type, value, enabled, outbound }) => ({ type, value, enabled, outbound })),
);
const localRuleKey = ({ type, value, enabled, outbound }: RouteRule) => (
`${type}:${String(value || '').trim().toLowerCase()}:${enabled}:${outbound}`
);
const sameRule = (left?: RouteRule, right?: RouteRule) => (
Boolean(left && right && localRuleKey(left) === localRuleKey(right))
);
const normalizeDraftRuleValue = (rule: DraftRule, value: string) => {
try {
return normalizeRouteRules([{ ...rule, value }], { strict: true })[0]?.value || value;
} catch {
return value;
}
};
function localRuleStatus(
rule: DraftRule,
index: number,
savedRules: RouteRule[],
activeRules: RouteRule[],
runtimeActive: boolean,
bypassed: boolean,
) {
if (!sameRule(rule, savedRules[index])) return ['unsaved', 'Не сохранено'];
if (!rule.enabled) return ['disabled', 'Выключено'];
if (bypassed) return ['bypassed', 'Обходится'];
if (!runtimeActive) return ['saved', 'Сохранено'];
if (sameRule(rule, activeRules[index])) 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 [liftedKey, setLiftedKey] = useState<string | null>(null);
const [reorderAnnouncement, setReorderAnnouncement] = useState('');
const panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const baselineRef = useRef('[]');
const rulesRef = useRef<DraftRule[]>(rules);
const dragRef = useRef<RuleDragSession | null>(null);
const suppressHandleClickRef = useRef(false);
rulesRef.current = rules;
const savedRules = route?.localRules || [];
const activeRules = route?.activeLocalRules || [];
const editable = route?.rulesContractVersion === ROUTE_RULES_CONTRACT_VERSION;
const bypassed = route?.mode === 'gateway-direct';
const dirty = localRulesSignature(rules) !== baselineRef.current;
const pendingRestart = connected && !bypassed && route?.localRulesPendingRestart === true;
const savedUntilStart = !connected && !bypassed && route?.localRulesPendingRestart === true;
const pendingCount = pendingRestart
? savedRules.filter((rule, index) => rule.enabled && !sameRule(rule, activeRules[index])).length
: 0;
const reorderedOnly = pendingRestart
&& savedRules.length === activeRules.length
&& JSON.stringify(savedRules.map(localRuleKey).sort()) === JSON.stringify(activeRules.map(localRuleKey).sort());
const pendingMessage = !pendingRestart
? ''
: reorderedOnly || pendingCount === 0
? 'Изменения правил не применены'
: `${pendingCount} ${pendingCount === 1 ? 'правило не применено' : 'правила не применены'}`;
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;
if (dragRef.current?.lifted) {
keyboardEvent.preventDefault();
cancelReorder();
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(() => () => {
const session = dragRef.current;
if (session && endRuleReorder(session, 'unmount').stopAutoScroll && session.raf !== null) {
cancelAnimationFrame(session.raf);
}
dragRef.current = null;
}, []);
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() {
const nextRules = savedRules.map(createLocalRuleDraft);
baselineRef.current = localRulesSignature(savedRules);
rulesRef.current = nextRules;
setRules(nextRules);
setRevision(route?.localRulesRevision || 0);
setConfirmingClose(false);
onDismissError();
setIsOpen(true);
}
function forceClose() {
cancelReorder(false);
setIsOpen(false);
}
function requestClose() {
const currentRules = cancelReorder(false);
if (localRulesSignature(currentRules) !== baselineRef.current) {
setConfirmingClose(true);
return false;
}
setIsOpen(false);
return true;
}
function discard() {
cancelReorder(false);
setConfirmingClose(false);
setIsOpen(false);
}
function updateRules(updater: (current: DraftRule[]) => DraftRule[]) {
setRules((current) => {
const next = updater(current);
rulesRef.current = next;
return next;
});
}
function change(
index: number,
field: keyof Pick<RouteRule, 'type' | 'value' | 'enabled' | 'outbound'>,
value: unknown,
) {
if (!editable || blocked) return;
updateRules((current) => current.map((rule, ruleIndex) => (
ruleIndex === index ? { ...rule, [field]: value } as DraftRule : rule
)));
}
function add() {
if (!editable || blocked) return;
updateRules((current) => [
...current,
createLocalRuleDraft({ type: 'domain', value: '', enabled: true, outbound: 'direct' }),
]);
}
function remove(ruleKey: string) {
if (!editable || blocked) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
updateRules((current) => current.filter((rule) => rule._key !== ruleKey));
return;
}
updateRules((current) => current.map((rule) => (
rule._key === ruleKey ? { ...rule, removing: true } : rule
)));
}
function finishRemove(ruleKey: string) {
const update = () => flushSync(() => {
updateRules((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();
if (!editable || blocked) return;
if (dragRef.current) dropReorder(false);
const values = localRuleValues(rulesRef.current);
const result = routingSaveState(await onSave(values, revision));
if (!result) return;
baselineRef.current = localRulesSignature(values);
setRevision(result.localRulesRevision);
setConfirmingClose(false);
if (!connected || !result.localRulesPendingRestart) setIsOpen(false);
}
function reducedMotion() {
return matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function ruleRows() {
return new Map(Array.from(
panelRef.current?.querySelectorAll<HTMLElement>('[data-rule-key]') || [],
(row) => [row.dataset.ruleKey || '', row],
));
}
function replaceRuleOrder(next: DraftRule[], draggedRuleKey: string) {
const rows = ruleRows();
const previousTops = new Map(Array.from(rows, ([key, row]) => [key, row.getBoundingClientRect().top]));
rulesRef.current = next;
flushSync(() => setRules(next));
if (reducedMotion()) return;
for (const [key, row] of ruleRows()) {
if (key === draggedRuleKey) continue;
const previousTop = previousTops.get(key);
if (previousTop === undefined) continue;
const delta = previousTop - row.getBoundingClientRect().top;
if (!delta) continue;
row.animate(
[{ transform: `translateY(${delta}px)` }, { transform: 'translateY(0)' }],
{ duration: RULE_REORDER_DURATION_MS, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' },
);
}
}
function moveRuleTo(ruleKey: string, target: number) {
const current = rulesRef.current;
const from = current.findIndex((rule) => rule._key === ruleKey);
if (from < 0 || target === from) return from;
replaceRuleOrder(moveRule(current, from, target), ruleKey);
return target;
}
function positionPointerRule(pointerY: number) {
const session = dragRef.current;
if (!session?.lifted) return;
const list = panelRef.current?.querySelector<HTMLElement>('.client-local-rules-list');
if (!list) return;
const rows = ruleRows();
let row = rows.get(session.key);
if (!row) return;
const desiredCenter = pointerY - session.pointerOffsetY;
const listTop = list.getBoundingClientRect().top;
const slotCenter = (element: HTMLElement) => listTop + element.offsetTop + element.offsetHeight / 2;
session.translateY = desiredCenter - slotCenter(row);
row.style.setProperty('--client-rule-drag-y', `${session.translateY}px`);
const currentIndex = rulesRef.current.findIndex((rule) => rule._key === session.key);
const centers = rulesRef.current.map((rule) => {
const ruleRow = rows.get(rule._key);
return ruleRow ? slotCenter(ruleRow) : desiredCenter;
});
const target = crossedRuleIndex(currentIndex, desiredCenter, centers);
if (target === currentIndex) return;
moveRuleTo(session.key, target);
row = ruleRows().get(session.key);
if (!row) return;
session.translateY = desiredCenter - slotCenter(row);
row.style.setProperty('--client-rule-drag-y', `${session.translateY}px`);
setReorderAnnouncement(`Правило перемещено на позицию ${target + 1}`);
}
function stopAutoScroll(session: RuleDragSession) {
if (session.raf !== null) cancelAnimationFrame(session.raf);
session.raf = null;
}
function continueAutoScroll(session: RuleDragSession) {
if (session.raf !== null) return;
const tick = () => {
session.raf = null;
if (dragRef.current !== session || !session.lifted) return;
const container = panelRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const delta = edgeScrollDelta(session.latestY, rect.top, rect.bottom);
if (!delta) return;
const previousTop = container.scrollTop;
container.scrollTop += delta;
if (container.scrollTop === previousTop) return;
positionPointerRule(session.latestY);
session.raf = requestAnimationFrame(tick);
};
session.raf = requestAnimationFrame(tick);
}
function startPointerReorder(event: ReactPointerEvent<HTMLButtonElement>, ruleKey: string) {
if (event.button !== 0 || !event.isPrimary || !editable || blocked || rulesRef.current.length < 2) return;
if (dragRef.current?.input === 'keyboard') finishReorder('focus-leave');
const lifecycle = beginRuleReorder(dragRef.current, ruleKey, 'pointer');
if (!lifecycle) return;
const row = event.currentTarget.closest<HTMLElement>('[data-rule-key]');
const capture = event.currentTarget.closest<HTMLElement>('.client-local-rules-list');
if (!row || !capture) return;
const rect = row.getBoundingClientRect();
capture.setPointerCapture(event.pointerId);
dragRef.current = {
...lifecycle,
originRules: [...rulesRef.current],
pointerId: event.pointerId,
startY: event.clientY,
latestY: event.clientY,
pointerOffsetY: event.clientY - (rect.top + rect.height / 2),
translateY: 0,
raf: null,
capture,
handle: event.currentTarget,
};
}
function movePointerReorder(event: ReactPointerEvent<HTMLElement>) {
const session = dragRef.current;
if (!session || session.pointerId !== event.pointerId) return;
session.latestY = event.clientY;
if (!session.lifted) {
if (!shouldLiftRule(session.startY, event.clientY)) return;
event.preventDefault();
session.lifted = true;
flushSync(() => setLiftedKey(session.key));
setReorderAnnouncement('Правило поднято');
}
event.preventDefault();
positionPointerRule(event.clientY);
const container = panelRef.current;
const bounds = container?.getBoundingClientRect();
if (!bounds || !edgeScrollDelta(session.latestY, bounds.top, bounds.bottom)) {
stopAutoScroll(session);
} else {
continueAutoScroll(session);
}
}
function clearDraggedStyle(session: RuleDragSession, settle: boolean) {
const row = ruleRows().get(session.key);
if (!row) return;
const translateY = session.translateY;
row.style.removeProperty('--client-rule-drag-y');
if (settle && translateY && !reducedMotion()) {
row.animate(
[{ transform: `translateY(${translateY}px)` }, { transform: 'translateY(0)' }],
{ duration: RULE_DROP_DURATION_MS, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' },
);
}
}
function finishReorder(reason: RuleReorderEndReason, announce = true) {
const session = dragRef.current;
if (!session) return rulesRef.current;
const outcome = endRuleReorder(session, reason, reducedMotion());
if (outcome.stopAutoScroll) stopAutoScroll(session);
dragRef.current = null;
let nextRules = rulesRef.current;
if (outcome.restoreOrder) {
nextRules = restoreRuleOrder(session.originRules, rulesRef.current, (rule) => rule._key);
replaceRuleOrder(nextRules, session.key);
}
clearDraggedStyle(session, outcome.animateDrop);
setLiftedKey(null);
if (
outcome.releasePointerCapture
&& session.pointerId !== null
&& session.capture.hasPointerCapture(session.pointerId)
) {
session.capture.releasePointerCapture(session.pointerId);
}
if (session.lifted) {
if (reason === 'drop' && session.input === 'pointer') suppressHandleClickRef.current = true;
if (announce && reason !== 'unmount') {
setReorderAnnouncement(reason === 'drop' ? 'Правило размещено' : 'Перемещение отменено');
}
}
if (outcome.restoreFocus) session.handle.focus();
return nextRules;
}
function dropReorder(announce = true) {
return finishReorder('drop', announce);
}
function cancelReorder(announce = true) {
return finishReorder('cancel', announce);
}
function finishPointerReorder(event: ReactPointerEvent<HTMLElement>) {
if (dragRef.current?.pointerId !== event.pointerId) return;
dropReorder();
}
function cancelPointerReorder(event: ReactPointerEvent<HTMLElement>) {
if (dragRef.current?.pointerId !== event.pointerId) return;
cancelReorder();
}
function losePointerReorder(event: ReactPointerEvent<HTMLElement>) {
if (dragRef.current?.pointerId !== event.pointerId) return;
finishReorder('lost-capture');
}
function toggleKeyboardReorder(handle: HTMLButtonElement, ruleKey: string) {
const active = dragRef.current;
if (active?.key === ruleKey && active.input === 'keyboard') {
dropReorder();
return;
}
if (active?.input === 'keyboard') finishReorder('focus-leave');
const lifecycle = beginRuleReorder(dragRef.current, ruleKey, 'keyboard');
if (!lifecycle || !editable || blocked || rulesRef.current.length < 2) return;
dragRef.current = {
...lifecycle,
originRules: [...rulesRef.current],
pointerId: null,
startY: 0,
latestY: 0,
pointerOffsetY: 0,
translateY: 0,
raf: null,
capture: handle,
handle,
};
setLiftedKey(ruleKey);
setReorderAnnouncement('Правило поднято');
}
function handleReorderKey(event: ReactKeyboardEvent<HTMLButtonElement>, ruleKey: string) {
const session = dragRef.current;
const ownsSession = session?.key === ruleKey && session.input === 'keyboard';
if (!ownsSession) return;
if (event.key === 'Tab') {
finishReorder('focus-leave');
return;
}
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
cancelReorder();
return;
}
if (!['ArrowUp', 'ArrowDown'].includes(event.key)) return;
event.preventDefault();
const current = rulesRef.current.findIndex((rule) => rule._key === ruleKey);
const target = keyboardRuleIndex(current, event.key === 'ArrowUp' ? -1 : 1, rulesRef.current.length);
if (target === current) return;
moveRuleTo(ruleKey, target);
setReorderAnnouncement(`Правило перемещено на позицию ${target + 1}`);
event.currentTarget.focus();
}
function handleReorderClick(event: ReactMouseEvent<HTMLButtonElement>, ruleKey: string) {
if (suppressHandleClickRef.current) {
suppressHandleClickRef.current = false;
event.preventDefault();
event.stopPropagation();
return;
}
if (event.detail === 0) toggleKeyboardReorder(event.currentTarget, ruleKey);
}
function handleReorderBlur(event: ReactFocusEvent<HTMLButtonElement>, ruleKey: string) {
const session = dragRef.current;
if (session?.key === ruleKey && session.input === 'keyboard' && event.relatedTarget !== event.currentTarget) {
finishReorder('focus-leave');
}
}
return {
isOpen,
rules,
savedRules,
activeRules,
connected,
editable,
bypassed,
dirty,
pendingRestart,
savedUntilStart,
pendingCount,
pendingMessage,
blocked,
liftedKey,
reorderAnnouncement,
confirmingClose,
panelRef,
toggleRef,
closeRef,
open,
forceClose,
requestClose,
setConfirmingClose,
discard,
change,
add,
remove,
finishRemove,
startPointerReorder,
movePointerReorder,
finishPointerReorder,
cancelPointerReorder,
losePointerReorder,
handleReorderKey,
handleReorderClick,
handleReorderBlur,
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>
);
}
function RuleOutboundPicker({
value,
index,
disabled,
onChange,
}: {
value: RouteRule['outbound'];
index: number;
disabled?: boolean;
onChange: (value: RouteRule['outbound']) => void;
}) {
const direct = value === 'direct';
const label = direct ? 'Напрямую' : 'VPN';
return <span className="client-rule-outbound client-tooltip-anchor">
<button
className={direct ? 'is-direct' : 'is-vpn'}
type="button"
aria-label={`Маршрут правила ${index + 1}: ${label}. Переключить на ${direct ? 'VPN' : 'Напрямую'}`}
aria-pressed={direct}
disabled={disabled}
onClick={() => onChange(direct ? 'vpn' : 'direct')}
>
{direct ? <svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 12h15M14 7l5 5-5 5" />
</svg> : <svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3 19 6v5c0 4.4-2.8 8-7 10-4.2-2-7-5.6-7-10V6l7-3Z" />
<path d="m9 12 2 2 4-4" />
</svg>}
</button>
<Tooltip>{label}</Tooltip>
</span>;
}
export function RoutingToggle({
feature,
open,
gatewayDirect,
isGateway,
hasSubscription,
onOpen,
}: {
feature: RoutingFeature;
open: boolean;
gatewayDirect: boolean;
isGateway: boolean;
hasSubscription: boolean;
onOpen: () => void;
}) {
const disabled = isGateway && !hasSubscription;
return (
<RailAction
buttonRef={feature.toggleRef}
className={`client-local-rules-toggle${feature.pendingRestart ? ' has-pending' : ''}`}
open={open}
controls="client-local-rules"
disabled={disabled}
ariaLabel={disabled
? 'Правила маршрутизации недоступны: сначала добавьте подписку'
: gatewayDirect
? open ? 'Закрыть правила маршрутизации' : 'Настроить правила, обходящиеся Harbor Gateway'
: open ? 'Закрыть правила маршрутизации' : 'Настроить правила маршрутизации'}
label={disabled
? 'Сначала добавьте подписку'
: feature.pendingRestart ? 'Правила ждут перезапуска' : 'Правила маршрутизации'}
onClick={() => open ? feature.requestClose() : onOpen()}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path className="client-rail-rule-track" d="M4 7h16M4 17h16" />
<g className="client-rail-rule-knob-position is-top">
<circle className="client-rail-rule-knob is-top" cx="15" cy="7" r="2" />
</g>
<g className="client-rail-rule-knob-position is-bottom">
<circle className="client-rail-rule-knob is-bottom" cx="9" cy="17" r="2" />
</g>
</svg>
</RailAction>
);
}
export function RoutingPendingStatus({
feature,
blocked,
onRestart,
}: {
feature: RoutingFeature;
blocked: boolean;
onRestart: () => unknown;
}) {
return <div className={`client-route-rules-pending${feature.pendingMessage ? ' is-visible' : ''}`} role="status" aria-live="polite">
{feature.pendingMessage && (
<>
<span>{feature.pendingMessage}</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 editorDisabled = feature.blocked || !feature.editable;
const canAdd = canAppendRouteRule(draftRules) && !editorDisabled;
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={editorDisabled || !feature.dirty}
>
Сохранить
</button>
<h2 id="local-rules-title">Правила маршрутизации</h2>
<p>Проверяются сверху вниз. Первое совпадение выбирает маршрут.</p>
{!feature.editable && (
<p className="client-local-rules-runtime" role="status">
Обновите Harbor, чтобы редактировать правила.
</p>
)}
{feature.bypassed && (
<p className="client-local-rules-runtime" role="status">
Локальный список сейчас обходится Harbor Gateway.
</p>
)}
{!feature.bypassed && feature.pendingRestart && (
<p className="client-local-rules-runtime" role="status">
Правила сохранены, но начнут работать после запуска или перезапуска sing-box.
</p>
)}
{feature.savedUntilStart && (
<p className="client-local-rules-runtime" role="status">
Правила сохранены и начнут работать после запуска Harbor.
</p>
)}
<details className="client-local-rules-help">
<summary>Как работают правила</summary>
<div>
<p>Правила проверяются сверху вниз. Первое совпадение выбирает маршрут: щит означает VPN, стрелка прямое подключение.</p>
<p>Правила применяются только к трафику внутри маршрутизации Harbor. Gateway в режиме «Напрямую» и Connect при активном Harbor Gateway обходят локальный список.</p>
<p>Можно вставить полный URL: Harbor сразу оставит только домен. Путь и параметры HTTPS для маршрутизации недоступны.</p>
</div>
</details>
</header>
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={feature.save}>
<span id="client-rule-reorder-instructions" className="client-rule-reorder-instructions">
Нажмите пробел или Enter, затем используйте стрелки вверх и вниз. Повторное нажатие размещает правило, Escape отменяет.
</span>
<span className="client-rule-reorder-live" role="status" aria-live="polite" aria-atomic="true">
{feature.reorderAnnouncement}
</span>
<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${draftRules.length > 1 ? ' has-order-flow' : ''}`}
role="list"
aria-label="Правила применяются сверху вниз"
onPointerMove={feature.movePointerReorder}
onPointerUp={feature.finishPointerReorder}
onPointerCancel={feature.cancelPointerReorder}
onLostPointerCapture={feature.losePointerReorder}
>
{feature.rules.map((rule, index) => {
const [status, statusLabel] = localRuleStatus(
rule,
index,
feature.savedRules,
feature.activeRules,
feature.connected,
feature.bypassed,
);
const lifted = feature.liftedKey === rule._key;
const handleDisabled = editorDisabled || rule.removing || draftRules.length < 2;
return (
<div
className={`client-local-rule client-deletable-row is-${status}${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}${lifted ? ' is-dragging' : ''}`}
key={rule._key}
data-rule-key={rule._key}
data-route={rule.outbound}
role="listitem"
style={{ viewTransitionName: rule.removing ? 'none' : rule._key }}
inert={rule.removing ? true : undefined}
>
<button
className="client-rule-handle"
type="button"
aria-label={`Переместить правило, позиция ${index + 1} из ${draftRules.length}`}
aria-describedby="client-rule-reorder-instructions"
aria-pressed={lifted}
disabled={handleDisabled}
onPointerDown={(event) => feature.startPointerReorder(event, rule._key)}
onKeyDown={(event) => feature.handleReorderKey(event, rule._key)}
onClick={(event) => feature.handleReorderClick(event, rule._key)}
onBlur={(event) => feature.handleReorderBlur(event, rule._key)}
>
<svg viewBox="0 0 16 28" aria-hidden="true">
<circle cx="5" cy="5" r="1.25" />
<circle cx="11" cy="5" r="1.25" />
<circle cx="5" cy="11" r="1.25" />
<circle cx="11" cy="11" r="1.25" />
<circle cx="5" cy="17" r="1.25" />
<circle cx="11" cy="17" r="1.25" />
<circle cx="5" cy="23" r="1.25" />
<circle cx="11" cy="23" r="1.25" />
</svg>
</button>
<span className="client-local-rule-enabled-wrap client-tooltip-anchor">
<button
className="client-local-rule-enabled"
type="button"
role="switch"
aria-checked={rule.enabled}
aria-label={`${rule.enabled ? 'Отключить' : 'Включить'} правило ${index + 1}`}
disabled={editorDisabled}
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>
<Tooltip>
{rule.enabled
? 'Отключить: правило перестанет участвовать в маршрутизации'
: 'Включить: правило снова будет участвовать в маршрутизации'}
</Tooltip>
</span>
<RuleTypePicker
value={rule.type}
ruleKey={rule._key}
index={index}
disabled={editorDisabled || rule.removing}
onChange={(type) => feature.change(index, 'type', type)}
/>
<input
type="text"
inputMode="url"
autoComplete="off"
spellCheck={false}
required
disabled={editorDisabled}
aria-label={`Значение правила ${index + 1}`}
placeholder={ROUTE_RULE_PLACEHOLDERS[rule.type]}
value={rule.value}
onChange={(event) => feature.change(index, 'value', event.target.value)}
onPaste={(event) => {
event.preventDefault();
feature.change(index, 'value', normalizeDraftRuleValue(rule, event.clipboardData.getData('text')));
}}
onBlur={() => feature.change(index, 'value', normalizeDraftRuleValue(rule, rule.value))}
/>
<RuleOutboundPicker
value={rule.outbound}
index={index}
disabled={editorDisabled}
onChange={(outbound) => feature.change(index, 'outbound', outbound)}
/>
<span
className="client-local-rule-status client-tooltip-anchor"
role="status"
aria-label={`Состояние правила: ${statusLabel}`}
>
<span className="client-rule-status-dot" aria-hidden="true" />
<Tooltip>{statusLabel}</Tooltip>
</span>
<button
className="client-row-delete"
type="button"
aria-label={`Удалить правило ${index + 1}`}
disabled={editorDisabled}
onClick={() => feature.remove(rule._key)}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path className="client-row-delete-lid" d="M8 7V5h8v2m-11 0h14" />
<path d="M7 7l1 13h8l1-13M10 10v7m4-7v7" />
</svg>
</button>
<span
className="client-delete-strike"
aria-hidden="true"
onAnimationEnd={() => feature.finishRemove(rule._key)}
/>
</div>
);
})}
{!feature.rules.length && <p className="client-local-rules-empty">Правил пока нет. Harbor использует выбранный режим соединения.</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>
{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}
/>;
}