Update Harbor client implementation
Build and Deploy Gateway / build-and-push (push) Successful in 34s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-09-10 21:54:18 +03:00
parent 1ae23d848b
commit 74c5b66482
11 changed files with 868 additions and 806 deletions
@@ -0,0 +1,79 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
export function TrafficReveal({ open, id, className = '', children }: {
open: boolean; id?: string; className?: string; children: ReactNode;
}) {
const [present, setPresent] = useState(open);
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (open) setPresent(true);
const node = ref.current;
if (!node) return;
const media = matchMedia('(prefers-reduced-motion: reduce)');
if (media.matches) {
node.style.opacity = open ? '1' : '0';
if (!open) setPresent(false);
return;
}
const animation = node.animate({ opacity: open ? 1 : 0 }, {
duration: 180, easing: 'cubic-bezier(0.2, 0, 0, 1)', fill: 'forwards',
});
animation.onfinish = () => { if (!open) setPresent(false); };
const reduce = () => {
if (!media.matches) return;
animation.cancel();
node.style.opacity = open ? '1' : '0';
if (!open) setPresent(false);
};
media.addEventListener('change', reduce);
return () => {
node.style.opacity = getComputedStyle(node).opacity;
animation.cancel();
media.removeEventListener('change', reduce);
};
}, [open]);
return open || present ? <div ref={ref} id={id} className={className}
style={{ opacity: 0 }} aria-hidden={!open} inert={!open || undefined}
>{children}</div> : null;
}
export function TrafficMenu({ label, active = false, closeOnSelect = false, children }: {
label: string; active?: boolean; closeOnSelect?: boolean; children: ReactNode;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const id = useId();
useEffect(() => {
if (!open) return;
const outside = (event: PointerEvent) => {
if (!ref.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener('pointerdown', outside);
return () => document.removeEventListener('pointerdown', outside);
}, [open]);
return <div className="client-traffic-menu" ref={ref} onKeyDown={(event) => {
if (event.key !== 'Escape' || !open) return;
event.stopPropagation();
setOpen(false);
trigger.current?.focus();
}}>
<button ref={trigger} type="button" className="client-traffic-menu-trigger"
aria-expanded={open} aria-controls={id} data-active={active} title={label}
onClick={() => setOpen((value) => !value)}
>{label}</button>
<TrafficReveal open={open} id={id} className="client-traffic-menu-content">
<div onClick={(event) => {
if (!closeOnSelect || !(event.target as HTMLElement).closest('button')) return;
setOpen(false);
trigger.current?.focus();
}}>{children}</div>
</TrafficReveal>
</div>;
}
export function TrafficColumns() {
return <div className="client-traffic-columns" aria-hidden="true">
<span>Сайт</span><span>Скачивание</span><span>Отправка</span>
</div>;
}
+204 -243
View File
@@ -12,6 +12,7 @@ import {
import { Drawer } from '../../ui/Drawer.js';
import { RailAction } from '../../ui/RailAction.js';
import { formatByteString } from '../../utils/format.js';
import { TrafficColumns, TrafficMenu, TrafficReveal } from './TrafficControls.js';
import { TrafficHistoryPanel, type LoadTrafficHistory } from './TrafficHistoryPanel.js';
import {
groupTrafficConnections,
@@ -223,7 +224,7 @@ function groupDestination(group: TrafficConnectionGroup) {
return port === null ? domain : `${domain} · порт ${port}`;
}
function TrafficGroupRow({
export function TrafficGroupRow({
group,
expanded,
exiting,
@@ -244,7 +245,7 @@ function TrafficGroupRow({
? `${group.origins.length} устройств · соединений: ${group.connections.length}`
: `${group.origin.label} · соединений: ${group.connections.length}`;
const chain = group.route.chain.length
? group.route.chain.join(' ')
? group.route.chain.join(' / ')
: group.route.outbound || '—';
return <div
@@ -266,44 +267,36 @@ function TrafficGroupRow({
onClick={onToggle}
>
<span className="client-traffic-identity">
<strong aria-label={group.connections.length > 1
? `${group.label}, соединений: ${group.connections.length}`
: undefined}
>{group.label}{group.connections.length > 1 ? ` ×${group.connections.length}` : ''}</strong>
<small>{groupStatus(group)}</small>
<strong title={group.label}>{group.label}</strong>
</span>
<span className="client-traffic-route" data-route={group.route.kind}>
{routeLabels[group.route.kind]}
<span className="client-traffic-download" aria-label={`Скачивание: ${group.activeCount > 0 ? `${formatByteString(group.traffic.downloadBytesPerSecond)}/с` : 'соединение завершено'}`}>
{group.activeCount > 0 ? `${formatByteString(group.traffic.downloadBytesPerSecond)}/с` : '—'}
</span>
<span className="client-traffic-values">
{group.activeCount > 0 && <strong>
<span> {formatByteString(group.traffic.downloadBytesPerSecond)}/с</span>
<span> {formatByteString(group.traffic.uploadBytesPerSecond)}/с</span>
</strong>}
<small>
<span> {formatByteString(group.traffic.downloadBytes)}</span>
<span> {formatByteString(group.traffic.uploadBytes)}</span>
</small>
<span className="client-traffic-upload" aria-label={`Отправка: ${group.activeCount > 0 ? `${formatByteString(group.traffic.uploadBytesPerSecond)}/с` : 'соединение завершено'}`}>
{group.activeCount > 0 ? `${formatByteString(group.traffic.uploadBytesPerSecond)}/с` : '—'}
</span>
<svg className="client-traffic-chevron" viewBox="0 0 16 16" aria-hidden="true">
<path d="m5 6 3 3 3-3" />
</svg>
</button>
{expanded && <dl id={detailsId} className="client-traffic-details">
{group.origins.length > 1 && group.origins.map((origin) => <div
className="client-traffic-origin-breakdown"
key={origin.id}
>
<dt>{origin.label}</dt>
<dd>
{origin.connections} соединений · {formatByteString(origin.traffic.downloadBytes)} · {formatByteString(origin.traffic.uploadBytes)}
</dd>
</div>)}
<div><dt>Источник</dt><dd>{source}</dd></div>
<div><dt>Назначение</dt><dd>{groupDestination(group)}</dd></div>
<div><dt>Правило</dt><dd>{group.route.rule || '—'}</dd></div>
<div><dt>Цепочка</dt><dd>{chain}</dd></div>
</dl>}
<TrafficReveal open={expanded} id={detailsId}>
<dl className="client-traffic-details">
<div><dt>Состояние</dt><dd>{groupStatus(group)}</dd></div>
<div><dt>Маршрут</dt><dd>{routeLabels[group.route.kind]}</dd></div>
<div><dt>Скачано</dt><dd>{formatByteString(group.traffic.downloadBytes)}</dd></div>
<div><dt>Отправлено</dt><dd>{formatByteString(group.traffic.uploadBytes)}</dd></div>
{group.origins.length > 1 && group.origins.map((origin) => <div
className="client-traffic-origin-breakdown"
key={origin.id}
>
<dt>{origin.label}</dt>
<dd>
Скачано {formatByteString(origin.traffic.downloadBytes)} · отправлено {formatByteString(origin.traffic.uploadBytes)}
</dd>
</div>)}
<div><dt>Источник</dt><dd>{source}</dd></div>
<div><dt>Назначение</dt><dd>{groupDestination(group)}</dd></div>
<div><dt>Правило</dt><dd>{group.route.rule || '—'}</dd></div>
<div><dt>Цепочка</dt><dd>{chain}</dd></div>
</dl>
</TrafficReveal>
</div>;
}
@@ -337,7 +330,6 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
const [query, setQuery] = useState('');
const [deviceQuery, setDeviceQuery] = useState('');
const [selectedOriginId, setSelectedOriginId] = useState('');
const [devicesExpanded, setDevicesExpanded] = useState(false);
const [settingsPending, setSettingsPending] = useState(false);
const [routeFilter, setRouteFilter] = useState<TrafficRouteFilter>('all');
const [qualityFilter, setQualityFilter] = useState<TrafficQualityFilter>('all');
@@ -348,6 +340,8 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
const [expandedId, setExpandedId] = useState('');
const snapshot = feature.snapshot;
const { grouping, sort: sortMode, retentionSeconds } = feature.settings;
const ordering = JSON.stringify([query, selectedOriginId, routeFilter, qualityFilter, grouping, sortMode]);
const previousOrdering = useRef(ordering);
const sourceState = snapshot?.source.state;
const snapshotTime = snapshot?.observedAt ? Date.parse(snapshot.observedAt) : Number.NaN;
const retainedConnections = useMemo(() => (snapshot?.connections || []).filter((connection) => {
@@ -364,8 +358,6 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
[origin.label, origin.ip].some((value) => String(value || '').toLocaleLowerCase('ru-RU').includes(needle))
)) : origins;
}, [origins, deviceQuery]);
const visibleOrigins = devicesExpanded ? matchingOrigins : matchingOrigins.slice(0, 3);
const hiddenOriginCount = Math.max(0, matchingOrigins.length - 3);
const selectedConnections = useMemo(() => selectedOriginId
? filteredConnections.filter((connection) => trafficOriginId(connection) === selectedOriginId)
: filteredConnections, [filteredConnections, selectedOriginId]);
@@ -391,8 +383,10 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
const desiredIds = new Set(groups.map((group) => group.id));
setExpandedId((current) => desiredIds.has(current) ? current : '');
}
setDisplayedGroups((current) => reconcileTrafficGroups(current, groups, immediate));
}, [groups, reducedMotion, snapshot, sourceState]);
const reorder = previousOrdering.current !== ordering;
previousOrdering.current = ordering;
setDisplayedGroups((current) => reconcileTrafficGroups(current, groups, immediate, !reorder));
}, [groups, reducedMotion, snapshot, sourceState, ordering]);
useEffect(() => {
if (selectedOriginId && !origins.some(({ id }) => id === selectedOriginId)) {
@@ -411,6 +405,7 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
}
function finishExit(id: string) {
if (groups.some((group) => group.id === id)) return;
setDisplayedGroups((current) => current.filter((row) => (
row.group.id !== id || !row.exiting
)));
@@ -432,218 +427,184 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
open={feature.isOpen}
labelledBy="client-traffic-title"
closeLabel="Закрыть трафик"
closeText="Закрыть"
onClose={feature.close}
>
<header className="client-traffic-header">
<div className="client-traffic-meta">
<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}
onClick={feature.togglePause}
>{feature.paused ? 'Продолжить' : 'Пауза'}</button>
</div>
<h2 id="client-traffic-title">Трафик</h2>
<p>Соединения сгруппированы по назначению, протоколу и маршруту.</p>
<button type="button" className="client-traffic-pause" aria-pressed={feature.paused}
onClick={feature.togglePause}
>{feature.paused ? 'Продолжить' : 'Пауза'}</button>
</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 className="client-traffic-tabs" role="group" aria-label="Режим трафика" data-view={feature.view}>
<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 className="client-traffic-views">
<section className={`client-traffic-view${feature.view === 'live' ? ' is-active' : ''}`}
aria-label="Сейчас" aria-hidden={feature.view !== 'live'} inert={feature.view !== 'live' || undefined}
>
<div className="client-traffic-toolbar">
<label className="client-traffic-search">
<span className="client-live-region">Поиск соединений</span>
<input type="search" value={query} aria-label="Найти сайт, IP или сервис" placeholder="Найти сайт"
onChange={(event) => setQuery(event.target.value)} />
</label>
{feature.isGateway && <TrafficMenu
label={origins.find((origin) => origin.id === selectedOriginId)?.label || 'Все устройства'}
active={Boolean(selectedOriginId)} closeOnSelect
>
<label className="client-traffic-search">
<input type="search" value={deviceQuery} aria-label="Найти устройство" placeholder="Найти устройство"
onChange={(event) => setDeviceQuery(event.target.value)} />
</label>
<div className="client-traffic-device-list">
<button type="button" aria-pressed={!selectedOriginId} onClick={() => setSelectedOriginId('')}>Все устройства</button>
{matchingOrigins.map((origin) => <button type="button" key={origin.id}
aria-pressed={selectedOriginId === origin.id} onClick={() => setSelectedOriginId(origin.id)}
>{origin.label}</button>)}
</div>
{!matchingOrigins.length && <p className="client-traffic-notice">Устройства не найдены.</p>}
</TrafficMenu>}
<TrafficMenu label="Фильтры" active={routeFilter !== 'all' || qualityFilter !== 'all'}>
<div className="client-traffic-settings">
<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={routeFilter === value}
onClick={() => setRouteFilter(value)}
>{label}</button>)}
</div>
</div>
<div className="client-traffic-option-row">
<span>Распознано</span>
<div className="client-traffic-filters" role="group" aria-label="Фильтр по качеству распознавания">
{([
['all', 'Все'],
['recognized', 'Распознано'],
['attention', 'Требует внимания'],
] as const).map(([value, label]) => <button
type="button"
key={value}
aria-pressed={qualityFilter === value}
onClick={() => setQualityFilter(value)}
>{label}</button>)}
</div>
</div>
<div className="client-traffic-option-row">
<span>Группировка</span>
<div className="client-traffic-filters" role="group" aria-label="Группировка соединений">
{([
['site', 'По сайтам'],
['device', 'По устройствам'],
] as const).map(([value, label]) => <button
type="button"
key={value}
disabled={settingsPending}
aria-pressed={grouping === value}
onClick={() => void saveSettings({ ...feature.settings, grouping: value })}
>{label}</button>)}
</div>
</div>
<div className="client-traffic-option-row">
<span>Сортировка</span>
<div className="client-traffic-filters" role="group" aria-label="Сортировка соединений">
{([
['popular', 'Популярные'],
['recent', 'Последние'],
] as const).map(([value, label]) => <button
type="button"
key={value}
disabled={settingsPending}
aria-pressed={sortMode === value}
onClick={() => void saveSettings({ ...feature.settings, sort: value })}
>{label}</button>)}
</div>
</div>
<div className="client-traffic-option-row">
<span>Завершённые</span>
<div className="client-traffic-filters" role="group" aria-label="Время показа завершённых соединений">
{TRAFFIC_RETENTION_OPTIONS.map((seconds) => <button
type="button"
key={seconds}
disabled={settingsPending}
aria-pressed={retentionSeconds === seconds}
onClick={() => void saveSettings({ ...feature.settings, retentionSeconds: seconds })}
>{seconds} с</button>)}
</div>
</div>
</div>
{snapshot && <div className="client-traffic-summary">
<span>Распознано: {snapshot.summary.recognized}</span>
<span>Без домена: {snapshot.summary.unresolved}</span>
<time dateTime={snapshot.observedAt || undefined}>{updatedAt(snapshot.observedAt)}</time>
</div>}
<p className="client-traffic-honesty">{feature.isGateway
? 'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.'
: 'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.'}</p>
</TrafficMenu>
</div>
<TrafficColumns />
<div className="client-traffic-scroll">
{feature.paused && snapshot && <p className="client-traffic-notice" role="status">
Пауза · показаны данные на {updatedAt(snapshot.observedAt).replace('обновлено ', '')}.
</p>}
{!feature.paused && stale && snapshot && <p className="client-traffic-notice is-warning" role="status">
Не удалось обновить. Показан последний полученный снимок.
</p>}
{!feature.paused && !stale && snapshot && sourceState === 'connecting' && snapshot.connections.length > 0 && <p
className="client-traffic-notice"
role="status"
>
Инспектор переподключается. Показан последний полученный снимок.
</p>}
{degraded && snapshot && <p className="client-traffic-notice is-warning" role="status">
Часть трафика не распознана.
</p>}
{feature.view === 'history' ? <TrafficHistoryPanel active={feature.isOpen && !feature.paused} isGateway={feature.isGateway} load={feature.loadHistory} /> : <>
<TrafficState feature={feature} />
{snapshot && <div className="client-traffic-summary" aria-label="Качество распознавания трафика">
<span><b>Активных распознано</b> {snapshot.summary.recognized}</span>
<span><b>Активных требует внимания</b> {snapshot.summary.unresolved}</span>
</div>}
{canShowList && retainedConnections.length === 0 && displayedGroups.length === 0 && <p className="client-traffic-state">
Активных соединений пока нет.
</p>}
{canShowList && retainedConnections.length > 0 && groups.length === 0 && displayedGroups.length === 0 && <p className="client-traffic-state">
По выбранным фильтрам ничего не найдено.
</p>}
{canShowList && displayedGroups.length > 0 && <div
className="client-traffic-list"
role="list"
aria-label="Группы активных и недавно завершённых соединений"
aria-busy={feature.requestState === 'loading'}
>
{displayedGroups.map((row) => <TrafficGroupRow
key={row.group.id}
group={row.group}
expanded={expandedId === row.group.id}
exiting={row.exiting}
onExited={() => finishExit(row.group.id)}
onToggle={() => setExpandedId((current) => current === row.group.id ? '' : row.group.id)}
/>)}
</div>}
<div className="client-traffic-tools">
<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={routeFilter === value}
onClick={() => setRouteFilter(value)}
>{label}</button>)}
{snapshot?.summary.truncated && <p className="client-traffic-truncated" role="status">
Показано не более 256 соединений.
</p>}
</div>
</div>
<div className="client-traffic-option-row">
<span>Распознано</span>
<div className="client-traffic-filters" role="group" aria-label="Фильтр по качеству распознавания">
{([
['all', 'Все'],
['recognized', 'Распознано'],
['attention', 'Требует внимания'],
] as const).map(([value, label]) => <button
type="button"
key={value}
aria-pressed={qualityFilter === value}
onClick={() => setQualityFilter(value)}
>{label}</button>)}
</div>
</div>
<div className="client-traffic-option-row">
<span>Группировка</span>
<div className="client-traffic-filters" role="group" aria-label="Группировка соединений">
{([
['site', 'По сайтам'],
['device', 'По устройствам'],
] as const).map(([value, label]) => <button
type="button"
key={value}
disabled={settingsPending}
aria-pressed={grouping === value}
onClick={() => void saveSettings({ ...feature.settings, grouping: value })}
>{label}</button>)}
</div>
</div>
<div className="client-traffic-option-row">
<span>Сортировка</span>
<div className="client-traffic-filters" role="group" aria-label="Сортировка соединений">
{([
['popular', 'Популярные'],
['recent', 'Последние'],
] as const).map(([value, label]) => <button
type="button"
key={value}
disabled={settingsPending}
aria-pressed={sortMode === value}
onClick={() => void saveSettings({ ...feature.settings, sort: value })}
>{label}</button>)}
</div>
</div>
<div className="client-traffic-option-row">
<span>Завершённые</span>
<div className="client-traffic-filters" role="group" aria-label="Время показа завершённых соединений">
{TRAFFIC_RETENTION_OPTIONS.map((seconds) => <button
type="button"
key={seconds}
disabled={settingsPending}
aria-pressed={retentionSeconds === seconds}
onClick={() => void saveSettings({ ...feature.settings, retentionSeconds: seconds })}
>{seconds} с</button>)}
</div>
</div>
</section>
<section className={`client-traffic-view${feature.view === 'history' ? ' is-active' : ''}`}
aria-label="История" aria-hidden={feature.view !== 'history'} inert={feature.view !== 'history' || undefined}
>
<TrafficHistoryPanel active={feature.isOpen && !feature.paused && feature.view === 'history'}
paused={feature.paused} isGateway={feature.isGateway} load={feature.loadHistory} />
</section>
</div>
{feature.isGateway && canShowList && <section className="client-traffic-devices" aria-labelledby="client-traffic-devices-title">
<div className="client-traffic-devices-heading">
<h3 id="client-traffic-devices-title">Устройства</h3>
<button
type="button"
aria-pressed={!selectedOriginId}
onClick={() => setSelectedOriginId('')}
>Все устройства · {origins.length}</button>
</div>
<label className="client-traffic-search is-device-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={deviceQuery}
aria-label="Найти устройство"
placeholder="Найти устройство"
onChange={(event) => setDeviceQuery(event.target.value)}
/>
</label>
<div className="client-traffic-device-list" id="client-traffic-device-list">
{visibleOrigins.map((origin) => <button
type="button"
key={origin.id}
aria-pressed={selectedOriginId === origin.id}
onClick={() => setSelectedOriginId((current) => current === origin.id ? '' : origin.id)}
>
<strong>{origin.label}</strong>
<small>{origin.connections} соединений · Устройство{origin.ip ? ` · ${origin.ip}` : ''}</small>
</button>)}
</div>
{hiddenOriginCount > 0 && <button
className="client-traffic-devices-more"
type="button"
aria-expanded={devicesExpanded}
aria-controls="client-traffic-device-list"
onClick={() => setDevicesExpanded((current) => !current)}
>{devicesExpanded ? 'Свернуть устройства' : `Ещё ${hiddenOriginCount} устройств`}</button>}
</section>}
{canShowList && <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}
aria-label="Найти сайт, IP или сервис"
placeholder="Найти сайт, IP или сервис"
onChange={(event) => setQuery(event.target.value)}
/>
</label>}
{feature.paused && snapshot && <p className="client-traffic-notice" role="status">
Пауза · показаны данные на {updatedAt(snapshot.observedAt).replace('обновлено ', '')}.
</p>}
{!feature.paused && stale && snapshot && <p className="client-traffic-notice is-warning" role="status">
Данные временно не обновляются. Показан последний полученный снимок.
</p>}
{!feature.paused && !stale && snapshot && sourceState === 'connecting' && snapshot.connections.length > 0 && <p
className="client-traffic-notice"
role="status"
>
Инспектор переподключается. Показан последний полученный снимок.
</p>}
{degraded && snapshot && <p className="client-traffic-notice is-warning" role="status">
Часть трафика не удалось сопоставить с соединениями: {formatByteString(snapshot.source.unattributedDownloadBytes)} · {formatByteString(snapshot.source.unattributedUploadBytes)}.
</p>}
<TrafficState feature={feature} />
{canShowList && retainedConnections.length === 0 && displayedGroups.length === 0 && <p className="client-traffic-state">
Активных соединений пока нет.
</p>}
{canShowList && retainedConnections.length > 0 && groups.length === 0 && displayedGroups.length === 0 && <p className="client-traffic-state">
По выбранным фильтрам ничего не найдено.
</p>}
{canShowList && displayedGroups.length > 0 && <div
className="client-traffic-list"
role="list"
aria-label="Группы активных и недавно завершённых соединений"
aria-busy={feature.requestState === 'loading'}
>
{displayedGroups.map((row) => <TrafficGroupRow
key={row.group.id}
group={row.group}
expanded={expandedId === row.group.id}
exiting={row.exiting}
onExited={() => finishExit(row.group.id)}
onToggle={() => setExpandedId((current) => current === row.group.id ? '' : row.group.id)}
/>)}
</div>}
{snapshot?.summary.truncated && <p className="client-traffic-truncated" role="status">
Снимок ограничен 256 соединениями; активные показаны первыми.
</p>}
</>}
<p className="client-traffic-honesty">
{feature.isGateway
? 'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.'
: 'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.'}
</p>
</Drawer>;
}
@@ -7,13 +7,13 @@ import {
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' };
const routes = { vpn: 'VPN', direct: 'Direct', other: 'Другое', mixed: 'Разные' };
function useHistory(active: boolean, query: TrafficHistoryQuery, load: LoadTrafficHistory) {
const key = JSON.stringify(query);
@@ -43,7 +43,7 @@ function useHistory(active: boolean, query: TrafficHistoryQuery, load: LoadTraff
return result.key === key ? result : { key, snapshot: null, status: 'loading' as const };
}
function HistoryRows({ snapshot, active, load }: { snapshot: TrafficHistorySnapshot; active: boolean; load: LoadTrafficHistory }) {
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;
@@ -58,18 +58,15 @@ function HistoryRows({ snapshot, active, load }: { snapshot: TrafficHistorySnaps
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>}
<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 && expanded === row.key && <div id={`${detailsId}-${index}`} className="client-traffic-history-children">
<HistoryBranch key={row.key} active={active} load={load} query={{
{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,
}} />
</div>}
</TrafficReveal>}
</div>)}
</div>;
}
@@ -79,8 +76,9 @@ function HistoryBranch({ active, query, load }: { active: boolean; query: Traffi
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.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} />
@@ -96,77 +94,84 @@ function HistoryPages({ snapshot, offset, change }: { snapshot: TrafficHistorySn
</div>;
}
export function TrafficHistoryPanel({ active, isGateway, load }: { active: boolean; isGateway: boolean; load: LoadTrafficHistory }) {
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 [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 })); }
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-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 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>
<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>)}
{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>
</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>
{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>
</>}
</>;
}
+10
View File
@@ -211,8 +211,18 @@ export function reconcileTrafficGroups(
current: DisplayedTrafficGroup[],
desired: TrafficConnectionGroup[],
immediate: boolean,
preserveOrder = false,
) {
const next = desired.map((group) => ({ group, exiting: false }));
if (preserveOrder) {
const remaining = new Map(next.map((row) => [row.group.id, row]));
const retained = current.flatMap((row) => {
const updated = remaining.get(row.group.id);
remaining.delete(row.group.id);
return updated ? [updated] : immediate ? [] : [{ ...row, exiting: true }];
});
return [...retained, ...remaining.values()];
}
if (immediate) return next;
const desiredIds = new Set(desired.map((group) => group.id));