Add local routing rules to Harbor
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 17s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-07-11 21:52:03 +03:00
parent 306a9b8ced
commit a0c66edb02
17 changed files with 823 additions and 26 deletions

View File

@@ -133,6 +133,7 @@ const operationProgress = {
subscriptionImport: ['subscription', 'Загружаем подписку…'],
subscriptionRefresh: ['subscription', 'Обновляем подписку…'],
subscriptionDelete: ['subscription', 'Удаляем подписку…'],
routeRules: ['routing', 'Применяем локальные правила…'],
};
function InlineProgress({ operations, context }) {
@@ -195,6 +196,111 @@ function InstructionBlock({ block, open, onToggle }) {
);
}
const ROUTE_RULE_OPTIONS = [
['domain', 'Точный домен'],
['domain_suffix', 'Суффикс'],
['domain_keyword', 'Содержит'],
];
const ROUTE_RULE_PLACEHOLDERS = {
domain: 'example.com или полный URL',
domain_suffix: 'example.org',
domain_keyword: 'cdn',
};
function LocalRulesPanel({
open,
rules,
blocked,
error,
operations,
panelRef,
onAdd,
onChange,
onRemove,
onClose,
onSave,
}) {
return (
<aside
ref={panelRef}
id="client-local-rules"
className={`client-local-rules${open ? ' is-open' : ''}`}
aria-labelledby="local-rules-title"
aria-hidden={!open}
inert={!open ? true : undefined}
>
<div className="client-local-rules-sheet">
<header className="client-local-rules-header">
<span>Маршрутизация</span>
<h2 id="local-rules-title">Локальные правила</h2>
<p>Эти домены идут напрямую. Остальной трафик через выбранный VPN.</p>
</header>
<form className="client-local-rules-form" onSubmit={onSave}>
<section className="client-local-rules-group" aria-labelledby="built-in-rules-title">
<span id="built-in-rules-title">Базовое правило</span>
<div className="client-local-rule is-built-in">
<strong>Суффикс</strong>
<code>*.ru</code>
<small>Всегда напрямую</small>
</div>
</section>
<section className="client-local-rules-group" aria-labelledby="custom-rules-title">
<span id="custom-rules-title">Ваши правила</span>
<div className="client-local-rules-list">
{rules.map((rule, index) => (
<div className="client-local-rule" key={index}>
<select
aria-label={`Тип правила ${index + 1}`}
value={rule.type}
onChange={(event) => onChange(index, 'type', event.target.value)}
>
{ROUTE_RULE_OPTIONS.map(([value, label]) => (
<option value={value} key={value}>{label}</option>
))}
</select>
<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) => onChange(index, 'value', event.target.value)}
/>
<button
type="button"
aria-label={`Удалить правило ${index + 1}`}
onClick={() => onRemove(index)}
>
×
</button>
</div>
))}
{!rules.length && <p className="client-local-rules-empty">Дополнительных исключений пока нет.</p>}
</div>
<button className="client-local-rule-add" type="button" onClick={onAdd}>+ Добавить правило</button>
</section>
<p className="client-local-rules-note">
Можно вставить полный URL: Harbor сохранит только домен. Путь и параметры HTTPS недоступны для маршрутизации.
</p>
<InlineError error={error} context="routing" />
<InlineProgress operations={operations} context="routing" />
<div className="client-local-rules-actions">
<button type="button" onClick={onClose}>Отмена</button>
<button className="is-primary" type="submit" disabled={blocked}>Сохранить</button>
</div>
</form>
</div>
</aside>
);
}
function DurationPart({ name, children }) {
return (
<span
@@ -305,6 +411,7 @@ export function ClientOverviewPage({
onRestart,
onStop,
onSetGatewayAuto,
onSaveRouteRules,
}) {
const isGateway = state?.mode === 'gateway';
const gatewayDirect = !isGateway && state?.gatewayAuto?.mode === 'gateway-direct';
@@ -335,6 +442,9 @@ export function ClientOverviewPage({
const [serverRevealVersion, setServerRevealVersion] = useState(0);
const [serversLeaving, setServersLeaving] = useState(false);
const [instructionsOpen, setInstructionsOpen] = useState(false);
const [localRulesOpen, setLocalRulesOpen] = useState(false);
const [localRulesDraft, setLocalRulesDraft] = useState([]);
const [localRulesRevision, setLocalRulesRevision] = useState(state?.revision || 0);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [openInstructionId, setOpenInstructionId] = useState('');
const subscriptionInputRef = useRef(null);
@@ -342,6 +452,8 @@ export function ClientOverviewPage({
const copyTimerRef = useRef(null);
const instructionsPanelRef = useRef(null);
const instructionsToggleRef = useRef(null);
const localRulesPanelRef = useRef(null);
const localRulesToggleRef = useRef(null);
const previousHasSubscriptionRef = useRef(hasSubscription);
const validationRequests = useRef(null);
if (!validationRequests.current) validationRequests.current = createLatestRequest();
@@ -381,6 +493,7 @@ export function ClientOverviewPage({
const subscriptionRefreshBlocked = operationBlocked(operations, 'subscriptionRefresh');
const subscriptionDeleteBlocked = operationBlocked(operations, 'subscriptionDelete');
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
const routeRulesBlocked = operationBlocked(operations, 'routeRules');
useEffect(() => {
setNow(Date.now());
@@ -498,6 +611,7 @@ export function ClientOverviewPage({
if (!hasSubscription) {
setEditingSubscription(true);
setInstructionsOpen(false);
setLocalRulesOpen(false);
}
}, [hasSubscription]);
@@ -551,6 +665,22 @@ export function ClientOverviewPage({
return () => document.removeEventListener('keydown', closeOnEscape);
}, [instructionsOpen]);
useEffect(() => {
if (!localRulesOpen) return undefined;
const closeLocalRules = (event) => {
if (event.type === 'keydown' && event.key !== 'Escape') return;
if (localRulesPanelRef.current?.contains(event.target)) return;
if (localRulesToggleRef.current?.contains(event.target)) return;
setLocalRulesOpen(false);
};
document.addEventListener('pointerdown', closeLocalRules);
document.addEventListener('keydown', closeLocalRules);
return () => {
document.removeEventListener('pointerdown', closeLocalRules);
document.removeEventListener('keydown', closeLocalRules);
};
}, [localRulesOpen]);
useEffect(() => {
if (!instructionsOpen) return undefined;
const closeInstructionsOutside = (event) => {
@@ -644,6 +774,25 @@ export function ClientOverviewPage({
});
}
function openLocalRules() {
setInstructionsOpen(false);
setLocalRulesDraft((state?.route?.localRules?.custom || []).map((rule) => ({ ...rule })));
setLocalRulesRevision(state?.revision || 0);
setLocalRulesOpen(true);
}
function changeLocalRule(index, field, value) {
setLocalRulesDraft((rules) => rules.map((rule, ruleIndex) => (
ruleIndex === index ? { ...rule, [field]: value } : rule
)));
}
async function saveLocalRules(event) {
event.preventDefault();
if (!await onSaveRouteRules(localRulesDraft, localRulesRevision)) return;
setLocalRulesOpen(false);
}
return (
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
@@ -661,7 +810,10 @@ export function ClientOverviewPage({
aria-expanded={instructionsOpen}
aria-controls="client-instructions"
aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'}
onClick={() => setInstructionsOpen((open) => !open)}
onClick={() => {
setLocalRulesOpen(false);
setInstructionsOpen((open) => !open);
}}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m3 5 7 7-7 7" />
@@ -670,6 +822,22 @@ export function ClientOverviewPage({
</svg>
<span>Как использовать</span>
</button>}
{hasSubscription && subscriptionContentReady && <button
ref={localRulesToggleRef}
className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}`}
type="button"
aria-expanded={localRulesOpen}
aria-controls="client-local-rules"
aria-label={localRulesOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
onClick={() => localRulesOpen ? setLocalRulesOpen(false) : openLocalRules()}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h9M17 7h3M4 17h3M11 17h9" />
<circle cx="15" cy="7" r="2" />
<circle cx="9" cy="17" r="2" />
</svg>
<span>Локальные правила</span>
</button>}
<main className={`client-panel${showPower ? '' : ' is-setup'}`}>
{showPower && (
<section className="client-power-section" aria-labelledby="connection-title">
@@ -1040,6 +1208,20 @@ export function ClientOverviewPage({
</div>
</aside>}
{hasSubscription && subscriptionContentReady && <LocalRulesPanel
open={localRulesOpen}
rules={localRulesDraft}
blocked={routeRulesBlocked}
error={error}
operations={operations}
panelRef={localRulesPanelRef}
onAdd={() => setLocalRulesDraft((rules) => [...rules, { type: 'domain', value: '' }])}
onChange={changeLocalRule}
onRemove={(index) => setLocalRulesDraft((rules) => rules.filter((_, ruleIndex) => ruleIndex !== index))}
onClose={() => setLocalRulesOpen(false)}
onSave={saveLocalRules}
/>}
</div>
);
}