Update Harbor client and gateway integration workflows
Build and Deploy Gateway / build-and-push (push) Failing after 14s
Build and Deploy Gateway / deploy (push) Has been skipped

This commit is contained in:
2026-08-19 18:16:10 +03:00
parent 416b2b294a
commit daec12e013
63 changed files with 5016 additions and 108 deletions
@@ -0,0 +1,167 @@
import { useEffect, useRef, useState } from 'react';
import { assertActivityJournalPage, type ActivityJournalEvent } from '../../../shared/activityJournal.js';
import { Drawer } from '../../ui/Drawer.js';
import { RailAction } from '../../ui/RailAction.js';
export function useActivityJournalFeature() {
const [isOpen, setIsOpen] = useState(false);
const panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!isOpen) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus());
const close = (event: PointerEvent | KeyboardEvent) => {
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
if (event.type !== 'keydown' && (
panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)
)) return;
setIsOpen(false);
};
document.addEventListener('pointerdown', close);
document.addEventListener('keydown', close);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('pointerdown', close);
document.removeEventListener('keydown', close);
requestAnimationFrame(() => {
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
});
};
}, [isOpen]);
return { isOpen, panelRef, toggleRef, closeRef, close: () => setIsOpen(false), toggle: () => setIsOpen((value) => !value) };
}
export type ActivityJournalFeature = ReturnType<typeof useActivityJournalFeature>;
export function ActivityJournalToggle({ feature, open, onToggle }: {
feature: ActivityJournalFeature;
open: boolean;
onToggle: () => void;
}) {
return <RailAction
buttonRef={feature.toggleRef}
className="client-journal-toggle"
open={open}
controls="client-activity-journal"
ariaLabel={open ? 'Закрыть журнал событий' : 'Открыть журнал событий'}
label="Журнал"
onClick={onToggle}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 8V4m0 4h4M5.6 7.1A8 8 0 1 1 4 12M12 7.5V12l3 2" />
<circle cx="12" cy="12" r=".8" />
</svg>
</RailAction>;
}
function eventCopy(event: ActivityJournalEvent) {
const value = event.data;
const copies: Record<string, [string, string]> = {
'connection.started': ['VPN включён', [value.profileLabel, value.serverLabel].filter(Boolean).join(' · ')],
'connection.stopped': ['VPN выключен', 'Остановлен пользователем'],
'connection.failed': ['VPN не запущен', String(value.errorCode || '')],
'subscription.added': ['Подписка добавлена', `${value.profileLabel || ''} · серверов: ${value.serverCount || 0}`],
'subscription.refreshed': ['Подписка обновлена', `${value.profileLabel || ''} · серверов: ${value.serverCount || 0} · +${value.added || 0} / ${value.removed || 0}`],
'subscription.refresh_failed': ['Подписка не обновлена', `${value.profileLabel || ''} · ${value.errorCode || ''}`],
'subscription.deleted': ['Подписка удалена', String(value.profileLabel || '')],
'failover.enabled': ['Резервный канал включён', 'Мониторинг начнётся после активации dual-config'],
'failover.disabled': ['Резервный канал выключен', 'Автоматика полностью остановлена'],
'failover.paused': ['Автопереключение на паузе', 'Проверки продолжаются'],
'failover.resumed': ['Автопереключение возобновлено', ''],
'failover.waiting_for_idle': ['Переключение отложено', 'Обнаружен активный трафик'],
'failover.switched': ['Новые соединения переключены', `${value.fromRole || ''}${value.toRole || ''} · ${value.reason || ''}`],
'failover.switch_failed': ['Переключение не выполнено', String(value.errorCode || '')],
'failover.both_unhealthy': ['Оба канала недоступны', 'Текущий маршрут сохранён'],
'failover.recovered': ['Основной канал восстановлен', String(value.reason || '')],
'journal.recovered': ['Журнал восстановлен', 'Повреждённый файл сохранён отдельно'],
};
return copies[event.type] || ['Системное событие', ''];
}
function dayLabel(value: string) {
const date = new Date(value);
const today = new Date();
const startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const start = startDate.getTime();
const yesterday = new Date(startDate);
yesterday.setDate(yesterday.getDate() - 1);
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
if (day === start) return 'Сегодня';
if (day === yesterday.getTime()) return 'Вчера';
return new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long' }).format(date);
}
export function ActivityJournalPanel({ feature, loadPage }: {
feature: ActivityJournalFeature;
loadPage: (cursor?: string | null) => Promise<unknown>;
}) {
const [events, setEvents] = useState<ActivityJournalEvent[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'refreshing' | 'older' | 'error'>('idle');
const [announcement, setAnnouncement] = useState('');
async function load(cursor: string | null = null, refresh = false) {
setStatus(cursor ? 'older' : refresh ? 'refreshing' : 'loading');
try {
const page = assertActivityJournalPage(await loadPage(cursor));
if (page.storage.status === 'error') throw new Error('journal unavailable');
if (refresh) {
const known = new Set(events.map(({ id }) => id));
setAnnouncement(`Журнал обновлён, новых событий: ${page.events.filter(({ id }) => !known.has(id)).length}`);
}
setEvents((current) => cursor ? [...current, ...page.events] : page.events);
setNextCursor(page.nextCursor);
setStatus('ready');
} catch {
setStatus('error');
}
}
useEffect(() => {
if (feature.isOpen) void load(null, status !== 'idle');
}, [feature.isOpen]);
const groups = events.reduce<Array<{ label: string; events: ActivityJournalEvent[] }>>((result, event) => {
const label = dayLabel(event.occurredAt);
const group = result.at(-1);
if (group?.label === label) group.events.push(event);
else result.push({ label, events: [event] });
return result;
}, []);
return <Drawer
panelRef={feature.panelRef}
closeRef={feature.closeRef}
id="client-activity-journal"
open={feature.isOpen}
label="Журнал Harbor"
closeLabel="Закрыть журнал"
onClose={feature.close}
className="client-journal-drawer"
>
<header className="client-journal-header">
<span>Важные события хранятся 30 дней</span>
<div><h2>Журнал</h2><button type="button" aria-label="Обновить журнал" disabled={status === 'refreshing'} onClick={() => void load(null, true)}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" /></svg>
</button></div>
</header>
<span className="client-live-region" role="status" aria-live="polite">{announcement}</span>
{status === 'error' && <div className="client-journal-error" role="status">Журнал временно недоступен <button type="button" onClick={() => void load()}>Повторить</button></div>}
{status === 'loading' && !events.length ? <div className="client-journal-skeleton" aria-label="Загружаем журнал">{[0, 1, 2, 3].map((value) => <span key={value} />)}</div>
: !events.length && status === 'ready' ? <p className="client-journal-empty">За последние 30 дней важных событий пока нет</p>
: <div className="client-journal-groups">{groups.map((group) => <section key={group.label}>
<h3>{group.label}</h3>
<ol>{group.events.map((event) => {
const [title, details] = eventCopy(event);
return <li key={event.id} className={event.severity === 'error' ? 'is-error' : event.severity === 'warning' ? 'is-warning' : ''}>
<div className="client-journal-time"><time dateTime={event.occurredAt}>{new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(event.occurredAt))}</time><span>{event.source}</span></div>
<div><strong>{title}</strong>{details && <span>{details}</span>}{event.severity !== 'info' && <em>{event.severity === 'error' ? 'Ошибка' : 'Внимание'}</em>}</div>
</li>;
})}</ol>
</section>)}</div>}
<footer className="client-journal-footer">
{nextCursor ? <button type="button" disabled={status === 'older'} onClick={() => void load(nextCursor)}>{status === 'older' ? 'Загружаем…' : 'Показать ещё'}</button> : <span>{events.length ? 'Это вся история за последние 30 дней' : 'Храним события 30 дней'}</span>}
</footer>
</Drawer>;
}
@@ -0,0 +1 @@
export { ActivityJournalPanel, ActivityJournalToggle, useActivityJournalFeature } from './ActivityJournalFeature.js';
@@ -23,6 +23,7 @@ import {
type DiagnosticSiteResult,
} from './connectivityResult.js';
import type { DiagnosticsFeature } from './DiagnosticsFeature.js';
import { saveCustomDiagnosticService } from './customServiceAction.js';
const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services';
const HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services';
@@ -334,19 +335,15 @@ export function ConnectivityDiagnosticsPanel({
async function addService(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
try {
if (customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES) return;
const parsed = new URL(serviceUrl.trim());
if (parsed.protocol !== 'https:') throw new Error('Нужен публичный HTTPS-адрес.');
setSettingsSaving(true);
const saved = await updateSettings({
customServices: [...customServices, {
id: `custom-${globalThis.crypto?.randomUUID?.() || Date.now()}`,
label: serviceName.trim() || parsed.hostname,
url: parsed.href,
}],
const saved = await saveCustomDiagnosticService({
name: serviceName,
url: serviceUrl,
customServices,
hiddenServiceIds,
updateSettings,
});
if (saved === false) throw new Error('Не удалось сохранить сервис.');
if (!saved) return;
setServiceName('');
setServiceUrl('');
setFormError('');
@@ -0,0 +1,33 @@
import {
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
type DiagnosticService,
type DiagnosticSettings,
} from '../../../shared/connectivityDiagnostics.js';
export async function saveCustomDiagnosticService({
name,
url,
customServices,
hiddenServiceIds,
updateSettings,
}: {
name: string;
url: string;
customServices: DiagnosticService[];
hiddenServiceIds: string[];
updateSettings: (settings: Pick<DiagnosticSettings, 'customServices' | 'hiddenServiceIds'>) => Promise<unknown>;
}) {
if (customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES) return false;
const parsed = new URL(url.trim());
if (parsed.protocol !== 'https:') throw new Error('Нужен публичный HTTPS-адрес.');
const saved = await updateSettings({
customServices: [...customServices, {
id: `custom-${globalThis.crypto?.randomUUID?.() || Date.now()}`,
label: name.trim() || parsed.hostname,
url: parsed.href,
}],
hiddenServiceIds,
});
if (saved === false) throw new Error('Не удалось сохранить сервис.');
return true;
}
+1
View File
@@ -4,3 +4,4 @@ export {
useDiagnosticsFeature,
type DiagnosticsFeature,
} from './DiagnosticsFeature.js';
export { saveCustomDiagnosticService } from './customServiceAction.js';
@@ -0,0 +1,331 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { CONNECTIVITY_SITES, MAX_CUSTOM_DIAGNOSTIC_SERVICES, type DiagnosticSettings } from '../../../shared/connectivityDiagnostics.js';
import { normalizeFailoverPolicy, type FailoverPolicy, type FailoverSnapshot } from '../../../shared/failover.js';
import type { ProfileSnapshot } from '../../../shared/contracts/state.js';
import { Drawer } from '../../ui/Drawer.js';
import { RailAction } from '../../ui/RailAction.js';
import { saveCustomDiagnosticService } from '../diagnostics/index.js';
export function useFailoverFeature() {
const [isOpen, setIsOpen] = useState(false);
const panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const beforeCloseRef = useRef<() => boolean>(() => true);
const close = () => {
if (beforeCloseRef.current()) setIsOpen(false);
};
useEffect(() => {
if (!isOpen) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus());
const handleClose = (event: PointerEvent | KeyboardEvent) => {
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
if (event.type !== 'keydown' && (
panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)
)) return;
close();
};
document.addEventListener('pointerdown', handleClose);
document.addEventListener('keydown', handleClose);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('pointerdown', handleClose);
document.removeEventListener('keydown', handleClose);
requestAnimationFrame(() => {
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
});
};
}, [isOpen]);
return {
isOpen, panelRef, toggleRef, closeRef, beforeCloseRef, close,
toggle: () => isOpen ? close() : setIsOpen(true),
};
}
export type FailoverFeature = ReturnType<typeof useFailoverFeature>;
export function FailoverToggle({ feature, open, onToggle }: {
feature: FailoverFeature;
open: boolean;
onToggle: () => void;
}) {
return <RailAction
buttonRef={feature.toggleRef}
className="client-failover-toggle"
open={open}
controls="client-failover"
ariaLabel={open ? 'Закрыть резервный канал' : 'Настроить резервный канал'}
label="Резерв"
onClick={onToggle}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 7h11m-3-3 3 3-3 3M19 17H8m3-3-3 3 3 3" />
<circle cx="4" cy="7" r="1" /><circle cx="20" cy="17" r="1" />
</svg>
</RailAction>;
}
const seconds = (milliseconds: number) => Math.round(milliseconds / 1000);
const milliseconds = (value: string, fallback: number) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.round(parsed * 1000) : fallback;
};
const reasonLabel = (reason: string | null) => ({
'primary-healthy': 'Основной канал работает',
'health-unknown': 'Ожидаем результаты проверки',
'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 ? `${profile.label} · ${server.label}` : 'Не выбран';
}
export function FailoverPanel({
feature,
snapshot,
profiles,
diagnostics,
blocked,
onSave,
onPause,
onSwitch,
onCheck,
onUpdateDiagnostics,
}: {
feature: FailoverFeature;
snapshot: FailoverSnapshot;
profiles: ProfileSnapshot[];
diagnostics: DiagnosticSettings;
blocked: boolean;
onSave: (policy: FailoverPolicy) => Promise<unknown>;
onPause: (paused: boolean) => Promise<unknown>;
onSwitch: (role: 'primary' | 'reserve') => Promise<unknown>;
onCheck: () => Promise<unknown>;
onUpdateDiagnostics: (settings: unknown) => Promise<unknown>;
}) {
const [draft, setDraft] = useState(() => snapshot.policy);
const [dirty, setDirty] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState(false);
const [addingService, setAddingService] = useState(false);
const [serviceName, setServiceName] = useState('');
const [serviceUrl, setServiceUrl] = useState('');
const [serviceError, setServiceError] = useState('');
useEffect(() => {
if (!dirty) setDraft(snapshot.policy);
}, [snapshot.policy, dirty]);
useEffect(() => {
feature.beforeCloseRef.current = () => {
if (!dirty) return true;
setConfirmDiscard(true);
return false;
};
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!dirty) return;
event.preventDefault();
};
window.addEventListener('beforeunload', beforeUnload);
return () => {
feature.beforeCloseRef.current = () => true;
window.removeEventListener('beforeunload', beforeUnload);
};
}, [dirty, feature.beforeCloseRef]);
const services = useMemo(() => [
...CONNECTIVITY_SITES,
...diagnostics.customServices,
], [diagnostics.customServices]);
const update = (value: Partial<FailoverPolicy>) => {
setDraft((current) => normalizeFailoverPolicy({ ...current, ...value }));
setDirty(true);
};
const updateTarget = (role: 'primary' | 'reserve', patch: Partial<FailoverPolicy[typeof role]>) => {
const next = { ...draft[role], ...patch };
const profile = profiles.find(({ id }) => id === next.profileId);
if (patch.profileId !== undefined) next.serverId = profile?.desiredServerId || profile?.servers[0]?.id || '';
update({ [role]: next } as Partial<FailoverPolicy>);
};
const role = snapshot.currentRole === 'primary' || snapshot.currentRole === 'reserve'
? snapshot.currentRole
: null;
const switchRole = role === 'primary' ? 'reserve' : role === 'reserve' ? 'primary' : null;
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 = 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({
name: serviceName,
url: serviceUrl,
customServices: diagnostics.customServices,
hiddenServiceIds: diagnostics.hiddenServiceIds,
updateSettings: onUpdateDiagnostics,
});
if (!saved) return;
setServiceName('');
setServiceUrl('');
setServiceError('');
setAddingService(false);
} catch (error) {
setServiceError(error instanceof Error ? error.message : 'Проверьте адрес.');
}
};
return <Drawer
panelRef={feature.panelRef}
closeRef={feature.closeRef}
id="client-failover"
open={feature.isOpen}
label="Резервный канал"
closeLabel="Закрыть резервный канал"
onClose={feature.close}
className="client-failover-drawer"
>
<header className="client-failover-header">
<span>Gateway</span>
<h2>Резервный канал</h2>
<p>Переключает только новые соединения. Уже открытые соединения Harbor не закрывает.</p>
</header>
<form className="client-failover-form" onSubmit={(event) => {
event.preventDefault();
void onSave(draft).then((result) => { if (result !== false) setDirty(false); });
}}>
<label className="client-failover-master">
<span><strong>Использовать резерв</strong><small>{draft.enabled ? 'Мониторинг включён' : 'Полностью пассивен'}</small></span>
<input type="checkbox" checked={draft.enabled} onChange={(event) => update({ enabled: event.target.checked })} />
</label>
{(['primary', 'reserve'] as const).map((channel) => {
const profile = profiles.find(({ id }) => id === draft[channel].profileId);
const health = snapshot[channel].health;
const missing = Boolean(snapshot[channel].target.profileId) && !targetExists(snapshot[channel].target);
const healthLabel = missing
? 'Цель недоступна'
: channel === 'primary' && snapshot.reason === 'failure-window'
? `Нестабилен · ${seconds(Math.max(0, Date.now() - Date.parse(snapshot.primary.stateSince || new Date().toISOString())))} из ${seconds(draft.failureWindowMs)} с`
: channel === 'primary' && snapshot.currentRole === 'reserve' && health === 'healthy' && snapshot.reason === 'recovery-hold'
? 'Восстанавливается'
: health === 'healthy' ? 'Работает' : health === 'unhealthy' ? 'Недоступен' : health === 'not-monitoring' ? 'Не проверяется' : 'Нет данных';
return <section className="client-failover-channel" key={channel}>
<div className="client-failover-channel-title">
<span>{channel === 'primary' ? 'Основной' : 'Резервный'}</span>
<strong className={health === 'healthy' ? 'is-healthy' : health === 'unhealthy' || missing ? 'is-unhealthy' : ''}>{healthLabel}</strong>
</div>
<label><span>Подписка</span><select value={draft[channel].profileId} onChange={(event) => updateTarget(channel, { profileId: event.target.value })}>
<option value="">Не выбрана</option>
{profiles.map((item) => <option value={item.id} key={item.id}>{item.label}</option>)}
</select></label>
<label><span>Сервер</span><select value={draft[channel].serverId} onChange={(event) => updateTarget(channel, { serverId: event.target.value })} disabled={!profile}>
<option value="">Не выбран</option>
{profile?.servers.map((server) => <option value={server.id} key={server.id}>{server.label}</option>)}
</select></label>
</section>;
})}
<fieldset className="client-failover-services">
<legend>Что проверять</legend>
{services.map((service) => {
const check = draft.checks.find(({ serviceId }) => serviceId === service.id);
return <div className="client-failover-service" key={service.id}>
<label>
<input type="checkbox" checked={Boolean(check)} onChange={(event) => update({
checks: event.target.checked
? [...draft.checks, { serviceId: service.id, timeoutMs: 6_000 }]
: draft.checks.filter(({ serviceId }) => serviceId !== service.id),
})} />
<span>{service.label}</span>
</label>
{check && <label className="client-failover-service-timeout">
<span>таймаут, сек</span>
<input
type="number"
min="2"
max="30"
aria-label={`Таймаут проверки: ${service.label}`}
value={seconds(check.timeoutMs)}
onChange={(event) => update({ checks: draft.checks.map((item) => item.serviceId === service.id
? { ...item, timeoutMs: milliseconds(event.target.value, item.timeoutMs) }
: item) })}
/>
</label>}
</div>;
})}
{!addingService && <button type="button" className="client-failover-add-service" disabled={diagnostics.customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES} onClick={() => setAddingService(true)}>+ Добавить HTTPS-сервис</button>}
{addingService && <div className="client-failover-service-editor">
<input aria-label="Название HTTPS-сервиса" placeholder="Название" value={serviceName} onChange={(event) => setServiceName(event.target.value)} />
<input aria-label="HTTPS-адрес сервиса" placeholder="https://example.com/health" value={serviceUrl} onChange={(event) => setServiceUrl(event.target.value)} />
{serviceError && <span role="alert">{serviceError}</span>}
<button type="button" onClick={() => void addService()}>Добавить</button>
<button type="button" onClick={() => { setAddingService(false); setServiceError(''); }}>Отмена</button>
</div>}
</fieldset>
<section className="client-failover-timing" aria-label="Пороги переключения">
<label><span>Проверять каждые, сек</span><input type="number" min="15" max="900" value={seconds(draft.intervalMs)} onChange={(event) => update({ intervalMs: milliseconds(event.target.value, draft.intervalMs) })} /></label>
<label><span>Сбой должен длиться, сек</span><input type="number" min={seconds(draft.intervalMs * 2)} max="1800" value={seconds(draft.failureWindowMs)} onChange={(event) => update({ failureWindowMs: milliseconds(event.target.value, draft.failureWindowMs) })} /></label>
<label><span>Восстановление, сек</span><input type="number" min="60" max="86400" value={seconds(draft.recoveryWindowMs)} onChange={(event) => update({ recoveryWindowMs: milliseconds(event.target.value, draft.recoveryWindowMs) })} /></label>
<label><span>Не переключать во время работы</span><input type="checkbox" checked={draft.trafficGuard.enabled} onChange={(event) => update({ trafficGuard: { ...draft.trafficGuard, enabled: event.target.checked } })} /></label>
<label><span>Тишина перед переключением, сек</span><input type="number" min="5" max="600" value={seconds(draft.trafficGuard.quietWindowMs)} onChange={(event) => update({ trafficGuard: { ...draft.trafficGuard, quietWindowMs: milliseconds(event.target.value, draft.trafficGuard.quietWindowMs) } })} /></label>
<label><span>Активный трафик, КБ/с</span><input type="number" min="1" max="102400" value={Math.round(draft.trafficGuard.thresholdBytesPerSecond / 1024)} onChange={(event) => update({ trafficGuard: { ...draft.trafficGuard, thresholdBytesPerSecond: Number(event.target.value) * 1024 } })} /></label>
</section>
<details className="client-failover-advanced"><summary>Защита от повторных сбоев</summary>
<label><span>Минимум на резерве, сек</span><input type="number" min="60" max="86400" value={seconds(draft.minimumReserveMs)} onChange={(event) => update({ minimumReserveMs: milliseconds(event.target.value, draft.minimumReserveMs) })} /></label>
<label><span>Падений до карантина</span><input type="number" min="2" max="10" value={draft.flapProtection.count} onChange={(event) => update({ flapProtection: { ...draft.flapProtection, count: Number(event.target.value) } })} /></label>
<label><span>Окно повторных сбоев, ч</span><input type="number" min="1" max="72" value={Math.round(draft.flapProtection.windowMs / 3_600_000)} onChange={(event) => update({ flapProtection: { ...draft.flapProtection, windowMs: Number(event.target.value) * 3_600_000 } })} /></label>
<label><span>Карантин основного, мин</span><input type="number" min="10" max="10080" value={Math.round(draft.flapProtection.quarantineMs / 60_000)} onChange={(event) => update({ flapProtection: { ...draft.flapProtection, quarantineMs: Number(event.target.value) * 60_000 } })} /></label>
</details>
<div className="client-failover-runtime">
<strong role="status" aria-live="polite">{blocked ? 'Harbor выполняет действие…' : snapshot.activation === 'pending' ? 'Включится при следующем запуске VPN' : reasonLabel(snapshot.reason)}</strong>
<span>Новые соединения: {role ? targetLabel(profiles, snapshot[role].target) : 'текущий канал вне настроенной пары'}</span>
{snapshot.nextDecisionAt && <span>Следующее решение не раньше чем через {seconds(Math.max(0, Date.parse(snapshot.nextDecisionAt) - Date.now()))} с</span>}
{snapshot.currentRole === 'other' && <span>Чтобы запустить автоматику, выключите VPN и включите снова на основном канале.</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>}
</div>
{!draftValid && <p className="client-failover-validation">Выберите два разных сервера. Они могут быть из одной подписки.</p>}
{confirmDiscard && <div className="client-failover-discard" role="alert">
<span>Отменить несохранённые изменения?</span>
<button type="button" onClick={() => { setDraft(snapshot.policy); setDirty(false); setConfirmDiscard(false); feature.beforeCloseRef.current = () => true; feature.close(); }}>Отменить изменения</button>
<button type="button" onClick={() => setConfirmDiscard(false)}>Продолжить настройку</button>
</div>}
<div className="client-failover-actions">
<button type="button" disabled={blocked || !snapshot.enabled || snapshot.activation !== 'active'} onClick={() => void onCheck()}>Проверить оба канала</button>
<button type="submit" disabled={blocked || !dirty || !draftValid}>Сохранить</button>
{snapshot.enabled && <button type="button" disabled={blocked} onClick={() => void onPause(!snapshot.paused)}>{snapshot.paused ? 'Возобновить' : 'Пауза'}</button>}
{switchRole && snapshot.enabled && snapshot.activation === 'active' && <button type="button" disabled={blocked} aria-label={`Переключить новые соединения на ${switchRole === 'reserve' ? 'резервный' : 'основной'} сейчас`} onClick={() => void onSwitch(switchRole)}>Переключить новые сейчас</button>}
{(snapshot.currentRole === 'primary' || snapshot.currentRole === 'reserve') && <small>Текущие соединения Harbor не закроет</small>}
</div>
</form>
</Drawer>;
}
+1
View File
@@ -0,0 +1 @@
export { FailoverPanel, FailoverToggle, useFailoverFeature } from './FailoverFeature.js';