178 lines
10 KiB
TypeScript
178 lines
10 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { assertActivityJournalPage, type ActivityJournalEvent } from '../../../shared/activityJournal.js';
|
||
import { compactActivityJournalEvents, refreshStreakCopy, type ActivityJournalDisplayItem } from './activityJournalModel.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 = compactActivityJournalEvents(events).reduce<Array<{ label: string; items: ActivityJournalDisplayItem[] }>>((result, item) => {
|
||
const label = dayLabel(item.event.occurredAt);
|
||
const group = result.at(-1);
|
||
if (group?.label === label) group.items.push(item);
|
||
else result.push({ label, items: [item] });
|
||
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.items.map((item) => {
|
||
const { event } = item;
|
||
const [title, details] = refreshStreakCopy(item) || eventCopy(event);
|
||
return <li key={event.id} className={event.severity === 'error'
|
||
? 'client-journal-event is-error'
|
||
: event.severity === 'warning'
|
||
? 'client-journal-event is-warning'
|
||
: event.type === 'failover.switched' && event.data.toRole === 'primary'
|
||
? 'client-journal-event is-switch-primary'
|
||
: event.type === 'failover.switched' && event.data.toRole === 'reserve'
|
||
? 'client-journal-event is-switch-reserve'
|
||
: 'client-journal-event'}>
|
||
<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>;
|
||
}
|