Update Harbor client implementation
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
</>}
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user