Track applied route rules separately from pending edits
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-07-11 23:07:20 +03:00
parent 65bf88bf41
commit f135ade43b
9 changed files with 215 additions and 49 deletions

View File

@@ -138,9 +138,7 @@ function InlineError({ error, context }) {
const operationProgress = {
connection: ['connection', 'Меняем состояние подключения…'],
serverApply: ['connection', 'Применяем сервер…'],
gatewayAuto: ['connection', 'Переключаем маршрут…'],
subscriptionImport: ['subscription', 'Загружаем подписку…'],
subscriptionRefresh: ['subscription', 'Обновляем подписку…'],
subscriptionDelete: ['subscription', 'Удаляем подписку…'],
routeRules: ['routing', 'Применяем локальные правила…'],
};
@@ -227,6 +225,15 @@ const localRuleValues = (rules) => rules
.filter((rule) => !rule.removing)
.map(({ type, value, enabled }) => ({ type, value, enabled }));
const localRulesSignature = (rules) => JSON.stringify(localRuleValues(rules));
const localRuleKey = ({ type, value, enabled }) => `${type}:${String(value || '').trim().toLowerCase()}:${enabled}`;
function localRuleStatus(rule, savedRules, activeRules) {
const key = localRuleKey(rule);
if (!savedRules.some((saved) => localRuleKey(saved) === key)) return ['unsaved', 'Не сохранено'];
if (!rule.enabled) return ['disabled', 'Выключено'];
if (activeRules.some((active) => localRuleKey(active) === key)) return ['active', 'Активно'];
return ['pending', 'Ждёт перезапуска'];
}
function RuleTypePicker({ value, ruleKey, index, disabled, onChange }) {
const [open, setOpen] = useState(false);
@@ -324,6 +331,8 @@ function RuleTypePicker({ value, ruleKey, index, disabled, onChange }) {
function LocalRulesPanel({
open,
rules,
savedRules,
activeRules,
blocked,
dirty,
restartPending,
@@ -337,9 +346,9 @@ function LocalRulesPanel({
onClose,
onSave,
}) {
const activeRules = rules.filter((rule) => !rule.removing);
const canAdd = canAppendRouteRule(activeRules) && !blocked;
const incomplete = activeRules.some((rule) => !String(rule.value || '').trim());
const draftRules = rules.filter((rule) => !rule.removing);
const canAdd = canAppendRouteRule(draftRules) && !blocked;
const incomplete = draftRules.some((rule) => !String(rule.value || '').trim());
return (
<aside
@@ -374,9 +383,11 @@ function LocalRulesPanel({
<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) => (
{rules.map((rule, index) => {
const [status, statusLabel] = localRuleStatus(rule, savedRules, activeRules);
return (
<div
className={`client-local-rule${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}`}
className={`client-local-rule 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}
@@ -417,6 +428,7 @@ function LocalRulesPanel({
value={rule.value}
onChange={(event) => onChange(index, 'value', event.target.value)}
/>
<span className="client-local-rule-status" role="status">{statusLabel}</span>
<button
type="button"
aria-label={`Удалить правило ${index + 1}`}
@@ -425,7 +437,8 @@ function LocalRulesPanel({
×
</button>
</div>
))}
);
})}
{!rules.length && <p className="client-local-rules-empty">Правил пока нет. Весь трафик идёт через VPN.</p>}
</div>
<div className="client-local-rule-add-slot">
@@ -561,6 +574,7 @@ export function ClientOverviewPage({
onStop,
onSetGatewayAuto,
onSaveRouteRules,
onDismissError,
}) {
const isGateway = state?.mode === 'gateway';
const gatewayDirect = !isGateway && state?.gatewayAuto?.mode === 'gateway-direct';
@@ -593,7 +607,7 @@ export function ClientOverviewPage({
const [instructionsOpen, setInstructionsOpen] = useState(false);
const [localRulesOpen, setLocalRulesOpen] = useState(false);
const [localRulesDraft, setLocalRulesDraft] = useState([]);
const [localRulesRevision, setLocalRulesRevision] = useState(state?.revision || 0);
const [localRulesRevision, setLocalRulesRevision] = useState(state?.route?.localRulesRevision || 0);
const [confirmingLocalRulesClose, setConfirmingLocalRulesClose] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [openInstructionId, setOpenInstructionId] = useState('');
@@ -646,6 +660,9 @@ export function ClientOverviewPage({
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
const routeRulesBlocked = operationBlocked(operations, 'routeRules');
const localRulesDirty = localRulesSignature(localRulesDraft) !== localRulesBaselineRef.current;
const pendingLocalRulesCount = (state?.route?.localRules || []).filter((rule) => (
rule.enabled && !(state?.route?.activeLocalRules || []).some((active) => localRuleKey(active) === localRuleKey(rule))
)).length;
useEffect(() => {
setNow(Date.now());
@@ -944,8 +961,9 @@ export function ClientOverviewPage({
setInstructionsOpen(false);
localRulesBaselineRef.current = localRulesSignature(rules);
setLocalRulesDraft(rules.map(createLocalRuleDraft));
setLocalRulesRevision(state?.revision || 0);
setLocalRulesRevision(state?.route?.localRulesRevision || 0);
setConfirmingLocalRulesClose(false);
onDismissError();
setLocalRulesOpen(true);
}
@@ -961,7 +979,7 @@ export function ClientOverviewPage({
const result = await onSaveRouteRules(rules, localRulesRevision);
if (!result) return;
localRulesBaselineRef.current = JSON.stringify(rules);
setLocalRulesRevision(result.state.revision);
setLocalRulesRevision(result.state.route.localRulesRevision);
setConfirmingLocalRulesClose(false);
if (!result.state.route.localRulesPendingRestart) setLocalRulesOpen(false);
}
@@ -1064,6 +1082,14 @@ export function ClientOverviewPage({
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
</svg>
</button>
<div className={`client-route-rules-pending${pendingLocalRulesCount ? ' is-visible' : ''}`} role="status" aria-live="polite">
{pendingLocalRulesCount > 0 && (
<>
<span>{pendingLocalRulesCount} {pendingLocalRulesCount === 1 ? 'правило не применено' : 'правила не применены'}</span>
<button type="button" disabled={connectionBlocked} onClick={onRestart}>Перезапустить VPN</button>
</>
)}
</div>
<div className="client-state-copy" aria-live="polite">
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
<span className={!connected ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
@@ -1156,9 +1182,10 @@ export function ClientOverviewPage({
<div className="client-access-point" role={isGateway ? 'tabpanel' : undefined} key="proxy">
{!isGateway && (
<span className={`client-proxy-label${gatewayDirect ? ' is-gateway' : ''}`}>
{gatewayDirect
? `Через Harbor Gateway · ${state.gatewayAuto.address}`
: 'Локальный VPN'}
<span className={!gatewayDirect ? 'is-active' : ''}>Локальный VPN</span>
<span className={gatewayDirect ? 'is-active' : ''}>
Через <a href={`http://${state.gatewayAuto.address}:8080`}>Harbor Gateway</a> · {state.gatewayAuto.address}
</span>
</span>
)}
<strong className="client-proxy-address">
@@ -1405,6 +1432,8 @@ export function ClientOverviewPage({
{hasSubscription && subscriptionContentReady && <LocalRulesPanel
open={localRulesOpen}
rules={localRulesDraft}
savedRules={state?.route?.localRules || []}
activeRules={state?.route?.activeLocalRules || []}
blocked={routeRulesBlocked || localRulesDraft.some((rule) => rule.removing)}
dirty={localRulesDirty}
restartPending={state?.route?.localRulesPendingRestart === true}