Migrate Harbor state and traffic history to SQLite
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
import { Drawer } from '../../ui/Drawer.js';
|
||||
import { RailAction } from '../../ui/RailAction.js';
|
||||
import { formatByteString } from '../../utils/format.js';
|
||||
import { TrafficHistoryPanel, type LoadTrafficHistory } from './TrafficHistoryPanel.js';
|
||||
import {
|
||||
groupTrafficConnections,
|
||||
reconcileTrafficGroups,
|
||||
@@ -34,6 +35,7 @@ interface TrafficFeatureOptions {
|
||||
enabled: boolean;
|
||||
isGateway: boolean;
|
||||
loadLiveTraffic: () => Promise<unknown>;
|
||||
loadHistory: LoadTrafficHistory;
|
||||
settings: TrafficSettings;
|
||||
updateSettings: (settings: TrafficSettings) => Promise<unknown>;
|
||||
}
|
||||
@@ -65,11 +67,13 @@ export function useTrafficFeature({
|
||||
enabled,
|
||||
isGateway,
|
||||
loadLiveTraffic,
|
||||
loadHistory,
|
||||
settings,
|
||||
updateSettings,
|
||||
}: TrafficFeatureOptions) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [view, setView] = useState<'live' | 'history'>('live');
|
||||
const [snapshot, setSnapshot] = useState<LiveTrafficSnapshot | null>(null);
|
||||
const [requestState, setRequestState] = useState<RequestState>('idle');
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
@@ -83,7 +87,7 @@ export function useTrafficFeature({
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !isOpen || paused) return undefined;
|
||||
if (!enabled || !isOpen || paused || view !== 'live') return undefined;
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
setRequestState((current) => current === 'idle' ? 'loading' : current);
|
||||
@@ -107,7 +111,7 @@ export function useTrafficFeature({
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [enabled, isOpen, paused, loadLiveTraffic]);
|
||||
}, [enabled, isOpen, paused, view, loadLiveTraffic]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
@@ -146,6 +150,9 @@ export function useTrafficFeature({
|
||||
isGateway,
|
||||
isOpen,
|
||||
paused,
|
||||
view,
|
||||
setView,
|
||||
loadHistory,
|
||||
snapshot,
|
||||
requestState,
|
||||
settings,
|
||||
@@ -429,8 +436,8 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
||||
>
|
||||
<header className="client-traffic-header">
|
||||
<div className="client-traffic-meta">
|
||||
<span>{feature.isGateway ? 'GATEWAY' : 'MAC'} · {snapshot?.summary.active || 0} АКТИВНЫХ</span>
|
||||
<time dateTime={snapshot?.observedAt || undefined}>{updatedAt(snapshot?.observedAt)}</time>
|
||||
<span>{feature.isGateway ? 'GATEWAY' : 'MAC'} · {feature.view === 'live' ? `${snapshot?.summary.active || 0} АКТИВНЫХ` : 'ИСТОРИЯ'}</span>
|
||||
<time dateTime={feature.view === 'live' ? snapshot?.observedAt || undefined : undefined}>{feature.view === 'live' ? updatedAt(snapshot?.observedAt) : '\u00a0'}</time>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={feature.paused}
|
||||
@@ -441,6 +448,15 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
||||
<p>Соединения сгруппированы по назначению, протоколу и маршруту.</p>
|
||||
</header>
|
||||
|
||||
<div className="client-traffic-tools">
|
||||
<div className="client-traffic-filters" role="group" aria-label="Режим трафика">
|
||||
<button type="button" aria-pressed={feature.view === 'live'} onClick={() => feature.setView('live')}>Сейчас</button>
|
||||
<button type="button" aria-pressed={feature.view === 'history'} onClick={() => feature.setView('history')}>История</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{feature.view === 'history' ? <TrafficHistoryPanel active={feature.isOpen && !feature.paused} isGateway={feature.isGateway} load={feature.loadHistory} /> : <>
|
||||
|
||||
{snapshot && <div className="client-traffic-summary" aria-label="Качество распознавания трафика">
|
||||
<span><b>Активных распознано</b> {snapshot.summary.recognized}</span>
|
||||
<span><b>Активных требует внимания</b> {snapshot.summary.unresolved}</span>
|
||||
@@ -623,6 +639,7 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
||||
{snapshot?.summary.truncated && <p className="client-traffic-truncated" role="status">
|
||||
Снимок ограничен 256 соединениями; активные показаны первыми.
|
||||
</p>}
|
||||
</>}
|
||||
<p className="client-traffic-honesty">
|
||||
{feature.isGateway
|
||||
? 'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.'
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
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';
|
||||
|
||||
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' };
|
||||
const routes = { vpn: 'VPN', direct: 'Direct', other: 'Другое', mixed: 'Разные' };
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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><small>{levelLabels[query.level]}</small></span>
|
||||
<span className="client-traffic-route" data-route={row.route}>{routes[row.route]}</span>
|
||||
<span className="client-traffic-values"><strong>
|
||||
<span>↓ {formatByteString(row.downloadBytes)}</span><span>↑ {formatByteString(row.uploadBytes)}</span>
|
||||
</strong></span>
|
||||
{childLevel && <svg className="client-traffic-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m5 6 3 3 3-3" /></svg>}
|
||||
</button>
|
||||
{childLevel && expanded === row.key && <div id={`${detailsId}-${index}`} className="client-traffic-history-children">
|
||||
<HistoryBranch key={row.key} active={active} load={load} query={{
|
||||
...query, level: childLevel, [query.level]: row.key, offset: 0,
|
||||
}} />
|
||||
</div>}
|
||||
</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 <>
|
||||
<p className="client-traffic-history-status" role="status">{result.status === 'loading' ? 'Загружаем…'
|
||||
: result.status === 'error' ? 'Данные временно недоступны.' : '\u00a0'}</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, isGateway, load }: { active: boolean; isGateway: boolean; load: LoadTrafficHistory }) {
|
||||
const [query, setQuery] = useState(() => parseTrafficHistoryQuery(new URLSearchParams()));
|
||||
const [deviceSearch, setDeviceSearch] = useState('');
|
||||
const [devicesExpanded, setDevicesExpanded] = useState(false);
|
||||
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 })); }
|
||||
return <>
|
||||
<div className="client-traffic-tools">
|
||||
<div className="client-traffic-option-row"><span>Период</span>
|
||||
<div className="client-traffic-filters" 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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
{isGateway && <section className="client-traffic-devices" aria-label="Устройства в истории">
|
||||
<div className="client-traffic-devices-heading"><h3>Устройства</h3>
|
||||
<button type="button" aria-pressed={!query.originId} onClick={() => change({ originId: '' })}>Все устройства</button>
|
||||
</div>
|
||||
<label className="client-traffic-search is-device-search">
|
||||
<span aria-hidden="true" /><span className="client-live-region">Найти устройство</span>
|
||||
<input type="search" value={deviceSearch} aria-label="Найти устройство в истории" placeholder="Найти устройство" onChange={(e) => setDeviceSearch(e.target.value)} />
|
||||
</label>
|
||||
<div className="client-traffic-device-list" id="client-history-devices">
|
||||
{(devicesExpanded ? origins : origins.slice(0, 3)).map((origin) => <button type="button" key={origin.id}
|
||||
aria-pressed={query.originId === origin.id} onClick={() => change({ originId: query.originId === origin.id ? '' : origin.id })}
|
||||
><strong>{origin.label}</strong></button>)}
|
||||
</div>
|
||||
{origins.length > 3 && <button type="button" className="client-traffic-devices-more" aria-expanded={devicesExpanded}
|
||||
aria-controls="client-history-devices" onClick={() => setDevicesExpanded((value) => !value)}
|
||||
>{devicesExpanded ? 'Свернуть устройства' : `Ещё ${origins.length - 3} устройств`}</button>}
|
||||
{snapshot?.originsTruncated && <p className="client-traffic-notice">Показаны первые 256 устройств.</p>}
|
||||
</section>}
|
||||
<label className="client-traffic-search client-traffic-list-search">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6" /><path d="m15 15 5 5" /></svg>
|
||||
<span className="client-live-region">Поиск истории</span>
|
||||
<input type="search" value={query.search} aria-label="Найти сайт, IP или сервис в истории" placeholder="Найти сайт, IP или сервис"
|
||||
onChange={(event) => change({ search: event.target.value })} />
|
||||
</label>
|
||||
<p className="client-traffic-history-status" role="status">{result.status === 'loading' ? 'Загружаем историю…'
|
||||
: result.status === 'error' ? `История временно недоступна.${snapshot ? ' Показаны последние полученные данные.' : ''}`
|
||||
: snapshot?.source === 'disabled' ? 'Сбор истории выключен; сохранённые данные доступны.'
|
||||
: snapshot?.source === 'stopped' ? 'VPN остановлен; показана сохранённая история.'
|
||||
: snapshot?.source === 'connecting' ? 'Сборщик подключается; показана сохранённая история.'
|
||||
: snapshot?.coverage.partial ? 'В выбранном периоде есть пропуски или неточное распределение по времени.' : '\u00a0'}</p>
|
||||
{snapshot && <>
|
||||
<div className="client-traffic-summary" aria-label="Расход за выбранный период">
|
||||
<span><b>Скачано</b>{formatByteString(snapshot.totals.downloadBytes)}</span>
|
||||
<span><b>Отправлено</b>{formatByteString(snapshot.totals.uploadBytes)}</span>
|
||||
</div>
|
||||
{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 }))} />
|
||||
<p className="client-traffic-honesty">
|
||||
Локальная история за 90 дней: завершённые минуты за последние 7 дней, ранее по часам.
|
||||
{` Данные до ${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')}.`}
|
||||
{' '}Внешний Prometheus не требуется. IP без домена не означает распознанный сайт.
|
||||
</p>
|
||||
</>}
|
||||
</>;
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export {
|
||||
useTrafficFeature,
|
||||
type TrafficFeature,
|
||||
} from './TrafficFeature.js';
|
||||
export type { LoadTrafficHistory } from './TrafficHistoryPanel.js';
|
||||
|
||||
Reference in New Issue
Block a user