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
+2 -2
View File
@@ -1,6 +1,6 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.37.0', macClient: '0.37.1',
gatewayClient: '0.39.0', gatewayClient: '0.39.1',
gatewayBackend: '0.39.0', gatewayBackend: '0.39.0',
}); });
@@ -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 { Drawer } from '../../ui/Drawer.js';
import { RailAction } from '../../ui/RailAction.js'; import { RailAction } from '../../ui/RailAction.js';
import { formatByteString } from '../../utils/format.js'; import { formatByteString } from '../../utils/format.js';
import { TrafficColumns, TrafficMenu, TrafficReveal } from './TrafficControls.js';
import { TrafficHistoryPanel, type LoadTrafficHistory } from './TrafficHistoryPanel.js'; import { TrafficHistoryPanel, type LoadTrafficHistory } from './TrafficHistoryPanel.js';
import { import {
groupTrafficConnections, groupTrafficConnections,
@@ -223,7 +224,7 @@ function groupDestination(group: TrafficConnectionGroup) {
return port === null ? domain : `${domain} · порт ${port}`; return port === null ? domain : `${domain} · порт ${port}`;
} }
function TrafficGroupRow({ export function TrafficGroupRow({
group, group,
expanded, expanded,
exiting, exiting,
@@ -244,7 +245,7 @@ function TrafficGroupRow({
? `${group.origins.length} устройств · соединений: ${group.connections.length}` ? `${group.origins.length} устройств · соединений: ${group.connections.length}`
: `${group.origin.label} · соединений: ${group.connections.length}`; : `${group.origin.label} · соединений: ${group.connections.length}`;
const chain = group.route.chain.length const chain = group.route.chain.length
? group.route.chain.join(' ') ? group.route.chain.join(' / ')
: group.route.outbound || '—'; : group.route.outbound || '—';
return <div return <div
@@ -266,44 +267,36 @@ function TrafficGroupRow({
onClick={onToggle} onClick={onToggle}
> >
<span className="client-traffic-identity"> <span className="client-traffic-identity">
<strong aria-label={group.connections.length > 1 <strong title={group.label}>{group.label}</strong>
? `${group.label}, соединений: ${group.connections.length}`
: undefined}
>{group.label}{group.connections.length > 1 ? ` ×${group.connections.length}` : ''}</strong>
<small>{groupStatus(group)}</small>
</span> </span>
<span className="client-traffic-route" data-route={group.route.kind}> <span className="client-traffic-download" aria-label={`Скачивание: ${group.activeCount > 0 ? `${formatByteString(group.traffic.downloadBytesPerSecond)}/с` : 'соединение завершено'}`}>
{routeLabels[group.route.kind]} {group.activeCount > 0 ? `${formatByteString(group.traffic.downloadBytesPerSecond)}/с` : '—'}
</span> </span>
<span className="client-traffic-values"> <span className="client-traffic-upload" aria-label={`Отправка: ${group.activeCount > 0 ? `${formatByteString(group.traffic.uploadBytesPerSecond)}/с` : 'соединение завершено'}`}>
{group.activeCount > 0 && <strong> {group.activeCount > 0 ? `${formatByteString(group.traffic.uploadBytesPerSecond)}/с` : '—'}
<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> </span>
<svg className="client-traffic-chevron" viewBox="0 0 16 16" aria-hidden="true">
<path d="m5 6 3 3 3-3" />
</svg>
</button> </button>
{expanded && <dl id={detailsId} className="client-traffic-details"> <TrafficReveal open={expanded} id={detailsId}>
{group.origins.length > 1 && group.origins.map((origin) => <div <dl className="client-traffic-details">
className="client-traffic-origin-breakdown" <div><dt>Состояние</dt><dd>{groupStatus(group)}</dd></div>
key={origin.id} <div><dt>Маршрут</dt><dd>{routeLabels[group.route.kind]}</dd></div>
> <div><dt>Скачано</dt><dd>{formatByteString(group.traffic.downloadBytes)}</dd></div>
<dt>{origin.label}</dt> <div><dt>Отправлено</dt><dd>{formatByteString(group.traffic.uploadBytes)}</dd></div>
<dd> {group.origins.length > 1 && group.origins.map((origin) => <div
{origin.connections} соединений · {formatByteString(origin.traffic.downloadBytes)} · {formatByteString(origin.traffic.uploadBytes)} className="client-traffic-origin-breakdown"
</dd> key={origin.id}
</div>)} >
<div><dt>Источник</dt><dd>{source}</dd></div> <dt>{origin.label}</dt>
<div><dt>Назначение</dt><dd>{groupDestination(group)}</dd></div> <dd>
<div><dt>Правило</dt><dd>{group.route.rule || '—'}</dd></div> Скачано {formatByteString(origin.traffic.downloadBytes)} · отправлено {formatByteString(origin.traffic.uploadBytes)}
<div><dt>Цепочка</dt><dd>{chain}</dd></div> </dd>
</dl>} </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>; </div>;
} }
@@ -337,7 +330,6 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [deviceQuery, setDeviceQuery] = useState(''); const [deviceQuery, setDeviceQuery] = useState('');
const [selectedOriginId, setSelectedOriginId] = useState(''); const [selectedOriginId, setSelectedOriginId] = useState('');
const [devicesExpanded, setDevicesExpanded] = useState(false);
const [settingsPending, setSettingsPending] = useState(false); const [settingsPending, setSettingsPending] = useState(false);
const [routeFilter, setRouteFilter] = useState<TrafficRouteFilter>('all'); const [routeFilter, setRouteFilter] = useState<TrafficRouteFilter>('all');
const [qualityFilter, setQualityFilter] = useState<TrafficQualityFilter>('all'); const [qualityFilter, setQualityFilter] = useState<TrafficQualityFilter>('all');
@@ -348,6 +340,8 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
const [expandedId, setExpandedId] = useState(''); const [expandedId, setExpandedId] = useState('');
const snapshot = feature.snapshot; const snapshot = feature.snapshot;
const { grouping, sort: sortMode, retentionSeconds } = feature.settings; 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 sourceState = snapshot?.source.state;
const snapshotTime = snapshot?.observedAt ? Date.parse(snapshot.observedAt) : Number.NaN; const snapshotTime = snapshot?.observedAt ? Date.parse(snapshot.observedAt) : Number.NaN;
const retainedConnections = useMemo(() => (snapshot?.connections || []).filter((connection) => { 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)) [origin.label, origin.ip].some((value) => String(value || '').toLocaleLowerCase('ru-RU').includes(needle))
)) : origins; )) : origins;
}, [origins, deviceQuery]); }, [origins, deviceQuery]);
const visibleOrigins = devicesExpanded ? matchingOrigins : matchingOrigins.slice(0, 3);
const hiddenOriginCount = Math.max(0, matchingOrigins.length - 3);
const selectedConnections = useMemo(() => selectedOriginId const selectedConnections = useMemo(() => selectedOriginId
? filteredConnections.filter((connection) => trafficOriginId(connection) === selectedOriginId) ? filteredConnections.filter((connection) => trafficOriginId(connection) === selectedOriginId)
: filteredConnections, [filteredConnections, selectedOriginId]); : filteredConnections, [filteredConnections, selectedOriginId]);
@@ -391,8 +383,10 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
const desiredIds = new Set(groups.map((group) => group.id)); const desiredIds = new Set(groups.map((group) => group.id));
setExpandedId((current) => desiredIds.has(current) ? current : ''); setExpandedId((current) => desiredIds.has(current) ? current : '');
} }
setDisplayedGroups((current) => reconcileTrafficGroups(current, groups, immediate)); const reorder = previousOrdering.current !== ordering;
}, [groups, reducedMotion, snapshot, sourceState]); previousOrdering.current = ordering;
setDisplayedGroups((current) => reconcileTrafficGroups(current, groups, immediate, !reorder));
}, [groups, reducedMotion, snapshot, sourceState, ordering]);
useEffect(() => { useEffect(() => {
if (selectedOriginId && !origins.some(({ id }) => id === selectedOriginId)) { if (selectedOriginId && !origins.some(({ id }) => id === selectedOriginId)) {
@@ -411,6 +405,7 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
} }
function finishExit(id: string) { function finishExit(id: string) {
if (groups.some((group) => group.id === id)) return;
setDisplayedGroups((current) => current.filter((row) => ( setDisplayedGroups((current) => current.filter((row) => (
row.group.id !== id || !row.exiting row.group.id !== id || !row.exiting
))); )));
@@ -432,218 +427,184 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
open={feature.isOpen} open={feature.isOpen}
labelledBy="client-traffic-title" labelledBy="client-traffic-title"
closeLabel="Закрыть трафик" closeLabel="Закрыть трафик"
closeText="Закрыть"
onClose={feature.close} onClose={feature.close}
> >
<header className="client-traffic-header"> <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> <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> </header>
<div className="client-traffic-tabs" role="group" aria-label="Режим трафика" data-view={feature.view}>
<div className="client-traffic-tools"> <button type="button" aria-pressed={feature.view === 'live'} onClick={() => feature.setView('live')}>Сейчас</button>
<div className="client-traffic-filters" role="group" aria-label="Режим трафика"> <button type="button" aria-pressed={feature.view === 'history'} onClick={() => feature.setView('history')}>История</button>
<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> </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="Качество распознавания трафика"> {canShowList && retainedConnections.length === 0 && displayedGroups.length === 0 && <p className="client-traffic-state">
<span><b>Активных распознано</b> {snapshot.summary.recognized}</span> Активных соединений пока нет.
<span><b>Активных требует внимания</b> {snapshot.summary.unresolved}</span> </p>}
</div>} {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"> {snapshot?.summary.truncated && <p className="client-traffic-truncated" role="status">
<div className="client-traffic-option-row"> Показано не более 256 соединений.
<span>Маршрут</span> </p>}
<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> </section>
<div className="client-traffic-option-row"> <section className={`client-traffic-view${feature.view === 'history' ? ' is-active' : ''}`}
<span>Распознано</span> aria-label="История" aria-hidden={feature.view !== 'history'} inert={feature.view !== 'history' || undefined}
<div className="client-traffic-filters" role="group" aria-label="Фильтр по качеству распознавания"> >
{([ <TrafficHistoryPanel active={feature.isOpen && !feature.paused && feature.view === 'history'}
['all', 'Все'], paused={feature.paused} isGateway={feature.isGateway} load={feature.loadHistory} />
['recognized', 'Распознано'], </section>
['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> </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>; </Drawer>;
} }
@@ -7,13 +7,13 @@ import {
type TrafficHistorySnapshot, type TrafficHistorySnapshot,
} from '../../../shared/trafficHistory.js'; } from '../../../shared/trafficHistory.js';
import { formatByteString } from '../../utils/format.js'; import { formatByteString } from '../../utils/format.js';
import { TrafficColumns, TrafficMenu, TrafficReveal } from './TrafficControls.js';
export type LoadTrafficHistory = (query: TrafficHistoryQuery, signal?: AbortSignal) => Promise<unknown>; export type LoadTrafficHistory = (query: TrafficHistoryQuery, signal?: AbortSignal) => Promise<unknown>;
const nextLevel: Record<TrafficHistoryLevel, TrafficHistoryLevel | null> = { const nextLevel: Record<TrafficHistoryLevel, TrafficHistoryLevel | null> = {
service: 'domain', domain: 'hostname', hostname: 'ip', ip: null, service: 'domain', domain: 'hostname', hostname: 'ip', ip: null,
}; };
const levelLabels = { service: 'Сервис', domain: 'Домен', hostname: 'Полное имя', ip: 'IP' }; const levelLabels = { service: 'Сервис', domain: 'Домен', hostname: 'Полное имя', ip: 'IP' };
const routes = { vpn: 'VPN', direct: 'Direct', other: 'Другое', mixed: 'Разные' };
function useHistory(active: boolean, query: TrafficHistoryQuery, load: LoadTrafficHistory) { function useHistory(active: boolean, query: TrafficHistoryQuery, load: LoadTrafficHistory) {
const key = JSON.stringify(query); 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 }; 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 [expanded, setExpanded] = useState<string | null>(null);
const detailsId = useId(); const detailsId = useId();
const { query } = snapshot; const { query } = snapshot;
@@ -58,18 +58,15 @@ function HistoryRows({ snapshot, active, load }: { snapshot: TrafficHistorySnaps
disabled={!childLevel} disabled={!childLevel}
onClick={() => setExpanded((current) => current === row.key ? null : row.key)} 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-identity"><strong title={row.label}>{row.label}</strong></span>
<span className="client-traffic-route" data-route={row.route}>{routes[row.route]}</span> <span className="client-traffic-download" aria-label={`Скачивание: ${formatByteString(row.downloadBytes)}`}>{formatByteString(row.downloadBytes)}</span>
<span className="client-traffic-values"><strong> <span className="client-traffic-upload" aria-label={`Отправка: ${formatByteString(row.uploadBytes)}`}>{formatByteString(row.uploadBytes)}</span>
<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> </button>
{childLevel && expanded === row.key && <div id={`${detailsId}-${index}`} className="client-traffic-history-children"> {childLevel && <TrafficReveal open={expanded === row.key} id={`${detailsId}-${index}`} className="client-traffic-history-children">
<HistoryBranch key={row.key} active={active} load={load} query={{ <HistoryBranch key={row.key} active={active && expanded === row.key} load={load} query={{
...query, level: childLevel, [query.level]: row.key, offset: 0, ...query, level: childLevel, [query.level]: row.key, offset: 0,
}} /> }} />
</div>} </TrafficReveal>}
</div>)} </div>)}
</div>; </div>;
} }
@@ -79,8 +76,9 @@ function HistoryBranch({ active, query, load }: { active: boolean; query: Traffi
const requested = useMemo(() => ({ ...query, offset }), [query, offset]); const requested = useMemo(() => ({ ...query, offset }), [query, offset]);
const result = useHistory(active, requested, load); const result = useHistory(active, requested, load);
return <> return <>
<p className="client-traffic-history-status" role="status">{result.status === 'loading' ? 'Загружаем…' {result.status !== 'ready' && <p className="client-traffic-history-status" role="status">
: result.status === 'error' ? 'Данные временно недоступны.' : '\u00a0'}</p> {result.status === 'loading' ? 'Загружаем…' : 'Данные временно недоступны.'}
</p>}
{result.snapshot && <> {result.snapshot && <>
<HistoryRows snapshot={result.snapshot} active={active} load={load} /> <HistoryRows snapshot={result.snapshot} active={active} load={load} />
<HistoryPages snapshot={result.snapshot} offset={offset} change={setOffset} /> <HistoryPages snapshot={result.snapshot} offset={offset} change={setOffset} />
@@ -96,77 +94,84 @@ function HistoryPages({ snapshot, offset, change }: { snapshot: TrafficHistorySn
</div>; </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 [query, setQuery] = useState(() => parseTrafficHistoryQuery(new URLSearchParams()));
const [deviceSearch, setDeviceSearch] = useState(''); const [deviceSearch, setDeviceSearch] = useState('');
const [devicesExpanded, setDevicesExpanded] = useState(false);
const result = useHistory(active, query, load); const result = useHistory(active, query, load);
const snapshot = result.snapshot; const snapshot = result.snapshot;
const origins = (snapshot?.origins || []).filter((origin) => origin.label.toLocaleLowerCase('ru').includes(deviceSearch.toLocaleLowerCase('ru'))); 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 })); } 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 <> return <>
<div className="client-traffic-tools"> <div className="client-traffic-toolbar">
<div className="client-traffic-option-row"><span>Период</span> <div className="client-traffic-filters client-traffic-periods" role="group" aria-label="Период истории">
<div className="client-traffic-filters" role="group" aria-label="Период истории"> {([['24h', '24 часа'], ['7d', '7 дней'], ['30d', '30 дней'], ['90d', '90 дней']] as const).map(([value, label]) => <button
{([['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 })}
type="button" key={value} aria-pressed={query.range === value} onClick={() => change({ range: value })} >{label}</button>)}
>{label}</button>)}
</div>
</div> </div>
<div className="client-traffic-option-row"><span>Маршрут</span> {isGateway && <TrafficMenu
<div className="client-traffic-filters" role="group" aria-label="Маршрут в истории"> label={snapshot?.origins.find((origin) => origin.id === query.originId)?.label || (query.originId ? 'Выбрано устройство' : 'Все устройства')}
{([['all', 'Все'], ['vpn', 'VPN'], ['direct', 'Direct'], ['other', 'Другое']] as const).map(([value, label]) => <button active={Boolean(query.originId)} closeOnSelect
type="button" key={value} aria-pressed={query.route === value} onClick={() => change({ route: value })} >
>{label}</button>)} <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>
</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> </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[], current: DisplayedTrafficGroup[],
desired: TrafficConnectionGroup[], desired: TrafficConnectionGroup[],
immediate: boolean, immediate: boolean,
preserveOrder = false,
) { ) {
const next = desired.map((group) => ({ group, exiting: 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; if (immediate) return next;
const desiredIds = new Set(desired.map((group) => group.id)); const desiredIds = new Set(desired.map((group) => group.id));
+385 -441
View File
@@ -3,158 +3,183 @@
height: 24px; height: 24px;
} }
.client-traffic-history-status { .client-traffic {
min-height: 48px; --font-size-control: 0.875rem;
margin: 0 8px 12px; --font-size-body: 0.875rem;
color: var(--client-muted); --font-size-data: 1rem;
font: var(--type-control); --font-size-item-title: 1rem;
letter-spacing: var(--type-control-tracking); --font-size-drawer-title: 1.5rem;
text-transform: var(--type-control-transform); --font-weight-bold: 500;
overflow-wrap: anywhere; --font-weight-strong: 500;
} --type-control: var(--font-weight-bold) var(--font-size-control)/var(--line-height-control) var(--font-family-client);
--type-body: var(--font-weight-body) var(--font-size-body)/var(--line-height-body) var(--font-family-client);
.client-traffic-history-children { --type-data: var(--font-weight-strong) var(--font-size-data)/var(--line-height-data) var(--font-family-client);
padding-left: 8px; --type-item-title: var(--font-weight-bold) var(--font-size-item-title)/var(--line-height-item-title) var(--font-family-client);
animation: client-traffic-details-in 180ms cubic-bezier(0.16, 1, 0.3, 1) both; --type-drawer-title: var(--font-weight-bold) var(--font-size-drawer-title)/var(--line-height-drawer-title) var(--font-family-client);
} --traffic-value-width: 112px;
width: min(800px, 100vw);
.client-traffic-history-row > button:disabled { overflow: hidden;
cursor: default;
} }
.client-traffic-sheet { .client-traffic-sheet {
padding: 54px 72px 72px 34px; height: 100%;
} min-height: 0;
display: grid;
@media (max-width: 768px) { grid-template-columns: minmax(0, 1fr) 112px 80px;
.client-traffic { grid-template-rows: 44px 56px minmax(0, 1fr);
width: 100vw; gap: 12px 16px;
} padding: 28px 78px 24px 32px;
} }
.client-traffic-header { .client-traffic-header {
grid-area: 1 / 1 / 2 / 3;
display: grid; display: grid;
gap: 9px; grid-template-columns: minmax(0, 1fr) 112px;
margin: 0 8px 28px;
}
.client-traffic-meta {
min-height: 28px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center; align-items: center;
gap: 12px; gap: 16px;
padding-right: 28px;
}
.client-traffic-meta > span,
.client-traffic-meta time {
color: var(--client-muted);
font: var(--type-label);
letter-spacing: var(--type-label-tracking);
text-transform: var(--type-label-transform);
font-variant-numeric: var(--numeric-tabular);
}
.client-traffic-meta time {
text-transform: var(--type-label-transform);
white-space: nowrap;
}
.client-traffic-meta button {
width: 96px;
min-height: 32px;
padding: 0;
border: 0;
background: transparent;
color: var(--client-text);
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
text-align: right;
cursor: pointer;
}
.client-traffic-meta button:hover,
.client-traffic-meta button:focus-visible,
.client-traffic-meta button[aria-pressed='true'] {
color: var(--client-accent);
}
.client-traffic-meta button:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 3px;
} }
.client-traffic-header h2 { .client-traffic-header h2 {
margin: 4px 0 0; margin: 0;
font: var(--type-drawer-title); font: var(--type-drawer-title);
letter-spacing: var(--type-drawer-title-tracking); letter-spacing: var(--type-drawer-title-tracking);
text-transform: var(--type-drawer-title-transform); text-transform: var(--type-drawer-title-transform);
} }
.client-traffic-header p { .client-traffic .client-drawer-close {
max-width: 44ch; grid-area: 1 / 3;
position: static;
width: 80px;
height: 44px;
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
text-align: right;
transform: none;
}
.client-traffic-pause,
.client-traffic-menu-trigger,
.client-traffic-tabs button,
.client-traffic-filters button,
.client-traffic-device-list button {
min-height: 36px;
padding: 4px 0;
border: 0;
background: transparent;
color: var(--client-muted); color: var(--client-muted);
font: var(--type-body); font: var(--type-control);
letter-spacing: var(--type-body-tracking); letter-spacing: var(--type-control-tracking);
text-transform: var(--type-body-transform); text-transform: var(--type-control-transform);
cursor: pointer;
transition: color 180ms ease;
} }
.client-traffic-summary { .client-traffic-pause {
display: grid; width: 112px;
gap: 6px; text-align: right;
margin: 0 8px 24px;
color: var(--client-text);
font: var(--type-data);
letter-spacing: var(--type-data-tracking);
text-transform: var(--type-data-transform);
font-variant-numeric: var(--numeric-tabular);
} }
.client-traffic-summary span { .client-traffic button:enabled:hover,
.client-traffic button[aria-pressed='true'],
.client-traffic-menu-trigger[data-active='true'],
.client-traffic-menu-trigger[aria-expanded='true'] {
color: var(--client-accent);
}
.client-traffic button:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: -2px;
}
.client-traffic button:disabled {
opacity: 0.55;
cursor: default;
}
.client-traffic-tabs {
grid-column: 1 / -1;
position: relative;
display: flex; display: flex;
gap: 8px; align-items: start;
gap: 24px;
} }
.client-traffic-summary b { .client-traffic-tabs button {
min-width: 112px; width: 88px;
color: var(--client-muted); text-align: left;
font: inherit;
} }
.client-traffic-tools { .client-traffic-tabs::after {
content: '';
position: absolute;
left: 0;
top: 36px;
width: 68px;
height: 2px;
background: var(--client-accent);
transition: transform 200ms cubic-bezier(0.2, 0, 0, 1);
}
.client-traffic-tabs[data-view='history']::after {
transform: translateX(112px);
}
.client-traffic-views {
grid-column: 1 / -1;
position: relative;
min-height: 0;
}
.client-traffic-view {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
min-width: 0;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 180ms cubic-bezier(0.2, 0, 0, 1), visibility 0s 180ms;
}
.client-traffic-view.is-active {
opacity: 1;
visibility: visible;
pointer-events: auto;
transition-delay: 0s;
}
.client-traffic-toolbar {
position: relative;
z-index: 2;
display: grid; display: grid;
gap: 4px; grid-template-columns: minmax(0, 1fr) auto auto;
margin: 0 8px 22px; align-items: center;
gap: 12px 24px;
min-height: 56px;
margin-bottom: 24px;
}
.client-traffic-toolbar > .client-traffic-menu:last-child {
grid-column: -2;
justify-self: end;
} }
.client-traffic-search { .client-traffic-search {
display: grid; display: block;
grid-template-columns: 28px minmax(0, 1fr); min-width: 0;
align-items: center;
border-bottom: 1px solid var(--client-border); border-bottom: 1px solid var(--client-border);
color: var(--client-muted);
} }
.client-traffic-search:focus-within { .client-traffic-search:focus-within {
border-bottom-color: var(--client-accent); border-bottom-color: var(--client-accent);
color: var(--client-accent);
}
.client-traffic-search svg {
width: 19px;
height: 19px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.7;
} }
.client-traffic-search input { .client-traffic-search input {
width: 100%; width: 100%;
height: 42px; min-width: 0;
height: 40px;
padding: 0; padding: 0;
border: 0; border: 0;
outline: 0; outline: 0;
@@ -170,184 +195,255 @@
opacity: 1; opacity: 1;
} }
.client-traffic-search input::-webkit-search-cancel-button {
display: none;
}
.client-traffic-menu {
min-width: 0;
}
.client-traffic-menu-trigger {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-traffic-menu-trigger[data-active='true'],
.client-traffic-menu-trigger[aria-expanded='true'] {
text-decoration: underline;
text-underline-offset: 6px;
}
.client-traffic-menu-content {
position: absolute;
top: 100%;
right: 0;
width: min(420px, 100%);
max-height: min(480px, calc(100dvh - 280px));
overflow-y: auto;
overscroll-behavior: contain;
padding: 18px;
border: 1px solid var(--client-border);
background: var(--client-bg);
}
.client-traffic-device-list {
display: grid;
margin-top: 12px;
}
.client-traffic-device-list button {
text-align: left;
overflow-wrap: anywhere;
}
.client-traffic-settings {
display: grid;
gap: 12px;
}
.client-traffic-filters { .client-traffic-filters {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px 18px; gap: 4px 16px;
} }
.client-traffic-filters button { .client-traffic-filters button {
min-height: 36px;
padding: 4px 0 2px;
border: 0;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
background: transparent;
color: var(--client-muted);
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
cursor: pointer;
}
.client-traffic-filters button:hover,
.client-traffic-filters button:focus-visible,
.client-traffic-filters button[aria-pressed='true'] {
color: var(--client-accent);
} }
.client-traffic-filters button[aria-pressed='true'] { .client-traffic-filters button[aria-pressed='true'] {
border-bottom-color: var(--client-accent); border-bottom-color: var(--client-accent);
} }
.client-traffic-filters button:focus-visible { .client-traffic-periods {
outline: 2px solid var(--client-accent); gap: 4px 12px;
outline-offset: 2px; }
.client-traffic-periods button {
white-space: nowrap;
} }
.client-traffic-option-row { .client-traffic-option-row {
min-height: 36px;
display: grid; display: grid;
grid-template-columns: 112px minmax(0, 1fr); gap: 4px;
align-items: center;
gap: 14px;
} }
.client-traffic-option-row > span { .client-traffic-option-row > span {
color: var(--client-muted); color: var(--client-muted);
font: var(--type-control); font: var(--type-body);
letter-spacing: var(--type-control-tracking); letter-spacing: var(--type-body-tracking);
text-transform: var(--type-control-transform); text-transform: var(--type-body-transform);
} }
.client-traffic-option-row .client-traffic-filters { .client-traffic-columns,
justify-content: flex-end; .client-traffic-connection-summary {
}
.client-traffic-filters button:disabled {
cursor: wait;
opacity: 0.55;
}
.client-traffic-devices {
display: grid; display: grid;
gap: 8px; grid-template-columns: minmax(0, 1fr) var(--traffic-value-width) var(--traffic-value-width);
margin: 0 8px 24px;
}
.client-traffic-devices-heading {
min-height: 36px;
display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 16px;
gap: 12px;
} }
.client-traffic-devices-heading h3, .client-traffic-columns {
.client-traffic-devices-heading button, flex: none;
.client-traffic-devices-more { min-height: 44px;
overflow-y: hidden;
scrollbar-width: thin;
scrollbar-gutter: stable;
border-bottom: 1px solid var(--client-border);
color: var(--client-muted);
font: var(--type-body);
letter-spacing: var(--type-body-tracking);
text-transform: var(--type-body-transform);
}
.client-traffic-columns span:not(:first-child) {
text-align: right;
}
.client-traffic-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-gutter: stable;
}
.client-traffic-list {
margin: 0; margin: 0;
border: 0; padding: 0;
background: transparent;
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
} }
.client-traffic-devices-heading h3 { .client-traffic-connection,
color: var(--client-muted); .client-traffic-history-row {
}
.client-traffic-devices-heading button,
.client-traffic-devices-more {
padding: 5px 0;
color: var(--client-muted);
cursor: pointer;
}
.client-traffic-devices-heading button:hover,
.client-traffic-devices-heading button:focus-visible,
.client-traffic-devices-heading button[aria-pressed='true'],
.client-traffic-devices-more:hover,
.client-traffic-devices-more:focus-visible {
color: var(--client-accent);
}
.client-traffic-devices-heading button:focus-visible,
.client-traffic-devices-more:focus-visible,
.client-traffic-device-list button:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 2px;
}
.client-traffic-device-list {
display: grid;
animation: client-traffic-details-in 180ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-traffic-device-list button {
min-width: 0; min-width: 0;
display: grid; box-shadow: inset 0 -1px 0 color-mix(in oklch, var(--client-border) 46%, transparent);
gap: 3px; }
padding: 9px 0;
.client-traffic-connection {
animation: client-traffic-connection-in 180ms cubic-bezier(0.2, 0, 0, 1) both;
}
.client-traffic-connection.is-exiting {
pointer-events: none;
animation: client-traffic-connection-out 180ms cubic-bezier(0.2, 0, 0, 1) both;
}
.client-traffic-connection-summary {
width: 100%;
min-height: 64px;
padding: 12px 0;
border: 0; border: 0;
border-bottom: 1px solid color-mix(in oklch, var(--client-border) 46%, transparent);
background: transparent; background: transparent;
color: var(--client-text); color: var(--client-text);
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
} }
.client-traffic-device-list button:hover, .client-traffic-identity {
.client-traffic-device-list button[aria-pressed='true'] { min-width: 0;
color: var(--client-accent);
} }
.client-traffic-device-list strong, .client-traffic-identity strong {
.client-traffic-device-list small { display: block;
overflow: hidden; overflow: hidden;
font: var(--type-item-title);
letter-spacing: var(--type-item-title-tracking);
text-transform: var(--type-item-title-transform);
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.client-traffic-device-list strong { .client-traffic-connection-summary:enabled:hover .client-traffic-identity strong,
font: var(--type-item-title); .client-traffic-connection-summary[aria-expanded='true'] .client-traffic-identity strong {
letter-spacing: var(--type-item-title-tracking); text-decoration: underline;
text-transform: var(--type-item-title-transform); text-underline-offset: 5px;
} }
.client-traffic-device-list small { .client-traffic-connection-summary:disabled {
opacity: 1;
}
.client-traffic-download,
.client-traffic-upload {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
text-align: right;
white-space: nowrap;
font: var(--type-data);
letter-spacing: var(--type-data-tracking);
text-transform: var(--type-data-transform);
font-variant-numeric: var(--numeric-tabular);
}
.client-traffic-download {
color: var(--harbor-connect);
}
.client-traffic-upload {
color: var(--client-muted); color: var(--client-muted);
font: var(--type-micro);
letter-spacing: var(--type-micro-tracking);
text-transform: var(--type-micro-transform);
} }
.client-traffic-devices-more { .client-traffic-history-children {
justify-self: start; padding-left: 20px;
} }
.client-traffic-list-search { .client-traffic-details {
margin: 0 8px 12px; display: grid;
gap: 8px;
margin: 0;
padding: 4px 0 20px;
}
.client-traffic-details > div {
min-width: 0;
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
gap: 12px;
}
.client-traffic-details dt,
.client-traffic-details dd {
margin: 0;
font: var(--type-body);
letter-spacing: var(--type-body-tracking);
text-transform: var(--type-body-transform);
overflow-wrap: anywhere;
}
.client-traffic-details dt {
color: var(--client-muted);
}
.client-traffic-details dd {
color: var(--client-text);
} }
.client-traffic-notice, .client-traffic-notice,
.client-traffic-history-status,
.client-traffic-state, .client-traffic-state,
.client-traffic-truncated, .client-traffic-summary,
.client-traffic-honesty { .client-traffic-honesty,
margin: 0 8px; .client-traffic-truncated {
margin: 12px 0;
color: var(--client-muted); color: var(--client-muted);
font: var(--type-control); font: var(--type-body);
letter-spacing: var(--type-control-tracking); letter-spacing: var(--type-body-tracking);
text-transform: var(--type-control-transform); text-transform: var(--type-body-transform);
overflow-wrap: anywhere;
} }
.client-traffic-notice { .client-traffic-summary {
margin-bottom: 14px; display: grid;
color: var(--client-accent); gap: 6px;
} }
.client-traffic-notice.is-warning { .client-traffic-notice.is-warning {
color: oklch(0.68 0.14 72); color: var(--client-accent);
} }
.client-traffic-state { .client-traffic-state {
@@ -360,237 +456,85 @@
.client-traffic-skeleton { .client-traffic-skeleton {
display: grid; display: grid;
gap: 10px; gap: 10px;
margin: 0 8px; padding: 12px 0;
} }
.client-traffic-skeleton span { .client-traffic-skeleton span {
height: 58px; height: 48px;
background: color-mix(in oklch, var(--client-border) 24%, transparent); background: color-mix(in oklch, var(--client-border) 24%, transparent);
opacity: 0.55; opacity: 0.55;
} }
.client-traffic-list {
display: grid;
margin: 0 8px;
padding: 0;
}
.client-traffic-connection {
min-width: 0;
box-shadow: inset 0 -1px 0 color-mix(in oklch, var(--client-border) 58%, transparent);
animation: client-traffic-connection-in 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-traffic-connection.is-exiting {
pointer-events: none;
animation: client-traffic-connection-out 240ms cubic-bezier(0.4, 0, 1, 1) both;
}
.client-traffic-connection-summary {
width: 100%;
min-height: 68px;
display: grid;
grid-template-columns: minmax(0, 1fr) 54px 154px 18px;
align-items: center;
gap: 10px;
padding: 10px 0;
border: 0;
background: transparent;
color: var(--client-text);
text-align: left;
cursor: pointer;
}
.client-traffic-connection-summary:hover,
.client-traffic-connection-summary:focus-visible {
outline: 0;
color: var(--client-accent);
}
.client-traffic-connection-summary:focus-visible {
box-shadow: inset 0 0 0 2px var(--client-accent);
}
.client-traffic-identity,
.client-traffic-values {
min-width: 0;
display: grid;
gap: 4px;
}
.client-traffic-identity strong {
overflow: hidden;
font: var(--type-item-title);
letter-spacing: var(--type-item-title-tracking);
text-overflow: ellipsis;
text-transform: var(--type-item-title-transform);
white-space: nowrap;
}
.client-traffic-identity small {
overflow: hidden;
color: var(--client-muted);
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-overflow: ellipsis;
text-transform: var(--type-control-transform);
white-space: nowrap;
}
.client-traffic-route {
justify-self: start;
color: var(--client-muted);
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
}
.client-traffic-route[data-route='vpn'] {
color: var(--harbor-connect);
}
.client-traffic-route[data-route='direct'] {
color: var(--harbor-gateway);
}
.client-traffic-values {
justify-items: end;
font-variant-numeric: var(--numeric-tabular);
}
.client-traffic-values strong,
.client-traffic-values small {
display: flex;
justify-content: flex-end;
gap: 10px;
white-space: nowrap;
}
.client-traffic-values strong {
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
}
.client-traffic-values small {
color: var(--client-muted);
font: var(--type-micro);
letter-spacing: var(--type-micro-tracking);
text-transform: var(--type-micro-transform);
}
.client-traffic-values span:first-child {
color: var(--harbor-connect);
}
.client-traffic-values span:last-child {
color: var(--harbor-gateway);
}
.client-traffic-chevron {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.6;
}
.client-traffic-details {
display: grid;
gap: 7px;
margin: 0;
padding: 4px 0 16px;
animation: client-traffic-details-in 180ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-traffic-details > div {
min-width: 0;
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
gap: 10px;
}
.client-traffic-details dt,
.client-traffic-details dd {
margin: 0;
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
}
.client-traffic-details dt {
color: var(--client-muted);
}
.client-traffic-details .client-traffic-origin-breakdown {
grid-template-columns: minmax(120px, 1fr) minmax(0, 1.5fr);
padding-bottom: 5px;
}
.client-traffic-origin-breakdown dt {
color: var(--client-text);
}
.client-traffic-details dd {
min-width: 0;
overflow-wrap: anywhere;
color: var(--client-text);
}
.client-traffic-truncated {
padding-top: 16px;
text-align: center;
}
.client-traffic-honesty {
margin-top: 26px;
padding-top: 16px;
box-shadow: inset 0 1px 0 color-mix(in oklch, var(--client-border) 46%, transparent);
}
@keyframes client-traffic-details-in {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes client-traffic-connection-in { @keyframes client-traffic-connection-in {
from { opacity: 0; transform: translateY(-6px); } from { opacity: 0; }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; }
} }
@keyframes client-traffic-connection-out { @keyframes client-traffic-connection-out {
from { opacity: 1; transform: translateY(0); } from { opacity: 1; }
to { opacity: 0; transform: translateY(4px); } to { opacity: 0; }
} }
@media (max-width: 560px) { @media (max-width: 768px) {
.client-traffic {
width: 100vw;
}
.client-traffic-sheet { .client-traffic-sheet {
padding: 40px 58px 60px 18px; padding: 20px 64px 20px 24px;
} }
.client-traffic-meta { .client-traffic-toolbar {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
min-height: 92px;
gap: 8px 16px;
margin-bottom: 12px;
} }
.client-traffic-meta > span { .client-traffic-toolbar > .client-traffic-search,
.client-traffic-periods {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
}
@media (max-width: 480px) {
.client-traffic-sheet {
grid-template-columns: minmax(0, 1fr) 80px 68px;
gap: 8px;
padding: 16px 56px 16px 16px;
}
.client-traffic-header {
grid-template-columns: minmax(0, 1fr) 80px;
gap: 8px;
}
.client-traffic-pause {
width: 80px;
}
.client-traffic .client-drawer-close {
width: 68px;
}
.client-traffic-columns,
.client-traffic-connection-summary { .client-traffic-connection-summary {
grid-template-columns: minmax(0, 1fr) auto 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px 16px;
} }
.client-traffic-values { .client-traffic-columns > span:first-child,
.client-traffic-identity {
grid-column: 1 / -1; grid-column: 1 / -1;
grid-row: 2;
justify-items: start;
} }
.client-traffic-values strong, .client-traffic-columns {
.client-traffic-values small { padding-bottom: 10px;
justify-content: flex-start; }
.client-traffic-columns > span:nth-child(2),
.client-traffic-download {
text-align: left;
} }
.client-traffic-details > div { .client-traffic-details > div {
@@ -598,21 +542,21 @@
gap: 2px; gap: 2px;
} }
.client-traffic-option-row { .client-traffic-history-children {
grid-template-columns: 1fr; padding-left: 12px;
gap: 0;
} }
}
.client-traffic-option-row .client-traffic-filters { @media (pointer: coarse) {
justify-content: flex-start; .client-traffic button {
min-height: 44px;
} }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.client-traffic-history-children, .client-traffic *,
.client-traffic-connection, .client-traffic-tabs::after {
.client-traffic-details,
.client-traffic-device-list {
animation: none; animation: none;
transition: none;
} }
} }
+3 -1
View File
@@ -9,6 +9,7 @@ interface DrawerBaseProps {
className?: string; className?: string;
sheetClassName?: string; sheetClassName?: string;
closeLabel: string; closeLabel: string;
closeText?: string;
onClose: () => unknown; onClose: () => unknown;
leading?: ReactNode; leading?: ReactNode;
children: ReactNode; children: ReactNode;
@@ -30,6 +31,7 @@ export function Drawer({
labelledBy, labelledBy,
label, label,
closeLabel, closeLabel,
closeText = '×',
onClose, onClose,
leading, leading,
children, children,
@@ -51,7 +53,7 @@ export function Drawer({
type="button" type="button"
aria-label={closeLabel} aria-label={closeLabel}
onClick={onClose} onClick={onClose}
>×</button> >{closeText}</button>
{children} {children}
</div> </div>
</aside>; </aside>;
+79 -21
View File
@@ -2,6 +2,12 @@ import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { TrafficGroupRow } from '../../.test-dist/src/web/features/traffic/TrafficFeature.js';
import { HistoryRows } from '../../.test-dist/src/web/features/traffic/TrafficHistoryPanel.js';
import { emptyTrafficHistory, parseTrafficHistoryQuery } from '../../.test-dist/src/shared/trafficHistory.js';
import { assertLiveTrafficSnapshot } from '../../.test-dist/src/shared/liveTraffic.js'; import { assertLiveTrafficSnapshot } from '../../.test-dist/src/shared/liveTraffic.js';
import { import {
@@ -23,6 +29,8 @@ const rowModel = source('src/web/features/traffic/trafficRows.ts');
const boundary = source('src/web/features/traffic/index.ts'); const boundary = source('src/web/features/traffic/index.ts');
const styles = source('src/web/styles/features/traffic.css'); const styles = source('src/web/styles/features/traffic.css');
const primitives = source('src/web/styles/primitives.css'); const primitives = source('src/web/styles/primitives.css');
const history = source('src/web/features/traffic/TrafficHistoryPanel.tsx');
const controls = source('src/web/features/traffic/TrafficControls.tsx');
const validSnapshot = { const validSnapshot = {
apiVersion: 1, apiVersion: 1,
@@ -175,7 +183,6 @@ test('traffic drawer exposes the requested truthful states and accessible contro
'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.', 'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.',
'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.', 'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.',
]) assert.match(feature, new RegExp(copy.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); ]) assert.match(feature, new RegExp(copy.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
assert.match(feature, /feature\.view === 'live' \? `\$\{snapshot\?\.summary\.active \|\| 0\} АКТИВНЫХ` : 'ИСТОРИЯ'/);
assert.match(feature, /feature\.isGateway[\s\S]*Инспектор трафика выключен в настройках Harbor Gateway\.[\s\S]*Инспектор трафика выключен в настройках Harbor Connect\./); assert.match(feature, /feature\.isGateway[\s\S]*Инспектор трафика выключен в настройках Harbor Gateway\.[\s\S]*Инспектор трафика выключен в настройках Harbor Connect\./);
assert.match(feature, /feature\.isGateway[\s\S]*Только соединения, прошедшие через sing-box Gateway\. Трафик, обходящий sing-box напрямую, здесь не виден\.[\s\S]*Только трафик через Harbor Connect\. Приложения macOS недоступны внутри Docker\./); assert.match(feature, /feature\.isGateway[\s\S]*Только соединения, прошедшие через sing-box Gateway\. Трафик, обходящий sing-box напрямую, здесь не виден\.[\s\S]*Только трафик через Harbor Connect\. Приложения macOS недоступны внутри Docker\./);
assert.match(feature, /aria-label="Найти устройство"/); assert.match(feature, /aria-label="Найти устройство"/);
@@ -205,7 +212,6 @@ test('traffic retention and grouping use canonical server settings and the froze
assert.match(feature, /groupTrafficConnections\(selectedConnections, grouping\)/); assert.match(feature, /groupTrafficConnections\(selectedConnections, grouping\)/);
assert.match(feature, /trafficGroupMatches\(group, query, 'all', 'all'\)/); assert.match(feature, /trafficGroupMatches\(group, query, 'all', 'all'\)/);
assert.match(feature, /group\.connections\.length === 1[\s\S]*`Завершено · \$\{group\.protocol\}`/); assert.match(feature, /group\.connections\.length === 1[\s\S]*`Завершено · \$\{group\.protocol\}`/);
assert.match(feature, /Соединения сгруппированы по назначению, протоколу и маршруту\./);
}); });
test('traffic groups combine compatible UUIDs with exact byte sums and whole-group search', () => { test('traffic groups combine compatible UUIDs with exact byte sums and whole-group search', () => {
@@ -384,7 +390,7 @@ test('traffic groups stay mounted and inert through exit while the same group ca
assert.match(rowModel, /const desiredIds = new Set\(desired\.map\(\(group\) => group\.id\)\)/); assert.match(rowModel, /const desiredIds = new Set\(desired\.map\(\(group\) => group\.id\)\)/);
assert.match(feature, /groupTrafficConnections\(selectedConnections, grouping\)/); assert.match(feature, /groupTrafficConnections\(selectedConnections, grouping\)/);
assert.match(feature, /trafficGroupMatches\(group, query, 'all', 'all'\)/); assert.match(feature, /trafficGroupMatches\(group, query, 'all', 'all'\)/);
assert.match(feature, /reconcileTrafficGroups\(current, groups, immediate\)/); assert.match(feature, /reconcileTrafficGroups\(current, groups, immediate, !reorder\)/);
assert.match(feature, /setExpandedId\(\(current\) => desiredIds\.has\(current\) \? current : ''\)/); assert.match(feature, /setExpandedId\(\(current\) => desiredIds\.has\(current\) \? current : ''\)/);
assert.match(feature, /inert=\{exiting \|\| undefined\}/); assert.match(feature, /inert=\{exiting \|\| undefined\}/);
assert.match(feature, /event\.target === event\.currentTarget[\s\S]*event\.animationName === 'client-traffic-connection-out'/); assert.match(feature, /event\.target === event\.currentTarget[\s\S]*event\.animationName === 'client-traffic-connection-out'/);
@@ -392,31 +398,83 @@ test('traffic groups stay mounted and inert through exit while the same group ca
assert.match(feature, /matchMedia\('\(prefers-reduced-motion: reduce\)'\)[\s\S]*media\.addEventListener\('change', update\)[\s\S]*media\.removeEventListener\('change', update\)/); assert.match(feature, /matchMedia\('\(prefers-reduced-motion: reduce\)'\)[\s\S]*media\.addEventListener\('change', update\)[\s\S]*media\.removeEventListener\('change', update\)/);
assert.match(feature, /const immediate = reducedMotion[\s\S]*\['disabled', 'incompatible', 'stopped'\]\.includes/); assert.match(feature, /const immediate = reducedMotion[\s\S]*\['disabled', 'incompatible', 'stopped'\]\.includes/);
assert.match(feature, /aria-label="Группы активных и недавно завершённых соединений"/); assert.match(feature, /aria-label="Группы активных и недавно завершённых соединений"/);
assert.match(feature, /group\.connections\.length > 1[\s\S]*×\$\{group\.connections\.length\}/);
assert.match(feature, /group\.activeCount > 0 && <strong>/);
assert.match(feature, /displayedGroups\.map\(\(row\) => <TrafficGroupRow[\s\S]*key=\{row\.group\.id\}/); assert.match(feature, /displayedGroups\.map\(\(row\) => <TrafficGroupRow[\s\S]*key=\{row\.group\.id\}/);
assert.match(feature, /<b>Активных распознано<\/b>/);
assert.match(feature, /<b>Активных требует внимания<\/b>/);
}); });
test('traffic styling preserves the shared drawer geometry and minimal motion', () => { test('polling updates values without reshuffling readable rows, while explicit sorting can reorder', () => {
const groups = groupTrafficConnections([
trafficConnection('a', { destination: { domain: 'a.test' } }),
trafficConnection('b', { destination: { domain: 'b.test' } }),
trafficConnection('c', { destination: { domain: 'c.test' } }),
]);
const [a, b, c] = groups;
const updated = { ...b, traffic: { ...b.traffic, downloadBytes: '9007199254740993' } };
for (const immediate of [false, true]) {
let rows = reconcileTrafficGroups([], [a, b], immediate, true);
rows = reconcileTrafficGroups(rows, [updated, c, a], immediate, true);
assert.deepEqual(rows.map(({ group }) => group.label), ['a.test', 'b.test', 'c.test']);
assert.equal(rows[1].group.traffic.downloadBytes, '9007199254740993');
const sorted = reconcileTrafficGroups(rows, [c, updated, a], immediate, false);
assert.deepEqual(sorted.map(({ group }) => group.label), ['c.test', 'b.test', 'a.test']);
rows = reconcileTrafficGroups(rows, [c, a], immediate, true);
assert.equal(rows.some((row) => row.group.id === b.id), !immediate);
rows = reconcileTrafficGroups(rows, [a, updated, c], immediate, true);
assert.equal(rows.find((row) => row.group.id === b.id).exiting, false);
}
});
test('minimal traffic rows distinguish current rates from historical totals and completed connections', () => {
const [group] = groupTrafficConnections([trafficConnection('current', { traffic: {
downloadBytes: '9007199254740993', uploadBytes: '1048576',
downloadBytesPerSecond: '2097152', uploadBytesPerSecond: '1024',
} })]);
const render = (group, expanded = false) => renderToStaticMarkup(createElement(TrafficGroupRow, {
group, expanded, exiting: false, onExited() {}, onToggle() {},
}));
const current = render(group);
assert.match(current, /aria-label="Скачивание: 2,0 МБ\/с"/);
assert.match(current, /aria-label="Отправка: 1,0 КБ\/с"/);
assert.doesNotMatch(current, /<svg|<img|<dl|8,0 ПБ|tls/);
assert.match(render(group, true), /<dt>Скачано<\/dt><dd>8,0 ПБ<\/dd>/);
const completed = render({ ...group, activeCount: 0, recentCount: 1 });
assert.match(completed, /aria-label="Скачивание: соединение завершено"/);
assert.doesNotMatch(completed, /МБ\/с/);
const snapshot = emptyTrafficHistory(parseTrafficHistoryQuery(new URLSearchParams()));
snapshot.rows = [{ key: 'example.com', label: 'example.com', downloadBytes: '9007199254740993', uploadBytes: '1048576', route: 'vpn' }];
const historical = renderToStaticMarkup(createElement(HistoryRows, {
snapshot, active: true, load() { throw new Error('Collapsed history must not load details'); },
}));
assert.match(historical, /aria-label="Скачивание: 8,0 ПБ"/);
assert.match(historical, /aria-label="Отправка: 1,0 МБ"/);
assert.doesNotMatch(historical, /<svg|<img|\/с|VPN|Загружаем/);
});
test('traffic modes retain mounted state and stop hidden polling; menu Escape stays inside the drawer', () => {
assert.doesNotMatch(feature, /view === 'history' && <TrafficHistoryPanel/);
assert.match(feature, /<TrafficHistoryPanel active=\{feature.isOpen && !feature.paused && feature.view === 'history'\}/);
assert.match(feature, /aria-hidden=\{feature.view !== 'history'\} inert=\{feature.view !== 'history' \|\| undefined\}/);
assert.match(history, /if \(!active\) return/);
assert.match(history, /controller.abort\(\); clearTimeout\(timer\)/);
assert.match(history, /until: snapshot.query.until/);
assert.match(controls, /event.stopPropagation\(\);[\s\S]*trigger.current\?\.focus\(\)/);
assert.match(controls, /aria-hidden=\{!open\} inert=\{!open \|\| undefined\}/);
assert.match(controls, /getComputedStyle\(node\).opacity;[\s\S]*animation.cancel\(\)/);
assert.match(controls, /media.addEventListener\('change', reduce\)/);
assert.match(controls, /media.removeEventListener\('change', reduce\)/);
});
test('traffic styling reserves readable columns and one list scroll owner with calm motion', () => {
assert.match(styles, /\.client-traffic-toggle svg \{[\s\S]*width: 24px;[\s\S]*height: 24px/); assert.match(styles, /\.client-traffic-toggle svg \{[\s\S]*width: 24px;[\s\S]*height: 24px/);
assert.match(primitives, /\.client-drawer \{[\s\S]*width: min\(580px, 100vw\)/); assert.match(primitives, /\.client-drawer \{[\s\S]*width: min\(580px, 100vw\)/);
assert.match(styles, /@media \(max-width: 768px\) \{[\s\S]*\.client-traffic \{[\s\S]*width: 100vw/); assert.match(styles, /width: min\(800px, 100vw\)/);
assert.doesNotMatch(styles, /overflow-y:\s*(?:auto|scroll)/); assert.match(styles, /@media \(max-width: 480px\)/);
assert.match(styles, /\.client-traffic-meta button \{[\s\S]*width: 96px/); assert.match(styles, /\.client-traffic-scroll \{[\s\S]*min-height: 0;[\s\S]*overflow-y: auto/);
assert.match(styles, /\.client-traffic-details \{[\s\S]*animation: client-traffic-details-in 180ms/); assert.match(styles, /\.client-traffic-pause \{[\s\S]*width: 112px/);
assert.match(styles, /\.client-traffic-connection \{[\s\S]*animation: client-traffic-connection-in 420ms/); assert.match(styles, /\.client-traffic-columns,[\s\S]*grid-template-columns: minmax\(0, 1fr\) var\(--traffic-value-width\) var\(--traffic-value-width\)/);
assert.match(styles, /\.client-traffic-connection\.is-exiting \{[\s\S]*animation: client-traffic-connection-out 240ms/);
const detailMotion = /@keyframes client-traffic-details-in \{([\s\S]*?)\n\}/.exec(styles)?.[1] || '';
assert.match(detailMotion, /opacity:/);
assert.match(detailMotion, /translateY/);
assert.doesNotMatch(detailMotion, /height|width|margin|padding|scale|filter/);
for (const name of ['client-traffic-connection-in', 'client-traffic-connection-out']) { for (const name of ['client-traffic-connection-in', 'client-traffic-connection-out']) {
const rowMotion = new RegExp(`@keyframes ${name} \\{([\\s\\S]*?)\\n\\}`).exec(styles)?.[1] || ''; const rowMotion = new RegExp(`@keyframes ${name} \\{([\\s\\S]*?)\\n\\}`).exec(styles)?.[1] || '';
assert.match(rowMotion, /opacity:/); assert.match(rowMotion, /opacity:/);
assert.match(rowMotion, /translateY/); assert.doesNotMatch(rowMotion, /transform|height|width|margin|padding|scale|filter/);
assert.doesNotMatch(rowMotion, /height|width|margin|padding|scale|filter/);
} }
assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{[\s\S]*\.client-traffic-connection,[\s\S]*\.client-traffic-details,[\s\S]*\.client-traffic-device-list \{[\s\S]*animation: none/); assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{[\s\S]*animation: none;[\s\S]*transition: none/);
}); });
+21 -19
View File
@@ -40,26 +40,26 @@ const expectedImports = [
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex'); const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const acceptedLedger = { const acceptedLedger = {
counts: { counts: {
cascadeEdges: 1190, cascadeEdges: 1210,
customProperties: 115, customProperties: 128,
declarations: 4872, declarations: 4842,
important: 0, important: 0,
keyframes: 55, keyframes: 54,
media: 23, media: 24,
rules: 1332, rules: 1321,
variableReferences: 1274, variableReferences: 1244,
}, },
hashes: { hashes: {
cascadeEdges: '07be381535d1cd8f78990a5b2eae27903471ec2c6cbe1589e36e8f1797f1e610', cascadeEdges: '224ac4ef12ba44e54b737ce00812315090f9f401cc8836e3eba483284b71da25',
customProperties: '81d1e70737ae5a8e4b20d4c1be1b24685975a404863444da4a8821be1f0d5097', customProperties: '35cecb86835ecd0f51e703424bb4440ddf291138d97eeb070b880706a8efc6a1',
declarations: 'bd186c9317ee7ff540b4eeddfc33d2c4ab64a1b2a9362f95480d180d4f644605', declarations: 'c1b1061f8cf328f5f266da23f8f72ed9878f9f3db057fa586ee7d69781b62db1',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848', duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
keyframes: 'b9b0bf3fad92e69b93ec0c24f01da15e78bbe89a095597d64ce4f7ef5e272fdd', keyframes: '4178d589addd5be04ff4d89a5fd93527427795f62d583527e063e0a541f70bd0',
ruleDeclarationSequences: 'b066b7e20fd1195c67c23c38cbaf7b58edcd6356e62bd173371234206c74817e', ruleDeclarationSequences: '7659444a0a4871d15b7702f886c3730f02ea8fdfa54dc5659465ecc4cd1743a6',
selectors: '2e38ff14b0a7d581b090b520c92b6bff60c84082db4dc20a49c454ef468fbeb5', selectors: 'e15851f9c14afb747e8c7d5969586d1c2193305dbd9ddc809020eae87d2514af',
variableReferences: '5fd2c93d2467be16976c102b595a5fb15c5692e2d98645022d7a9acf2f42d829', variableReferences: '3f5340864200e324948e4387a4bb534e82732ed91bc4e3958081890cf7bb854e',
witnesses: 'e9ae2a8416aa44a75a01452bc17884dcb133cccb939880cdecc761ea1043721c', witnesses: '27f96ddab7b128a8924c7a733244523fe007a34ba91d192f63ba19a50c38d4a5',
}, },
}; };
@@ -212,7 +212,7 @@ test('client typography uses the shared semantic scale outside the token owner',
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => { test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
const witnesses = readStyleWitnesses(root); const witnesses = readStyleWitnesses(root);
assert.equal(witnesses.length, 1486); assert.equal(witnesses.length, 1463);
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0); assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
const ledger = createStyleLedger(readStyleSource(root), { witnesses }); const ledger = createStyleLedger(readStyleSource(root), { witnesses });
assert.deepEqual(ledger.counts, acceptedLedger.counts); assert.deepEqual(ledger.counts, acceptedLedger.counts);
@@ -328,6 +328,8 @@ test('selector proof uses the observed level-four grammar and exact specificity'
['.client-instructions-toggle:not(.client-devices-toggle):not(.client-diagnostics-toggle)', [{ a: 0, b: 3, c: 0 }]], ['.client-instructions-toggle:not(.client-devices-toggle):not(.client-diagnostics-toggle)', [{ a: 0, b: 3, c: 0 }]],
[".client-power[aria-checked='true']::before", [{ a: 0, b: 2, c: 1 }]], [".client-power[aria-checked='true']::before", [{ a: 0, b: 2, c: 1 }]],
['.client-failover-number-setting input::-webkit-inner-spin-button', [{ a: 0, b: 1, c: 2 }]], ['.client-failover-number-setting input::-webkit-inner-spin-button', [{ a: 0, b: 1, c: 2 }]],
['.client-traffic-search input::-webkit-search-cancel-button', [{ a: 0, b: 1, c: 2 }]],
['.client-traffic button:enabled:hover', [{ a: 0, b: 3, c: 1 }]],
['.client-instruction-block:nth-child(n)', [{ a: 0, b: 2, c: 0 }]], ['.client-instruction-block:nth-child(n)', [{ a: 0, b: 2, c: 0 }]],
['.client-confirmation-actions button:hover:not(:disabled), #root', [ ['.client-confirmation-actions button:hover:not(:disabled), #root', [
{ a: 0, b: 3, c: 1 }, { a: 0, b: 3, c: 1 },
@@ -411,8 +413,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1); assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css')); const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
assert.deepEqual(assets, ['index-D6ACNk74.css']); assert.deepEqual(assets, ['index-DVxr9dv9.css']);
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0])); const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 185399); assert.equal(built.byteLength, 183862);
assert.equal(sha256(built), '3327b4873e7dba63fd44c21d34c4fe19f78167ef6c5badcfd8dfe08eb2705c5d'); assert.equal(sha256(built), 'b717ce3c169f63cd28d08fd708758e2d4fe5e85587a649dae15d1cf30ce778a0');
}); });
+4 -4
View File
@@ -479,7 +479,7 @@ export function readStyleWitnesses(root) {
source: fs.readFileSync(file, 'utf8'), source: fs.readFileSync(file, 'utf8'),
})); }));
// History has exactly service/domain/hostname/IP levels, not arbitrary JSX recursion. // History has exactly service/domain/hostname/IP levels, not arbitrary JSX recursion.
return createStyleWitnesses(files, { recursionLimits: { HistoryRows: 4, HistoryBranch: 3 } }); return createStyleWitnesses(files, { recursionLimits: { HistoryRows: 4, HistoryBranch: 3, TrafficReveal: 3 } });
} }
const OBSERVED_PROPERTIES = new Set(` const OBSERVED_PROPERTIES = new Set(`
@@ -499,7 +499,7 @@ margin margin-bottom margin-inline margin-left margin-right margin-top max-heigh
min-height min-width mix-blend-mode opacity order outline outline-offset overflow min-height min-width mix-blend-mode opacity order outline outline-offset overflow
overflow-wrap overflow-x overflow-y overscroll-behavior padding padding-block overflow-wrap overflow-x overflow-y overscroll-behavior padding padding-block
padding-bottom padding-inline padding-left padding-right padding-top place-content padding-bottom padding-inline padding-left padding-right padding-top place-content
place-items pointer-events position right row-gap scrollbar-width stroke stroke-dasharray place-items pointer-events position right row-gap scrollbar-width scrollbar-gutter stroke stroke-dasharray
stroke-dashoffset stroke-linecap stroke-linejoin stroke-width table-layout text-align stroke-dashoffset stroke-linecap stroke-linejoin stroke-width table-layout text-align
text-decoration text-overflow text-shadow text-transform text-underline-offset top text-decoration text-overflow text-shadow text-transform text-underline-offset top
touch-action transform transform-box transform-origin transition transition-delay user-select touch-action transform transform-box transform-origin transition transition-delay user-select
@@ -525,9 +525,9 @@ const PROPERTY_FAMILIES = new Map([
const SUPPORTED_SELECTOR_NODES = new Set(['attribute', 'class', 'combinator', 'id', 'pseudo', 'selector', 'tag', 'universal']); const SUPPORTED_SELECTOR_NODES = new Set(['attribute', 'class', 'combinator', 'id', 'pseudo', 'selector', 'tag', 'universal']);
const SUPPORTED_COMBINATORS = new Set([' ', '+', '>']); const SUPPORTED_COMBINATORS = new Set([' ', '+', '>']);
const SUPPORTED_PSEUDOS = new Set([ const SUPPORTED_PSEUDOS = new Set([
':-webkit-autofill', '::-webkit-inner-spin-button', '::-webkit-outer-spin-button', '::-webkit-scrollbar', ':-webkit-autofill', '::-webkit-inner-spin-button', '::-webkit-outer-spin-button', '::-webkit-scrollbar', '::-webkit-search-cancel-button',
'::after', '::before', '::marker', '::placeholder', '::after', '::before', '::marker', '::placeholder',
'::view-transition-group', '::view-transition-new', '::view-transition-old', ':active', ':disabled', '::view-transition-group', '::view-transition-new', '::view-transition-old', ':active', ':disabled', ':enabled',
':first-child', ':focus', ':focus-visible', ':focus-within', ':has', ':hover', ':last-child', ':not', ':first-child', ':focus', ':focus-visible', ':focus-within', ':has', ':hover', ':last-child', ':not',
':nth-child', ':root', ':nth-child', ':root',
]); ]);
+1
View File
@@ -25,6 +25,7 @@
"src/web/features/instructions/prometheus.ts", "src/web/features/instructions/prometheus.ts",
"src/web/features/routing/ruleReorderModel.ts", "src/web/features/routing/ruleReorderModel.ts",
"src/web/features/traffic/trafficRows.ts", "src/web/features/traffic/trafficRows.ts",
"src/web/features/traffic/*.tsx",
"monitoring/grafana/harbor-gateway.json" "monitoring/grafana/harbor-gateway.json"
], ],
"exclude": [".test-dist", "dist", "node_modules"] "exclude": [".test-dist", "dist", "node_modules"]