Improve failover controls and preserve manual switching state
Build and Deploy Gateway / build-and-push (push) Failing after 1s
Build and Deploy Gateway / deploy (push) Has been skipped

This commit is contained in:
2026-08-28 19:40:53 +03:00
parent 8f2f418569
commit a84cca0668
30 changed files with 547 additions and 112 deletions
+16 -6
View File
@@ -514,12 +514,22 @@ test('selector acknowledgement precedes state commit and a failed commit rolls s
assert.equal(state.appliedServerId, 'server-1');
});
test('manual selector and pause publish in one canonical commit', async () => {
const testHarness = harness(serviceState());
await testHarness.service.manualSwitch('reserve');
assert.equal(testHarness.read().appliedServerId, 'server-2');
assert.equal(testHarness.read().failoverPolicy.paused, true);
assert.equal(testHarness.calls.filter((call) => call === 'state:update').length, 1);
test('manual switch preserves automation and starts the configured reserve hold', async () => {
const now = new Date('2026-08-27T12:00:00.000Z');
for (const paused of [false, true]) {
const state = serviceState(normalizeFailoverPolicy({
...enabled,
paused,
minimumReserveMs: 300_000,
}));
const testHarness = harness(state, { now: () => now });
await testHarness.service.manualSwitch('reserve');
assert.equal(testHarness.read().appliedServerId, 'server-2');
assert.equal(testHarness.read().failoverPolicy.enabled, true);
assert.equal(testHarness.read().failoverPolicy.paused, paused);
assert.equal(testHarness.read().failoverRuntimeState.holdUntil, new Date(now.getTime() + 300_000).toISOString());
assert.equal(testHarness.calls.filter((call) => call === 'state:update').length, 1);
}
});
test('post-commit monitoring failure does not report a committed pause as failed', async () => {
+30 -6
View File
@@ -46,12 +46,32 @@ test('failover settings use Harbor switches, a two-column channel table and plai
assert.doesNotMatch(feature, /<details|<summary|Нет данных|текущий канал вне настроенной пары/);
assert.doesNotMatch(page, /Текущий канал вне резервной пары/);
assert.match(page, /Резерв применится после перезапуска VPN/);
assert.match(feature, /Приостановить автоматическое переключение[\s\S]*Проверить оба канала[\s\S]*Переключить новые подключения на/);
assert.match(feature, /beforeunload/);
assert.match(feature, /client-failover-header-actions[\s\S]*Автоматика[\s\S]*checked=\{!snapshot\.paused\}[\s\S]*Включить автоматическое переключение[\s\S]*Остановить автоматическое переключение[\s\S]*className="client-failover-save"[\s\S]*type="submit"[\s\S]*form="client-failover-form"[\s\S]*Сохранить/);
assert.match(feature, /onChange=\{\(automatic\) => void onPause\(!automatic\)\}/);
assert.match(feature, /checked=\{!snapshot\.paused\}[\s\S]*disabled=\{blocked \|\| !snapshot.enabled\}/);
assert.match(feature, /client-failover-check-action[\s\S]*disabled=\{blocked \|\| checking \|\| !snapshot.enabled \|\| snapshot.activation !== 'active'\}/);
assert.doesNotMatch(feature, /client-failover-actions|client-failover-global-actions|client-failover-discard/);
assert.match(feature, /channel === 'primary' && <div className="client-failover-channel-actions"[\s\S]*client-failover-check-action client-tooltip-anchor[\s\S]*aria-label="Проверить оба канала"[\s\S]*aria-busy=\{checking\}[\s\S]*client-failover-direction-action client-tooltip-anchor[\s\S]*aria-label=\{switchRole \? `Переключить на \$\{switchRole === 'primary' \? 'основной' : 'резервный'\} канал`/);
assert.match(feature, /<Tooltip>Проверить оба канала<\/Tooltip>[\s\S]*<Tooltip>\{switchRole \? `Переключить на/);
assert.match(feature, /M20 11a8 8 0 1 0-2\.3 6\.7M20 5v6h-6/);
assert.match(feature, /M4 12h15M14 7l5 5-5 5/);
assert.match(feature, /normalizeFailoverDraft = \(policy: FailoverPolicy\)[\s\S]*minimumReserveMs: Math\.round\(normalized\.minimumReserveMs \/ 60_000\) \* 60_000/);
assert.match(feature, /before="Оставаться на резервном канале не меньше" after="минут" min=\{1\} max=\{1440\} step=\{1\}[\s\S]*value=\{draft\.minimumReserveMs \/ 60_000\}[\s\S]*minimumReserveMs: Math\.round\(value\) \* 60_000/);
assert.doesNotMatch(feature, /className="client-failover-number-input"[\s\S]*max=\{max\}[\s\S]*step=\{step\}/);
assert.match(feature, /<ConfirmationDialog[\s\S]*id="discard-failover"[\s\S]*Закрыть без сохранения\?[\s\S]*Несохранённые настройки резервного канала будут потеряны\.[\s\S]*Остаться[\s\S]*Закрыть без сохранения/);
assert.match(feature, /discardContinuationFrameRef[\s\S]*cancelAnimationFrame[\s\S]*requestAnimationFrame[\s\S]*continueNavigation[\s\S]*feature\.close\(\)/);
assert.match(page, /if \(!failoverFeature\.beforeCloseRef\.current\(\)\)[\s\S]*pendingTargetRef\.current = \(\) => switchDrawer\(target\)[\s\S]*pendingTargetRef\.current = null/);
assert.match(styles, /\.client-failover-check-action\.is-running svg \{ animation: client-spin 900ms linear infinite; \}/);
assert.match(styles, /\.client-failover-direction-action svg \{[^}]*transition: transform 260ms cubic-bezier\(0\.16, 1, 0\.3, 1\)/);
assert.match(styles, /\.client-failover-direction-action svg\.is-primary \{ transform: rotate\(180deg\); \}/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*client-failover-direction-action svg[\s\S]*transition: none[\s\S]*client-failover-check-action\.is-running svg[\s\S]*animation: none/);
assert.match(styles, /\.client-failover-switch\[aria-checked='true'\][\s\S]*translateX\(22px\)/);
assert.match(styles, /\.client-failover-header \{[^}]*position:\s*sticky[^}]*top:\s*0[^}]*background:/);
assert.match(styles, /\.client-failover-heading \{[^}]*justify-content:\s*space-between[^}]*}/);
assert.doesNotMatch(styles, /\.client-failover-heading \{[^}]*padding-right/);
assert.match(styles, /\.client-failover-channels \{[^}]*grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/);
assert.match(styles, /\.client-failover-channels \{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) 52px minmax\(0, 1fr\)/);
assert.match(styles, /\.client-failover-channel-actions \{[^}]*grid-column:\s*2[^}]*grid-row:\s*1/);
assert.match(styles, /\.client-failover-check-action, \.client-failover-direction-action \{[^}]*width:\s*44px[^}]*height:\s*44px/);
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-failover-channels \{ grid-template-columns:\s*minmax\(0, 1fr\)/);
assert.match(styles, /\.client-failover-picker-list[\s\S]*opacity:\s*0[\s\S]*\.client-failover-picker\.is-open[\s\S]*opacity:\s*1/);
assert.match(styles, /failover-service-out 300ms[\s\S]*@media \(prefers-reduced-motion: reduce\)/);
@@ -67,13 +87,17 @@ test('failover settings use Harbor switches, a two-column channel table and plai
assert.doesNotMatch(styles, /client-failover-advanced|failover-advanced-in|failover-advanced-out/);
});
test('runtime status keeps fixed geometry and explains active traffic without motion dependence', () => {
test('runtime status sits beside the channel controls and explains active traffic without motion dependence', () => {
assert.match(feature, /Ждём паузу в передаче данных/);
assert.match(feature, /Сейчас передаётся [\s\S]*Активных подключений:[\s\S]*transmittingConnections/);
assert.match(feature, /activity\?\.blockers\.slice\(0, 2\)\.map/);
assert.match(feature, /Ещё \{activity\.blockers\.length - 2\}/);
assert.match(feature, /Проверяем основной канал ·[\s\S]*Проверим каналы снова через/);
assert.match(feature, /switchRole && snapshot\.enabled && snapshot\.activation === 'active'/);
assert.match(styles, /\.client-failover-runtime \{[^}]*min-height:\s*132px/);
assert.match(feature, /client-failover-direction-action[\s\S]*disabled=\{blocked \|\| !snapshot.enabled \|\| snapshot.activation !== 'active' \|\| !switchRole\}/);
assert.match(feature, /client-failover-channels[\s\S]*client-failover-runtime[\s\S]*Что проверять/);
assert.match(feature, /Сейчас используется[\s\S]*Основной канал[\s\S]*Резервный канал[\s\S]*Новые подключения:/);
assert.match(styles, /\.client-failover-runtime\.is-primary strong \{[^}]*var\(--harbor-connect\)/);
assert.match(styles, /\.client-failover-runtime\.is-reserve strong \{[^}]*var\(--harbor-gateway\)/);
assert.doesNotMatch(styles, /\.client-failover-runtime \{[^}]*min-height:\s*132px/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[^{]*\{[^}]*\.client-failover-number-control:hover \.client-failover-number-steps[^}]*transition:\s*none/);
});
+2 -1
View File
@@ -104,9 +104,10 @@ test('tablet and mobile regions use normal flow with viewport-safe widths', () =
assert.match(mobile, /\.client-secondary-menu \{[\s\S]*right:\s*8px/);
assert.match(mobile, /\.client-power-control > \.harbor-brand \{[\s\S]*scale\(1\.35\)/);
assert.match(mobile, /\.client-server-check \{[\s\S]*width:\s*44px/);
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-failover-heading \{[\s\S]*flex-wrap:\s*wrap[\s\S]*\.client-failover-channel-actions \{[\s\S]*grid-column:\s*1[\s\S]*grid-row:\s*auto[\s\S]*display:\s*flex[\s\S]*justify-content:\s*center/);
assert.match(styles, /\.client-failover-channel-actions \{[^}]*grid-column:\s*2[^}]*display:\s*grid/);
assert.match(rule('.client-drawer'), /width:\s*min\(580px, 100vw\)/);
assert.match(styles, /\.client-confirmation-dialog\s*\{[\s\S]*width:\s*min\(430px, calc\(100vw - 48px\)\)/);
for (const viewport of [320, 390, 768]) {
const pagePadding = viewport <= 560 ? 28 : 48;
const panelPadding = 12;
+5 -2
View File
@@ -11,6 +11,7 @@ const connection = fs.readFileSync(path.join(root, 'src/web/features/connection/
const subscription = fs.readFileSync(path.join(root, 'src/web/features/subscription/SubscriptionFeature.tsx'), 'utf8');
const routing = fs.readFileSync(path.join(root, 'src/web/features/routing/RoutingFeature.tsx'), 'utf8');
const instructions = fs.readFileSync(path.join(root, 'src/web/features/instructions/InstructionsFeature.tsx'), 'utf8');
const failover = fs.readFileSync(path.join(root, 'src/web/features/failover/FailoverFeature.tsx'), 'utf8');
const dialog = fs.readFileSync(path.join(root, 'src/web/ui/ConfirmationDialog.tsx'), 'utf8');
const copyButton = fs.readFileSync(path.join(root, 'src/web/ui/CopyButton.tsx'), 'utf8');
const styles = readStyleSource(root);
@@ -54,12 +55,14 @@ test('critical confirmations share one accessible blocking popup', () => {
(component.match(/<ConfirmationDialog/g) || []).length
+ (connection.match(/<ConfirmationDialog/g) || []).length
+ (subscription.match(/<ConfirmationDialog/g) || []).length
+ (routing.match(/<ConfirmationDialog/g) || []).length,
3,
+ (routing.match(/<ConfirmationDialog/g) || []).length
+ (failover.match(/<ConfirmationDialog/g) || []).length,
4,
);
assert.match(connection, /id="stop-connection"/);
assert.match(routing, /id="discard-local-rules"/);
assert.match(subscription, /id="delete-subscription"/);
assert.match(failover, /id="discard-failover"/);
assert.doesNotMatch(component, /client-local-rules-discard|client-delete-confirmation/);
assert.match(dialog, /role="alertdialog"/);
assert.match(dialog, /aria-modal="true"/);
+14 -14
View File
@@ -39,26 +39,26 @@ const expectedImports = [
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const acceptedLedger = {
counts: {
cascadeEdges: 1058,
cascadeEdges: 1072,
customProperties: 111,
declarations: 4131,
declarations: 4172,
important: 0,
keyframes: 50,
media: 17,
rules: 1114,
variableReferences: 1055,
rules: 1128,
variableReferences: 1052,
},
hashes: {
cascadeEdges: '77b6f34b3bc07b326f477cc436926fb9c4c573e5da9bba840c9bf937542b4488',
cascadeEdges: '2a01e700f68f3aae944f48e14490abaf58dbb0fc5c7c798622d95537cfaefc5d',
customProperties: '42f9fa9f2caaab6e5d90ae7d224563355822fa270083ea3496cfe2caaaea50bf',
declarations: 'ac1ac026d0c9d656fde9b28f78faea216a518a44bd2856b2005041f7cb4abd11',
declarations: '92af2ebf3ebcce46292f3ff4ab8ce687b0340f894fb122cb6c19a2f46dba2507',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
keyframes: 'af4b9ae18d070fd2462a9b050293f3821bf5017c6e7cc53bbe18dc826f0fe3b9',
ruleDeclarationSequences: '885efbe91db2ecb739f3c5aa42023576a849d28e639a1694ad13ed696bef77c5',
selectors: 'fbd8ac40b8d543a29b7140db906d1a3f7fc16a64ebbb380f358629f78657456a',
variableReferences: '70cef326744490dbb615e83457cd42b81b6f2322b0e93e4de6661d53db97e14b',
witnesses: 'e9f8c6e640c19375635847dc064edd6752a8216c6acd3c2aad6ffd18c1a4aaa2',
ruleDeclarationSequences: '415524da6346d9f93de0ef53cd82146f5781a7f304be7f90ee89202d81805939',
selectors: '0ff8c058c365d549c12c65153759007e59006d6f522d7545402408e811676e46',
variableReferences: '0fbcb39e235f068290e60ce52d92ae8f47f29fd11326d94ad9864b8c3051e92c',
witnesses: '78a56eafce572ab39538f6d3778fd0300da84e934cc8dacaacd8e789180fe543',
},
};
@@ -211,7 +211,7 @@ test('client typography uses the shared semantic scale outside the token owner',
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
const witnesses = readStyleWitnesses(root);
assert.equal(witnesses.length, 1090);
assert.equal(witnesses.length, 1116);
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
assert.deepEqual(ledger.counts, acceptedLedger.counts);
@@ -408,8 +408,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
assert.deepEqual(assets, ['index-3s7H6Y0X.css']);
assert.deepEqual(assets, ['index-CPDEnja7.css']);
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 157874);
assert.equal(sha256(built), '99b874016cac6f1b617cd21d5a7ac957f491d8926fe78e8ba514d9313255488f');
assert.equal(built.byteLength, 159016);
assert.equal(sha256(built), '2c9cf378a317ea633b1006299856806dccc4270a3bfe3e0aea00ec7f0ab3385e');
});
+1 -1
View File
@@ -32,7 +32,7 @@ test('all repeated client primitive consumers use the shared owners', () => {
.map(({ source }) => source)
.join('\n');
assert.equal((production.match(/<Tooltip\b/g) || []).length, 14);
assert.equal((production.match(/<Tooltip\b/g) || []).length, 16);
assert.equal((production.match(/<CopyButton\b/g) || []).length, 2);
assert.equal((production.match(/<RailAction\b/g) || []).length, 7);
assert.equal((production.match(/<Drawer\b/g) || []).length, 7);