Improve local rules persistence and dirty-state handling
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-07-11 22:37:04 +03:00
parent 1304a22f1f
commit 387cc273e8
9 changed files with 256 additions and 38 deletions

View File

@@ -72,8 +72,10 @@ Use exponential ease-out curves such as `cubic-bezier(0.16, 1, 0.3, 1)` for arri
## Dynamic editors
- Reveal added rows with opacity, blur, and a small transform while keeping surrounding geometry predictable.
- Give removal its own exit state and keep the row mounted until `animationend`; under reduced motion, remove it immediately.
- Keep interactive add latency constant regardless of collection length. Never multiply an added row's delay by its index; use bounded staggering only for a one-time group reveal.
- Give removal its own exit state and keep the row mounted until `animationend`; then animate surviving rows into their new positions instead of letting layout snap. Under reduced motion, remove it immediately.
- Do not let repeated add actions accumulate unfinished rows. Disable add while any current row lacks its required value and explain the disabled state in a reserved hint slot.
- Track the editor's dirty draft against its open/save baseline. Guard Escape, outside click, navigation controls, Cancel, and page unload; use an inline discard confirmation for in-app exits.
- Replace browser-native dropdowns when their platform chrome conflicts with the client surface. Use an accessible custom listbox with trigger, selected state, outside-click and Escape closing, arrow-key navigation, and restored trigger focus.
- Let picker options appear as a short staggered cloud using opacity, blur, and transform. Avoid borders, shadows, raised cards, and layout-property animation.

View File

@@ -127,6 +127,8 @@ VPN_PROXY_CLIENT_UI_PORT=3457 \
Полный URL можно вставить в поле точного домена, но Harbor сохранит только hostname. Путь и параметры HTTPS зашифрованы и недоступны sing-box на уровне маршрутизации. GeoSite, GeoIP и подключаемые списки пока не поддерживаются.
При сохранении Harbor проверяет фактическое состояние sing-box. Работающий процесс автоматически перезагружает новую конфигурацию. Если sing-box остановлен, правила сохраняются с признаком «ждут перезапуска» и начнут работать при следующем запуске или restart; этот статус виден в интерфейсе.
## Системный прокси macOS
Сначала посмотрите точное имя сетевого подключения:

View File

@@ -380,11 +380,14 @@ async function applySelectedServer(selectedTag, { persist = true } = {}) {
else restoreSingboxConfig(previousConfig);
throw error;
}
if (persist) {
if (persist || stateStore.read().routeRulesPendingRestart) {
updateStoredState((state) => ({
...state,
...(persist ? {
appliedTag: selectedTag,
appliedAt: new Date().toISOString(),
} : {}),
routeRulesPendingRestart: false,
}));
}
}
@@ -393,18 +396,26 @@ async function applyRouteRules(routeRules) {
const state = normalizeStoredState(stateStore.read());
const cached = readSubscriptionCache();
if (!state.selectedTag || !cached?.config) {
updateStoredState((current) => ({ ...current, routeRules }));
updateStoredState((current) => ({
...current,
routeRules,
routeRulesPendingRestart: true,
}));
return;
}
const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8')
: null;
const wasRunning = singboxRuntime.running;
const wasRunning = Boolean((await singboxRuntime.refresh()).running);
try {
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedTag, routeRules));
if (wasRunning) await startSingbox();
updateStoredState((current) => ({ ...current, routeRules }));
updateStoredState((current) => ({
...current,
routeRules,
routeRulesPendingRestart: !wasRunning,
}));
} catch (error) {
if (previousConfig === null) removeSingboxConfig();
else restoreSingboxConfig(previousConfig);
@@ -656,6 +667,7 @@ async function handleApi(req, res) {
...state,
appliedTag: state.selectedTag,
connectionDesired: 'running',
routeRulesPendingRestart: false,
}));
}));
return sendState(res, { singboxRunning: true });
@@ -713,7 +725,13 @@ await refreshGatewayAutoMode({ reconfigure: false })
if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) {
writeCurrentConfig();
}
await startSingbox().catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`));
await startSingbox()
.then(() => {
if (fs.existsSync(settings.configPath) && stateStore.read().routeRulesPendingRestart) {
updateStoredState((state) => ({ ...state, routeRulesPendingRestart: false }));
}
})
.catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`));
server.listen(settings.port, '0.0.0.0', () => {
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);

View File

@@ -20,6 +20,7 @@ export function normalizeStoredState(value) {
appliedTag: Object.hasOwn(state, 'appliedTag') ? text(state.appliedTag) : selectedTag,
servers: Array.isArray(state.servers) ? state.servers : [],
routeRules: normalizeRouteRules(state.routeRules),
routeRulesPendingRestart: state.routeRulesPendingRestart === true,
};
}
@@ -84,6 +85,7 @@ export function createStateSnapshot({
lastVerifiedAt: null,
reason: mode === 'client' && stored.gatewayAutoEnabled !== false ? 'auto' : 'manual',
localRules: stored.routeRules,
localRulesPendingRestart: stored.routeRulesPendingRestart,
},
operation: {
kind: nullableText(operation.kind),
@@ -177,6 +179,7 @@ export function assertStateSnapshot(snapshot) {
typeof snapshot.route.reason !== 'string' ||
!Array.isArray(snapshot.route.localRules) ||
!snapshot.route.localRules.every(validRouteRule) ||
typeof snapshot.route.localRulesPendingRestart !== 'boolean' ||
!snapshot.operation ||
!nullableString(snapshot.operation.kind) ||
!OPERATION_STATES.has(snapshot.operation.status) ||

View File

@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.4.0',
gatewayClient: '0.4.0',
gatewayBackend: '0.4.0',
macClient: '0.5.0',
gatewayClient: '0.5.0',
gatewayBackend: '0.5.0',
});
export function parseVersion(value) {

View File

@@ -222,6 +222,10 @@ const createLocalRuleDraft = (rule) => ({
enabled: rule?.enabled !== false,
_key: `route-rule-${localRuleDraftId += 1}`,
});
const localRuleValues = (rules) => rules
.filter((rule) => !rule.removing)
.map(({ type, value, enabled }) => ({ type, value, enabled }));
const localRulesSignature = (rules) => JSON.stringify(localRuleValues(rules));
function RuleTypePicker({ value, ruleKey, index, disabled, onChange }) {
const [open, setOpen] = useState(false);
@@ -320,6 +324,9 @@ function LocalRulesPanel({
open,
rules,
blocked,
dirty,
restartPending,
confirmingClose,
error,
operations,
panelRef,
@@ -327,6 +334,8 @@ function LocalRulesPanel({
onChange,
onRemove,
onRemoveComplete,
onKeepEditing,
onDiscard,
onClose,
onSave,
}) {
@@ -346,11 +355,34 @@ function LocalRulesPanel({
<div className="client-local-rules-sheet">
<header className="client-local-rules-header">
<span>Маршрутизация</span>
<button
className="client-local-rules-save"
type="submit"
form="client-local-rules-form"
disabled={blocked || !dirty}
>
Сохранить
</button>
<h2 id="local-rules-title">Локальные правила</h2>
<p>Эти домены идут напрямую. Остальной трафик через выбранный VPN.</p>
{restartPending && (
<p className="client-local-rules-runtime" role="status">
Правила сохранены, но начнут работать после запуска или перезапуска sing-box.
</p>
)}
</header>
<form className="client-local-rules-form" onSubmit={onSave}>
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={onSave}>
{confirmingClose && (
<section className="client-local-rules-discard" role="alert">
<strong>Есть несохранённые настройки</strong>
<span>Закрыть редактор и потерять изменения?</span>
<div>
<button type="button" onClick={onKeepEditing}>Остаться</button>
<button type="button" className="is-danger" onClick={onDiscard}>Закрыть без сохранения</button>
</div>
</section>
)}
<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">
@@ -358,7 +390,7 @@ function LocalRulesPanel({
<div
className={`client-local-rule${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}`}
key={rule._key}
style={{ '--rule-index': index }}
style={{ viewTransitionName: rule.removing ? 'none' : rule._key }}
inert={rule.removing ? true : undefined}
onAnimationEnd={(event) => {
if (event.animationName === 'client-local-rule-leave') {
@@ -423,7 +455,6 @@ function LocalRulesPanel({
<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>
@@ -575,6 +606,7 @@ export function ClientOverviewPage({
const [localRulesOpen, setLocalRulesOpen] = useState(false);
const [localRulesDraft, setLocalRulesDraft] = useState([]);
const [localRulesRevision, setLocalRulesRevision] = useState(state?.revision || 0);
const [confirmingLocalRulesClose, setConfirmingLocalRulesClose] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [openInstructionId, setOpenInstructionId] = useState('');
const subscriptionInputRef = useRef(null);
@@ -584,6 +616,7 @@ export function ClientOverviewPage({
const instructionsToggleRef = useRef(null);
const localRulesPanelRef = useRef(null);
const localRulesToggleRef = useRef(null);
const localRulesBaselineRef = useRef('[]');
const previousHasSubscriptionRef = useRef(hasSubscription);
const validationRequests = useRef(null);
if (!validationRequests.current) validationRequests.current = createLatestRequest();
@@ -624,6 +657,7 @@ export function ClientOverviewPage({
const subscriptionDeleteBlocked = operationBlocked(operations, 'subscriptionDelete');
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
const routeRulesBlocked = operationBlocked(operations, 'routeRules');
const localRulesDirty = localRulesSignature(localRulesDraft) !== localRulesBaselineRef.current;
useEffect(() => {
setNow(Date.now());
@@ -798,10 +832,13 @@ export function ClientOverviewPage({
useEffect(() => {
if (!localRulesOpen) return undefined;
const closeLocalRules = (event) => {
if (event.type === 'keydown' && event.key !== 'Escape') return;
if (event.type === 'keydown') {
if (event.key !== 'Escape' || event.defaultPrevented) return;
} else {
if (localRulesPanelRef.current?.contains(event.target)) return;
if (localRulesToggleRef.current?.contains(event.target)) return;
setLocalRulesOpen(false);
}
requestCloseLocalRules();
};
document.addEventListener('pointerdown', closeLocalRules);
document.addEventListener('keydown', closeLocalRules);
@@ -809,7 +846,17 @@ export function ClientOverviewPage({
document.removeEventListener('pointerdown', closeLocalRules);
document.removeEventListener('keydown', closeLocalRules);
};
}, [localRulesOpen]);
}, [localRulesOpen, localRulesDirty]);
useEffect(() => {
if (!localRulesOpen || !localRulesDirty) return undefined;
const warnBeforeUnload = (event) => {
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', warnBeforeUnload);
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
}, [localRulesOpen, localRulesDirty]);
useEffect(() => {
if (!instructionsOpen) return undefined;
@@ -905,9 +952,12 @@ export function ClientOverviewPage({
}
function openLocalRules() {
const rules = state?.route?.localRules || [];
setInstructionsOpen(false);
setLocalRulesDraft((state?.route?.localRules || []).map(createLocalRuleDraft));
localRulesBaselineRef.current = localRulesSignature(rules);
setLocalRulesDraft(rules.map(createLocalRuleDraft));
setLocalRulesRevision(state?.revision || 0);
setConfirmingLocalRulesClose(false);
setLocalRulesOpen(true);
}
@@ -919,10 +969,26 @@ export function ClientOverviewPage({
async function saveLocalRules(event) {
event.preventDefault();
const rules = localRulesDraft
.filter((rule) => !rule.removing)
.map(({ type, value, enabled }) => ({ type, value, enabled }));
if (!await onSaveRouteRules(rules, localRulesRevision)) return;
const rules = localRuleValues(localRulesDraft);
const result = await onSaveRouteRules(rules, localRulesRevision);
if (!result) return;
localRulesBaselineRef.current = JSON.stringify(rules);
setLocalRulesRevision(result.state.revision);
setConfirmingLocalRulesClose(false);
if (!result.state.route.localRulesPendingRestart) setLocalRulesOpen(false);
}
function requestCloseLocalRules() {
if (localRulesDirty) {
setConfirmingLocalRulesClose(true);
return false;
}
setLocalRulesOpen(false);
return true;
}
function discardLocalRules() {
setConfirmingLocalRulesClose(false);
setLocalRulesOpen(false);
}
@@ -936,6 +1002,17 @@ export function ClientOverviewPage({
)));
}
function finishRemoveLocalRule(ruleKey) {
const update = () => flushSync(() => {
setLocalRulesDraft((rules) => rules.filter((rule) => rule._key !== ruleKey));
});
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
update();
return;
}
document.startViewTransition(update);
}
return (
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
@@ -954,7 +1031,7 @@ export function ClientOverviewPage({
aria-controls="client-instructions"
aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'}
onClick={() => {
setLocalRulesOpen(false);
if (localRulesOpen && !requestCloseLocalRules()) return;
setInstructionsOpen((open) => !open);
}}
>
@@ -967,19 +1044,19 @@ export function ClientOverviewPage({
</button>}
{hasSubscription && subscriptionContentReady && <button
ref={localRulesToggleRef}
className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}`}
className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}${state?.route?.localRulesPendingRestart ? ' has-pending' : ''}`}
type="button"
aria-expanded={localRulesOpen}
aria-controls="client-local-rules"
aria-label={localRulesOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
onClick={() => localRulesOpen ? setLocalRulesOpen(false) : openLocalRules()}
onClick={() => localRulesOpen ? requestCloseLocalRules() : 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>
<span>{state?.route?.localRulesPendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}</span>
</button>}
<main className={`client-panel${showPower ? '' : ' is-setup'}`}>
{showPower && (
@@ -1355,6 +1432,9 @@ export function ClientOverviewPage({
open={localRulesOpen}
rules={localRulesDraft}
blocked={routeRulesBlocked || localRulesDraft.some((rule) => rule.removing)}
dirty={localRulesDirty}
restartPending={state?.route?.localRulesPendingRestart === true}
confirmingClose={confirmingLocalRulesClose}
error={error}
operations={operations}
panelRef={localRulesPanelRef}
@@ -1364,10 +1444,10 @@ export function ClientOverviewPage({
])}
onChange={changeLocalRule}
onRemove={(index) => removeLocalRule(localRulesDraft[index]._key)}
onRemoveComplete={(ruleKey) => setLocalRulesDraft((rules) => (
rules.filter((rule) => rule._key !== ruleKey)
))}
onClose={() => setLocalRulesOpen(false)}
onRemoveComplete={finishRemoveLocalRule}
onKeepEditing={() => setConfirmingLocalRulesClose(false)}
onDiscard={discardLocalRules}
onClose={requestCloseLocalRules}
onSave={saveLocalRules}
/>}

View File

@@ -783,6 +783,14 @@ p {
color: var(--client-accent);
}
.client-local-rules-toggle.has-pending:not(.is-open) {
color: oklch(0.68 0.14 72);
}
.client-local-rules-toggle.has-pending:not(.is-open) svg {
filter: drop-shadow(0 0 7px oklch(0.68 0.14 72 / 0.42));
}
.client-local-rules-toggle.is-open svg {
transform: rotate(90deg);
}
@@ -821,6 +829,7 @@ p {
.client-local-rules-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 9px;
margin: 0 8px 34px;
}
@@ -835,17 +844,46 @@ p {
}
.client-local-rules-header h2 {
grid-column: 1 / -1;
font-size: 22px;
letter-spacing: -0.045em;
}
.client-local-rules-header p {
grid-column: 1 / -1;
max-width: 46ch;
color: var(--client-muted);
font-size: 11px;
line-height: 1.7;
}
.client-local-rules-save {
align-self: center;
padding: 0;
border: 0;
background: transparent;
color: var(--client-accent);
font-size: 10px;
font-weight: 700;
cursor: pointer;
transition: color 200ms ease, filter 300ms ease, opacity 220ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-local-rules-save:hover:not(:disabled) {
filter: drop-shadow(0 0 7px color-mix(in oklch, var(--client-accent) 42%, transparent));
transform: translateY(-1px);
}
.client-local-rules-save:disabled {
opacity: 0.28;
cursor: default;
}
.client-local-rules-header .client-local-rules-runtime {
color: oklch(0.68 0.14 72);
animation: client-local-rules-notice 480ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-local-rules-form,
.client-local-rules-group,
.client-local-rules-list {
@@ -871,7 +909,7 @@ p {
align-items: center;
gap: 10px;
padding: 7px 0;
animation: client-local-rule-enter 560ms calc(var(--rule-index) * 55ms) cubic-bezier(0.16, 1, 0.3, 1) both;
animation: client-local-rule-enter 560ms cubic-bezier(0.16, 1, 0.3, 1) both;
transition: opacity 260ms ease, filter 360ms ease;
}
@@ -1093,6 +1131,8 @@ p {
.client-rule-type-trigger:focus-visible,
.client-local-rule > button:last-child:focus-visible,
.client-local-rule-add:focus-visible,
.client-local-rules-save:focus-visible,
.client-local-rules-discard button:focus-visible,
.client-local-rules-actions button:focus-visible {
outline: 0;
color: var(--client-accent);
@@ -1163,6 +1203,39 @@ p {
transform: none;
}
.client-local-rules-discard {
display: grid;
gap: 8px;
color: var(--client-muted);
font-size: 9px;
line-height: 1.55;
animation: client-local-rules-notice 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-local-rules-discard strong {
color: oklch(0.68 0.15 28);
font-size: 10px;
}
.client-local-rules-discard div {
display: flex;
gap: 12px;
}
.client-local-rules-discard button {
padding: 4px 0;
border: 0;
background: transparent;
color: var(--client-text);
font-size: 9px;
font-weight: 700;
cursor: pointer;
}
.client-local-rules-discard .is-danger {
color: oklch(0.68 0.15 28);
}
.client-local-rules-actions {
display: flex;
justify-content: flex-end;
@@ -1186,10 +1259,6 @@ p {
transform: translateY(-1px);
}
.client-local-rules-actions .is-primary {
color: var(--client-accent);
}
.client-local-rules-actions button:disabled {
opacity: 0.45;
cursor: wait;
@@ -1205,6 +1274,11 @@ p {
to { opacity: 0; filter: blur(8px); transform: translateX(18px) scale(0.97); }
}
@keyframes client-local-rules-notice {
from { opacity: 0; filter: blur(6px); transform: translateY(-6px); }
to { opacity: 1; filter: blur(0); transform: translateY(0); }
}
.client-instructions {
position: fixed;
inset: 0 auto 0 0;
@@ -1310,6 +1384,11 @@ p {
mix-blend-mode: normal;
}
::view-transition-group(*) {
animation-duration: 420ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
::view-transition-group(instruction-proxybridge),
::view-transition-group(instruction-switchyomega),
::view-transition-group(instruction-vscode),
@@ -2801,6 +2880,9 @@ p {
.client-local-rule > button:last-child,
.client-local-rule-add,
.client-local-rule-add-slot > span,
.client-local-rules-save,
.client-local-rules-runtime,
.client-local-rules-discard,
.client-local-rules-actions button {
transition: none;
animation: none;

View File

@@ -172,6 +172,7 @@ setInterval(() => {}, 60_000);
assert.deepEqual(initial.route.localRules, [
{ type: 'domain_suffix', value: 'ru', enabled: true },
]);
assert.equal(initial.route.localRulesPendingRestart, false);
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
const stateKeys = Object.keys(initial).sort();
let revision = initial.revision;
@@ -238,7 +239,7 @@ setInterval(() => {}, 60_000);
assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running');
const rulesRevision = revision;
const routed = await mutation('/api/route-rules', 'PUT', {
let routed = await mutation('/api/route-rules', 'PUT', {
expectedRevision: rulesRevision,
rules: [
{ type: 'domain_suffix', value: 'ru', enabled: false },
@@ -251,12 +252,26 @@ setInterval(() => {}, 60_000);
{ type: 'domain', value: 'example.com', enabled: true },
{ type: 'domain_suffix', value: 'example.org', enabled: true },
]);
assert.equal(routed.state.route.localRulesPendingRestart, false);
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 3), [
{ domain: ['example.com'], outbound: 'direct' },
{ domain_suffix: ['example.org'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
]);
await mutation('/api/singbox/stop');
routed = await mutation('/api/route-rules', 'PUT', {
expectedRevision: revision,
rules: [
...routed.state.route.localRules,
{ type: 'domain_keyword', value: 'media', enabled: true },
],
});
assert.equal(routed.state.connection.process, 'stopped');
assert.equal(routed.state.route.localRulesPendingRestart, true);
const restartedRules = await mutation('/api/singbox/restart');
assert.equal(restartedRules.state.route.localRulesPendingRestart, false);
const invalidRules = await rawRequest(port, '/api/route-rules', 'PUT', {
expectedRevision: revision,
rules: [{ type: 'domain_regex', value: '.*' }],

View File

@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = path.resolve(import.meta.dirname, '../..');
const component = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
test('rule editor add latency stays constant and dirty exits are guarded', () => {
const rowRule = /\.client-local-rule \{([\s\S]*?)\n\}/.exec(styles)?.[1] || '';
assert.doesNotMatch(rowRule, /--rule-index|calc\(/);
assert.match(component, /addEventListener\('beforeunload', warnBeforeUnload\)/);
assert.match(component, /requestCloseLocalRules\(\)/);
assert.match(component, /localRulesPendingRestart/);
});