import { useEffect, useId, useMemo, useState } from 'react'; import { assertTrafficHistorySnapshot, parseTrafficHistoryQuery, type TrafficHistoryLevel, type TrafficHistoryQuery, type TrafficHistorySnapshot, } from '../../../shared/trafficHistory.js'; import { formatByteString } from '../../utils/format.js'; import { TrafficColumns, TrafficMenu, TrafficReveal } from './TrafficControls.js'; export type LoadTrafficHistory = (query: TrafficHistoryQuery, signal?: AbortSignal) => Promise; const nextLevel: Record = { service: 'domain', domain: 'hostname', hostname: 'ip', ip: null, }; const levelLabels = { service: 'Сервис', domain: 'Домен', hostname: 'Полное имя', ip: 'IP' }; function useHistory(active: boolean, query: TrafficHistoryQuery, load: LoadTrafficHistory) { const key = JSON.stringify(query); const [result, setResult] = useState<{ key: string; snapshot: TrafficHistorySnapshot | null; status: 'loading' | 'ready' | 'error' }>({ key, snapshot: null, status: 'loading', }); useEffect(() => { if (!active) return; const controller = new AbortController(); let timer: ReturnType; const poll = async () => { try { const snapshot = assertTrafficHistorySnapshot(await load(JSON.parse(key) as TrafficHistoryQuery, controller.signal)); if (snapshot.storage.status === 'error') throw new Error('History unavailable'); if (!controller.signal.aborted) setResult({ key, snapshot, status: 'ready' }); } catch { if (!controller.signal.aborted) setResult((previous) => ({ key, snapshot: previous.key === key ? previous.snapshot : null, status: 'error', })); } finally { if (!controller.signal.aborted) timer = setTimeout(poll, 15_000); } }; timer = setTimeout(poll, 200); return () => { controller.abort(); clearTimeout(timer); }; }, [active, key, load]); return result.key === key ? result : { key, snapshot: null, status: 'loading' as const }; } export function HistoryRows({ snapshot, active, load }: { snapshot: TrafficHistorySnapshot; active: boolean; load: LoadTrafficHistory }) { const [expanded, setExpanded] = useState(null); const detailsId = useId(); const { query } = snapshot; const childLevel = nextLevel[query.level]; return
{snapshot.rows.map((row, index) =>
{childLevel && }
)}
; } function HistoryBranch({ active, query, load }: { active: boolean; query: TrafficHistoryQuery; load: LoadTrafficHistory }) { const [offset, setOffset] = useState(0); const requested = useMemo(() => ({ ...query, offset }), [query, offset]); const result = useHistory(active, requested, load); return <> {result.status !== 'ready' &&

{result.status === 'loading' ? 'Загружаем…' : 'Данные временно недоступны.'}

} {result.snapshot && <> } ; } function HistoryPages({ snapshot, offset, change }: { snapshot: TrafficHistorySnapshot; offset: number; change: (offset: number) => void }) { if (!offset && snapshot.nextOffset === null) return null; return
; } export function TrafficHistoryPanel({ active, paused = false, isGateway, load }: { active: boolean; paused?: boolean; isGateway: boolean; load: LoadTrafficHistory; }) { const [query, setQuery] = useState(() => parseTrafficHistoryQuery(new URLSearchParams())); const [deviceSearch, setDeviceSearch] = useState(''); const result = useHistory(active, query, load); const snapshot = result.snapshot; const origins = (snapshot?.origins || []).filter((origin) => origin.label.toLocaleLowerCase('ru').includes(deviceSearch.toLocaleLowerCase('ru'))); function change(patch: Partial) { setQuery((current) => ({ ...current, ...patch, offset: 0, until: null })); } const status = paused ? 'История на паузе.' : result.status === 'loading' ? 'Загружаем историю…' : result.status === 'error' ? `История временно недоступна.${snapshot ? ' Показаны последние данные.' : ''}` : snapshot?.source === 'disabled' ? 'Сбор выключен. Показана сохранённая история.' : snapshot?.source === 'stopped' ? 'VPN остановлен. Показана сохранённая история.' : snapshot?.source === 'connecting' ? 'Сборщик подключается. Показана сохранённая история.' : snapshot?.coverage.partial ? 'В периоде есть пропуски.' : ''; return <>
{([['24h', '24 часа'], ['7d', '7 дней'], ['30d', '30 дней'], ['90d', '90 дней']] as const).map(([value, label]) => )}
{isGateway && origin.id === query.originId)?.label || (query.originId ? 'Выбрано устройство' : 'Все устройства')} active={Boolean(query.originId)} closeOnSelect >
{origins.map((origin) => )}
{!origins.length &&

Устройства не найдены.

} {snapshot?.originsTruncated &&

Показаны первые 256 устройств.

}
}
Маршрут
{([['all', 'Все'], ['vpn', 'VPN'], ['direct', 'Direct'], ['other', 'Другое']] as const).map(([value, label]) => )}
{snapshot && <>
Скачано {formatByteString(snapshot.totals.downloadBytes)} Отправлено {formatByteString(snapshot.totals.uploadBytes)}

{`Данные до ${new Date(snapshot.period.to).toLocaleString('ru-RU')}.`} {snapshot.period.availableFrom && ` Сбор с ${new Date(snapshot.period.availableFrom).toLocaleString('ru-RU')}.`} {snapshot.coverage.lastObservedAt && ` Обновлено ${new Date(snapshot.coverage.lastObservedAt).toLocaleString('ru-RU')}.`} {' '}IP без домена не означает распознанный сайт.

}
{status &&

{status} {snapshot && (paused || result.status === 'error') && ` Обновлено ${new Date(snapshot.generatedAt).toLocaleTimeString('ru-RU')}.`}

} {snapshot && <> {snapshot.rows.length ? :

{query.search || query.originId || query.route !== 'all' ? 'По выбранным фильтрам ничего не найдено.' : 'История пока пуста.'}

} setQuery((current) => ({ ...current, offset, until: snapshot.query.until }))} /> }
; }