Track applied route rules separately from pending edits
This commit is contained in:
@@ -89,7 +89,7 @@ function App() {
|
||||
setError({
|
||||
context,
|
||||
message: context === 'routing' && safeError.code === 'STATE_CONFLICT'
|
||||
? 'Правила изменились. Закройте панель и откройте её снова.'
|
||||
? 'Правила уже изменились в другом окне. Проверьте статусы строк и сохраните ещё раз.'
|
||||
: safeError.message,
|
||||
code: safeError.code,
|
||||
correlationId: safeError.correlationId,
|
||||
@@ -165,6 +165,7 @@ function App() {
|
||||
() => api.routeRules.update(rules, expectedRevision),
|
||||
'routing',
|
||||
)}
|
||||
onDismissError={() => setError(null)}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -77,9 +77,9 @@ export const api = {
|
||||
}),
|
||||
},
|
||||
routeRules: {
|
||||
update: (rules, expectedRevision) => request('/api/route-rules', {
|
||||
update: (rules, expectedRulesRevision) => request('/api/route-rules', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ rules, expectedRevision }),
|
||||
body: JSON.stringify({ rules, expectedRulesRevision }),
|
||||
}),
|
||||
},
|
||||
singbox: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -905,7 +905,7 @@ p {
|
||||
.client-local-rule {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 24px 116px minmax(0, 1fr) 28px;
|
||||
grid-template-columns: 24px 116px minmax(0, 1fr) 112px 28px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
@@ -913,6 +913,25 @@ p {
|
||||
transition: opacity 260ms ease, filter 360ms ease;
|
||||
}
|
||||
|
||||
.client-local-rule-status {
|
||||
color: var(--client-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-align: right;
|
||||
text-transform: uppercase;
|
||||
transition: color 280ms ease, opacity 280ms ease, filter 360ms ease;
|
||||
}
|
||||
|
||||
.client-local-rule.is-active .client-local-rule-status {
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-local-rule.is-pending .client-local-rule-status,
|
||||
.client-local-rule.is-unsaved .client-local-rule-status {
|
||||
color: var(--client-warning, oklch(0.72 0.12 72));
|
||||
}
|
||||
|
||||
.client-local-rule.is-disabled {
|
||||
opacity: 0.42;
|
||||
filter: saturate(0);
|
||||
@@ -1529,6 +1548,44 @@ p {
|
||||
animation: client-power-arrive 850ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.client-route-rules-pending {
|
||||
min-height: 17px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: -10px 0 -8px;
|
||||
color: var(--client-warning, oklch(0.72 0.12 72));
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
opacity: 0;
|
||||
filter: blur(5px);
|
||||
transform: translateY(-3px);
|
||||
transition: opacity 240ms ease, filter 320ms ease, transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-route-rules-pending.is-visible {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.client-route-rules-pending button {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--client-accent);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.client-route-rules-pending button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
@keyframes client-power-arrive {
|
||||
0% { opacity: 0; filter: blur(8px); transform: scale(0.94); }
|
||||
100% { opacity: 1; filter: blur(0); transform: scale(1); }
|
||||
@@ -2525,6 +2582,8 @@ p {
|
||||
}
|
||||
|
||||
.client-proxy-label {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--client-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
@@ -2532,11 +2591,43 @@ p {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.client-proxy-label.is-gateway {
|
||||
.client-proxy-label > span {
|
||||
grid-area: 1 / 1;
|
||||
opacity: 0;
|
||||
filter: blur(3px);
|
||||
pointer-events: none;
|
||||
transform: translateX(-12px);
|
||||
transition: color 700ms ease, opacity 520ms ease, filter 620ms ease, transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-proxy-label > span:last-child {
|
||||
color: var(--client-accent);
|
||||
transform: translateX(12px);
|
||||
}
|
||||
|
||||
.client-proxy-label > span.is-active {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
pointer-events: auto;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.client-proxy-label a {
|
||||
color: var(--client-accent);
|
||||
text-decoration: none;
|
||||
text-shadow: 0 0 10px color-mix(in oklch, var(--client-accent) 42%, transparent);
|
||||
}
|
||||
|
||||
.client-proxy-label a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.client-proxy-label a:focus-visible {
|
||||
outline: 2px solid var(--client-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.client-proxy-address {
|
||||
color: var(--client-text);
|
||||
font-size: 12px;
|
||||
@@ -2739,6 +2830,15 @@ p {
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.client-local-rule {
|
||||
grid-template-columns: 24px 104px minmax(0, 1fr) 28px;
|
||||
}
|
||||
|
||||
.client-local-rule-status {
|
||||
grid-column: 3;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.client-mode .app-main {
|
||||
padding: 20px 14px;
|
||||
}
|
||||
@@ -2877,7 +2977,8 @@ p {
|
||||
.client-subscription-edit::after,
|
||||
.client-subscription-submit,
|
||||
.client-subscription-summary,
|
||||
.client-subscription-summary strong {
|
||||
.client-subscription-summary strong,
|
||||
.client-proxy-label > span {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user