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 = { 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; onSave: (rules: RouteRule[], expectedRevision: number) => Promise; 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) => 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).state; if (!state || typeof state !== 'object' || Array.isArray(state)) { throw new TypeError('Harbor route mutation returned invalid state'); } const route = (state as Record).route; if (!route || typeof route !== 'object' || Array.isArray(route)) { throw new TypeError('Harbor route mutation returned invalid state'); } const { localRulesRevision, localRulesPendingRestart } = route as Record; 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([]); const [revision, setRevision] = useState(route?.localRulesRevision || 0); const [confirmingClose, setConfirmingClose] = useState(false); const [liftedKey, setLiftedKey] = useState(null); const [reorderAnnouncement, setReorderAnnouncement] = useState(''); const panelRef = useRef(null); const toggleRef = useRef(null); const closeRef = useRef(null); const baselineRef = useRef('[]'); const rulesRef = useRef(rules); const dragRef = useRef(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, 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) { 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('[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('.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, 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('[data-rule-key]'); const capture = event.currentTarget.closest('.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) { 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) { if (dragRef.current?.pointerId !== event.pointerId) return; dropReorder(); } function cancelPointerReorder(event: ReactPointerEvent) { if (dragRef.current?.pointerId !== event.pointerId) return; cancelReorder(); } function losePointerReorder(event: ReactPointerEvent) { 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, 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, 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, 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; 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(null); const triggerRef = useRef(null); const optionRefs = useRef>([]); 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, 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 (
{ROUTE_RULE_OPTIONS.map(([type, label], optionIndex) => ( ))}
); } 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 {label} ; } 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 ( open ? feature.requestClose() : onOpen()} > ); } export function RoutingPendingStatus({ feature, blocked, onRestart, }: { feature: RoutingFeature; blocked: boolean; onRestart: () => unknown; }) { return
{feature.pendingMessage && ( <> {feature.pendingMessage} )}
; } 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 (
Маршрутизация

Правила маршрутизации

Проверяются сверху вниз. Первое совпадение выбирает маршрут.

{!feature.editable && (

Обновите Harbor, чтобы редактировать правила.

)} {feature.bypassed && (

Локальный список сейчас обходится Harbor Gateway.

)} {!feature.bypassed && feature.pendingRestart && (

Правила сохранены, но начнут работать после запуска или перезапуска sing-box.

)} {feature.savedUntilStart && (

Правила сохранены и начнут работать после запуска Harbor.

)}
Как работают правила

Правила проверяются сверху вниз. Первое совпадение выбирает маршрут: щит означает VPN, стрелка — прямое подключение.

Правила применяются только к трафику внутри маршрутизации Harbor. Gateway в режиме «Напрямую» и Connect при активном Harbor Gateway обходят локальный список.

Можно вставить полный URL: Harbor сразу оставит только домен. Путь и параметры HTTPS для маршрутизации недоступны.

Нажмите пробел или Enter, затем используйте стрелки вверх и вниз. Повторное нажатие размещает правило, Escape отменяет. {feature.reorderAnnouncement}
Правила
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 (
{rule.enabled ? 'Отключить: правило перестанет участвовать в маршрутизации' : 'Включить: правило снова будет участвовать в маршрутизации'} feature.change(index, 'type', type)} /> 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))} /> feature.change(index, 'outbound', outbound)} />
); })} {!feature.rules.length &&

Правил пока нет. Harbor использует выбранный режим соединения.

}
Сначала заполните текущее правило
{statusSlot}
); } export function RoutingDiscardDialog({ feature }: { feature: RoutingFeature }) { return feature.setConfirmingClose(false)} onConfirm={feature.discard} />; }