diff --git a/src/shared/versions.ts b/src/shared/versions.ts index 94405eb..49799d7 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,6 +1,6 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.37.0', - gatewayClient: '0.39.0', + macClient: '0.37.1', + gatewayClient: '0.39.1', gatewayBackend: '0.39.0', }); diff --git a/src/web/features/traffic/TrafficControls.tsx b/src/web/features/traffic/TrafficControls.tsx new file mode 100644 index 0000000..d398f49 --- /dev/null +++ b/src/web/features/traffic/TrafficControls.tsx @@ -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(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 ?
{children}
: 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(null); + const trigger = useRef(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
{ + if (event.key !== 'Escape' || !open) return; + event.stopPropagation(); + setOpen(false); + trigger.current?.focus(); + }}> + + +
{ + if (!closeOnSelect || !(event.target as HTMLElement).closest('button')) return; + setOpen(false); + trigger.current?.focus(); + }}>{children}
+
+
; +} + +export function TrafficColumns() { + return ; +} diff --git a/src/web/features/traffic/TrafficFeature.tsx b/src/web/features/traffic/TrafficFeature.tsx index 25d9afb..acf0670 100644 --- a/src/web/features/traffic/TrafficFeature.tsx +++ b/src/web/features/traffic/TrafficFeature.tsx @@ -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
- 1 - ? `${group.label}, соединений: ${group.connections.length}` - : undefined} - >{group.label}{group.connections.length > 1 ? ` ×${group.connections.length}` : ''} - {groupStatus(group)} + {group.label} - - {routeLabels[group.route.kind]} + 0 ? `${formatByteString(group.traffic.downloadBytesPerSecond)}/с` : 'соединение завершено'}`}> + {group.activeCount > 0 ? `${formatByteString(group.traffic.downloadBytesPerSecond)}/с` : '—'} - - {group.activeCount > 0 && - ↓ {formatByteString(group.traffic.downloadBytesPerSecond)}/с - ↑ {formatByteString(group.traffic.uploadBytesPerSecond)}/с - } - - ↓ {formatByteString(group.traffic.downloadBytes)} - ↑ {formatByteString(group.traffic.uploadBytes)} - + 0 ? `${formatByteString(group.traffic.uploadBytesPerSecond)}/с` : 'соединение завершено'}`}> + {group.activeCount > 0 ? `${formatByteString(group.traffic.uploadBytesPerSecond)}/с` : '—'} - - {expanded &&
- {group.origins.length > 1 && group.origins.map((origin) =>
-
{origin.label}
-
- {origin.connections} соединений · ↓ {formatByteString(origin.traffic.downloadBytes)} · ↑ {formatByteString(origin.traffic.uploadBytes)} -
-
)} -
Источник
{source}
-
Назначение
{groupDestination(group)}
-
Правило
{group.route.rule || '—'}
-
Цепочка
{chain}
-
} + +
+
Состояние
{groupStatus(group)}
+
Маршрут
{routeLabels[group.route.kind]}
+
Скачано
{formatByteString(group.traffic.downloadBytes)}
+
Отправлено
{formatByteString(group.traffic.uploadBytes)}
+ {group.origins.length > 1 && group.origins.map((origin) =>
+
{origin.label}
+
+ Скачано {formatByteString(origin.traffic.downloadBytes)} · отправлено {formatByteString(origin.traffic.uploadBytes)} +
+
)} +
Источник
{source}
+
Назначение
{groupDestination(group)}
+
Правило
{group.route.rule || '—'}
+
Цепочка
{chain}
+
+
; } @@ -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('all'); const [qualityFilter, setQualityFilter] = useState('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} >
-
- {feature.isGateway ? 'GATEWAY' : 'MAC'} · {feature.view === 'live' ? `${snapshot?.summary.active || 0} АКТИВНЫХ` : 'ИСТОРИЯ'} - - -

Трафик

-

Соединения сгруппированы по назначению, протоколу и маршруту.

+
- -
-
- - -
+
+ +
+
+
+
+ + {feature.isGateway && origin.id === selectedOriginId)?.label || 'Все устройства'} + active={Boolean(selectedOriginId)} closeOnSelect + > + +
+ + {matchingOrigins.map((origin) => )} +
+ {!matchingOrigins.length &&

Устройства не найдены.

} +
} + +
+
+ Маршрут +
+ {([ + ['all', 'Все'], + ['vpn', 'VPN'], + ['direct', 'Direct'], + ['other', 'Другое'], + ] as const).map(([value, label]) => )} +
+
+
+ Распознано +
+ {([ + ['all', 'Все'], + ['recognized', 'Распознано'], + ['attention', 'Требует внимания'], + ] as const).map(([value, label]) => )} +
+
+
+ Группировка +
+ {([ + ['site', 'По сайтам'], + ['device', 'По устройствам'], + ] as const).map(([value, label]) => )} +
+
+
+ Сортировка +
+ {([ + ['popular', 'Популярные'], + ['recent', 'Последние'], + ] as const).map(([value, label]) => )} +
+
+
+ Завершённые +
+ {TRAFFIC_RETENTION_OPTIONS.map((seconds) => )} +
+
+
+ {snapshot &&
+ Распознано: {snapshot.summary.recognized} + Без домена: {snapshot.summary.unresolved} + +
} +

{feature.isGateway + ? 'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.' + : 'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.'}

+
+
+ +
+ {feature.paused && snapshot &&

+ Пауза · показаны данные на {updatedAt(snapshot.observedAt).replace('обновлено ', '')}. +

} + {!feature.paused && stale && snapshot &&

+ Не удалось обновить. Показан последний полученный снимок. +

} + {!feature.paused && !stale && snapshot && sourceState === 'connecting' && snapshot.connections.length > 0 &&

+ Инспектор переподключается. Показан последний полученный снимок. +

} + {degraded && snapshot &&

+ Часть трафика не распознана. +

} - {feature.view === 'history' ? : <> + - {snapshot &&
- Активных распознано {snapshot.summary.recognized} - Активных требует внимания {snapshot.summary.unresolved} -
} + {canShowList && retainedConnections.length === 0 && displayedGroups.length === 0 &&

+ Активных соединений пока нет. +

} + {canShowList && retainedConnections.length > 0 && groups.length === 0 && displayedGroups.length === 0 &&

+ По выбранным фильтрам ничего не найдено. +

} + {canShowList && displayedGroups.length > 0 &&
+ {displayedGroups.map((row) => finishExit(row.group.id)} + onToggle={() => setExpandedId((current) => current === row.group.id ? '' : row.group.id)} + />)} +
} -
-
- Маршрут -
- {([ - ['all', 'Все'], - ['vpn', 'VPN'], - ['direct', 'Direct'], - ['other', 'Другое'], - ] as const).map(([value, label]) => )} + {snapshot?.summary.truncated &&

+ Показано не более 256 соединений. +

}
-
-
- Распознано -
- {([ - ['all', 'Все'], - ['recognized', 'Распознано'], - ['attention', 'Требует внимания'], - ] as const).map(([value, label]) => )} -
-
-
- Группировка -
- {([ - ['site', 'По сайтам'], - ['device', 'По устройствам'], - ] as const).map(([value, label]) => )} -
-
-
- Сортировка -
- {([ - ['popular', 'Популярные'], - ['recent', 'Последние'], - ] as const).map(([value, label]) => )} -
-
-
- Завершённые -
- {TRAFFIC_RETENTION_OPTIONS.map((seconds) => )} -
-
+
+
+ +
- - {feature.isGateway && canShowList &&
-
-

Устройства

- -
- -
- {visibleOrigins.map((origin) => )} -
- {hiddenOriginCount > 0 && } -
} - - {canShowList && } - - {feature.paused && snapshot &&

- Пауза · показаны данные на {updatedAt(snapshot.observedAt).replace('обновлено ', '')}. -

} - {!feature.paused && stale && snapshot &&

- Данные временно не обновляются. Показан последний полученный снимок. -

} - {!feature.paused && !stale && snapshot && sourceState === 'connecting' && snapshot.connections.length > 0 &&

- Инспектор переподключается. Показан последний полученный снимок. -

} - {degraded && snapshot &&

- Часть трафика не удалось сопоставить с соединениями: ↓ {formatByteString(snapshot.source.unattributedDownloadBytes)} · ↑ {formatByteString(snapshot.source.unattributedUploadBytes)}. -

} - - - - {canShowList && retainedConnections.length === 0 && displayedGroups.length === 0 &&

- Активных соединений пока нет. -

} - {canShowList && retainedConnections.length > 0 && groups.length === 0 && displayedGroups.length === 0 &&

- По выбранным фильтрам ничего не найдено. -

} - {canShowList && displayedGroups.length > 0 &&
- {displayedGroups.map((row) => finishExit(row.group.id)} - onToggle={() => setExpandedId((current) => current === row.group.id ? '' : row.group.id)} - />)} -
} - - {snapshot?.summary.truncated &&

- Снимок ограничен 256 соединениями; активные показаны первыми. -

} - } -

- {feature.isGateway - ? 'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.' - : 'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.'} -

; } diff --git a/src/web/features/traffic/TrafficHistoryPanel.tsx b/src/web/features/traffic/TrafficHistoryPanel.tsx index 6ad868a..5e92b62 100644 --- a/src/web/features/traffic/TrafficHistoryPanel.tsx +++ b/src/web/features/traffic/TrafficHistoryPanel.tsx @@ -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; const nextLevel: Record = { 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(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)} > - {row.label}{levelLabels[query.level]} - {routes[row.route]} - - ↓ {formatByteString(row.downloadBytes)}↑ {formatByteString(row.uploadBytes)} - - {childLevel && } + {row.label} + {formatByteString(row.downloadBytes)} + {formatByteString(row.uploadBytes)} - {childLevel && expanded === row.key &&
- + -
} + }
)} ; } @@ -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 <> -

{result.status === 'loading' ? 'Загружаем…' - : result.status === 'error' ? 'Данные временно недоступны.' : '\u00a0'}

+ {result.status !== 'ready' &&

+ {result.status === 'loading' ? 'Загружаем…' : 'Данные временно недоступны.'} +

} {result.snapshot && <> @@ -96,77 +94,84 @@ function HistoryPages({ snapshot, offset, change }: { snapshot: TrafficHistorySn ; } -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) { 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 <> -
-
Период -
- {([['24h', '24 часа'], ['7d', '7 дней'], ['30d', '30 дней'], ['90d', '90 дней']] as const).map(([value, label]) => )} -
+
+
+ {([['24h', '24 часа'], ['7d', '7 дней'], ['30d', '30 дней'], ['90d', '90 дней']] as const).map(([value, label]) => )}
-
Маршрут -
- {([['all', 'Все'], ['vpn', 'VPN'], ['direct', 'Direct'], ['other', 'Другое']] as const).map(([value, label]) => )} + {isGateway && origin.id === query.originId)?.label || (query.originId ? 'Выбрано устройство' : 'Все устройства')} + active={Boolean(query.originId)} closeOnSelect + > + +
+ + {origins.map((origin) => )}
-
+ {!origins.length &&

Устройства не найдены.

} + {snapshot?.originsTruncated &&

Показаны первые 256 устройств.

} + } + + +
Маршрут +
+ {([['all', 'Все'], ['vpn', 'VPN'], ['direct', 'Direct'], ['other', 'Другое']] as const).map(([value, label]) => )} +
+
+ {snapshot && <> +
+ Скачано {formatByteString(snapshot.totals.downloadBytes)} + Отправлено {formatByteString(snapshot.totals.uploadBytes)} +
+

+ {`Данные до ${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 без домена не означает распознанный сайт. +

+ } +
+
+ +
+ {status &&

{status} + {snapshot && (paused || result.status === 'error') && ` Обновлено ${new Date(snapshot.generatedAt).toLocaleTimeString('ru-RU')}.`} +

} + {snapshot && <> + {snapshot.rows.length ? + :

{query.search || query.originId || query.route !== 'all' + ? 'По выбранным фильтрам ничего не найдено.' : 'История пока пуста.'}

} + setQuery((current) => ({ ...current, offset, until: snapshot.query.until }))} /> + }
- {isGateway &&
-

Устройства

- -
- -
- {(devicesExpanded ? origins : origins.slice(0, 3)).map((origin) => )} -
- {origins.length > 3 && } - {snapshot?.originsTruncated &&

Показаны первые 256 устройств.

} -
} - -

{result.status === 'loading' ? 'Загружаем историю…' - : result.status === 'error' ? `История временно недоступна.${snapshot ? ' Показаны последние полученные данные.' : ''}` - : snapshot?.source === 'disabled' ? 'Сбор истории выключен; сохранённые данные доступны.' - : snapshot?.source === 'stopped' ? 'VPN остановлен; показана сохранённая история.' - : snapshot?.source === 'connecting' ? 'Сборщик подключается; показана сохранённая история.' - : snapshot?.coverage.partial ? 'В выбранном периоде есть пропуски или неточное распределение по времени.' : '\u00a0'}

- {snapshot && <> -
- Скачано{formatByteString(snapshot.totals.downloadBytes)} - Отправлено{formatByteString(snapshot.totals.uploadBytes)} -
- {snapshot.rows.length ? - :

{query.search || query.originId || query.route !== 'all' - ? 'По выбранным фильтрам ничего не найдено.' : 'История пока пуста. Данные появятся после начала сбора трафика.'}

} - setQuery((current) => ({ ...current, offset, until: snapshot.query.until }))} /> -

- Локальная история за 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 без домена не означает распознанный сайт. -

- } ; } diff --git a/src/web/features/traffic/trafficRows.ts b/src/web/features/traffic/trafficRows.ts index 7b082c0..c34beac 100644 --- a/src/web/features/traffic/trafficRows.ts +++ b/src/web/features/traffic/trafficRows.ts @@ -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)); diff --git a/src/web/styles/features/traffic.css b/src/web/styles/features/traffic.css index 3321919..10ce34c 100644 --- a/src/web/styles/features/traffic.css +++ b/src/web/styles/features/traffic.css @@ -3,158 +3,183 @@ height: 24px; } -.client-traffic-history-status { - min-height: 48px; - margin: 0 8px 12px; - color: var(--client-muted); - font: var(--type-control); - letter-spacing: var(--type-control-tracking); - text-transform: var(--type-control-transform); - overflow-wrap: anywhere; -} - -.client-traffic-history-children { - padding-left: 8px; - animation: client-traffic-details-in 180ms cubic-bezier(0.16, 1, 0.3, 1) both; -} - -.client-traffic-history-row > button:disabled { - cursor: default; +.client-traffic { + --font-size-control: 0.875rem; + --font-size-body: 0.875rem; + --font-size-data: 1rem; + --font-size-item-title: 1rem; + --font-size-drawer-title: 1.5rem; + --font-weight-bold: 500; + --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); + --type-data: var(--font-weight-strong) var(--font-size-data)/var(--line-height-data) var(--font-family-client); + --type-item-title: var(--font-weight-bold) var(--font-size-item-title)/var(--line-height-item-title) var(--font-family-client); + --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); + overflow: hidden; } .client-traffic-sheet { - padding: 54px 72px 72px 34px; -} - -@media (max-width: 768px) { - .client-traffic { - width: 100vw; - } + height: 100%; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 112px 80px; + grid-template-rows: 44px 56px minmax(0, 1fr); + gap: 12px 16px; + padding: 28px 78px 24px 32px; } .client-traffic-header { + grid-area: 1 / 1 / 2 / 3; display: grid; - gap: 9px; - margin: 0 8px 28px; -} - -.client-traffic-meta { - min-height: 28px; - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; + grid-template-columns: minmax(0, 1fr) 112px; align-items: center; - gap: 12px; - 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; + gap: 16px; } .client-traffic-header h2 { - margin: 4px 0 0; + margin: 0; font: var(--type-drawer-title); letter-spacing: var(--type-drawer-title-tracking); text-transform: var(--type-drawer-title-transform); } -.client-traffic-header p { - max-width: 44ch; +.client-traffic .client-drawer-close { + 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); - font: var(--type-body); - letter-spacing: var(--type-body-tracking); - text-transform: var(--type-body-transform); + font: var(--type-control); + letter-spacing: var(--type-control-tracking); + text-transform: var(--type-control-transform); + cursor: pointer; + transition: color 180ms ease; } -.client-traffic-summary { - display: grid; - gap: 6px; - 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-pause { + width: 112px; + text-align: right; } -.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; - gap: 8px; + align-items: start; + gap: 24px; } -.client-traffic-summary b { - min-width: 112px; - color: var(--client-muted); - font: inherit; +.client-traffic-tabs button { + width: 88px; + text-align: left; } -.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; - gap: 4px; - margin: 0 8px 22px; + grid-template-columns: minmax(0, 1fr) auto auto; + 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 { - display: grid; - grid-template-columns: 28px minmax(0, 1fr); - align-items: center; + display: block; + min-width: 0; border-bottom: 1px solid var(--client-border); - color: var(--client-muted); } .client-traffic-search:focus-within { 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 { width: 100%; - height: 42px; + min-width: 0; + height: 40px; padding: 0; border: 0; outline: 0; @@ -170,184 +195,255 @@ 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 { display: flex; flex-wrap: wrap; - gap: 4px 18px; + gap: 4px 16px; } .client-traffic-filters button { - min-height: 36px; - padding: 4px 0 2px; - border: 0; 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'] { border-bottom-color: var(--client-accent); } -.client-traffic-filters button:focus-visible { - outline: 2px solid var(--client-accent); - outline-offset: 2px; +.client-traffic-periods { + gap: 4px 12px; +} + +.client-traffic-periods button { + white-space: nowrap; } .client-traffic-option-row { - min-height: 36px; display: grid; - grid-template-columns: 112px minmax(0, 1fr); - align-items: center; - gap: 14px; + gap: 4px; } .client-traffic-option-row > span { color: var(--client-muted); - font: var(--type-control); - letter-spacing: var(--type-control-tracking); - text-transform: var(--type-control-transform); + font: var(--type-body); + letter-spacing: var(--type-body-tracking); + text-transform: var(--type-body-transform); } -.client-traffic-option-row .client-traffic-filters { - justify-content: flex-end; -} - -.client-traffic-filters button:disabled { - cursor: wait; - opacity: 0.55; -} - -.client-traffic-devices { +.client-traffic-columns, +.client-traffic-connection-summary { display: grid; - gap: 8px; - margin: 0 8px 24px; -} - -.client-traffic-devices-heading { - min-height: 36px; - display: flex; + grid-template-columns: minmax(0, 1fr) var(--traffic-value-width) var(--traffic-value-width); align-items: center; - justify-content: space-between; - gap: 12px; + gap: 16px; } -.client-traffic-devices-heading h3, -.client-traffic-devices-heading button, -.client-traffic-devices-more { +.client-traffic-columns { + flex: none; + 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; - border: 0; - background: transparent; - font: var(--type-control); - letter-spacing: var(--type-control-tracking); - text-transform: var(--type-control-transform); + padding: 0; } -.client-traffic-devices-heading h3 { - color: var(--client-muted); -} - -.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 { +.client-traffic-connection, +.client-traffic-history-row { min-width: 0; - display: grid; - gap: 3px; - padding: 9px 0; + box-shadow: inset 0 -1px 0 color-mix(in oklch, var(--client-border) 46%, transparent); +} + +.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-bottom: 1px solid color-mix(in oklch, var(--client-border) 46%, transparent); background: transparent; color: var(--client-text); text-align: left; cursor: pointer; } -.client-traffic-device-list button:hover, -.client-traffic-device-list button[aria-pressed='true'] { - color: var(--client-accent); +.client-traffic-identity { + min-width: 0; } -.client-traffic-device-list strong, -.client-traffic-device-list small { +.client-traffic-identity strong { + display: block; overflow: hidden; + font: var(--type-item-title); + letter-spacing: var(--type-item-title-tracking); + text-transform: var(--type-item-title-transform); text-overflow: ellipsis; white-space: nowrap; } -.client-traffic-device-list strong { - font: var(--type-item-title); - letter-spacing: var(--type-item-title-tracking); - text-transform: var(--type-item-title-transform); +.client-traffic-connection-summary:enabled:hover .client-traffic-identity strong, +.client-traffic-connection-summary[aria-expanded='true'] .client-traffic-identity strong { + text-decoration: underline; + 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); - font: var(--type-micro); - letter-spacing: var(--type-micro-tracking); - text-transform: var(--type-micro-transform); } -.client-traffic-devices-more { - justify-self: start; +.client-traffic-history-children { + padding-left: 20px; } -.client-traffic-list-search { - margin: 0 8px 12px; +.client-traffic-details { + 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-history-status, .client-traffic-state, -.client-traffic-truncated, -.client-traffic-honesty { - margin: 0 8px; +.client-traffic-summary, +.client-traffic-honesty, +.client-traffic-truncated { + margin: 12px 0; color: var(--client-muted); - font: var(--type-control); - letter-spacing: var(--type-control-tracking); - text-transform: var(--type-control-transform); + font: var(--type-body); + letter-spacing: var(--type-body-tracking); + text-transform: var(--type-body-transform); + overflow-wrap: anywhere; } -.client-traffic-notice { - margin-bottom: 14px; - color: var(--client-accent); +.client-traffic-summary { + display: grid; + gap: 6px; } .client-traffic-notice.is-warning { - color: oklch(0.68 0.14 72); + color: var(--client-accent); } .client-traffic-state { @@ -360,237 +456,85 @@ .client-traffic-skeleton { display: grid; gap: 10px; - margin: 0 8px; + padding: 12px 0; } .client-traffic-skeleton span { - height: 58px; + height: 48px; background: color-mix(in oklch, var(--client-border) 24%, transparent); 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 { - from { opacity: 0; transform: translateY(-6px); } - to { opacity: 1; transform: translateY(0); } + from { opacity: 0; } + to { opacity: 1; } } @keyframes client-traffic-connection-out { - from { opacity: 1; transform: translateY(0); } - to { opacity: 0; transform: translateY(4px); } + from { opacity: 1; } + to { opacity: 0; } } -@media (max-width: 560px) { +@media (max-width: 768px) { + .client-traffic { + width: 100vw; + } + .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; + 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; } +} +@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 { - 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-row: 2; - justify-items: start; } - .client-traffic-values strong, - .client-traffic-values small { - justify-content: flex-start; + .client-traffic-columns { + padding-bottom: 10px; + } + + .client-traffic-columns > span:nth-child(2), + .client-traffic-download { + text-align: left; } .client-traffic-details > div { @@ -598,21 +542,21 @@ gap: 2px; } - .client-traffic-option-row { - grid-template-columns: 1fr; - gap: 0; + .client-traffic-history-children { + padding-left: 12px; } +} - .client-traffic-option-row .client-traffic-filters { - justify-content: flex-start; +@media (pointer: coarse) { + .client-traffic button { + min-height: 44px; } } @media (prefers-reduced-motion: reduce) { - .client-traffic-history-children, - .client-traffic-connection, - .client-traffic-details, - .client-traffic-device-list { + .client-traffic *, + .client-traffic-tabs::after { animation: none; + transition: none; } } diff --git a/src/web/ui/Drawer.tsx b/src/web/ui/Drawer.tsx index cb8ea06..996f1a6 100644 --- a/src/web/ui/Drawer.tsx +++ b/src/web/ui/Drawer.tsx @@ -9,6 +9,7 @@ interface DrawerBaseProps { className?: string; sheetClassName?: string; closeLabel: string; + closeText?: string; onClose: () => unknown; leading?: ReactNode; children: ReactNode; @@ -30,6 +31,7 @@ export function Drawer({ labelledBy, label, closeLabel, + closeText = '×', onClose, leading, children, @@ -51,7 +53,7 @@ export function Drawer({ type="button" aria-label={closeLabel} onClick={onClose} - >× + >{closeText} {children}
; diff --git a/test/web/live-traffic-contract.test.js b/test/web/live-traffic-contract.test.js index 0034c92..71f98f4 100644 --- a/test/web/live-traffic-contract.test.js +++ b/test/web/live-traffic-contract.test.js @@ -2,6 +2,12 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; 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 { @@ -23,6 +29,8 @@ const rowModel = source('src/web/features/traffic/trafficRows.ts'); const boundary = source('src/web/features/traffic/index.ts'); const styles = source('src/web/styles/features/traffic.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 = { apiVersion: 1, @@ -175,7 +183,6 @@ test('traffic drawer exposes the requested truthful states and accessible contro 'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.', 'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.', ]) 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]*Только соединения, прошедшие через sing-box Gateway\. Трафик, обходящий sing-box напрямую, здесь не виден\.[\s\S]*Только трафик через Harbor Connect\. Приложения macOS недоступны внутри Docker\./); 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, /trafficGroupMatches\(group, query, 'all', 'all'\)/); 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', () => { @@ -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(feature, /groupTrafficConnections\(selectedConnections, grouping\)/); 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, /inert=\{exiting \|\| undefined\}/); 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, /const immediate = reducedMotion[\s\S]*\['disabled', 'incompatible', 'stopped'\]\.includes/); assert.match(feature, /aria-label="Группы активных и недавно завершённых соединений"/); - assert.match(feature, /group\.connections\.length > 1[\s\S]*×\$\{group\.connections\.length\}/); - assert.match(feature, /group\.activeCount > 0 && /); assert.match(feature, /displayedGroups\.map\(\(row\) => Активных распознано<\/b>/); - assert.match(feature, /Активных требует внимания<\/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, /Скачано<\/dt>
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, /