Update Harbor client and gateway functionality
This commit is contained in:
@@ -2,16 +2,37 @@ 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 } from '../../../shared/routingRules.js';
|
||||
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', 'Точный домен'],
|
||||
@@ -31,6 +52,8 @@ interface DraftRule extends RouteRule {
|
||||
}
|
||||
|
||||
interface RoutingState {
|
||||
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
|
||||
mode?: string;
|
||||
localRules?: RouteRule[];
|
||||
activeLocalRules?: RouteRule[];
|
||||
localRulesRevision?: number;
|
||||
@@ -50,39 +73,57 @@ interface RoutingSaveState {
|
||||
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 }) => ({ type, value, enabled }));
|
||||
.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 }) => ({ type, value, enabled })),
|
||||
.map(({ type, value, enabled, outbound }) => ({ type, value, enabled, outbound })),
|
||||
);
|
||||
|
||||
const localRuleKey = ({ type, value, enabled }: RouteRule) => (
|
||||
`${type}:${String(value || '').trim().toLowerCase()}:${enabled}`
|
||||
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,
|
||||
) {
|
||||
const key = localRuleKey(rule);
|
||||
if (!savedRules.some((saved) => localRuleKey(saved) === key)) return ['unsaved', 'Не сохранено'];
|
||||
if (!sameRule(rule, savedRules[index])) return ['unsaved', 'Не сохранено'];
|
||||
if (!rule.enabled) return ['disabled', 'Выключено'];
|
||||
if (bypassed) return ['bypassed', 'Обходится'];
|
||||
if (!runtimeActive) return ['saved', 'Сохранено'];
|
||||
if (activeRules.some((active) => localRuleKey(active) === key)) return ['active', 'Активно'];
|
||||
if (sameRule(rule, activeRules[index])) return ['active', 'Активно'];
|
||||
return ['pending', 'Ждёт перезапуска'];
|
||||
}
|
||||
|
||||
@@ -121,19 +162,34 @@ export function useRoutingFeature({
|
||||
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 && route?.localRulesPendingRestart === true;
|
||||
const pendingRestart = connected && !bypassed && route?.localRulesPendingRestart === true;
|
||||
const savedUntilStart = !connected && !bypassed && route?.localRulesPendingRestart === true;
|
||||
const pendingCount = pendingRestart
|
||||
? savedRules.filter((rule) => (
|
||||
rule.enabled && !activeRules.some((active) => localRuleKey(active) === localRuleKey(rule))
|
||||
)).length
|
||||
? 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(() => {
|
||||
@@ -153,6 +209,11 @@ export function useRoutingFeature({
|
||||
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;
|
||||
@@ -167,6 +228,14 @@ export function useRoutingFeature({
|
||||
};
|
||||
}, [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) => {
|
||||
@@ -178,8 +247,10 @@ export function useRoutingFeature({
|
||||
}, [isOpen, dirty]);
|
||||
|
||||
function open() {
|
||||
baselineRef.current = JSON.stringify(savedRules.map(({ type, value, enabled }) => ({ type, value, enabled })));
|
||||
setRules(savedRules.map(createLocalRuleDraft));
|
||||
const nextRules = savedRules.map(createLocalRuleDraft);
|
||||
baselineRef.current = localRulesSignature(savedRules);
|
||||
rulesRef.current = nextRules;
|
||||
setRules(nextRules);
|
||||
setRevision(route?.localRulesRevision || 0);
|
||||
setConfirmingClose(false);
|
||||
onDismissError();
|
||||
@@ -187,11 +258,13 @@ export function useRoutingFeature({
|
||||
}
|
||||
|
||||
function forceClose() {
|
||||
cancelReorder(false);
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function requestClose() {
|
||||
if (dirty) {
|
||||
const currentRules = cancelReorder(false);
|
||||
if (localRulesSignature(currentRules) !== baselineRef.current) {
|
||||
setConfirmingClose(true);
|
||||
return false;
|
||||
}
|
||||
@@ -200,36 +273,52 @@ export function useRoutingFeature({
|
||||
}
|
||||
|
||||
function discard() {
|
||||
cancelReorder(false);
|
||||
setConfirmingClose(false);
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function change(index: number, field: keyof Pick<RouteRule, 'type' | 'value' | 'enabled'>, value: unknown) {
|
||||
setRules((current) => current.map((rule, ruleIndex) => (
|
||||
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() {
|
||||
setRules((current) => [
|
||||
if (!editable || blocked) return;
|
||||
updateRules((current) => [
|
||||
...current,
|
||||
createLocalRuleDraft({ type: 'domain', value: '', enabled: true }),
|
||||
createLocalRuleDraft({ type: 'domain', value: '', enabled: true, outbound: 'direct' }),
|
||||
]);
|
||||
}
|
||||
|
||||
function remove(ruleKey: string) {
|
||||
if (!editable || blocked) return;
|
||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
setRules((current) => current.filter((rule) => rule._key !== ruleKey));
|
||||
updateRules((current) => current.filter((rule) => rule._key !== ruleKey));
|
||||
return;
|
||||
}
|
||||
setRules((current) => current.map((rule) => (
|
||||
updateRules((current) => current.map((rule) => (
|
||||
rule._key === ruleKey ? { ...rule, removing: true } : rule
|
||||
)));
|
||||
}
|
||||
|
||||
function finishRemove(ruleKey: string) {
|
||||
const update = () => flushSync(() => {
|
||||
setRules((current) => current.filter((rule) => rule._key !== ruleKey));
|
||||
updateRules((current) => current.filter((rule) => rule._key !== ruleKey));
|
||||
});
|
||||
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
update();
|
||||
@@ -240,25 +329,298 @@ export function useRoutingFeature({
|
||||
|
||||
async function save(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const values = localRuleValues(rules);
|
||||
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 = JSON.stringify(values);
|
||||
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,
|
||||
@@ -272,6 +634,14 @@ export function useRoutingFeature({
|
||||
add,
|
||||
remove,
|
||||
finishRemove,
|
||||
startPointerReorder,
|
||||
movePointerReorder,
|
||||
finishPointerReorder,
|
||||
cancelPointerReorder,
|
||||
losePointerReorder,
|
||||
handleReorderKey,
|
||||
handleReorderClick,
|
||||
handleReorderBlur,
|
||||
save,
|
||||
};
|
||||
}
|
||||
@@ -379,6 +749,32 @@ function RuleTypePicker({ value, ruleKey, index, disabled, onChange }: RuleTypeP
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -394,7 +790,7 @@ export function RoutingToggle({
|
||||
hasSubscription: boolean;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const disabled = gatewayDirect || (isGateway && !hasSubscription);
|
||||
const disabled = isGateway && !hasSubscription;
|
||||
return (
|
||||
<RailAction
|
||||
buttonRef={feature.toggleRef}
|
||||
@@ -403,15 +799,13 @@ export function RoutingToggle({
|
||||
controls="client-local-rules"
|
||||
disabled={disabled}
|
||||
ariaLabel={disabled
|
||||
? hasSubscription
|
||||
? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway'
|
||||
: 'Локальные правила недоступны: сначала добавьте подписку'
|
||||
: open ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
|
||||
? 'Правила маршрутизации недоступны: сначала добавьте подписку'
|
||||
: gatewayDirect
|
||||
? open ? 'Закрыть правила маршрутизации' : 'Настроить правила, обходящиеся Harbor Gateway'
|
||||
: open ? 'Закрыть правила маршрутизации' : 'Настроить правила маршрутизации'}
|
||||
label={disabled
|
||||
? hasSubscription
|
||||
? 'Локальные правила недоступны: сейчас работают правила Gateway'
|
||||
: 'Сначала добавьте подписку'
|
||||
: feature.pendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}
|
||||
? 'Сначала добавьте подписку'
|
||||
: feature.pendingRestart ? 'Правила ждут перезапуска' : 'Правила маршрутизации'}
|
||||
onClick={() => open ? feature.requestClose() : onOpen()}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
@@ -434,10 +828,10 @@ export function RoutingPendingStatus({
|
||||
blocked: boolean;
|
||||
onRestart: () => unknown;
|
||||
}) {
|
||||
return <div className={`client-route-rules-pending${feature.pendingCount ? ' is-visible' : ''}`} role="status" aria-live="polite">
|
||||
{feature.pendingCount > 0 && (
|
||||
return <div className={`client-route-rules-pending${feature.pendingMessage ? ' is-visible' : ''}`} role="status" aria-live="polite">
|
||||
{feature.pendingMessage && (
|
||||
<>
|
||||
<span>{feature.pendingCount} {feature.pendingCount === 1 ? 'правило не применено' : 'правила не применены'}</span>
|
||||
<span>{feature.pendingMessage}</span>
|
||||
<button type="button" disabled={blocked} onClick={onRestart}>Перезапустить VPN</button>
|
||||
</>
|
||||
)}
|
||||
@@ -446,7 +840,8 @@ export function RoutingPendingStatus({
|
||||
|
||||
export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; statusSlot?: ReactNode }) {
|
||||
const draftRules = feature.rules.filter((rule) => !rule.removing);
|
||||
const canAdd = canAppendRouteRule(draftRules) && !feature.blocked;
|
||||
const editorDisabled = feature.blocked || !feature.editable;
|
||||
const canAdd = canAppendRouteRule(draftRules) && !editorDisabled;
|
||||
const incomplete = draftRules.some((rule) => !String(rule.value || '').trim());
|
||||
|
||||
return (
|
||||
@@ -458,7 +853,7 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
|
||||
sheetClassName="client-local-rules-sheet"
|
||||
open={feature.isOpen}
|
||||
labelledBy="local-rules-title"
|
||||
closeLabel="Закрыть локальные правила"
|
||||
closeLabel="Закрыть правила маршрутизации"
|
||||
onClose={feature.requestClose}
|
||||
>
|
||||
<header className="client-local-rules-header">
|
||||
@@ -467,43 +862,92 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
|
||||
className="client-local-rules-save"
|
||||
type="submit"
|
||||
form="client-local-rules-form"
|
||||
disabled={feature.blocked || !feature.dirty}
|
||||
disabled={editorDisabled || !feature.dirty}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<h2 id="local-rules-title">Локальные правила</h2>
|
||||
<p>Эти домены идут напрямую. Остальной трафик — через выбранный VPN.</p>
|
||||
{feature.pendingRestart && (
|
||||
<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' : ''}`}
|
||||
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">
|
||||
@@ -515,7 +959,7 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
|
||||
value={rule.type}
|
||||
ruleKey={rule._key}
|
||||
index={index}
|
||||
disabled={rule.removing}
|
||||
disabled={editorDisabled || rule.removing}
|
||||
onChange={(type) => feature.change(index, 'type', type)}
|
||||
/>
|
||||
<input
|
||||
@@ -524,16 +968,17 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
|
||||
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)}
|
||||
/>
|
||||
<span className="client-local-rule-status" role="status">{statusLabel}</span>
|
||||
<button
|
||||
className="client-row-delete"
|
||||
type="button"
|
||||
aria-label={`Удалить правило ${index + 1}`}
|
||||
disabled={editorDisabled}
|
||||
onClick={() => feature.remove(rule._key)}
|
||||
>
|
||||
×
|
||||
@@ -543,10 +988,19 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
|
||||
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">Правил пока нет. Весь трафик идёт через VPN.</p>}
|
||||
{!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}>
|
||||
@@ -556,6 +1010,9 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
export const RULE_DRAG_THRESHOLD_PX = 4;
|
||||
export const RULE_EDGE_ZONE_PX = 32;
|
||||
export const RULE_REORDER_DURATION_MS = 220;
|
||||
export const RULE_DROP_DURATION_MS = 260;
|
||||
|
||||
export type RuleReorderInput = 'pointer' | 'keyboard';
|
||||
export type RuleReorderEndReason = 'drop' | 'cancel' | 'focus-leave' | 'lost-capture' | 'unmount';
|
||||
|
||||
export interface RuleReorderLifecycle {
|
||||
key: string;
|
||||
input: RuleReorderInput;
|
||||
lifted: boolean;
|
||||
}
|
||||
|
||||
export function beginRuleReorder(
|
||||
current: RuleReorderLifecycle | null,
|
||||
key: string,
|
||||
input: RuleReorderInput,
|
||||
): RuleReorderLifecycle | null {
|
||||
if (current) return null;
|
||||
return { key, input, lifted: input === 'keyboard' };
|
||||
}
|
||||
|
||||
export function endRuleReorder(
|
||||
session: RuleReorderLifecycle,
|
||||
reason: RuleReorderEndReason,
|
||||
reducedMotion = false,
|
||||
) {
|
||||
return {
|
||||
restoreOrder: reason !== 'drop' && session.lifted,
|
||||
stopAutoScroll: true as const,
|
||||
restoreFocus: !['focus-leave', 'unmount'].includes(reason),
|
||||
releasePointerCapture: session.input === 'pointer' && !['lost-capture', 'unmount'].includes(reason),
|
||||
animateDrop: reason === 'drop' && session.lifted && !reducedMotion,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldLiftRule(startY: number, currentY: number) {
|
||||
return Math.abs(currentY - startY) >= RULE_DRAG_THRESHOLD_PX;
|
||||
}
|
||||
|
||||
export function moveRule<T>(rules: readonly T[], from: number, to: number): T[] {
|
||||
if (from === to || from < 0 || to < 0 || from >= rules.length || to >= rules.length) {
|
||||
return [...rules];
|
||||
}
|
||||
const next = [...rules];
|
||||
const [rule] = next.splice(from, 1);
|
||||
next.splice(to, 0, rule);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function restoreRuleOrder<T>(
|
||||
origin: readonly T[],
|
||||
current: readonly T[],
|
||||
keyOf: (item: T) => string,
|
||||
): T[] {
|
||||
const currentByKey = new Map(current.map((item) => [keyOf(item), item]));
|
||||
const originKeys = new Set(origin.map(keyOf));
|
||||
return [
|
||||
...origin.map((item) => currentByKey.get(keyOf(item))).filter((item): item is T => item !== undefined),
|
||||
...current.filter((item) => !originKeys.has(keyOf(item))),
|
||||
];
|
||||
}
|
||||
|
||||
export function crossedRuleIndex(current: number, draggedCenterY: number, centers: readonly number[]) {
|
||||
let target = current;
|
||||
while (target > 0 && draggedCenterY < centers[target - 1]) target -= 1;
|
||||
while (target < centers.length - 1 && draggedCenterY > centers[target + 1]) target += 1;
|
||||
return target;
|
||||
}
|
||||
|
||||
export function keyboardRuleIndex(current: number, direction: -1 | 1, length: number) {
|
||||
return Math.max(0, Math.min(length - 1, current + direction));
|
||||
}
|
||||
|
||||
export function edgeScrollDelta(
|
||||
pointerY: number,
|
||||
top: number,
|
||||
bottom: number,
|
||||
zone = RULE_EDGE_ZONE_PX,
|
||||
) {
|
||||
const edgeDelta = (distance: number) => Math.min(12, Math.max(2, 12 - (distance / zone) * 10));
|
||||
if (pointerY >= top && pointerY < top + zone) return -edgeDelta(pointerY - top);
|
||||
if (pointerY <= bottom && pointerY > bottom - zone) return edgeDelta(bottom - pointerY);
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user