Refine failover channel status and switch controls
Build and Deploy Gateway / build-and-push (push) Successful in 24s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-08-28 21:13:09 +03:00
parent 14b3c2afac
commit f977874da6
5 changed files with 47 additions and 101 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.31.1', macClient: '0.31.2',
gatewayClient: '0.32.1', gatewayClient: '0.32.2',
gatewayBackend: '0.32.2', gatewayBackend: '0.32.2',
}); });
+11 -63
View File
@@ -131,37 +131,6 @@ function FailoverNumberSetting({ before, after, value, min, max, step = 10, disa
</div>; </div>;
} }
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() const channelLabel = (profile: string, server: string) => profile.trim() === server.trim()
? server ? server
: `${profile} · ${server}`; : `${profile} · ${server}`;
@@ -409,16 +378,15 @@ export function FailoverPanel({
? snapshot.currentRole ? snapshot.currentRole
: null; : null;
const switchRole = role === 'primary' ? 'reserve' : role === 'reserve' ? 'primary' : 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 checking = manualChecking || snapshot.reason === 'checking-channels';
const activity = snapshot.trafficActivity;
const targetExists = (target: FailoverPolicy['primary']) => profiles const targetExists = (target: FailoverPolicy['primary']) => profiles
.find(({ id }) => id === target.profileId)?.servers.some(({ id }) => id === target.serverId); .find(({ id }) => id === target.profileId)?.servers.some(({ id }) => id === target.serverId);
const targetsValid = targetExists(draft.primary) && targetExists(draft.reserve) const targetsValid = targetExists(draft.primary) && targetExists(draft.reserve)
&& (draft.primary.profileId !== draft.reserve.profileId || draft.primary.serverId !== draft.reserve.serverId); && (draft.primary.profileId !== draft.reserve.profileId || draft.primary.serverId !== draft.reserve.serverId);
const draftValid = !draft.enabled || Boolean(targetsValid && draft.checks.length); 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 () => { const addService = async () => {
try { try {
const saved = await saveCustomDiagnosticService({ const saved = await saveCustomDiagnosticService({
@@ -542,6 +510,9 @@ export function FailoverPanel({
onOpenChange={(open) => setOpenPicker(open ? channel : '')} onOpenChange={(open) => setOpenPicker(open ? channel : '')}
onChange={(optionId) => updateTarget(channel, optionId)} onChange={(optionId) => updateTarget(channel, optionId)}
/> />
<span className={`client-failover-channel-checking${checking ? ' is-visible' : ''}`} aria-hidden="true">
Проверяем {channel === 'primary' ? 'основной' : 'резервный'} канал
</span>
</article>{channel === 'primary' && <div className="client-failover-channel-actions" aria-label="Действия с каналами"> </article>{channel === 'primary' && <div className="client-failover-channel-actions" aria-label="Действия с каналами">
<button <button
className={`client-failover-check-action client-tooltip-anchor${checking ? ' is-running' : ''}`} className={`client-failover-check-action client-tooltip-anchor${checking ? ' is-running' : ''}`}
@@ -560,43 +531,20 @@ export function FailoverPanel({
<Tooltip>Проверить оба канала</Tooltip> <Tooltip>Проверить оба канала</Tooltip>
</button> </button>
<button <button
className="client-failover-direction-action client-tooltip-anchor" className={`client-failover-direction-action client-tooltip-anchor${role === 'primary' ? ' is-primary' : role === 'reserve' ? ' is-reserve' : ''}`}
type="button" type="button"
aria-label={switchRole ? `Переключить на ${switchRole === 'primary' ? 'основной' : 'резервный'} канал` : 'Переключить канал'} aria-label={switchActionLabel}
disabled={blocked || !snapshot.enabled || snapshot.activation !== 'active' || !switchRole} disabled={blocked || !snapshot.enabled || snapshot.activation !== 'active' || !switchRole}
onClick={() => { if (switchRole) void onSwitch(switchRole); }} onClick={() => { if (switchRole) void onSwitch(switchRole); }}
> >
<svg className={`client-failover-direction-icon ${switchRole === 'primary' ? 'is-primary' : 'is-reserve'}`} viewBox="0 0 24 24" aria-hidden="true"> <svg className={role === 'primary' ? 'is-primary' : role === 'reserve' ? 'is-reserve' : undefined} viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 12h15M14 7l5 5-5 5" /> <path d="M4 12h15M14 7l5 5-5 5" />
</svg> </svg>
<Tooltip>{switchRole ? `Переключить на ${switchRole === 'primary' ? 'основной' : 'резервный'} канал` : 'Переключение недоступно'}</Tooltip> <Tooltip>{switchActionLabel}</Tooltip>
</button> </button>
</div>}</div>; </div>}</div>;
})} })}
<div className="client-failover-checking" role="status" aria-live="polite"> <span className="client-live-region" role="status" aria-live="polite">{checking ? 'Проверяем основной и резервный каналы' : ''}</span>
{(manualChecking || snapshot.reason === 'checking-channels' ? ['primary', 'reserve'] as const : []).map((checkingRole) => <span key={checkingRole}>Проверяем {checkingRole === 'primary' ? 'основной' : 'резервный'} канал</span>)}
</div>
</section>
<section className={`client-failover-runtime ${role === 'primary' ? 'is-primary' : role === 'reserve' ? 'is-reserve' : 'is-none'}`} aria-label="Текущее состояние каналов">
<span className="client-failover-runtime-label">Сейчас используется</span>
<strong role="status" aria-live="polite">{blocked
? 'Harbor завершает текущее действие'
: role === 'primary'
? 'Основной канал'
: role === 'reserve'
? 'Резервный канал'
: 'Канал пока не выбран'}</strong>
<span>{blocked ? 'Подождите…' : reasonLabel(snapshot.reason)}</span>
<span>{role
? `Новые подключения: ${targetLabel(profiles, snapshot[role].target)}`
: snapshot.reason === 'vpn-stopped'
? `При запуске VPN будет выбран: ${targetLabel(profiles, draft.primary)}`
: `После перезапуска VPN будет выбран: ${targetLabel(profiles, draft.primary)}`}</span>
{snapshot.nextDecisionAt && <span>Проверим каналы снова через {seconds(Math.max(0, Date.parse(snapshot.nextDecisionAt) - Date.now()))} с</span>}
{activity && <span>{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)} с` : 'Не удалось проверить передачу данных. Автоматическое переключение остановлено'}</span>}
{activity?.blockers.slice(0, 2).map((blocker) => <small key={`${blocker.device}:${blocker.service}`}>{blocker.device} · {blocker.service} · {Math.round((blocker.uploadBytesPerSecond + blocker.downloadBytesPerSecond) / 1024)} КБ/с</small>)}
{activity && activity.blockers.length > 2 && <small>Ещё {activity.blockers.length - 2}</small>}
</section> </section>
<fieldset className="client-failover-services"> <fieldset className="client-failover-services">
+6 -9
View File
@@ -28,9 +28,12 @@
.client-failover-channel-title strong.is-healthy { color: var(--client-accent); } .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-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-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-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-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-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-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; } .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-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 { display: grid; gap: 5px; }
.client-failover-protection-options { display: grid; gap: 0; } .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-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-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; } .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); } .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-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); } } @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 (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-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 (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; } }
+15 -14
View File
@@ -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, /\['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, /channelLabel[\s\S]*profile\.trim\(\) === server\.trim\(\)[\s\S]*`\$\{profile} · \$\{server}`/);
assert.match(feature, /client-failover-channels[\s\S]*<article className="client-failover-channel"[\s\S]*<h3>\{channel === 'primary' \? 'Основной' : 'Резервный'}/); assert.match(feature, /client-failover-channels[\s\S]*<article className="client-failover-channel"[\s\S]*<h3>\{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, /ArrowDown[\s\S]*Home[\s\S]*End[\s\S]*Escape/);
assert.match(feature, /role="listbox"[\s\S]*role="option"/); assert.match(feature, /role="listbox"[\s\S]*role="option"/);
assert.match(feature, /Что проверять[\s\S]*Harbor проверяет эти сайты через оба канала, чтобы понять, какой канал работает/); 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, /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.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.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, /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=\{switchActionLabel\}/);
assert.match(feature, /<Tooltip>Проверить оба канала<\/Tooltip>[\s\S]*<Tooltip>\{switchRole \? `Переключить на/); assert.match(feature, /switchActionLabel = role && switchRole[\s\S]*`Сейчас \$\{role === 'primary' \? 'основной' : 'резервный'\} канал\. Переключить на/);
assert.match(feature, /<Tooltip>Проверить оба канала<\/Tooltip>[\s\S]*<Tooltip>\{switchActionLabel\}<\/Tooltip>/);
assert.match(feature, /M20 11a8 8 0 1 0-2\.3 6\.7M20 5v6h-6/); 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, /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, /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-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 \{[^}]*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 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, /@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-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-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/); 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', () => { test('active channel and checks stay attached to their channel columns without a duplicate runtime block', () => {
assert.match(feature, /Ждём паузу в передаче данных/); 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, /client-failover-direction-action[\s\S]*disabled=\{blocked \|\| !snapshot.enabled \|\| snapshot.activation !== 'active' \|\| !switchRole\}/); 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, /className=\{`client-failover-direction-action client-tooltip-anchor\$\{role === 'primary'[\s\S]*role === 'reserve'/);
assert.match(feature, /Сейчас используется[\s\S]*Основной канал[\s\S]*Резервный канал[\s\S]*Новые подключения:/); assert.match(feature, /<svg className=\{role === 'primary' \? 'is-primary' : role === 'reserve' \? 'is-reserve' : undefined\}/);
assert.match(styles, /\.client-failover-runtime\.is-primary strong \{[^}]*var\(--harbor-connect\)/); assert.doesNotMatch(feature, /client-failover-runtime|Сейчас используется|Новые подключения:/);
assert.match(styles, /\.client-failover-runtime\.is-reserve strong \{[^}]*var\(--harbor-gateway\)/); assert.doesNotMatch(styles, /\.client-failover-runtime/);
assert.doesNotMatch(styles, /\.client-failover-runtime \{[^}]*min-height:\s*132px/); assert.match(styles, /\.client-failover-channel-checking \{[^}]*min-height:\s*16px[^}]*opacity:\s*0[^}]*visibility:\s*hidden/);
assert.match(styles, /\.client-failover-channel-checking\.is-visible \{[^}]*opacity:\s*1[^}]*visibility:\s*visible/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[^{]*\{[^}]*\.client-failover-number-control:hover \.client-failover-number-steps[^}]*transition:\s*none/); assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[^{]*\{[^}]*\.client-failover-number-control:hover \.client-failover-number-steps[^}]*transition:\s*none/);
}); });
+13 -13
View File
@@ -41,24 +41,24 @@ const acceptedLedger = {
counts: { counts: {
cascadeEdges: 1072, cascadeEdges: 1072,
customProperties: 111, customProperties: 111,
declarations: 4172, declarations: 4151,
important: 0, important: 0,
keyframes: 50, keyframes: 50,
media: 17, media: 17,
rules: 1128, rules: 1124,
variableReferences: 1052, variableReferences: 1036,
}, },
hashes: { hashes: {
cascadeEdges: '2a01e700f68f3aae944f48e14490abaf58dbb0fc5c7c798622d95537cfaefc5d', cascadeEdges: 'd60343b571f5a9bcf809609b4731500587da3bdd9ee9f40e33938ecbd8aec6ce',
customProperties: '42f9fa9f2caaab6e5d90ae7d224563355822fa270083ea3496cfe2caaaea50bf', customProperties: '42f9fa9f2caaab6e5d90ae7d224563355822fa270083ea3496cfe2caaaea50bf',
declarations: '92af2ebf3ebcce46292f3ff4ab8ce687b0340f894fb122cb6c19a2f46dba2507', declarations: 'ee6b58dc6ef2edd16b8edf1762bb5c19aaf12d908958110c691f0a42f8e9067c',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848', duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
keyframes: 'af4b9ae18d070fd2462a9b050293f3821bf5017c6e7cc53bbe18dc826f0fe3b9', keyframes: 'af4b9ae18d070fd2462a9b050293f3821bf5017c6e7cc53bbe18dc826f0fe3b9',
ruleDeclarationSequences: '415524da6346d9f93de0ef53cd82146f5781a7f304be7f90ee89202d81805939', ruleDeclarationSequences: 'e6a9c7019ab48d5bb15ac8ff0ffa51f8df7f1143ee9f8f562bdc78237a64b4b1',
selectors: '0ff8c058c365d549c12c65153759007e59006d6f522d7545402408e811676e46', selectors: '4788b9fbc3aef9455ea9bf071f111dde318fbfe1fa96fe78127cc436131c4506',
variableReferences: '0fbcb39e235f068290e60ce52d92ae8f47f29fd11326d94ad9864b8c3051e92c', variableReferences: '04c19450f58f80dbe90e6df8988b8fa97f8c963d579d4f8acdb5bccd9d4020e9',
witnesses: '78a56eafce572ab39538f6d3778fd0300da84e934cc8dacaacd8e789180fe543', witnesses: '7e42ab6ad3901fd68c9328e612eb413308ad8716031cbedd3134bf85224f6b9a',
}, },
}; };
@@ -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', () => { test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
const witnesses = readStyleWitnesses(root); 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); assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
const ledger = createStyleLedger(readStyleSource(root), { witnesses }); const ledger = createStyleLedger(readStyleSource(root), { witnesses });
assert.deepEqual(ledger.counts, acceptedLedger.counts); 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); assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css')); 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])); const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 159016); assert.equal(built.byteLength, 158205);
assert.equal(sha256(built), '2c9cf378a317ea633b1006299856806dccc4270a3bfe3e0aea00ec7f0ab3385e'); assert.equal(sha256(built), 'b866b95d9cac46602023a35e0f55656af353e87ff230c7ad47d0aaeb768a7d3e');
}); });