178 lines
11 KiB
TypeScript
178 lines
11 KiB
TypeScript
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<unknown>;
|
||
const nextLevel: Record<TrafficHistoryLevel, TrafficHistoryLevel | null> = {
|
||
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<typeof setTimeout>;
|
||
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<string | null>(null);
|
||
const detailsId = useId();
|
||
const { query } = snapshot;
|
||
const childLevel = nextLevel[query.level];
|
||
return <div className="client-traffic-list" role="list" aria-label={`История: ${levelLabels[query.level]}`}>
|
||
{snapshot.rows.map((row, index) => <div className="client-traffic-history-row" role="listitem" key={row.key}>
|
||
<button
|
||
type="button"
|
||
className="client-traffic-connection-summary"
|
||
aria-expanded={childLevel ? expanded === row.key : undefined}
|
||
aria-controls={childLevel ? `${detailsId}-${index}` : undefined}
|
||
disabled={!childLevel}
|
||
onClick={() => setExpanded((current) => current === row.key ? null : row.key)}
|
||
>
|
||
<span className="client-traffic-identity"><strong title={row.label}>{row.label}</strong></span>
|
||
<span className="client-traffic-download" aria-label={`Скачивание: ${formatByteString(row.downloadBytes)}`}>{formatByteString(row.downloadBytes)}</span>
|
||
<span className="client-traffic-upload" aria-label={`Отправка: ${formatByteString(row.uploadBytes)}`}>{formatByteString(row.uploadBytes)}</span>
|
||
</button>
|
||
{childLevel && <TrafficReveal open={expanded === row.key} id={`${detailsId}-${index}`} className="client-traffic-history-children">
|
||
<HistoryBranch key={row.key} active={active && expanded === row.key} load={load} query={{
|
||
...query, level: childLevel, [query.level]: row.key, offset: 0,
|
||
}} />
|
||
</TrafficReveal>}
|
||
</div>)}
|
||
</div>;
|
||
}
|
||
|
||
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' && <p className="client-traffic-history-status" role="status">
|
||
{result.status === 'loading' ? 'Загружаем…' : 'Данные временно недоступны.'}
|
||
</p>}
|
||
{result.snapshot && <>
|
||
<HistoryRows snapshot={result.snapshot} active={active} load={load} />
|
||
<HistoryPages snapshot={result.snapshot} offset={offset} change={setOffset} />
|
||
</>}
|
||
</>;
|
||
}
|
||
|
||
function HistoryPages({ snapshot, offset, change }: { snapshot: TrafficHistorySnapshot; offset: number; change: (offset: number) => void }) {
|
||
if (!offset && snapshot.nextOffset === null) return null;
|
||
return <div className="client-traffic-filters" role="group" aria-label="Страницы истории">
|
||
<button type="button" disabled={!offset} onClick={() => change(Math.max(0, offset - 100))}>Назад</button>
|
||
<button type="button" disabled={snapshot.nextOffset === null} onClick={() => change(snapshot.nextOffset!)}>Далее</button>
|
||
</div>;
|
||
}
|
||
|
||
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<TrafficHistoryQuery>) { 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 <>
|
||
<div className="client-traffic-toolbar">
|
||
<div className="client-traffic-filters client-traffic-periods" role="group" aria-label="Период истории">
|
||
{([['24h', '24 часа'], ['7d', '7 дней'], ['30d', '30 дней'], ['90d', '90 дней']] as const).map(([value, label]) => <button
|
||
type="button" key={value} aria-pressed={query.range === value} onClick={() => change({ range: value })}
|
||
>{label}</button>)}
|
||
</div>
|
||
{isGateway && <TrafficMenu
|
||
label={snapshot?.origins.find((origin) => origin.id === query.originId)?.label || (query.originId ? 'Выбрано устройство' : 'Все устройства')}
|
||
active={Boolean(query.originId)} closeOnSelect
|
||
>
|
||
<label className="client-traffic-search">
|
||
<input type="search" value={deviceSearch} aria-label="Найти устройство в истории" placeholder="Найти устройство"
|
||
onChange={(event) => setDeviceSearch(event.target.value)} />
|
||
</label>
|
||
<div className="client-traffic-device-list">
|
||
<button type="button" aria-pressed={!query.originId} onClick={() => change({ originId: '' })}>Все устройства</button>
|
||
{origins.map((origin) => <button type="button" key={origin.id} aria-pressed={query.originId === origin.id}
|
||
onClick={() => change({ originId: origin.id })}
|
||
>{origin.label}</button>)}
|
||
</div>
|
||
{!origins.length && <p className="client-traffic-notice">Устройства не найдены.</p>}
|
||
{snapshot?.originsTruncated && <p className="client-traffic-notice">Показаны первые 256 устройств.</p>}
|
||
</TrafficMenu>}
|
||
<TrafficMenu label="Фильтры" active={Boolean(query.search) || query.route !== 'all'}>
|
||
<label className="client-traffic-search">
|
||
<input type="search" value={query.search} maxLength={200} aria-label="Найти сайт, IP или сервис в истории" placeholder="Найти сайт"
|
||
onChange={(event) => change({ search: event.target.value })} />
|
||
</label>
|
||
<div className="client-traffic-option-row"><span>Маршрут</span>
|
||
<div className="client-traffic-filters" role="group" aria-label="Маршрут в истории">
|
||
{([['all', 'Все'], ['vpn', 'VPN'], ['direct', 'Direct'], ['other', 'Другое']] as const).map(([value, label]) => <button
|
||
type="button" key={value} aria-pressed={query.route === value} onClick={() => change({ route: value })}
|
||
>{label}</button>)}
|
||
</div>
|
||
</div>
|
||
{snapshot && <>
|
||
<div className="client-traffic-summary" aria-label="Расход за выбранный период">
|
||
<span>Скачано {formatByteString(snapshot.totals.downloadBytes)}</span>
|
||
<span>Отправлено {formatByteString(snapshot.totals.uploadBytes)}</span>
|
||
</div>
|
||
<p className="client-traffic-honesty">
|
||
{`Данные до ${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 без домена не означает распознанный сайт.
|
||
</p>
|
||
</>}
|
||
</TrafficMenu>
|
||
</div>
|
||
<TrafficColumns />
|
||
<div className="client-traffic-scroll">
|
||
{status && <p className="client-traffic-history-status" role="status">{status}
|
||
{snapshot && (paused || result.status === 'error') && ` Обновлено ${new Date(snapshot.generatedAt).toLocaleTimeString('ru-RU')}.`}
|
||
</p>}
|
||
{snapshot && <>
|
||
{snapshot.rows.length ? <HistoryRows key={JSON.stringify(query)} snapshot={snapshot} active={active} load={load} />
|
||
: <p className="client-traffic-state">{query.search || query.originId || query.route !== 'all'
|
||
? 'По выбранным фильтрам ничего не найдено.' : 'История пока пуста.'}</p>}
|
||
<HistoryPages snapshot={snapshot} offset={query.offset}
|
||
change={(offset) => setQuery((current) => ({ ...current, offset, until: snapshot.query.until }))} />
|
||
</>}
|
||
</div>
|
||
</>;
|
||
}
|