diff --git a/src/shared/versions.ts b/src/shared/versions.ts index 08b5ce3..949de41 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,6 +1,6 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.31.1', - gatewayClient: '0.32.1', + macClient: '0.31.2', + gatewayClient: '0.32.2', gatewayBackend: '0.32.2', }); diff --git a/src/web/features/failover/FailoverFeature.tsx b/src/web/features/failover/FailoverFeature.tsx index ad55b60..07e23c0 100644 --- a/src/web/features/failover/FailoverFeature.tsx +++ b/src/web/features/failover/FailoverFeature.tsx @@ -131,37 +131,6 @@ function FailoverNumberSetting({ before, after, value, min, max, step = 10, disa ; } -const reasonLabel = (reason: string | null) => ({ - 'primary-healthy': 'Основной канал работает', - 'health-unknown': 'Ждём первую проверку', - 'checking-channels': 'Проверяем оба канала', - 'failure-window': 'Убеждаемся, что основной канал не работает', - 'reserve-not-healthy': 'Ждём, пока резервный канал начнёт работать', - 'both-unhealthy': 'Оба канала недоступны', - 'primary-not-recovered': 'Основной канал ещё не восстановился', - 'recovery-hold': 'Убеждаемся, что основной канал работает без сбоев', - 'activity-unknown': 'Не удалось проверить передачу данных', - 'active-traffic': 'Ждём паузу в передаче данных', - 'quiet-window': 'Проверяем, что передача данных остановилась', - 'primary-failed': 'Основной канал недоступен', - 'primary-recovered': 'Основной канал восстановился', - 'pending-activation': 'Настройка применится при запуске VPN', - 'vpn-stopped': 'VPN выключен', - paused: 'Автоматическое переключение приостановлено', - disabled: 'Резервный канал выключен', - 'switch-failed': 'Не удалось переключить канал', - 'selector-unknown': 'Не удалось проверить выбранный канал', - 'reconcile-failed': 'Настройки сохранены. Повторим проверку позже', - 'revalidation-required': 'Проверяем условия ещё раз', - 'manual-check': 'Оба канала проверены', -}[reason || ''] || 'Следим за каналами'); - -function targetLabel(profiles: ProfileSnapshot[], target: { profileId: string; serverId: string }) { - const profile = profiles.find(({ id }) => id === target.profileId); - const server = profile?.servers.find(({ id }) => id === target.serverId); - return profile && server ? channelLabel(profile.label, server.label) : 'Не выбран'; -} - const channelLabel = (profile: string, server: string) => profile.trim() === server.trim() ? server : `${profile} · ${server}`; @@ -409,16 +378,15 @@ export function FailoverPanel({ ? snapshot.currentRole : null; const switchRole = role === 'primary' ? 'reserve' : role === 'reserve' ? 'primary' : null; + const switchActionLabel = role && switchRole + ? `Сейчас ${role === 'primary' ? 'основной' : 'резервный'} канал. Переключить на ${switchRole === 'primary' ? 'основной' : 'резервный'} канал` + : 'Переключение недоступно'; const checking = manualChecking || snapshot.reason === 'checking-channels'; - const activity = snapshot.trafficActivity; const targetExists = (target: FailoverPolicy['primary']) => profiles .find(({ id }) => id === target.profileId)?.servers.some(({ id }) => id === target.serverId); const targetsValid = targetExists(draft.primary) && targetExists(draft.reserve) && (draft.primary.profileId !== draft.reserve.profileId || draft.primary.serverId !== draft.reserve.serverId); const draftValid = !draft.enabled || Boolean(targetsValid && draft.checks.length); - const quietElapsed = activity?.quietSince - ? Math.max(0, Date.now() - Date.parse(activity.quietSince)) - : 0; const addService = async () => { try { const saved = await saveCustomDiagnosticService({ @@ -542,6 +510,9 @@ export function FailoverPanel({ onOpenChange={(open) => setOpenPicker(open ? channel : '')} onChange={(optionId) => updateTarget(channel, optionId)} /> + {channel === 'primary' &&
}; })} -
- {(manualChecking || snapshot.reason === 'checking-channels' ? ['primary', 'reserve'] as const : []).map((checkingRole) => Проверяем {checkingRole === 'primary' ? 'основной' : 'резервный'} канал)} -
- - -
- Сейчас используется - {blocked - ? 'Harbor завершает текущее действие' - : role === 'primary' - ? 'Основной канал' - : role === 'reserve' - ? 'Резервный канал' - : 'Канал пока не выбран'} - {blocked ? 'Подождите…' : reasonLabel(snapshot.reason)} - {role - ? `Новые подключения: ${targetLabel(profiles, snapshot[role].target)}` - : snapshot.reason === 'vpn-stopped' - ? `При запуске VPN будет выбран: ${targetLabel(profiles, draft.primary)}` - : `После перезапуска VPN будет выбран: ${targetLabel(profiles, draft.primary)}`} - {snapshot.nextDecisionAt && Проверим каналы снова через {seconds(Math.max(0, Date.parse(snapshot.nextDecisionAt) - Date.now()))} с} - {activity && {activity.state === 'active' ? `Сейчас передаётся ${Math.round(activity.totalBytesPerSecond / 1024)} КБ/с. Активных подключений: ${activity.transmittingConnections}` : activity.state === 'quiet' ? `Передачи данных нет: ${Math.min(seconds(quietElapsed), seconds(draft.trafficGuard.quietWindowMs))} из ${seconds(draft.trafficGuard.quietWindowMs)} с` : 'Не удалось проверить передачу данных. Автоматическое переключение остановлено'}} - {activity?.blockers.slice(0, 2).map((blocker) => {blocker.device} · {blocker.service} · {Math.round((blocker.uploadBytesPerSecond + blocker.downloadBytesPerSecond) / 1024)} КБ/с)} - {activity && activity.blockers.length > 2 && Ещё {activity.blockers.length - 2}} + {checking ? 'Проверяем основной и резервный каналы' : ''}
diff --git a/src/web/styles/features/failover.css b/src/web/styles/features/failover.css index be2f12d..156e318 100644 --- a/src/web/styles/features/failover.css +++ b/src/web/styles/features/failover.css @@ -28,9 +28,12 @@ .client-failover-channel-title strong.is-healthy { color: var(--client-accent); } .client-failover-channel-title strong.is-unhealthy { color: oklch(0.68 0.15 28); } .client-failover-picker { position: relative; min-width: 0; z-index: 1; } -.client-failover-checking { grid-column: 1 / -1; min-height: 22px; display: flex; flex-wrap: wrap; gap: 6px 22px; color: var(--client-muted); font: var(--type-control); letter-spacing: var(--type-control-tracking); text-transform: var(--type-control-transform); } +.client-failover-channel-checking { min-height: 16px; color: var(--client-muted); font: var(--type-control); letter-spacing: var(--type-control-tracking); text-transform: var(--type-control-transform); opacity: 0; visibility: hidden; transition: opacity 180ms ease; } +.client-failover-channel-checking.is-visible { opacity: 1; visibility: visible; } .client-failover-channel-actions { grid-column: 2; grid-row: 1; min-height: 66px; display: grid; place-content: center; gap: 2px; } .client-failover-check-action, .client-failover-direction-action { width: 44px; height: 44px; display: grid; place-items: center; padding: 0; border: 0; background: transparent; color: var(--client-accent); cursor: pointer; } +.client-failover-direction-action.is-primary { color: var(--harbor-connect); } +.client-failover-direction-action.is-reserve { color: var(--harbor-gateway); } .client-failover-check-action:disabled, .client-failover-direction-action:disabled { color: var(--client-muted); cursor: default; opacity: .45; } .client-failover-save:focus-visible { outline: 2px solid var(--client-accent); outline-offset: 2px; } .client-failover-check-action svg { flex: 0 0 auto; width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } @@ -91,12 +94,6 @@ .client-failover-traffic-switch small { color: var(--client-muted); font: var(--type-control); letter-spacing: var(--type-control-tracking); text-transform: var(--type-control-transform); } .client-failover-protection { display: grid; gap: 5px; } .client-failover-protection-options { display: grid; gap: 0; } -.client-failover-runtime { display: grid; gap: 4px; margin-top: -10px; padding: 8px 0 2px; font-variant-numeric: var(--numeric-tabular); } -.client-failover-runtime-label { color: var(--client-muted); font: var(--type-label); letter-spacing: var(--type-label-tracking); text-transform: var(--type-label-transform); } -.client-failover-runtime strong { color: var(--client-muted); font: var(--type-item-title); font-variant-numeric: var(--numeric-tabular); letter-spacing: var(--type-item-title-tracking); text-transform: var(--type-item-title-transform); } -.client-failover-runtime.is-primary strong { color: var(--harbor-connect); text-shadow: 0 0 9px color-mix(in oklch, var(--harbor-connect) 30%, transparent); } -.client-failover-runtime.is-reserve strong { color: var(--harbor-gateway); text-shadow: 0 0 9px color-mix(in oklch, var(--harbor-gateway) 30%, transparent); } -.client-failover-runtime span, .client-failover-runtime small { color: var(--client-muted); font: var(--type-control); letter-spacing: var(--type-control-tracking); text-transform: var(--type-control-transform); } .client-failover-validation { margin: -12px 0 0; color: oklch(0.68 0.15 28); font: var(--type-control); letter-spacing: var(--type-control-tracking); text-transform: var(--type-control-transform); } .client-failover-pause-hint { color: var(--client-muted); font: var(--type-control); letter-spacing: var(--type-control-tracking); text-transform: var(--type-control-transform); } .client-failover-check-action:focus-visible, .client-failover-direction-action:focus-visible, .client-failover-service-editor button:focus-visible, .client-failover-number-steps button:focus-visible { outline: 2px solid var(--client-accent); outline-offset: 2px; } @@ -104,5 +101,5 @@ .client-failover-number-input:focus-visible { outline: 0; color: var(--client-accent); text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 38%, transparent); } @keyframes failover-service-in { from { opacity: 0; filter: blur(5px); transform: translateY(-4px); } to { opacity: 1; filter: blur(0); transform: translateY(0); } } @keyframes failover-service-out { from { opacity: 1; filter: blur(0); transform: translateX(0) scale(1); } to { opacity: 0; filter: blur(6px); transform: translateX(16px) scale(.96); } } -@media (max-width: 560px) { .client-failover-heading { align-items: flex-start; flex-wrap: wrap; } .client-failover-heading > h2 { min-width: 0; flex: 1 1 auto; } .client-failover-header-actions { flex: 1 1 100%; justify-content: flex-start; gap: 4px 10px; } .client-failover-channels { grid-template-columns: minmax(0, 1fr); gap: 12px; } .client-failover-checking { grid-column: 1; margin-top: -6px; } .client-failover-channel-actions { grid-column: 1; grid-row: auto; min-height: 44px; display: flex; justify-content: center; gap: 8px; } .client-failover-service { grid-template-columns: minmax(0, 1fr) 32px; gap: 6px 10px; } .client-failover-service > .client-failover-number-setting { grid-column: 1; grid-row: 2; } .client-failover-remove-service { grid-column: 2; grid-row: 1 / span 2; } .client-failover-service-editor { grid-template-columns: 1fr; gap: 2px; } } -@media (prefers-reduced-motion: reduce) { .client-failover-channel-title strong, .client-failover-switch::before, .client-failover-switch > span, .client-failover-picker-trigger, .client-failover-picker-trigger::before, .client-failover-picker-trigger svg, .client-failover-picker-list, .client-failover-picker-list button, .client-failover-number-steps, .client-failover-number-control:hover .client-failover-number-steps, .client-failover-number-control:focus-within .client-failover-number-steps, .client-failover-remove-service, .client-failover-remove-lid, .client-failover-direction-action svg { transition: none; } .client-failover-check-action.is-running svg { animation: none; } .client-failover-service, .client-failover-service-editor { animation: none; } } +@media (max-width: 560px) { .client-failover-heading { align-items: flex-start; flex-wrap: wrap; } .client-failover-heading > h2 { min-width: 0; flex: 1 1 auto; } .client-failover-header-actions { flex: 1 1 100%; justify-content: flex-start; gap: 4px 10px; } .client-failover-channels { grid-template-columns: minmax(0, 1fr); gap: 12px; } .client-failover-channel-actions { grid-column: 1; grid-row: auto; min-height: 44px; display: flex; justify-content: center; gap: 8px; } .client-failover-service { grid-template-columns: minmax(0, 1fr) 32px; gap: 6px 10px; } .client-failover-service > .client-failover-number-setting { grid-column: 1; grid-row: 2; } .client-failover-remove-service { grid-column: 2; grid-row: 1 / span 2; } .client-failover-service-editor { grid-template-columns: 1fr; gap: 2px; } } +@media (prefers-reduced-motion: reduce) { .client-failover-channel-title strong, .client-failover-channel-checking, .client-failover-switch::before, .client-failover-switch > span, .client-failover-picker-trigger, .client-failover-picker-trigger::before, .client-failover-picker-trigger svg, .client-failover-picker-list, .client-failover-picker-list button, .client-failover-number-steps, .client-failover-number-control:hover .client-failover-number-steps, .client-failover-number-control:focus-within .client-failover-number-steps, .client-failover-remove-service, .client-failover-remove-lid, .client-failover-direction-action svg { transition: none; } .client-failover-check-action.is-running svg { animation: none; } .client-failover-service, .client-failover-service-editor { animation: none; } } diff --git a/test/web/failover-feature-contract.test.js b/test/web/failover-feature-contract.test.js index 0c18373..31ff63d 100644 --- a/test/web/failover-feature-contract.test.js +++ b/test/web/failover-feature-contract.test.js @@ -23,7 +23,8 @@ test('failover settings use Harbor switches, a two-column channel table and plai assert.match(feature, /\['primary', 'reserve'\][\s\S]*client-failover-\$\{channel\}-options[\s\S]*updateTarget\(channel, optionId\)/); assert.match(feature, /channelLabel[\s\S]*profile\.trim\(\) === server\.trim\(\)[\s\S]*`\$\{profile} · \$\{server}`/); assert.match(feature, /client-failover-channels[\s\S]*
\{channel === 'primary' \? 'Основной' : 'Резервный'}/); - assert.match(feature, /snapshot\.reason === 'checking-channels'[\s\S]*Проверяем \{checkingRole === 'primary' \? 'основной' : 'резервный'} канал/); + assert.match(feature, /client-failover-channel-checking[\s\S]*Проверяем \{channel === 'primary' \? 'основной' : 'резервный'} канал/); + assert.match(feature, /client-live-region[\s\S]*Проверяем основной и резервный каналы/); assert.match(feature, /ArrowDown[\s\S]*Home[\s\S]*End[\s\S]*Escape/); assert.match(feature, /role="listbox"[\s\S]*role="option"/); assert.match(feature, /Что проверять[\s\S]*Harbor проверяет эти сайты через оба канала, чтобы понять, какой канал работает/); @@ -51,8 +52,9 @@ test('failover settings use Harbor switches, a two-column channel table and plai 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' &&
Проверить оба канала<\/Tooltip>[\s\S]*\{switchRole \? `Переключить на/); + assert.match(feature, /channel === 'primary' &&
Проверить оба канала<\/Tooltip>[\s\S]*\{switchActionLabel\}<\/Tooltip>/); 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/); @@ -64,6 +66,8 @@ test('failover settings use Harbor switches, a two-column channel table and plai 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, /\.client-failover-direction-action\.is-primary \{ color: var\(--harbor-connect\); \}/); + assert.match(styles, /\.client-failover-direction-action\.is-reserve \{ color: var\(--harbor-gateway\); \}/); 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:/); @@ -87,17 +91,14 @@ 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 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]*Проверим каналы снова через/); +test('active channel and checks stay attached to their channel columns without a duplicate runtime block', () => { + assert.match(feature, /Проверяем основной канал ·/); 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(feature, /className=\{`client-failover-direction-action client-tooltip-anchor\$\{role === 'primary'[\s\S]*role === 'reserve'/); + assert.match(feature, / { const witnesses = readStyleWitnesses(root); - assert.equal(witnesses.length, 1116); + assert.equal(witnesses.length, 1107); 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-CPDEnja7.css']); + assert.deepEqual(assets, ['index-oz5Ps7e5.css']); const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0])); - assert.equal(built.byteLength, 159016); - assert.equal(sha256(built), '2c9cf378a317ea633b1006299856806dccc4270a3bfe3e0aea00ec7f0ab3385e'); + assert.equal(built.byteLength, 158205); + assert.equal(sha256(built), 'b866b95d9cac46602023a35e0f55656af353e87ff230c7ad47d0aaeb768a7d3e'); });