Files
harbor-net/src/web/features/routing/RoutingFeature.tsx
T
dokril 7c255192b0
Build and Deploy Gateway / build-and-push (push) Successful in 35s
Build and Deploy Gateway / deploy (push) Successful in 14s
Update Harbor client and gateway functionality
2026-08-17 15:23:16 +03:00

1040 lines
38 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 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,
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 {
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;
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))
);
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 rows = ruleRows();
let row = rows.get(session.key);
if (!row) return;
let rect = row.getBoundingClientRect();
const desiredCenter = pointerY - session.pointerOffsetY;
const baseCenter = rect.top + rect.height / 2 - session.translateY;
session.translateY = desiredCenter - baseCenter;
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 ruleRect = rows.get(rule._key)?.getBoundingClientRect();
return ruleRect ? ruleRect.top + ruleRect.height / 2 : desiredCenter;
});
const target = crossedRuleIndex(currentIndex, desiredCenter, centers);
if (target === currentIndex) return;
moveRuleTo(session.key, target);
row = ruleRows().get(session.key);
if (!row) return;
rect = row.getBoundingClientRect();
session.translateY = desiredCenter - (rect.top + rect.height / 2 - session.translateY);
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]');
if (!row) return;
const rect = row.getBoundingClientRect();
event.currentTarget.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,
handle: event.currentTarget,
};
}
function movePointerReorder(event: ReactPointerEvent<HTMLButtonElement>) {
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.handle.hasPointerCapture(session.pointerId)
) {
session.handle.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<HTMLButtonElement>) {
if (dragRef.current?.pointerId !== event.pointerId) return;
dropReorder();
}
function cancelPointerReorder(event: ReactPointerEvent<HTMLButtonElement>) {
if (dragRef.current?.pointerId !== event.pointerId) return;
cancelReorder();
}
function losePointerReorder(event: ReactPointerEvent<HTMLButtonElement>) {
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,
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;
}) {
return <div className="client-rule-outbound" role="group" aria-label={`Маршрут правила ${index + 1}`}>
{([['vpn', 'VPN'], ['direct', 'Напрямую']] as const).map(([outbound, label]) => (
<button
type="button"
key={outbound}
aria-pressed={value === outbound}
disabled={disabled}
onClick={() => onChange(outbound)}
>
{label}
</button>
))}
</div>;
}
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" />
<path className="client-rail-rule-scan is-top" pathLength="1" d="M4 7h16" />
<path className="client-rail-rule-scan is-bottom" pathLength="1" d="M4 17h16" />
<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.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>
)}
</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">
{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}
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)}
onPointerMove={feature.movePointerReorder}
onPointerUp={feature.finishPointerReorder}
onPointerCancel={feature.cancelPointerReorder}
onLostPointerCapture={feature.losePointerReorder}
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 12 28" aria-hidden="true">
<circle cx="6" cy="6" r="1.6" />
<circle cx="6" cy="14" r="1.6" />
<circle cx="6" cy="22" r="1.6" />
</svg>
</button>
<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>
<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)}
/>
<button
className="client-row-delete"
type="button"
aria-label={`Удалить правило ${index + 1}`}
disabled={editorDisabled}
onClick={() => feature.remove(rule._key)}
>
×
</button>
<span
className="client-delete-strike"
aria-hidden="true"
onAnimationEnd={() => feature.finishRemove(rule._key)}
/>
<div className="client-local-rule-meta">
<RuleOutboundPicker
value={rule.outbound}
index={index}
disabled={editorDisabled}
onChange={(outbound) => feature.change(index, 'outbound', outbound)}
/>
<span className="client-local-rule-status" role="status">{statusLabel}</span>
</div>
</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>
<p className="client-local-rules-note">
Правила применяются только к трафику, который вошёл в VPN-маршрутизацию Harbor. Устройство Gateway в режиме «Напрямую» и Connect при активном Harbor Gateway обходят локальный список; «Напрямую» внутри правила результат уже найденного совпадения.
</p>
<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}
/>;
}