Files
harbor-net/src/web/features/traffic/TrafficHistoryPanel.tsx
T
dokril 74c5b66482
Build and Deploy Gateway / build-and-push (push) Successful in 34s
Build and Deploy Gateway / deploy (push) Successful in 13s
Update Harbor client implementation
2026-09-10 21:54:18 +03:00

178 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>;
}