1108 lines
42 KiB
TypeScript
1108 lines
42 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 [helpOpen, setHelpOpen] = useState(false);
|
|
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>
|
|
{!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>
|
|
)}
|
|
<div className={`client-local-rules-help${helpOpen ? ' is-open' : ''}`}>
|
|
<button
|
|
type="button"
|
|
aria-expanded={helpOpen}
|
|
aria-controls="client-local-rules-help-content"
|
|
onClick={() => setHelpOpen((current) => !current)}
|
|
>
|
|
Как работают правила
|
|
</button>
|
|
<div className="client-local-rules-help-reveal" aria-hidden={!helpOpen}>
|
|
<div id="client-local-rules-help-content" className="client-local-rules-help-content">
|
|
<div className="client-local-rules-help-copy">
|
|
<p>Правила проверяются сверху вниз. Первое совпадение выбирает маршрут: щит означает VPN, стрелка — прямое подключение.</p>
|
|
<p>Правила применяются только к трафику внутри маршрутизации Harbor. Gateway в режиме «Напрямую» и Connect при активном Harbor Gateway обходят локальный список.</p>
|
|
<p>Можно вставить полный URL: Harbor сразу оставит только домен. Путь и параметры HTTPS для маршрутизации недоступны.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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}
|
|
/>;
|
|
}
|