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
+11 -63
View File
@@ -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">