Track applied route rules separately from pending edits
This commit is contained in:
@@ -380,17 +380,15 @@ async function applySelectedServer(selectedTag, { persist = true } = {}) {
|
|||||||
else restoreSingboxConfig(previousConfig);
|
else restoreSingboxConfig(previousConfig);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (persist || stateStore.read().routeRulesPendingRestart) {
|
|
||||||
updateStoredState((state) => ({
|
updateStoredState((state) => ({
|
||||||
...state,
|
...state,
|
||||||
...(persist ? {
|
...(persist ? {
|
||||||
appliedTag: selectedTag,
|
appliedTag: selectedTag,
|
||||||
appliedAt: new Date().toISOString(),
|
appliedAt: new Date().toISOString(),
|
||||||
} : {}),
|
} : {}),
|
||||||
routeRulesPendingRestart: false,
|
appliedRouteRules: state.routeRules,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function applyRouteRules(routeRules) {
|
async function applyRouteRules(routeRules) {
|
||||||
const state = normalizeStoredState(stateStore.read());
|
const state = normalizeStoredState(stateStore.read());
|
||||||
@@ -399,7 +397,7 @@ async function applyRouteRules(routeRules) {
|
|||||||
updateStoredState((current) => ({
|
updateStoredState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
routeRules,
|
routeRules,
|
||||||
routeRulesPendingRestart: true,
|
routeRulesRevision: current.routeRulesRevision + 1,
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -414,7 +412,8 @@ async function applyRouteRules(routeRules) {
|
|||||||
updateStoredState((current) => ({
|
updateStoredState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
routeRules,
|
routeRules,
|
||||||
routeRulesPendingRestart: !wasRunning,
|
...(wasRunning ? { appliedRouteRules: routeRules } : {}),
|
||||||
|
routeRulesRevision: current.routeRulesRevision + 1,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (previousConfig === null) removeSingboxConfig();
|
if (previousConfig === null) removeSingboxConfig();
|
||||||
@@ -611,19 +610,23 @@ async function handleApi(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'PUT' && req.url === '/api/route-rules') {
|
if (req.method === 'PUT' && req.url === '/api/route-rules') {
|
||||||
const { rules, expectedRevision } = await readBody(req);
|
const { rules, expectedRulesRevision, expectedRevision } = await readBody(req);
|
||||||
let routeRules;
|
let routeRules;
|
||||||
try {
|
try {
|
||||||
routeRules = normalizeRouteRules(rules, { strict: true });
|
routeRules = normalizeRouteRules(rules, { strict: true });
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
throw new HarborError('REQUEST_INVALID', { cause });
|
throw new HarborError('REQUEST_INVALID', { cause });
|
||||||
}
|
}
|
||||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
const rulesRevision = expectedRulesRevision ?? expectedRevision;
|
||||||
|
if (!Number.isSafeInteger(rulesRevision) || rulesRevision < 0) {
|
||||||
throw new HarborError('REQUEST_INVALID');
|
throw new HarborError('REQUEST_INVALID');
|
||||||
}
|
}
|
||||||
await serializeControl(async () => {
|
await serializeControl(async () => {
|
||||||
const current = normalizeStoredState(stateStore.read());
|
const current = normalizeStoredState(stateStore.read());
|
||||||
if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
const currentRevision = expectedRulesRevision == null
|
||||||
|
? current.revision
|
||||||
|
: current.routeRulesRevision;
|
||||||
|
if (currentRevision !== rulesRevision) throw new HarborError('STATE_CONFLICT');
|
||||||
if (isDeepStrictEqual(current.routeRules, routeRules)) return;
|
if (isDeepStrictEqual(current.routeRules, routeRules)) return;
|
||||||
await withOperation('route-rules', () => applyRouteRules(routeRules));
|
await withOperation('route-rules', () => applyRouteRules(routeRules));
|
||||||
});
|
});
|
||||||
@@ -667,7 +670,7 @@ async function handleApi(req, res) {
|
|||||||
...state,
|
...state,
|
||||||
appliedTag: state.selectedTag,
|
appliedTag: state.selectedTag,
|
||||||
connectionDesired: 'running',
|
connectionDesired: 'running',
|
||||||
routeRulesPendingRestart: false,
|
appliedRouteRules: state.routeRules,
|
||||||
}));
|
}));
|
||||||
}));
|
}));
|
||||||
return sendState(res, { singboxRunning: true });
|
return sendState(res, { singboxRunning: true });
|
||||||
@@ -727,8 +730,8 @@ if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) {
|
|||||||
}
|
}
|
||||||
await startSingbox()
|
await startSingbox()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (fs.existsSync(settings.configPath) && stateStore.read().routeRulesPendingRestart) {
|
if (fs.existsSync(settings.configPath)) {
|
||||||
updateStoredState((state) => ({ ...state, routeRulesPendingRestart: false }));
|
updateStoredState((state) => ({ ...state, appliedRouteRules: state.routeRules }));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`));
|
.catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`));
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ export function normalizeStoredState(value) {
|
|||||||
appliedTag: Object.hasOwn(state, 'appliedTag') ? text(state.appliedTag) : selectedTag,
|
appliedTag: Object.hasOwn(state, 'appliedTag') ? text(state.appliedTag) : selectedTag,
|
||||||
servers: Array.isArray(state.servers) ? state.servers : [],
|
servers: Array.isArray(state.servers) ? state.servers : [],
|
||||||
routeRules: normalizeRouteRules(state.routeRules),
|
routeRules: normalizeRouteRules(state.routeRules),
|
||||||
routeRulesPendingRestart: state.routeRulesPendingRestart === true,
|
appliedRouteRules: normalizeRouteRules(state.appliedRouteRules),
|
||||||
|
routeRulesRevision: Number.isSafeInteger(state.routeRulesRevision) && state.routeRulesRevision >= 0
|
||||||
|
? state.routeRulesRevision
|
||||||
|
: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +60,7 @@ export function createStateSnapshot({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent';
|
const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent';
|
||||||
|
const activeLocalRules = runtime?.running ? stored.appliedRouteRules : [];
|
||||||
|
|
||||||
return assertStateSnapshot({
|
return assertStateSnapshot({
|
||||||
apiVersion: 1,
|
apiVersion: 1,
|
||||||
@@ -85,7 +89,9 @@ export function createStateSnapshot({
|
|||||||
lastVerifiedAt: null,
|
lastVerifiedAt: null,
|
||||||
reason: mode === 'client' && stored.gatewayAutoEnabled !== false ? 'auto' : 'manual',
|
reason: mode === 'client' && stored.gatewayAutoEnabled !== false ? 'auto' : 'manual',
|
||||||
localRules: stored.routeRules,
|
localRules: stored.routeRules,
|
||||||
localRulesPendingRestart: stored.routeRulesPendingRestart,
|
activeLocalRules,
|
||||||
|
localRulesRevision: stored.routeRulesRevision,
|
||||||
|
localRulesPendingRestart: !isSameRules(stored.routeRules, activeLocalRules),
|
||||||
},
|
},
|
||||||
operation: {
|
operation: {
|
||||||
kind: nullableText(operation.kind),
|
kind: nullableText(operation.kind),
|
||||||
@@ -179,6 +185,10 @@ export function assertStateSnapshot(snapshot) {
|
|||||||
typeof snapshot.route.reason !== 'string' ||
|
typeof snapshot.route.reason !== 'string' ||
|
||||||
!Array.isArray(snapshot.route.localRules) ||
|
!Array.isArray(snapshot.route.localRules) ||
|
||||||
!snapshot.route.localRules.every(validRouteRule) ||
|
!snapshot.route.localRules.every(validRouteRule) ||
|
||||||
|
!Array.isArray(snapshot.route.activeLocalRules) ||
|
||||||
|
!snapshot.route.activeLocalRules.every(validRouteRule) ||
|
||||||
|
!Number.isSafeInteger(snapshot.route.localRulesRevision) ||
|
||||||
|
snapshot.route.localRulesRevision < 0 ||
|
||||||
typeof snapshot.route.localRulesPendingRestart !== 'boolean' ||
|
typeof snapshot.route.localRulesPendingRestart !== 'boolean' ||
|
||||||
!snapshot.operation ||
|
!snapshot.operation ||
|
||||||
!nullableString(snapshot.operation.kind) ||
|
!nullableString(snapshot.operation.kind) ||
|
||||||
@@ -193,3 +203,7 @@ export function assertStateSnapshot(snapshot) {
|
|||||||
|
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSameRules(left, right) {
|
||||||
|
return JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.5.1',
|
macClient: '0.6.1',
|
||||||
gatewayClient: '0.5.1',
|
gatewayClient: '0.6.0',
|
||||||
gatewayBackend: '0.5.0',
|
gatewayBackend: '0.6.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function parseVersion(value) {
|
export function parseVersion(value) {
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ function App() {
|
|||||||
setError({
|
setError({
|
||||||
context,
|
context,
|
||||||
message: context === 'routing' && safeError.code === 'STATE_CONFLICT'
|
message: context === 'routing' && safeError.code === 'STATE_CONFLICT'
|
||||||
? 'Правила изменились. Закройте панель и откройте её снова.'
|
? 'Правила уже изменились в другом окне. Проверьте статусы строк и сохраните ещё раз.'
|
||||||
: safeError.message,
|
: safeError.message,
|
||||||
code: safeError.code,
|
code: safeError.code,
|
||||||
correlationId: safeError.correlationId,
|
correlationId: safeError.correlationId,
|
||||||
@@ -165,6 +165,7 @@ function App() {
|
|||||||
() => api.routeRules.update(rules, expectedRevision),
|
() => api.routeRules.update(rules, expectedRevision),
|
||||||
'routing',
|
'routing',
|
||||||
)}
|
)}
|
||||||
|
onDismissError={() => setError(null)}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
routeRules: {
|
routeRules: {
|
||||||
update: (rules, expectedRevision) => request('/api/route-rules', {
|
update: (rules, expectedRulesRevision) => request('/api/route-rules', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ rules, expectedRevision }),
|
body: JSON.stringify({ rules, expectedRulesRevision }),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
singbox: {
|
singbox: {
|
||||||
|
|||||||
@@ -138,9 +138,7 @@ function InlineError({ error, context }) {
|
|||||||
const operationProgress = {
|
const operationProgress = {
|
||||||
connection: ['connection', 'Меняем состояние подключения…'],
|
connection: ['connection', 'Меняем состояние подключения…'],
|
||||||
serverApply: ['connection', 'Применяем сервер…'],
|
serverApply: ['connection', 'Применяем сервер…'],
|
||||||
gatewayAuto: ['connection', 'Переключаем маршрут…'],
|
|
||||||
subscriptionImport: ['subscription', 'Загружаем подписку…'],
|
subscriptionImport: ['subscription', 'Загружаем подписку…'],
|
||||||
subscriptionRefresh: ['subscription', 'Обновляем подписку…'],
|
|
||||||
subscriptionDelete: ['subscription', 'Удаляем подписку…'],
|
subscriptionDelete: ['subscription', 'Удаляем подписку…'],
|
||||||
routeRules: ['routing', 'Применяем локальные правила…'],
|
routeRules: ['routing', 'Применяем локальные правила…'],
|
||||||
};
|
};
|
||||||
@@ -227,6 +225,15 @@ const localRuleValues = (rules) => rules
|
|||||||
.filter((rule) => !rule.removing)
|
.filter((rule) => !rule.removing)
|
||||||
.map(({ type, value, enabled }) => ({ type, value, enabled }));
|
.map(({ type, value, enabled }) => ({ type, value, enabled }));
|
||||||
const localRulesSignature = (rules) => JSON.stringify(localRuleValues(rules));
|
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 }) {
|
function RuleTypePicker({ value, ruleKey, index, disabled, onChange }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -324,6 +331,8 @@ function RuleTypePicker({ value, ruleKey, index, disabled, onChange }) {
|
|||||||
function LocalRulesPanel({
|
function LocalRulesPanel({
|
||||||
open,
|
open,
|
||||||
rules,
|
rules,
|
||||||
|
savedRules,
|
||||||
|
activeRules,
|
||||||
blocked,
|
blocked,
|
||||||
dirty,
|
dirty,
|
||||||
restartPending,
|
restartPending,
|
||||||
@@ -337,9 +346,9 @@ function LocalRulesPanel({
|
|||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
}) {
|
}) {
|
||||||
const activeRules = rules.filter((rule) => !rule.removing);
|
const draftRules = rules.filter((rule) => !rule.removing);
|
||||||
const canAdd = canAppendRouteRule(activeRules) && !blocked;
|
const canAdd = canAppendRouteRule(draftRules) && !blocked;
|
||||||
const incomplete = activeRules.some((rule) => !String(rule.value || '').trim());
|
const incomplete = draftRules.some((rule) => !String(rule.value || '').trim());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
@@ -374,9 +383,11 @@ function LocalRulesPanel({
|
|||||||
<section className="client-local-rules-group" aria-labelledby="local-rules-list-title">
|
<section className="client-local-rules-group" aria-labelledby="local-rules-list-title">
|
||||||
<span id="local-rules-list-title">Правила</span>
|
<span id="local-rules-list-title">Правила</span>
|
||||||
<div className="client-local-rules-list">
|
<div className="client-local-rules-list">
|
||||||
{rules.map((rule, index) => (
|
{rules.map((rule, index) => {
|
||||||
|
const [status, statusLabel] = localRuleStatus(rule, savedRules, activeRules);
|
||||||
|
return (
|
||||||
<div
|
<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}
|
key={rule._key}
|
||||||
style={{ viewTransitionName: rule.removing ? 'none' : rule._key }}
|
style={{ viewTransitionName: rule.removing ? 'none' : rule._key }}
|
||||||
inert={rule.removing ? true : undefined}
|
inert={rule.removing ? true : undefined}
|
||||||
@@ -417,6 +428,7 @@ function LocalRulesPanel({
|
|||||||
value={rule.value}
|
value={rule.value}
|
||||||
onChange={(event) => onChange(index, 'value', event.target.value)}
|
onChange={(event) => onChange(index, 'value', event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<span className="client-local-rule-status" role="status">{statusLabel}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Удалить правило ${index + 1}`}
|
aria-label={`Удалить правило ${index + 1}`}
|
||||||
@@ -425,7 +437,8 @@ function LocalRulesPanel({
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{!rules.length && <p className="client-local-rules-empty">Правил пока нет. Весь трафик идёт через VPN.</p>}
|
{!rules.length && <p className="client-local-rules-empty">Правил пока нет. Весь трафик идёт через VPN.</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="client-local-rule-add-slot">
|
<div className="client-local-rule-add-slot">
|
||||||
@@ -561,6 +574,7 @@ export function ClientOverviewPage({
|
|||||||
onStop,
|
onStop,
|
||||||
onSetGatewayAuto,
|
onSetGatewayAuto,
|
||||||
onSaveRouteRules,
|
onSaveRouteRules,
|
||||||
|
onDismissError,
|
||||||
}) {
|
}) {
|
||||||
const isGateway = state?.mode === 'gateway';
|
const isGateway = state?.mode === 'gateway';
|
||||||
const gatewayDirect = !isGateway && state?.gatewayAuto?.mode === 'gateway-direct';
|
const gatewayDirect = !isGateway && state?.gatewayAuto?.mode === 'gateway-direct';
|
||||||
@@ -593,7 +607,7 @@ export function ClientOverviewPage({
|
|||||||
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
||||||
const [localRulesOpen, setLocalRulesOpen] = useState(false);
|
const [localRulesOpen, setLocalRulesOpen] = useState(false);
|
||||||
const [localRulesDraft, setLocalRulesDraft] = useState([]);
|
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 [confirmingLocalRulesClose, setConfirmingLocalRulesClose] = useState(false);
|
||||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||||
const [openInstructionId, setOpenInstructionId] = useState('');
|
const [openInstructionId, setOpenInstructionId] = useState('');
|
||||||
@@ -646,6 +660,9 @@ export function ClientOverviewPage({
|
|||||||
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
|
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
|
||||||
const routeRulesBlocked = operationBlocked(operations, 'routeRules');
|
const routeRulesBlocked = operationBlocked(operations, 'routeRules');
|
||||||
const localRulesDirty = localRulesSignature(localRulesDraft) !== localRulesBaselineRef.current;
|
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(() => {
|
useEffect(() => {
|
||||||
setNow(Date.now());
|
setNow(Date.now());
|
||||||
@@ -944,8 +961,9 @@ export function ClientOverviewPage({
|
|||||||
setInstructionsOpen(false);
|
setInstructionsOpen(false);
|
||||||
localRulesBaselineRef.current = localRulesSignature(rules);
|
localRulesBaselineRef.current = localRulesSignature(rules);
|
||||||
setLocalRulesDraft(rules.map(createLocalRuleDraft));
|
setLocalRulesDraft(rules.map(createLocalRuleDraft));
|
||||||
setLocalRulesRevision(state?.revision || 0);
|
setLocalRulesRevision(state?.route?.localRulesRevision || 0);
|
||||||
setConfirmingLocalRulesClose(false);
|
setConfirmingLocalRulesClose(false);
|
||||||
|
onDismissError();
|
||||||
setLocalRulesOpen(true);
|
setLocalRulesOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -961,7 +979,7 @@ export function ClientOverviewPage({
|
|||||||
const result = await onSaveRouteRules(rules, localRulesRevision);
|
const result = await onSaveRouteRules(rules, localRulesRevision);
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
localRulesBaselineRef.current = JSON.stringify(rules);
|
localRulesBaselineRef.current = JSON.stringify(rules);
|
||||||
setLocalRulesRevision(result.state.revision);
|
setLocalRulesRevision(result.state.route.localRulesRevision);
|
||||||
setConfirmingLocalRulesClose(false);
|
setConfirmingLocalRulesClose(false);
|
||||||
if (!result.state.route.localRulesPendingRestart) setLocalRulesOpen(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" />
|
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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">
|
<div className="client-state-copy" aria-live="polite">
|
||||||
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
|
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
|
||||||
<span className={!connected ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
|
<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">
|
<div className="client-access-point" role={isGateway ? 'tabpanel' : undefined} key="proxy">
|
||||||
{!isGateway && (
|
{!isGateway && (
|
||||||
<span className={`client-proxy-label${gatewayDirect ? ' is-gateway' : ''}`}>
|
<span className={`client-proxy-label${gatewayDirect ? ' is-gateway' : ''}`}>
|
||||||
{gatewayDirect
|
<span className={!gatewayDirect ? 'is-active' : ''}>Локальный VPN</span>
|
||||||
? `Через Harbor Gateway · ${state.gatewayAuto.address}`
|
<span className={gatewayDirect ? 'is-active' : ''}>
|
||||||
: 'Локальный VPN'}
|
Через <a href={`http://${state.gatewayAuto.address}:8080`}>Harbor Gateway</a> · {state.gatewayAuto.address}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<strong className="client-proxy-address">
|
<strong className="client-proxy-address">
|
||||||
@@ -1405,6 +1432,8 @@ export function ClientOverviewPage({
|
|||||||
{hasSubscription && subscriptionContentReady && <LocalRulesPanel
|
{hasSubscription && subscriptionContentReady && <LocalRulesPanel
|
||||||
open={localRulesOpen}
|
open={localRulesOpen}
|
||||||
rules={localRulesDraft}
|
rules={localRulesDraft}
|
||||||
|
savedRules={state?.route?.localRules || []}
|
||||||
|
activeRules={state?.route?.activeLocalRules || []}
|
||||||
blocked={routeRulesBlocked || localRulesDraft.some((rule) => rule.removing)}
|
blocked={routeRulesBlocked || localRulesDraft.some((rule) => rule.removing)}
|
||||||
dirty={localRulesDirty}
|
dirty={localRulesDirty}
|
||||||
restartPending={state?.route?.localRulesPendingRestart === true}
|
restartPending={state?.route?.localRulesPendingRestart === true}
|
||||||
|
|||||||
@@ -905,7 +905,7 @@ p {
|
|||||||
.client-local-rule {
|
.client-local-rule {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 24px 116px minmax(0, 1fr) 28px;
|
grid-template-columns: 24px 116px minmax(0, 1fr) 112px 28px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 7px 0;
|
padding: 7px 0;
|
||||||
@@ -913,6 +913,25 @@ p {
|
|||||||
transition: opacity 260ms ease, filter 360ms ease;
|
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 {
|
.client-local-rule.is-disabled {
|
||||||
opacity: 0.42;
|
opacity: 0.42;
|
||||||
filter: saturate(0);
|
filter: saturate(0);
|
||||||
@@ -1529,6 +1548,44 @@ p {
|
|||||||
animation: client-power-arrive 850ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
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 {
|
@keyframes client-power-arrive {
|
||||||
0% { opacity: 0; filter: blur(8px); transform: scale(0.94); }
|
0% { opacity: 0; filter: blur(8px); transform: scale(0.94); }
|
||||||
100% { opacity: 1; filter: blur(0); transform: scale(1); }
|
100% { opacity: 1; filter: blur(0); transform: scale(1); }
|
||||||
@@ -2525,6 +2582,8 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-proxy-label {
|
.client-proxy-label {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -2532,11 +2591,43 @@ p {
|
|||||||
text-transform: uppercase;
|
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);
|
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);
|
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 {
|
.client-proxy-address {
|
||||||
color: var(--client-text);
|
color: var(--client-text);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -2739,6 +2830,15 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@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 {
|
.client-mode .app-main {
|
||||||
padding: 20px 14px;
|
padding: 20px 14px;
|
||||||
}
|
}
|
||||||
@@ -2877,7 +2977,8 @@ p {
|
|||||||
.client-subscription-edit::after,
|
.client-subscription-edit::after,
|
||||||
.client-subscription-submit,
|
.client-subscription-submit,
|
||||||
.client-subscription-summary,
|
.client-subscription-summary,
|
||||||
.client-subscription-summary strong {
|
.client-subscription-summary strong,
|
||||||
|
.client-proxy-label > span {
|
||||||
transition: none;
|
transition: none;
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,10 +172,13 @@ setInterval(() => {}, 60_000);
|
|||||||
assert.deepEqual(initial.route.localRules, [
|
assert.deepEqual(initial.route.localRules, [
|
||||||
{ type: 'domain_suffix', value: 'ru', enabled: true },
|
{ type: 'domain_suffix', value: 'ru', enabled: true },
|
||||||
]);
|
]);
|
||||||
|
assert.deepEqual(initial.route.activeLocalRules, initial.route.localRules);
|
||||||
|
assert.equal(initial.route.localRulesRevision, 0);
|
||||||
assert.equal(initial.route.localRulesPendingRestart, false);
|
assert.equal(initial.route.localRulesPendingRestart, false);
|
||||||
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
|
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
|
||||||
const stateKeys = Object.keys(initial).sort();
|
const stateKeys = Object.keys(initial).sort();
|
||||||
let revision = initial.revision;
|
let revision = initial.revision;
|
||||||
|
let rulesRevision = initial.route.localRulesRevision;
|
||||||
|
|
||||||
const invalidSubscription = await rawRequest(
|
const invalidSubscription = await rawRequest(
|
||||||
port,
|
port,
|
||||||
@@ -238,21 +241,22 @@ setInterval(() => {}, 60_000);
|
|||||||
assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped');
|
assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped');
|
||||||
assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running');
|
assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running');
|
||||||
|
|
||||||
const rulesRevision = revision;
|
|
||||||
let routed = await mutation('/api/route-rules', 'PUT', {
|
let routed = await mutation('/api/route-rules', 'PUT', {
|
||||||
expectedRevision: rulesRevision,
|
expectedRulesRevision: rulesRevision,
|
||||||
rules: [
|
rules: [
|
||||||
{ type: 'domain_suffix', value: 'ru', enabled: false },
|
{ type: 'domain_suffix', value: 'ru', enabled: false },
|
||||||
{ type: 'domain', value: 'https://Example.com/private?q=1', enabled: true },
|
{ type: 'domain', value: 'https://Example.com/private?q=1', enabled: true },
|
||||||
{ type: 'domain_suffix', value: '*.Example.org', enabled: true },
|
{ type: 'domain_suffix', value: '*.Example.org', enabled: true },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
rulesRevision = routed.state.route.localRulesRevision;
|
||||||
assert.deepEqual(routed.state.route.localRules, [
|
assert.deepEqual(routed.state.route.localRules, [
|
||||||
{ type: 'domain_suffix', value: 'ru', enabled: false },
|
{ type: 'domain_suffix', value: 'ru', enabled: false },
|
||||||
{ type: 'domain', value: 'example.com', enabled: true },
|
{ type: 'domain', value: 'example.com', enabled: true },
|
||||||
{ type: 'domain_suffix', value: 'example.org', enabled: true },
|
{ type: 'domain_suffix', value: 'example.org', enabled: true },
|
||||||
]);
|
]);
|
||||||
assert.equal(routed.state.route.localRulesPendingRestart, false);
|
assert.equal(routed.state.route.localRulesPendingRestart, false);
|
||||||
|
assert.deepEqual(routed.state.route.activeLocalRules, routed.state.route.localRules);
|
||||||
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 3), [
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 3), [
|
||||||
{ domain: ['example.com'], outbound: 'direct' },
|
{ domain: ['example.com'], outbound: 'direct' },
|
||||||
{ domain_suffix: ['example.org'], outbound: 'direct' },
|
{ domain_suffix: ['example.org'], outbound: 'direct' },
|
||||||
@@ -261,19 +265,22 @@ setInterval(() => {}, 60_000);
|
|||||||
|
|
||||||
await mutation('/api/singbox/stop');
|
await mutation('/api/singbox/stop');
|
||||||
routed = await mutation('/api/route-rules', 'PUT', {
|
routed = await mutation('/api/route-rules', 'PUT', {
|
||||||
expectedRevision: revision,
|
expectedRulesRevision: rulesRevision,
|
||||||
rules: [
|
rules: [
|
||||||
...routed.state.route.localRules,
|
...routed.state.route.localRules,
|
||||||
{ type: 'domain_keyword', value: 'media', enabled: true },
|
{ type: 'domain_keyword', value: 'media', enabled: true },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
rulesRevision = routed.state.route.localRulesRevision;
|
||||||
assert.equal(routed.state.connection.process, 'stopped');
|
assert.equal(routed.state.connection.process, 'stopped');
|
||||||
assert.equal(routed.state.route.localRulesPendingRestart, true);
|
assert.equal(routed.state.route.localRulesPendingRestart, true);
|
||||||
|
assert.deepEqual(routed.state.route.activeLocalRules, []);
|
||||||
const restartedRules = await mutation('/api/singbox/restart');
|
const restartedRules = await mutation('/api/singbox/restart');
|
||||||
assert.equal(restartedRules.state.route.localRulesPendingRestart, false);
|
assert.equal(restartedRules.state.route.localRulesPendingRestart, false);
|
||||||
|
assert.deepEqual(restartedRules.state.route.activeLocalRules, routed.state.route.localRules);
|
||||||
|
|
||||||
const invalidRules = await rawRequest(port, '/api/route-rules', 'PUT', {
|
const invalidRules = await rawRequest(port, '/api/route-rules', 'PUT', {
|
||||||
expectedRevision: revision,
|
expectedRulesRevision: rulesRevision,
|
||||||
rules: [{ type: 'domain_regex', value: '.*' }],
|
rules: [{ type: 'domain_regex', value: '.*' }],
|
||||||
});
|
});
|
||||||
assert.equal(invalidRules.response.status, 400);
|
assert.equal(invalidRules.response.status, 400);
|
||||||
@@ -281,13 +288,19 @@ setInterval(() => {}, 60_000);
|
|||||||
assert.equal((await request(port, '/api/state')).revision, revision);
|
assert.equal((await request(port, '/api/state')).revision, revision);
|
||||||
|
|
||||||
const staleRules = await rawRequest(port, '/api/route-rules', 'PUT', {
|
const staleRules = await rawRequest(port, '/api/route-rules', 'PUT', {
|
||||||
expectedRevision: rulesRevision,
|
expectedRulesRevision: 0,
|
||||||
rules: [],
|
rules: [],
|
||||||
});
|
});
|
||||||
assert.equal(staleRules.response.status, 409);
|
assert.equal(staleRules.response.status, 409);
|
||||||
assert.equal(staleRules.payload.error.code, 'STATE_CONFLICT');
|
assert.equal(staleRules.payload.error.code, 'STATE_CONFLICT');
|
||||||
assert.deepEqual((await request(port, '/api/state')).route.localRules, routed.state.route.localRules);
|
assert.deepEqual((await request(port, '/api/state')).route.localRules, routed.state.route.localRules);
|
||||||
|
|
||||||
|
const legacyNoop = await rawRequest(port, '/api/route-rules', 'PUT', {
|
||||||
|
expectedRevision: revision,
|
||||||
|
rules: routed.state.route.localRules,
|
||||||
|
});
|
||||||
|
assert.equal(legacyNoop.response.status, 200);
|
||||||
|
|
||||||
const workingConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
|
const workingConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
|
||||||
fs.writeFileSync(singboxPath, `#!/usr/bin/env node
|
fs.writeFileSync(singboxPath, `#!/usr/bin/env node
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
@@ -301,7 +314,7 @@ setInterval(() => {}, 60_000);
|
|||||||
`);
|
`);
|
||||||
fs.chmodSync(singboxPath, 0o755);
|
fs.chmodSync(singboxPath, 0o755);
|
||||||
const failedRules = await rawRequest(port, '/api/route-rules', 'PUT', {
|
const failedRules = await rawRequest(port, '/api/route-rules', 'PUT', {
|
||||||
expectedRevision: revision,
|
expectedRulesRevision: rulesRevision,
|
||||||
rules: [{ type: 'domain', value: 'broken.example' }],
|
rules: [{ type: 'domain', value: 'broken.example' }],
|
||||||
});
|
});
|
||||||
assert.equal(failedRules.response.status, 422);
|
assert.equal(failedRules.response.status, 422);
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ test('rule editor add latency stays constant and dirty exits are guarded', () =>
|
|||||||
assert.match(component, /addEventListener\('beforeunload', warnBeforeUnload\)/);
|
assert.match(component, /addEventListener\('beforeunload', warnBeforeUnload\)/);
|
||||||
assert.match(component, /requestCloseLocalRules\(\)/);
|
assert.match(component, /requestCloseLocalRules\(\)/);
|
||||||
assert.match(component, /localRulesPendingRestart/);
|
assert.match(component, /localRulesPendingRestart/);
|
||||||
|
assert.match(component, /activeLocalRules/);
|
||||||
|
assert.match(component, /localRulesRevision/);
|
||||||
|
assert.match(component, /Не сохранено/);
|
||||||
|
assert.match(component, /Ждёт перезапуска/);
|
||||||
|
assert.match(component, /Перезапустить VPN/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('critical confirmations share one accessible blocking popup', () => {
|
test('critical confirmations share one accessible blocking popup', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user