Refine failover channel status and switch controls
This commit is contained in:
@@ -131,37 +131,6 @@ function FailoverNumberSetting({ before, after, value, min, max, step = 10, disa
|
||||
</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()
|
||||
? 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)}
|
||||
/>
|
||||
<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="Действия с каналами">
|
||||
<button
|
||||
className={`client-failover-check-action client-tooltip-anchor${checking ? ' is-running' : ''}`}
|
||||
@@ -560,43 +531,20 @@ export function FailoverPanel({
|
||||
<Tooltip>Проверить оба канала</Tooltip>
|
||||
</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"
|
||||
aria-label={switchRole ? `Переключить на ${switchRole === 'primary' ? 'основной' : 'резервный'} канал` : 'Переключить канал'}
|
||||
aria-label={switchActionLabel}
|
||||
disabled={blocked || !snapshot.enabled || snapshot.activation !== 'active' || !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" />
|
||||
</svg>
|
||||
<Tooltip>{switchRole ? `Переключить на ${switchRole === 'primary' ? 'основной' : 'резервный'} канал` : 'Переключение недоступно'}</Tooltip>
|
||||
<Tooltip>{switchActionLabel}</Tooltip>
|
||||
</button>
|
||||
</div>}</div>;
|
||||
})}
|
||||
<div className="client-failover-checking" role="status" aria-live="polite">
|
||||
{(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>}
|
||||
<span className="client-live-region" role="status" aria-live="polite">{checking ? 'Проверяем основной и резервный каналы' : ''}</span>
|
||||
</section>
|
||||
|
||||
<fieldset className="client-failover-services">
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
Reference in New Issue
Block a user