Refactor VPN proxy client implementation
This commit is contained in:
@@ -0,0 +1,584 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { canAppendRouteRule } from '../../../shared/routingRules.js';
|
||||
import type { RouteRule } from '../../../shared/contracts/state.js';
|
||||
import { operationBlocked } from '../../state/operations.js';
|
||||
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.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 {
|
||||
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;
|
||||
}
|
||||
|
||||
let localRuleDraftId = 0;
|
||||
|
||||
const createLocalRuleDraft = (rule: RouteRule): DraftRule => ({
|
||||
...rule,
|
||||
enabled: rule?.enabled !== false,
|
||||
_key: `route-rule-${localRuleDraftId += 1}`,
|
||||
});
|
||||
|
||||
const localRuleValues = (rules: DraftRule[]): RouteRule[] => rules
|
||||
.filter((rule) => !rule.removing)
|
||||
.map(({ type, value, enabled }) => ({ type, value, enabled }));
|
||||
|
||||
const localRulesSignature = (rules: Array<RouteRule & { removing?: boolean }>) => JSON.stringify(
|
||||
rules
|
||||
.filter((rule) => !rule.removing)
|
||||
.map(({ type, value, enabled }) => ({ type, value, enabled })),
|
||||
);
|
||||
|
||||
const localRuleKey = ({ type, value, enabled }: RouteRule) => (
|
||||
`${type}:${String(value || '').trim().toLowerCase()}:${enabled}`
|
||||
);
|
||||
|
||||
function localRuleStatus(
|
||||
rule: DraftRule,
|
||||
savedRules: RouteRule[],
|
||||
activeRules: RouteRule[],
|
||||
runtimeActive: boolean,
|
||||
) {
|
||||
const key = localRuleKey(rule);
|
||||
if (!savedRules.some((saved) => localRuleKey(saved) === key)) return ['unsaved', 'Не сохранено'];
|
||||
if (!rule.enabled) return ['disabled', 'Выключено'];
|
||||
if (!runtimeActive) return ['saved', 'Сохранено'];
|
||||
if (activeRules.some((active) => localRuleKey(active) === key)) 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 panelRef = useRef<HTMLElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const baselineRef = useRef('[]');
|
||||
const savedRules = route?.localRules || [];
|
||||
const activeRules = route?.activeLocalRules || [];
|
||||
const dirty = localRulesSignature(rules) !== baselineRef.current;
|
||||
const pendingRestart = connected && route?.localRulesPendingRestart === true;
|
||||
const pendingCount = pendingRestart
|
||||
? savedRules.filter((rule) => (
|
||||
rule.enabled && !activeRules.some((active) => localRuleKey(active) === localRuleKey(rule))
|
||||
)).length
|
||||
: 0;
|
||||
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;
|
||||
} 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(() => {
|
||||
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() {
|
||||
baselineRef.current = JSON.stringify(savedRules.map(({ type, value, enabled }) => ({ type, value, enabled })));
|
||||
setRules(savedRules.map(createLocalRuleDraft));
|
||||
setRevision(route?.localRulesRevision || 0);
|
||||
setConfirmingClose(false);
|
||||
onDismissError();
|
||||
setIsOpen(true);
|
||||
}
|
||||
|
||||
function forceClose() {
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function requestClose() {
|
||||
if (dirty) {
|
||||
setConfirmingClose(true);
|
||||
return false;
|
||||
}
|
||||
setIsOpen(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
function discard() {
|
||||
setConfirmingClose(false);
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function change(index: number, field: keyof Pick<RouteRule, 'type' | 'value' | 'enabled'>, value: unknown) {
|
||||
setRules((current) => current.map((rule, ruleIndex) => (
|
||||
ruleIndex === index ? { ...rule, [field]: value } as DraftRule : rule
|
||||
)));
|
||||
}
|
||||
|
||||
function add() {
|
||||
setRules((current) => [
|
||||
...current,
|
||||
createLocalRuleDraft({ type: 'domain', value: '', enabled: true }),
|
||||
]);
|
||||
}
|
||||
|
||||
function remove(ruleKey: string) {
|
||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
setRules((current) => current.filter((rule) => rule._key !== ruleKey));
|
||||
return;
|
||||
}
|
||||
setRules((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));
|
||||
});
|
||||
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
update();
|
||||
return;
|
||||
}
|
||||
document.startViewTransition(update);
|
||||
}
|
||||
|
||||
async function save(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const values = localRuleValues(rules);
|
||||
const result = routingSaveState(await onSave(values, revision));
|
||||
if (!result) return;
|
||||
baselineRef.current = JSON.stringify(values);
|
||||
setRevision(result.localRulesRevision);
|
||||
setConfirmingClose(false);
|
||||
if (!connected || !result.localRulesPendingRestart) setIsOpen(false);
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
rules,
|
||||
savedRules,
|
||||
activeRules,
|
||||
connected,
|
||||
dirty,
|
||||
pendingRestart,
|
||||
pendingCount,
|
||||
blocked,
|
||||
confirmingClose,
|
||||
panelRef,
|
||||
toggleRef,
|
||||
closeRef,
|
||||
open,
|
||||
forceClose,
|
||||
requestClose,
|
||||
setConfirmingClose,
|
||||
discard,
|
||||
change,
|
||||
add,
|
||||
remove,
|
||||
finishRemove,
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoutingToggle({
|
||||
feature,
|
||||
gatewayDirect,
|
||||
isGateway,
|
||||
hasSubscription,
|
||||
onOpen,
|
||||
}: {
|
||||
feature: RoutingFeature;
|
||||
gatewayDirect: boolean;
|
||||
isGateway: boolean;
|
||||
hasSubscription: boolean;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const disabled = gatewayDirect || (isGateway && !hasSubscription);
|
||||
return (
|
||||
<button
|
||||
ref={feature.toggleRef}
|
||||
className={`client-local-rules-toggle${feature.isOpen ? ' is-open' : ''}${feature.pendingRestart ? ' has-pending' : ''}`}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-expanded={feature.isOpen}
|
||||
aria-controls="client-local-rules"
|
||||
aria-label={disabled
|
||||
? hasSubscription
|
||||
? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway'
|
||||
: 'Локальные правила недоступны: сначала добавьте подписку'
|
||||
: feature.isOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
|
||||
onClick={() => feature.isOpen ? feature.requestClose() : onOpen()}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 7h9M17 7h3M4 17h3M11 17h9" />
|
||||
<circle className="client-rail-rule-knob is-top" cx="15" cy="7" r="2" />
|
||||
<circle className="client-rail-rule-knob is-bottom" cx="9" cy="17" r="2" />
|
||||
</svg>
|
||||
<span>{disabled
|
||||
? hasSubscription
|
||||
? 'Локальные правила недоступны: сейчас работают правила Gateway'
|
||||
: 'Сначала добавьте подписку'
|
||||
: feature.pendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoutingPendingStatus({
|
||||
feature,
|
||||
blocked,
|
||||
onRestart,
|
||||
}: {
|
||||
feature: RoutingFeature;
|
||||
blocked: boolean;
|
||||
onRestart: () => unknown;
|
||||
}) {
|
||||
return <div className={`client-route-rules-pending${feature.pendingCount ? ' is-visible' : ''}`} role="status" aria-live="polite">
|
||||
{feature.pendingCount > 0 && (
|
||||
<>
|
||||
<span>{feature.pendingCount} {feature.pendingCount === 1 ? 'правило не применено' : 'правила не применены'}</span>
|
||||
<button type="button" disabled={blocked} onClick={onRestart}>Перезапустить VPN</button>
|
||||
</>
|
||||
)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; statusSlot?: ReactNode }) {
|
||||
const draftRules = feature.rules.filter((rule) => !rule.removing);
|
||||
const canAdd = canAppendRouteRule(draftRules) && !feature.blocked;
|
||||
const incomplete = draftRules.some((rule) => !String(rule.value || '').trim());
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={feature.panelRef as RefObject<HTMLElement>}
|
||||
id="client-local-rules"
|
||||
className={`client-drawer client-local-rules${feature.isOpen ? ' is-open' : ''}`}
|
||||
aria-labelledby="local-rules-title"
|
||||
aria-hidden={!feature.isOpen}
|
||||
inert={!feature.isOpen ? true : undefined}
|
||||
>
|
||||
<div className="client-drawer-sheet client-local-rules-sheet">
|
||||
<button
|
||||
ref={feature.closeRef}
|
||||
className="client-drawer-close"
|
||||
type="button"
|
||||
aria-label="Закрыть локальные правила"
|
||||
onClick={feature.requestClose}
|
||||
>×</button>
|
||||
<header className="client-local-rules-header">
|
||||
<span>Маршрутизация</span>
|
||||
<button
|
||||
className="client-local-rules-save"
|
||||
type="submit"
|
||||
form="client-local-rules-form"
|
||||
disabled={feature.blocked || !feature.dirty}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<h2 id="local-rules-title">Локальные правила</h2>
|
||||
<p>Эти домены идут напрямую. Остальной трафик — через выбранный VPN.</p>
|
||||
{feature.pendingRestart && (
|
||||
<p className="client-local-rules-runtime" role="status">
|
||||
Правила сохранены, но начнут работать после запуска или перезапуска sing-box.
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={feature.save}>
|
||||
<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,
|
||||
feature.savedRules,
|
||||
feature.activeRules,
|
||||
feature.connected,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={`client-local-rule client-deletable-row is-${status}${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}`}
|
||||
key={rule._key}
|
||||
style={{ viewTransitionName: rule.removing ? 'none' : rule._key }}
|
||||
inert={rule.removing ? true : undefined}
|
||||
>
|
||||
<button
|
||||
className="client-local-rule-enabled"
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={rule.enabled}
|
||||
aria-label={`${rule.enabled ? 'Выключить' : 'Включить'} правило ${index + 1}`}
|
||||
onClick={() => feature.change(index, 'enabled', !rule.enabled)}
|
||||
>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||||
<circle cx="10" cy="10" r="6" />
|
||||
<path className="client-rule-check" d="m6.8 10.1 2.1 2.2 4.5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
<RuleTypePicker
|
||||
value={rule.type}
|
||||
ruleKey={rule._key}
|
||||
index={index}
|
||||
disabled={rule.removing}
|
||||
onChange={(type) => feature.change(index, 'type', type)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
required
|
||||
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-local-rule-delete"
|
||||
type="button"
|
||||
aria-label={`Удалить правило ${index + 1}`}
|
||||
onClick={() => feature.remove(rule._key)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<span
|
||||
className="client-delete-strike"
|
||||
aria-hidden="true"
|
||||
onAnimationEnd={() => feature.finishRemove(rule._key)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!feature.rules.length && <p className="client-local-rules-empty">Правил пока нет. Весь трафик идёт через VPN.</p>}
|
||||
</div>
|
||||
<div className="client-local-rule-add-slot">
|
||||
<button className="client-local-rule-add" type="button" disabled={!canAdd} onClick={feature.add}>
|
||||
+ Добавить правило
|
||||
</button>
|
||||
<span className={incomplete ? 'is-visible' : ''}>Сначала заполните текущее правило</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="client-local-rules-note">
|
||||
Можно вставить полный URL: Harbor сохранит только домен. Путь и параметры HTTPS недоступны для маршрутизации.
|
||||
</p>
|
||||
{statusSlot}
|
||||
<div className="client-local-rules-actions">
|
||||
<button type="button" onClick={feature.requestClose}>Отмена</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
RoutingDiscardDialog,
|
||||
RoutingPanel,
|
||||
RoutingPendingStatus,
|
||||
RoutingToggle,
|
||||
useRoutingFeature,
|
||||
} from './RoutingFeature.js';
|
||||
Reference in New Issue
Block a user