Update Harbor client and gateway integration workflows
This commit is contained in:
@@ -25,6 +25,7 @@ const componentActions = {
|
||||
setDevicePolicy: api.devices.setPolicy,
|
||||
pingServers: api.servers.ping,
|
||||
runConnectivityDiagnostics: api.diagnostics.connectivity,
|
||||
loadActivityJournal: api.activityJournal.page,
|
||||
};
|
||||
|
||||
interface UiError {
|
||||
@@ -51,6 +52,10 @@ const operationErrorContext: Record<string, string> = {
|
||||
'subscription-refresh': 'subscription',
|
||||
'subscription-forget': 'subscription',
|
||||
'route-rules': 'routing',
|
||||
'failover-save': 'failover',
|
||||
'failover-pause': 'failover',
|
||||
'failover-resume': 'failover',
|
||||
'failover-switch': 'failover',
|
||||
};
|
||||
|
||||
function asHarborApiError(error: unknown) {
|
||||
@@ -304,6 +309,26 @@ export function App() {
|
||||
() => api.diagnostics.updateSettings(settings, revisionRef.current),
|
||||
'diagnostics',
|
||||
)}
|
||||
onSaveFailover={(policy: unknown) => run(
|
||||
'failover',
|
||||
() => api.failover.save(policy, revisionRef.current),
|
||||
'failover',
|
||||
)}
|
||||
onPauseFailover={(paused: boolean) => run(
|
||||
'failover',
|
||||
() => api.failover.pause(paused, revisionRef.current),
|
||||
'failover',
|
||||
)}
|
||||
onSwitchFailover={(role: 'primary' | 'reserve') => run(
|
||||
'failover',
|
||||
() => api.failover.switch(role, revisionRef.current),
|
||||
'failover',
|
||||
)}
|
||||
onCheckFailover={() => run(
|
||||
'failover',
|
||||
() => api.failover.check(),
|
||||
'failover',
|
||||
)}
|
||||
onDismissError={() => {
|
||||
setError(null);
|
||||
setDismissedCanonicalError(canonicalErrorId);
|
||||
|
||||
@@ -192,6 +192,26 @@ export const api = {
|
||||
},
|
||||
),
|
||||
},
|
||||
failover: {
|
||||
save: (policy: unknown, expectedRevision: number) => request('/api/failover', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ policy, expectedRevision }),
|
||||
}),
|
||||
pause: (paused: boolean, expectedRevision: number) => request('/api/failover/pause', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ paused, expectedRevision }),
|
||||
}),
|
||||
switch: (role: 'primary' | 'reserve', expectedRevision: number) => request('/api/failover/switch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ role, expectedRevision }),
|
||||
}),
|
||||
check: () => request('/api/failover/check', {
|
||||
method: 'POST',
|
||||
}),
|
||||
},
|
||||
activityJournal: {
|
||||
page: (cursor: string | null = null) => request(`/api/activity-journal?limit=50${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`),
|
||||
},
|
||||
singbox: {
|
||||
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
||||
restart: () => request('/api/singbox/restart', { method: 'POST' }),
|
||||
@@ -237,6 +257,7 @@ export function parseHarborState(value: unknown): HarborClientState {
|
||||
selection: snapshot.selection,
|
||||
connection: snapshot.connection,
|
||||
diagnostics: snapshot.diagnostics,
|
||||
failover: snapshot.failover,
|
||||
route: snapshot.route,
|
||||
operation: snapshot.operation,
|
||||
servers: snapshot.servers,
|
||||
|
||||
@@ -47,6 +47,13 @@ import {
|
||||
InstructionsToggle,
|
||||
useInstructionsFeature,
|
||||
} from '../features/instructions/index.js';
|
||||
import { FailoverPanel, FailoverToggle, useFailoverFeature } from '../features/failover/index.js';
|
||||
import {
|
||||
ActivityJournalPanel,
|
||||
ActivityJournalToggle,
|
||||
useActivityJournalFeature,
|
||||
} from '../features/activity-journal/index.js';
|
||||
import type { FailoverPolicy } from '../../shared/failover.js';
|
||||
import {
|
||||
HARBOR_VERSIONS,
|
||||
parseVersion,
|
||||
@@ -65,9 +72,33 @@ const VERSION_PARTS = [
|
||||
] as const;
|
||||
|
||||
const DRAWER_SWITCH_MS = 620;
|
||||
const DRAWER_ORDER = ['subscription', 'instructions', 'devices', 'diagnostics', 'routing'] as const;
|
||||
const DRAWER_ORDER = ['subscription', 'failover', 'instructions', 'devices', 'diagnostics', 'routing', 'journal'] as const;
|
||||
type DrawerKey = typeof DRAWER_ORDER[number];
|
||||
|
||||
const failoverReasonLabel = (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-stopped': 'VPN выключен',
|
||||
paused: 'автоматика на паузе',
|
||||
disabled: 'резерв выключен',
|
||||
'switch-failed': 'не удалось переключить',
|
||||
'selector-unknown': 'текущий канал неизвестен',
|
||||
'reconcile-failed': 'мониторинг временно недоступен',
|
||||
'revalidation-required': 'условия проверяются заново',
|
||||
'manual-check': 'оба канала проверены',
|
||||
}[reason || ''] || 'наблюдение');
|
||||
|
||||
interface UiError {
|
||||
context?: string;
|
||||
profileId?: string;
|
||||
@@ -93,6 +124,7 @@ interface ComponentActions {
|
||||
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
|
||||
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
|
||||
runConnectivityDiagnostics: (target?: unknown) => Promise<unknown>;
|
||||
loadActivityJournal: (cursor?: string | null) => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface ClientViewState extends StateSnapshot {
|
||||
@@ -119,6 +151,10 @@ interface ClientOverviewPageProps {
|
||||
onSetGatewayAuto: (enabled: boolean) => Promise<unknown>;
|
||||
onSaveRouteRules: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
|
||||
onUpdateDiagnosticsSettings: (settings: unknown) => Promise<unknown>;
|
||||
onSaveFailover: (policy: FailoverPolicy) => Promise<unknown>;
|
||||
onPauseFailover: (paused: boolean) => Promise<unknown>;
|
||||
onSwitchFailover: (role: 'primary' | 'reserve') => Promise<unknown>;
|
||||
onCheckFailover: () => Promise<unknown>;
|
||||
onDismissError: () => void;
|
||||
}
|
||||
|
||||
@@ -252,6 +288,7 @@ const operationProgress: Partial<Record<keyof OperationRegistrySnapshot, readonl
|
||||
profileRefresh: ['subscription', 'Обновляем подписку…'],
|
||||
profileDelete: ['subscription', 'Удаляем подписку…'],
|
||||
routeRules: ['routing', 'Применяем локальные правила…'],
|
||||
failover: ['failover', 'Применяем настройки резерва…'],
|
||||
};
|
||||
|
||||
const canonicalOperationKeys: Record<string, OperationKey> = {
|
||||
@@ -266,6 +303,10 @@ const canonicalOperationKeys: Record<string, OperationKey> = {
|
||||
'profile-delete': 'profileDelete',
|
||||
'gateway-auto': 'gatewayAuto',
|
||||
'route-rules': 'routeRules',
|
||||
'failover-save': 'failover',
|
||||
'failover-pause': 'failover',
|
||||
'failover-resume': 'failover',
|
||||
'failover-switch': 'failover',
|
||||
'subscription-import': 'profileAdd',
|
||||
'subscription-refresh': 'profileRefresh',
|
||||
'subscription-forget': 'profileDelete',
|
||||
@@ -425,6 +466,10 @@ export function ClientOverviewPage({
|
||||
onSetGatewayAuto,
|
||||
onSaveRouteRules,
|
||||
onUpdateDiagnosticsSettings,
|
||||
onSaveFailover,
|
||||
onPauseFailover,
|
||||
onSwitchFailover,
|
||||
onCheckFailover,
|
||||
onDismissError,
|
||||
}: ClientOverviewPageProps) {
|
||||
const isGateway = state?.mode === 'gateway';
|
||||
@@ -533,6 +578,8 @@ export function ClientOverviewPage({
|
||||
port: state?.clientRuntime?.proxyPort || (isGateway ? 8080 : 8082),
|
||||
controlHost,
|
||||
});
|
||||
const failoverFeature = useFailoverFeature();
|
||||
const activityJournalFeature = useActivityJournalFeature();
|
||||
const diagnosticsAvailable = hasSubscription;
|
||||
const drawerControls = {
|
||||
subscription: {
|
||||
@@ -541,6 +588,12 @@ export function ClientOverviewPage({
|
||||
show: subscriptionFeature.toggle,
|
||||
close: subscriptionFeature.close,
|
||||
},
|
||||
failover: {
|
||||
isOpen: failoverFeature.isOpen,
|
||||
panelRef: failoverFeature.panelRef,
|
||||
show: failoverFeature.toggle,
|
||||
close: failoverFeature.close,
|
||||
},
|
||||
instructions: {
|
||||
isOpen: instructionsFeature.isOpen,
|
||||
panelRef: instructionsFeature.panelRef,
|
||||
@@ -565,10 +618,16 @@ export function ClientOverviewPage({
|
||||
show: routingFeature.open,
|
||||
close: routingFeature.forceClose,
|
||||
},
|
||||
journal: {
|
||||
isOpen: activityJournalFeature.isOpen,
|
||||
panelRef: activityJournalFeature.panelRef,
|
||||
show: activityJournalFeature.toggle,
|
||||
close: activityJournalFeature.close,
|
||||
},
|
||||
};
|
||||
const drawerOrder = isGateway
|
||||
? DRAWER_ORDER
|
||||
: DRAWER_ORDER.filter((drawer) => drawer !== 'devices');
|
||||
: DRAWER_ORDER.filter((drawer) => !['devices', 'failover', 'journal'].includes(drawer));
|
||||
const activeRailDrawer = drawerSwitchTarget && drawerControls[drawerSwitchTarget].isOpen
|
||||
? drawerSwitchTarget
|
||||
: drawerOrder.find((drawer) => drawerControls[drawer].isOpen) || null;
|
||||
@@ -587,6 +646,8 @@ export function ClientOverviewPage({
|
||||
instructionsFeature.close();
|
||||
devicesFeature.close();
|
||||
diagnosticsFeature.close();
|
||||
failoverFeature.close();
|
||||
activityJournalFeature.close();
|
||||
}
|
||||
}, [hasSubscription, isGateway]);
|
||||
|
||||
@@ -656,6 +717,7 @@ export function ClientOverviewPage({
|
||||
routingFeature.requestClose();
|
||||
return;
|
||||
}
|
||||
if (current === 'failover' && !failoverFeature.beforeCloseRef.current()) return;
|
||||
if (!current) {
|
||||
drawerControls[target].show();
|
||||
return;
|
||||
@@ -733,6 +795,13 @@ export function ClientOverviewPage({
|
||||
: switchingServer && operationProfile && operationServer
|
||||
? `Переключаем на ${subscriptionDomain(operationProfile.subscription.host)} · ${operationServer.label}`
|
||||
: '';
|
||||
const failoverIdentity = isGateway && state.failover.enabled
|
||||
? `${state.failover.currentRole === 'reserve'
|
||||
? 'Резервный канал'
|
||||
: state.failover.currentRole === 'primary'
|
||||
? 'Основной канал'
|
||||
: 'Текущий канал вне резервной пары'} · ${failoverReasonLabel(state.failover.reason)}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -752,6 +821,11 @@ export function ClientOverviewPage({
|
||||
open={activeRailDrawer === 'subscription'}
|
||||
onToggle={() => switchDrawer('subscription')}
|
||||
/>
|
||||
{isGateway && <FailoverToggle
|
||||
feature={failoverFeature}
|
||||
open={activeRailDrawer === 'failover'}
|
||||
onToggle={() => switchDrawer('failover')}
|
||||
/>}
|
||||
<InstructionsToggle
|
||||
feature={instructionsFeature}
|
||||
open={activeRailDrawer === 'instructions'}
|
||||
@@ -775,6 +849,11 @@ export function ClientOverviewPage({
|
||||
hasSubscription={hasSubscription}
|
||||
onOpen={() => switchDrawer('routing')}
|
||||
/>
|
||||
{isGateway && <ActivityJournalToggle
|
||||
feature={activityJournalFeature}
|
||||
open={activeRailDrawer === 'journal'}
|
||||
onToggle={() => switchDrawer('journal')}
|
||||
/>}
|
||||
</nav>}
|
||||
<main className={`client-panel${showPower ? '' : ' is-setup'}${!isGateway && hasSubscription ? ' has-subscription' : ''}${isGateway && hasSubscription ? ' is-gateway-home' : ''}`}>
|
||||
<ConnectionPanel
|
||||
@@ -807,7 +886,7 @@ export function ClientOverviewPage({
|
||||
blocked={connectionBlocked}
|
||||
onRestart={onRestart}
|
||||
/>}
|
||||
serverSlot={<AppliedIdentity identity={mainIdentity} operation={switchIdentity} />}
|
||||
serverSlot={<AppliedIdentity identity={mainIdentity} operation={switchIdentity || failoverIdentity} />}
|
||||
statusSlot={<>
|
||||
<InlineError error={error} context="connection" />
|
||||
<InlineProgress operations={visibleOperations} context="connection" />
|
||||
@@ -857,6 +936,22 @@ export function ClientOverviewPage({
|
||||
<InlineProgress operations={visibleOperations} context="routing" />
|
||||
</>}
|
||||
/>}
|
||||
{isGateway && hasSubscription && <FailoverPanel
|
||||
feature={failoverFeature}
|
||||
snapshot={state.failover}
|
||||
profiles={profiles}
|
||||
diagnostics={state.diagnostics}
|
||||
blocked={operationBlocked(visibleOperations, 'failover')}
|
||||
onSave={onSaveFailover}
|
||||
onPause={onPauseFailover}
|
||||
onSwitch={onSwitchFailover}
|
||||
onCheck={onCheckFailover}
|
||||
onUpdateDiagnostics={onUpdateDiagnosticsSettings}
|
||||
/>}
|
||||
{isGateway && hasSubscription && <ActivityJournalPanel
|
||||
feature={activityJournalFeature}
|
||||
loadPage={actions.loadActivityJournal}
|
||||
/>}
|
||||
<RoutingDiscardDialog feature={routingFeature} />
|
||||
<SubscriptionDeleteDialog feature={subscriptionFeature} />
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { FailoverPanel, FailoverToggle, useFailoverFeature } from './FailoverFeature.js';
|
||||
@@ -4,6 +4,10 @@ export type SyncErrorKind = 'incompatible-api' | 'control-unreachable' | 'fatal'
|
||||
|
||||
export interface HarborReducerState {
|
||||
snapshot: HarborClientState | null;
|
||||
failoverTransport: {
|
||||
activeEpoch: string;
|
||||
retiredEpochs: string[];
|
||||
};
|
||||
transport: {
|
||||
bootStatus: 'loading' | 'ready' | SyncErrorKind;
|
||||
lastSuccessfulSyncAt: string | null;
|
||||
@@ -20,6 +24,7 @@ export type HarborAction =
|
||||
|
||||
export const initialHarborState: HarborReducerState = {
|
||||
snapshot: null,
|
||||
failoverTransport: { activeEpoch: '', retiredEpochs: [] },
|
||||
transport: {
|
||||
bootStatus: 'loading',
|
||||
lastSuccessfulSyncAt: null,
|
||||
@@ -31,6 +36,14 @@ export const initialHarborState: HarborReducerState = {
|
||||
|
||||
export const STALE_FAILURE_THRESHOLD = 3;
|
||||
|
||||
function failoverEpoch(snapshot: HarborClientState | null) {
|
||||
return snapshot?.failover?.observationEpoch || '';
|
||||
}
|
||||
|
||||
function failoverSequence(snapshot: HarborClientState | null) {
|
||||
return snapshot?.failover?.observationSequence || 0;
|
||||
}
|
||||
|
||||
export function classifySyncError(error: unknown): SyncErrorKind {
|
||||
const candidate = error && typeof error === 'object' ? error as Record<string, unknown> : {};
|
||||
const status = Number(candidate.status) || 0;
|
||||
@@ -71,9 +84,29 @@ export function harborReducer(current: HarborReducerState, action: HarborAction)
|
||||
|
||||
const snapshot = action.snapshot;
|
||||
const newer = !current.snapshot || snapshot.revision > current.snapshot.revision;
|
||||
const equal = Boolean(current.snapshot) && snapshot.revision === current.snapshot?.revision;
|
||||
const incomingEpoch = failoverEpoch(snapshot);
|
||||
const activeEpoch = current.failoverTransport.activeEpoch || failoverEpoch(current.snapshot);
|
||||
const unseenEpoch = Boolean(incomingEpoch)
|
||||
&& incomingEpoch !== activeEpoch
|
||||
&& !current.failoverTransport.retiredEpochs.includes(incomingEpoch);
|
||||
const newerObservation = equal && Boolean(snapshot.failover) && (
|
||||
unseenEpoch
|
||||
|| (incomingEpoch === activeEpoch && failoverSequence(snapshot) > failoverSequence(current.snapshot))
|
||||
);
|
||||
const nextSnapshot = newer
|
||||
? snapshot
|
||||
: newerObservation && current.snapshot
|
||||
? { ...current.snapshot, failover: snapshot.failover }
|
||||
: current.snapshot;
|
||||
const nextActiveEpoch = newer || unseenEpoch ? incomingEpoch : activeEpoch;
|
||||
const retiredEpochs = unseenEpoch && activeEpoch
|
||||
? [...current.failoverTransport.retiredEpochs, activeEpoch]
|
||||
: current.failoverTransport.retiredEpochs;
|
||||
|
||||
return {
|
||||
snapshot: newer ? snapshot : current.snapshot,
|
||||
snapshot: nextSnapshot,
|
||||
failoverTransport: { activeEpoch: nextActiveEpoch, retiredEpochs },
|
||||
transport: {
|
||||
bootStatus: 'ready',
|
||||
lastSuccessfulSyncAt: action.receivedAt,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type OperationKey = 'connection' | 'serverApply' | 'profileAdd' | 'profileRename'
|
||||
| 'profileSelect' | 'profileActivate' | 'profileRefresh' | 'profileDelete'
|
||||
| 'gatewayAuto' | 'routeRules' | 'diagnosticsSettings';
|
||||
| 'gatewayAuto' | 'routeRules' | 'diagnosticsSettings' | 'failover';
|
||||
|
||||
export interface OperationState {
|
||||
status: 'running';
|
||||
@@ -22,6 +22,7 @@ const OPERATION_KEYS: readonly OperationKey[] = [
|
||||
'gatewayAuto',
|
||||
'routeRules',
|
||||
'diagnosticsSettings',
|
||||
'failover',
|
||||
];
|
||||
|
||||
// The backend has one canonical revision and one mutation queue, so the UI mirrors that lock.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.client-journal-header { display: grid; gap: 7px; margin: 0 8px 30px; }
|
||||
.client-journal-header > span, .client-journal-groups h3 { color: var(--client-muted); font: var(--type-label); letter-spacing: var(--type-label-tracking); text-transform: var(--type-label-transform); }
|
||||
.client-journal-header > div { display: flex; align-items: center; gap: 12px; }
|
||||
.client-journal-header h2 { margin: 0; font: var(--type-drawer-title); letter-spacing: var(--type-drawer-title-tracking); text-transform: var(--type-drawer-title-transform); }
|
||||
.client-journal-header button { width: 34px; height: 34px; display: grid; place-items: center; border: 0; background: transparent; color: var(--client-muted); cursor: pointer; }
|
||||
.client-journal-header svg { width: 17px; fill: none; stroke: currentColor; stroke-width: 1.7; }
|
||||
.client-journal-header button:hover, .client-journal-header button:focus-visible { color: var(--client-accent); outline: 0; filter: drop-shadow(0 0 7px var(--client-accent)); }
|
||||
.client-journal-header button:disabled svg { animation: client-spin 900ms linear infinite; }
|
||||
.client-journal-groups { display: grid; gap: 26px; margin: 0 8px; }
|
||||
.client-journal-groups section { display: grid; gap: 7px; }
|
||||
.client-journal-groups h3 { margin: 0; }
|
||||
.client-journal-groups ol { display: grid; margin: 0; padding: 0; }
|
||||
.client-journal-groups li { min-height: 58px; display: grid; grid-template-columns: 52px minmax(0, 1fr); gap: 14px; align-items: start; padding: 10px 0; border-top: 1px solid color-mix(in oklch, var(--client-border) 48%, transparent); }
|
||||
.client-journal-time, .client-journal-groups li > div:last-child { min-width: 0; display: grid; gap: 3px; }
|
||||
.client-journal-time time { color: var(--client-text); font: var(--type-data); font-variant-numeric: var(--numeric-tabular); letter-spacing: var(--type-data-tracking); text-transform: var(--type-data-transform); }
|
||||
.client-journal-time span, .client-journal-groups li span { color: var(--client-muted); font: var(--type-label); letter-spacing: var(--type-label-tracking); text-transform: var(--type-label-transform); overflow-wrap: anywhere; }
|
||||
.client-journal-time span { text-transform: var(--type-label-transform); }
|
||||
.client-journal-groups li strong { font: var(--type-body); letter-spacing: var(--type-body-tracking); text-transform: var(--type-body-transform); }
|
||||
.client-journal-groups li em { width: fit-content; color: oklch(0.68 0.15 28); font: var(--type-label); font-style: normal; letter-spacing: var(--type-label-tracking); text-transform: var(--type-label-transform); }
|
||||
.client-journal-error, .client-journal-empty { min-height: 80px; margin: 0 8px; color: var(--client-muted); font: var(--type-body); letter-spacing: var(--type-body-tracking); text-transform: var(--type-body-transform); }
|
||||
.client-journal-error button, .client-journal-footer button { border: 0; background: transparent; color: var(--client-accent); font: var(--type-control); letter-spacing: var(--type-control-tracking); text-transform: var(--type-control-transform); cursor: pointer; }
|
||||
.client-journal-skeleton { display: grid; gap: 14px; margin: 0 8px; }
|
||||
.client-journal-skeleton span { height: 48px; background: color-mix(in oklch, var(--client-border) 24%, transparent); opacity: .55; }
|
||||
.client-journal-footer { min-height: 72px; display: grid; place-items: center; margin: 12px 8px 0; color: var(--client-muted); font: var(--type-label); letter-spacing: var(--type-label-tracking); text-transform: var(--type-label-transform); text-align: center; }
|
||||
.client-journal-footer button:focus-visible, .client-journal-error button:focus-visible { outline: 2px solid var(--client-accent); outline-offset: 2px; }
|
||||
@media (max-width: 560px) { .client-journal-groups li { grid-template-columns: 1fr; gap: 5px; } .client-journal-time { display: flex; gap: 8px; } }
|
||||
@media (prefers-reduced-motion: reduce) { .client-journal-header button:disabled svg { animation: none; } }
|
||||
@@ -0,0 +1,45 @@
|
||||
.client-failover-header { display: grid; gap: 8px; margin: 0 8px 30px; }
|
||||
.client-failover-header > span, .client-failover-channel-title > span, .client-failover-services legend { color: var(--client-muted); font: var(--type-label); letter-spacing: var(--type-label-tracking); text-transform: var(--type-label-transform); }
|
||||
.client-failover-header h2 { margin: 0; font: var(--type-drawer-title); letter-spacing: var(--type-drawer-title-tracking); text-transform: var(--type-drawer-title-transform); }
|
||||
.client-failover-header p { margin: 0; color: var(--client-muted); font: var(--type-body); letter-spacing: var(--type-body-tracking); text-transform: var(--type-body-transform); }
|
||||
.client-failover-form { display: grid; gap: 24px; margin: 0 8px; }
|
||||
.client-failover-master { min-height: 54px; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid color-mix(in oklch, var(--client-border) 48%, transparent); }
|
||||
.client-failover-master > span { display: grid; gap: 3px; }
|
||||
.client-failover-master strong { font: var(--type-body); letter-spacing: var(--type-body-tracking); text-transform: var(--type-body-transform); }
|
||||
.client-failover-master small { color: var(--client-muted); font: var(--type-label); letter-spacing: var(--type-label-tracking); text-transform: var(--type-label-transform); }
|
||||
.client-failover-master input { width: 42px; height: 22px; }
|
||||
.client-failover-channel { display: grid; gap: 10px; }
|
||||
.client-failover-channel-title { min-height: 28px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.client-failover-channel-title strong { color: var(--client-muted); font: var(--type-label); letter-spacing: var(--type-label-tracking); text-transform: var(--type-label-transform); transition: color 600ms ease, filter 600ms ease; }
|
||||
.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 label, .client-failover-timing label, .client-failover-advanced label { display: grid; grid-template-columns: minmax(0, 1fr) minmax(150px, 48%); align-items: center; gap: 16px; min-height: 38px; color: var(--client-muted); font: var(--type-label); letter-spacing: var(--type-label-tracking); text-transform: var(--type-label-transform); }
|
||||
.client-failover-channel select, .client-failover-timing input, .client-failover-advanced input { min-width: 0; min-height: 34px; border: 0; border-bottom: 1px solid var(--client-border); background: transparent; color: var(--client-text); font: var(--type-body); letter-spacing: var(--type-body-tracking); text-transform: var(--type-body-transform); }
|
||||
.client-failover-services { display: grid; gap: 9px; margin: 0; padding: 0; border: 0; }
|
||||
.client-failover-services legend { margin-bottom: 10px; }
|
||||
.client-failover-service { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 34px; }
|
||||
.client-failover-service > label { display: flex; align-items: center; gap: 10px; font: var(--type-body); letter-spacing: var(--type-body-tracking); text-transform: var(--type-body-transform); }
|
||||
.client-failover-service > .client-failover-service-timeout { color: var(--client-muted); font: var(--type-label); letter-spacing: var(--type-label-tracking); text-transform: var(--type-label-transform); }
|
||||
.client-failover-service-timeout input { width: 52px; min-height: 30px; border: 0; border-bottom: 1px solid var(--client-border); background: transparent; color: var(--client-text); font: var(--type-body); letter-spacing: var(--type-body-tracking); text-transform: var(--type-body-transform); }
|
||||
.client-failover-add-service { justify-self: start; padding: 7px 0; border: 0; background: transparent; color: var(--client-accent); font: var(--type-control); letter-spacing: var(--type-control-tracking); text-transform: var(--type-control-transform); cursor: pointer; }
|
||||
.client-failover-add-service:disabled { color: var(--client-muted); cursor: default; opacity: .45; }
|
||||
.client-failover-service-editor { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr); gap: 8px 12px; padding: 8px 0; }
|
||||
.client-failover-service-editor input { min-width: 0; min-height: 34px; border: 0; border-bottom: 1px solid var(--client-border); background: transparent; color: var(--client-text); font: var(--type-body); letter-spacing: var(--type-body-tracking); text-transform: var(--type-body-transform); }
|
||||
.client-failover-service-editor span { grid-column: 1 / -1; color: oklch(0.68 0.15 28); font: var(--type-label); letter-spacing: var(--type-label-tracking); text-transform: var(--type-label-transform); }
|
||||
.client-failover-service-editor button { justify-self: start; padding: 4px 0; border: 0; background: transparent; color: var(--client-accent); font: var(--type-control); letter-spacing: var(--type-control-tracking); text-transform: var(--type-control-transform); cursor: pointer; }
|
||||
.client-failover-timing { display: grid; gap: 5px; }
|
||||
.client-failover-advanced summary { min-height: 38px; color: var(--client-accent); font: var(--type-control); letter-spacing: var(--type-control-tracking); text-transform: var(--type-control-transform); cursor: pointer; }
|
||||
.client-failover-runtime { min-height: 132px; display: grid; align-content: center; gap: 5px; padding: 14px 0; border-top: 1px solid color-mix(in oklch, var(--client-border) 48%, transparent); border-bottom: 1px solid color-mix(in oklch, var(--client-border) 48%, transparent); font-variant-numeric: var(--numeric-tabular); }
|
||||
.client-failover-runtime strong { font: var(--type-body); letter-spacing: var(--type-body-tracking); text-transform: var(--type-body-transform); }
|
||||
.client-failover-runtime span, .client-failover-runtime small { color: var(--client-muted); font: var(--type-label); letter-spacing: var(--type-label-tracking); text-transform: var(--type-label-transform); }
|
||||
.client-failover-validation { margin: -12px 0 0; color: oklch(0.68 0.15 28); font: var(--type-label); letter-spacing: var(--type-label-tracking); text-transform: var(--type-label-transform); }
|
||||
.client-failover-discard { min-height: 74px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; padding: 10px 0; border-top: 1px solid var(--client-border); border-bottom: 1px solid var(--client-border); }
|
||||
.client-failover-discard span { flex-basis: 100%; color: var(--client-text); font: var(--type-body); letter-spacing: var(--type-body-tracking); text-transform: var(--type-body-transform); }
|
||||
.client-failover-discard button { padding: 4px 0; border: 0; background: transparent; color: var(--client-accent); font: var(--type-control); letter-spacing: var(--type-control-tracking); text-transform: var(--type-control-transform); cursor: pointer; }
|
||||
.client-failover-actions { min-height: 42px; display: flex; flex-wrap: wrap; gap: 18px; }
|
||||
.client-failover-actions button { padding: 7px 0; border: 0; background: transparent; color: var(--client-accent); font: var(--type-control); letter-spacing: var(--type-control-tracking); text-transform: var(--type-control-transform); cursor: pointer; }
|
||||
.client-failover-actions small { flex-basis: 100%; margin-top: -14px; color: var(--client-muted); font: var(--type-label); letter-spacing: var(--type-label-tracking); text-transform: var(--type-label-transform); }
|
||||
.client-failover-actions button:disabled { color: var(--client-muted); cursor: default; opacity: .45; }
|
||||
.client-failover-actions button:focus-visible, .client-failover-form input:focus-visible, .client-failover-form select:focus-visible, .client-failover-advanced summary:focus-visible, .client-failover-add-service:focus-visible, .client-failover-service-editor button:focus-visible, .client-failover-discard button:focus-visible { outline: 2px solid var(--client-accent); outline-offset: 2px; }
|
||||
@media (max-width: 560px) { .client-failover-channel label, .client-failover-timing label, .client-failover-advanced label { grid-template-columns: 1fr; gap: 4px; } .client-failover-service, .client-failover-service-editor { grid-template-columns: 1fr; gap: 2px; } }
|
||||
@media (prefers-reduced-motion: reduce) { .client-failover-channel-title strong { transition: none; } }
|
||||
@@ -8,5 +8,7 @@
|
||||
@import './features/servers.css';
|
||||
@import './primitives.css';
|
||||
@import './features/diagnostics.css';
|
||||
@import './features/failover.css';
|
||||
@import './features/activity-journal.css';
|
||||
@import './layout.css';
|
||||
@import './themes.css';
|
||||
|
||||
Reference in New Issue
Block a user