Polish routing rules UI and normalize pasted values
Build and Deploy Gateway / build-and-push (push) Successful in 22s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-08-17 16:27:25 +03:00
parent bc86741397
commit f3be0b2fd0
11 changed files with 254 additions and 97 deletions
+52 -28
View File
@@ -12,6 +12,7 @@ import {
import { flushSync } from 'react-dom';
import {
canAppendRouteRule,
normalizeRouteRules,
ROUTE_RULES_CONTRACT_VERSION,
} from '../../../shared/routingRules.js';
import type { RouteRule } from '../../../shared/contracts/state.js';
@@ -112,6 +113,14 @@ 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,
@@ -382,27 +391,28 @@ export function useRoutingFeature({
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;
let rect = row.getBoundingClientRect();
const desiredCenter = pointerY - session.pointerOffsetY;
const baseCenter = rect.top + rect.height / 2 - session.translateY;
session.translateY = desiredCenter - baseCenter;
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 ruleRect = rows.get(rule._key)?.getBoundingClientRect();
return ruleRect ? ruleRect.top + ruleRect.height / 2 : desiredCenter;
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;
rect = row.getBoundingClientRect();
session.translateY = desiredCenter - (rect.top + rect.height / 2 - session.translateY);
session.translateY = desiredCenter - slotCenter(row);
row.style.setProperty('--client-rule-drag-y', `${session.translateY}px`);
setReorderAnnouncement(`Правило перемещено на позицию ${target + 1}`);
}
@@ -896,6 +906,14 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
Правила сохранены и начнут работать после запуска Harbor.
</p>
)}
<details className="client-local-rules-help">
<summary>Как работают правила</summary>
<div>
<p>Правила проверяются сверху вниз. Первое совпадение выбирает маршрут: щит означает VPN, стрелка прямое подключение.</p>
<p>Правила применяются только к трафику внутри маршрутизации Harbor. Gateway в режиме «Напрямую» и Connect при активном Harbor Gateway обходят локальный список.</p>
<p>Можно вставить полный URL: Harbor сразу оставит только домен. Путь и параметры HTTPS для маршрутизации недоступны.</p>
</div>
</details>
</header>
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={feature.save}>
@@ -928,6 +946,7 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
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}
@@ -954,20 +973,23 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
<circle cx="6" cy="22" r="1.6" />
</svg>
</button>
<button
className="client-local-rule-enabled"
type="button"
role="switch"
aria-checked={rule.enabled}
aria-label={`${rule.enabled ? 'Выключить' : 'Включить'} правило ${index + 1}`}
disabled={editorDisabled}
onClick={() => feature.change(index, 'enabled', !rule.enabled)}
>
<svg viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="6" />
<path className="client-rule-check" d="m6.8 10.1 2.1 2.2 4.5-5" />
</svg>
</button>
<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">
<rect className="client-rule-switch-track" x="2" y="6" width="16" height="8" rx="4" />
<circle className="client-rule-switch-thumb" cx="6" cy="10" r="2.5" />
</svg>
</button>
<Tooltip>{rule.enabled ? 'Отключить правило' : 'Включить правило'}</Tooltip>
</span>
<RuleTypePicker
value={rule.type}
ruleKey={rule._key}
@@ -986,6 +1008,11 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
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}
@@ -1008,7 +1035,10 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
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"
@@ -1028,12 +1058,6 @@ 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>
{statusSlot}
<div className="client-local-rules-actions">
<button type="button" onClick={feature.requestClose}>Отмена</button>