Add enabled local routing rules and gateway version reporting
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-07-11 22:14:43 +03:00
parent a0c66edb02
commit 1304a22f1f
22 changed files with 585 additions and 188 deletions

View File

@@ -13,6 +13,7 @@ import {
import { formatBytes } from '../utils/format.js';
import { instructionBlocks } from '../instructions.js';
import { createLatestRequest, operationBlocked } from '../state/operations.js';
import { canAppendRouteRule } from '../../shared/routingRules.js';
import {
HARBOR_VERSIONS,
parseVersion,
@@ -32,7 +33,7 @@ const VERSION_PARTS = [
['hotfix', 'Hotfix'],
];
function VersionBadge({ code, component, componentKey, version, singBox, incompatible = false }) {
function VersionBadge({ code, component, componentKey, version, runtime, incompatible = false }) {
const parsed = parseVersion(version);
const values = parsed ? VERSION_PARTS.map(([key]) => parsed[key]) : ['', '', ''];
@@ -63,7 +64,7 @@ function VersionBadge({ code, component, componentKey, version, singBox, incompa
<span className="harbor-version-tooltip" id={tooltipId} role="tooltip">
<strong>{component} · {label} {values[index]}</strong>
<span>{description(key)}</span>
{singBox && <small>Runtime: sing-box {singBox}</small>}
{runtime && <small>{runtime}</small>}
{incompatible && <small className="is-warning">Версии Gateway несовместимы.</small>}
</span>
</span>
@@ -83,12 +84,13 @@ function VersionDisplay({ isGateway, versionInfo }) {
component="Mac client"
componentKey="macClient"
version={versionInfo?.components?.macClient || HARBOR_VERSIONS.macClient}
singBox={runtimeSingBox}
runtime={runtimeSingBox ? `Runtime: sing-box ${runtimeSingBox}` : null}
/>
</aside>;
}
const backendVersion = versionInfo?.components?.gatewayBackend;
const dataplaneVersion = versionInfo?.runtime?.dataplaneVersion;
const compatibility = backendVersion && versionCompatibility({
...HARBOR_VERSIONS,
gatewayBackend: backendVersion,
@@ -97,19 +99,25 @@ function VersionDisplay({ isGateway, versionInfo }) {
return <aside className="harbor-versions" aria-label="Версии Harbor Gateway">
<VersionBadge
code="C"
component="Gateway client"
component="Gateway client UI"
componentKey="gatewayClient"
version={HARBOR_VERSIONS.gatewayClient}
incompatible={incompatible}
/>
<VersionBadge
code="B"
component="Gateway backend"
component="Gateway control backend"
componentKey="gatewayBackend"
version={backendVersion}
singBox={runtimeSingBox}
incompatible={incompatible}
/>
<VersionBadge
code="D"
component="Gateway dataplane"
componentKey="gatewayDataplane"
version={dataplaneVersion}
runtime={runtimeSingBox ? `Runtime: sing-box ${runtimeSingBox}` : null}
/>
</aside>;
}
@@ -208,6 +216,106 @@ const ROUTE_RULE_PLACEHOLDERS = {
domain_keyword: 'cdn',
};
let localRuleDraftId = 0;
const createLocalRuleDraft = (rule) => ({
...rule,
enabled: rule?.enabled !== false,
_key: `route-rule-${localRuleDraftId += 1}`,
});
function RuleTypePicker({ value, ruleKey, index, disabled, onChange }) {
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) => {
if (event.type === 'keydown' && event.key !== 'Escape') return;
if (rootRef.current?.contains(event.target)) 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) {
onChange(type);
setOpen(false);
triggerRef.current?.focus();
}
function moveOption(event, offset) {
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);
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>
);
}
function LocalRulesPanel({
open,
rules,
@@ -218,9 +326,14 @@ function LocalRulesPanel({
onAdd,
onChange,
onRemove,
onRemoveComplete,
onClose,
onSave,
}) {
const activeRules = rules.filter((rule) => !rule.removing);
const canAdd = canAppendRouteRule(activeRules) && !blocked;
const incomplete = activeRules.some((rule) => !String(rule.value || '').trim());
return (
<aside
ref={panelRef}
@@ -238,29 +351,41 @@ function LocalRulesPanel({
</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>
<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">
{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)}
<div
className={`client-local-rule${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}`}
key={rule._key}
style={{ '--rule-index': index }}
inert={rule.removing ? true : undefined}
onAnimationEnd={(event) => {
if (event.animationName === 'client-local-rule-leave') {
onRemoveComplete(rule._key);
}
}}
>
<button
className="client-local-rule-enabled"
type="button"
role="switch"
aria-checked={rule.enabled}
aria-label={`${rule.enabled ? 'Выключить' : 'Включить'} правило ${index + 1}`}
onClick={() => onChange(index, 'enabled', !rule.enabled)}
>
{ROUTE_RULE_OPTIONS.map(([value, label]) => (
<option value={value} key={value}>{label}</option>
))}
</select>
<svg viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="6" />
<path 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) => onChange(index, 'type', type)}
/>
<input
type="text"
inputMode="url"
@@ -281,9 +406,14 @@ function LocalRulesPanel({
</button>
</div>
))}
{!rules.length && <p className="client-local-rules-empty">Дополнительных исключений пока нет.</p>}
{!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={onAdd}>
+ Добавить правило
</button>
<span className={incomplete ? 'is-visible' : ''}>Сначала заполните текущее правило</span>
</div>
<button className="client-local-rule-add" type="button" onClick={onAdd}>+ Добавить правило</button>
</section>
<p className="client-local-rules-note">
@@ -776,7 +906,7 @@ export function ClientOverviewPage({
function openLocalRules() {
setInstructionsOpen(false);
setLocalRulesDraft((state?.route?.localRules?.custom || []).map((rule) => ({ ...rule })));
setLocalRulesDraft((state?.route?.localRules || []).map(createLocalRuleDraft));
setLocalRulesRevision(state?.revision || 0);
setLocalRulesOpen(true);
}
@@ -789,10 +919,23 @@ export function ClientOverviewPage({
async function saveLocalRules(event) {
event.preventDefault();
if (!await onSaveRouteRules(localRulesDraft, localRulesRevision)) return;
const rules = localRulesDraft
.filter((rule) => !rule.removing)
.map(({ type, value, enabled }) => ({ type, value, enabled }));
if (!await onSaveRouteRules(rules, localRulesRevision)) return;
setLocalRulesOpen(false);
}
function removeLocalRule(ruleKey) {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setLocalRulesDraft((rules) => rules.filter((rule) => rule._key !== ruleKey));
return;
}
setLocalRulesDraft((rules) => rules.map((rule) => (
rule._key === ruleKey ? { ...rule, removing: true } : rule
)));
}
return (
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
@@ -1211,13 +1354,19 @@ export function ClientOverviewPage({
{hasSubscription && subscriptionContentReady && <LocalRulesPanel
open={localRulesOpen}
rules={localRulesDraft}
blocked={routeRulesBlocked}
blocked={routeRulesBlocked || localRulesDraft.some((rule) => rule.removing)}
error={error}
operations={operations}
panelRef={localRulesPanelRef}
onAdd={() => setLocalRulesDraft((rules) => [...rules, { type: 'domain', value: '' }])}
onAdd={() => setLocalRulesDraft((rules) => [
...rules,
createLocalRuleDraft({ type: 'domain', value: '', enabled: true }),
])}
onChange={changeLocalRule}
onRemove={(index) => setLocalRulesDraft((rules) => rules.filter((_, ruleIndex) => ruleIndex !== index))}
onRemove={(index) => removeLocalRule(localRulesDraft[index]._key)}
onRemoveComplete={(ruleKey) => setLocalRulesDraft((rules) => (
rules.filter((rule) => rule._key !== ruleKey)
))}
onClose={() => setLocalRulesOpen(false)}
onSave={saveLocalRules}
/>}