Update Harbor client implementation
This commit is contained in:
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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}>
|
||||||
|
<dl className="client-traffic-details">
|
||||||
|
<div><dt>Состояние</dt><dd>{groupStatus(group)}</dd></div>
|
||||||
|
<div><dt>Маршрут</dt><dd>{routeLabels[group.route.kind]}</dd></div>
|
||||||
|
<div><dt>Скачано</dt><dd>{formatByteString(group.traffic.downloadBytes)}</dd></div>
|
||||||
|
<div><dt>Отправлено</dt><dd>{formatByteString(group.traffic.uploadBytes)}</dd></div>
|
||||||
{group.origins.length > 1 && group.origins.map((origin) => <div
|
{group.origins.length > 1 && group.origins.map((origin) => <div
|
||||||
className="client-traffic-origin-breakdown"
|
className="client-traffic-origin-breakdown"
|
||||||
key={origin.id}
|
key={origin.id}
|
||||||
>
|
>
|
||||||
<dt>{origin.label}</dt>
|
<dt>{origin.label}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{origin.connections} соединений · ↓ {formatByteString(origin.traffic.downloadBytes)} · ↑ {formatByteString(origin.traffic.uploadBytes)}
|
Скачано {formatByteString(origin.traffic.downloadBytes)} · отправлено {formatByteString(origin.traffic.uploadBytes)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>)}
|
</div>)}
|
||||||
<div><dt>Источник</dt><dd>{source}</dd></div>
|
<div><dt>Источник</dt><dd>{source}</dd></div>
|
||||||
<div><dt>Назначение</dt><dd>{groupDestination(group)}</dd></div>
|
<div><dt>Назначение</dt><dd>{groupDestination(group)}</dd></div>
|
||||||
<div><dt>Правило</dt><dd>{group.route.rule || '—'}</dd></div>
|
<div><dt>Правило</dt><dd>{group.route.rule || '—'}</dd></div>
|
||||||
<div><dt>Цепочка</dt><dd>{chain}</dd></div>
|
<div><dt>Цепочка</dt><dd>{chain}</dd></div>
|
||||||
</dl>}
|
</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,37 +427,47 @@ 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">
|
<h2 id="client-traffic-title">Трафик</h2>
|
||||||
<span>{feature.isGateway ? 'GATEWAY' : 'MAC'} · {feature.view === 'live' ? `${snapshot?.summary.active || 0} АКТИВНЫХ` : 'ИСТОРИЯ'}</span>
|
<button type="button" className="client-traffic-pause" aria-pressed={feature.paused}
|
||||||
<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}
|
onClick={feature.togglePause}
|
||||||
>{feature.paused ? 'Продолжить' : 'Пауза'}</button>
|
>{feature.paused ? 'Продолжить' : 'Пауза'}</button>
|
||||||
</div>
|
|
||||||
<h2 id="client-traffic-title">Трафик</h2>
|
|
||||||
<p>Соединения сгруппированы по назначению, протоколу и маршруту.</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
<div className="client-traffic-tabs" role="group" aria-label="Режим трафика" data-view={feature.view}>
|
||||||
<div className="client-traffic-tools">
|
|
||||||
<div className="client-traffic-filters" role="group" aria-label="Режим трафика">
|
|
||||||
<button type="button" aria-pressed={feature.view === 'live'} onClick={() => feature.setView('live')}>Сейчас</button>
|
<button type="button" aria-pressed={feature.view === 'live'} onClick={() => feature.setView('live')}>Сейчас</button>
|
||||||
<button type="button" aria-pressed={feature.view === 'history'} onClick={() => feature.setView('history')}>История</button>
|
<button type="button" aria-pressed={feature.view === 'history'} onClick={() => feature.setView('history')}>История</button>
|
||||||
</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>
|
</div>
|
||||||
|
{!matchingOrigins.length && <p className="client-traffic-notice">Устройства не найдены.</p>}
|
||||||
{feature.view === 'history' ? <TrafficHistoryPanel active={feature.isOpen && !feature.paused} isGateway={feature.isGateway} load={feature.loadHistory} /> : <>
|
</TrafficMenu>}
|
||||||
|
<TrafficMenu label="Фильтры" active={routeFilter !== 'all' || qualityFilter !== 'all'}>
|
||||||
{snapshot && <div className="client-traffic-summary" aria-label="Качество распознавания трафика">
|
<div className="client-traffic-settings">
|
||||||
<span><b>Активных распознано</b> {snapshot.summary.recognized}</span>
|
|
||||||
<span><b>Активных требует внимания</b> {snapshot.summary.unresolved}</span>
|
|
||||||
</div>}
|
|
||||||
|
|
||||||
<div className="client-traffic-tools">
|
|
||||||
<div className="client-traffic-option-row">
|
<div className="client-traffic-option-row">
|
||||||
<span>Маршрут</span>
|
<span>Маршрут</span>
|
||||||
<div className="client-traffic-filters" role="group" aria-label="Фильтр по маршруту">
|
<div className="client-traffic-filters" role="group" aria-label="Фильтр по маршруту">
|
||||||
@@ -537,70 +542,23 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{snapshot && <div className="client-traffic-summary">
|
||||||
{feature.isGateway && canShowList && <section className="client-traffic-devices" aria-labelledby="client-traffic-devices-title">
|
<span>Распознано: {snapshot.summary.recognized}</span>
|
||||||
<div className="client-traffic-devices-heading">
|
<span>Без домена: {snapshot.summary.unresolved}</span>
|
||||||
<h3 id="client-traffic-devices-title">Устройства</h3>
|
<time dateTime={snapshot.observedAt || undefined}>{updatedAt(snapshot.observedAt)}</time>
|
||||||
<button
|
</div>}
|
||||||
type="button"
|
<p className="client-traffic-honesty">{feature.isGateway
|
||||||
aria-pressed={!selectedOriginId}
|
? 'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.'
|
||||||
onClick={() => setSelectedOriginId('')}
|
: 'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.'}</p>
|
||||||
>Все устройства · {origins.length}</button>
|
</TrafficMenu>
|
||||||
</div>
|
</div>
|
||||||
<label className="client-traffic-search is-device-search">
|
<TrafficColumns />
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<div className="client-traffic-scroll">
|
||||||
<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">
|
{feature.paused && snapshot && <p className="client-traffic-notice" role="status">
|
||||||
Пауза · показаны данные на {updatedAt(snapshot.observedAt).replace('обновлено ', '')}.
|
Пауза · показаны данные на {updatedAt(snapshot.observedAt).replace('обновлено ', '')}.
|
||||||
</p>}
|
</p>}
|
||||||
{!feature.paused && stale && snapshot && <p className="client-traffic-notice is-warning" role="status">
|
{!feature.paused && stale && snapshot && <p className="client-traffic-notice is-warning" role="status">
|
||||||
Данные временно не обновляются. Показан последний полученный снимок.
|
Не удалось обновить. Показан последний полученный снимок.
|
||||||
</p>}
|
</p>}
|
||||||
{!feature.paused && !stale && snapshot && sourceState === 'connecting' && snapshot.connections.length > 0 && <p
|
{!feature.paused && !stale && snapshot && sourceState === 'connecting' && snapshot.connections.length > 0 && <p
|
||||||
className="client-traffic-notice"
|
className="client-traffic-notice"
|
||||||
@@ -609,7 +567,7 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
|||||||
Инспектор переподключается. Показан последний полученный снимок.
|
Инспектор переподключается. Показан последний полученный снимок.
|
||||||
</p>}
|
</p>}
|
||||||
{degraded && snapshot && <p className="client-traffic-notice is-warning" role="status">
|
{degraded && snapshot && <p className="client-traffic-notice is-warning" role="status">
|
||||||
Часть трафика не удалось сопоставить с соединениями: ↓ {formatByteString(snapshot.source.unattributedDownloadBytes)} · ↑ {formatByteString(snapshot.source.unattributedUploadBytes)}.
|
Часть трафика не распознана.
|
||||||
</p>}
|
</p>}
|
||||||
|
|
||||||
<TrafficState feature={feature} />
|
<TrafficState feature={feature} />
|
||||||
@@ -637,13 +595,16 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
|||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
{snapshot?.summary.truncated && <p className="client-traffic-truncated" role="status">
|
{snapshot?.summary.truncated && <p className="client-traffic-truncated" role="status">
|
||||||
Снимок ограничен 256 соединениями; активные показаны первыми.
|
Показано не более 256 соединений.
|
||||||
</p>}
|
</p>}
|
||||||
</>}
|
</div>
|
||||||
<p className="client-traffic-honesty">
|
</section>
|
||||||
{feature.isGateway
|
<section className={`client-traffic-view${feature.view === 'history' ? ' is-active' : ''}`}
|
||||||
? 'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.'
|
aria-label="История" aria-hidden={feature.view !== 'history'} inert={feature.view !== 'history' || undefined}
|
||||||
: 'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.'}
|
>
|
||||||
</p>
|
<TrafficHistoryPanel active={feature.isOpen && !feature.paused && feature.view === 'history'}
|
||||||
|
paused={feature.paused} isGateway={feature.isGateway} load={feature.loadHistory} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</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,23 +94,51 @@ 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>
|
||||||
|
{isGateway && <TrafficMenu
|
||||||
|
label={snapshot?.origins.find((origin) => origin.id === query.originId)?.label || (query.originId ? 'Выбрано устройство' : 'Все устройства')}
|
||||||
|
active={Boolean(query.originId)} closeOnSelect
|
||||||
|
>
|
||||||
|
<label className="client-traffic-search">
|
||||||
|
<input type="search" value={deviceSearch} aria-label="Найти устройство в истории" placeholder="Найти устройство"
|
||||||
|
onChange={(event) => setDeviceSearch(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="client-traffic-device-list">
|
||||||
|
<button type="button" aria-pressed={!query.originId} onClick={() => change({ originId: '' })}>Все устройства</button>
|
||||||
|
{origins.map((origin) => <button type="button" key={origin.id} aria-pressed={query.originId === origin.id}
|
||||||
|
onClick={() => change({ originId: origin.id })}
|
||||||
|
>{origin.label}</button>)}
|
||||||
</div>
|
</div>
|
||||||
|
{!origins.length && <p className="client-traffic-notice">Устройства не найдены.</p>}
|
||||||
|
{snapshot?.originsTruncated && <p className="client-traffic-notice">Показаны первые 256 устройств.</p>}
|
||||||
|
</TrafficMenu>}
|
||||||
|
<TrafficMenu label="Фильтры" active={Boolean(query.search) || query.route !== 'all'}>
|
||||||
|
<label className="client-traffic-search">
|
||||||
|
<input type="search" value={query.search} maxLength={200} aria-label="Найти сайт, IP или сервис в истории" placeholder="Найти сайт"
|
||||||
|
onChange={(event) => change({ search: event.target.value })} />
|
||||||
|
</label>
|
||||||
<div className="client-traffic-option-row"><span>Маршрут</span>
|
<div className="client-traffic-option-row"><span>Маршрут</span>
|
||||||
<div className="client-traffic-filters" role="group" aria-label="Маршрут в истории">
|
<div className="client-traffic-filters" role="group" aria-label="Маршрут в истории">
|
||||||
{([['all', 'Все'], ['vpn', 'VPN'], ['direct', 'Direct'], ['other', 'Другое']] as const).map(([value, label]) => <button
|
{([['all', 'Все'], ['vpn', 'VPN'], ['direct', 'Direct'], ['other', 'Другое']] as const).map(([value, label]) => <button
|
||||||
@@ -120,53 +146,32 @@ export function TrafficHistoryPanel({ active, isGateway, load }: { active: boole
|
|||||||
>{label}</button>)}
|
>{label}</button>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{isGateway && <section className="client-traffic-devices" aria-label="Устройства в истории">
|
|
||||||
<div className="client-traffic-devices-heading"><h3>Устройства</h3>
|
|
||||||
<button type="button" aria-pressed={!query.originId} onClick={() => change({ originId: '' })}>Все устройства</button>
|
|
||||||
</div>
|
|
||||||
<label className="client-traffic-search is-device-search">
|
|
||||||
<span aria-hidden="true" /><span className="client-live-region">Найти устройство</span>
|
|
||||||
<input type="search" value={deviceSearch} aria-label="Найти устройство в истории" placeholder="Найти устройство" onChange={(e) => setDeviceSearch(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<div className="client-traffic-device-list" id="client-history-devices">
|
|
||||||
{(devicesExpanded ? origins : origins.slice(0, 3)).map((origin) => <button type="button" key={origin.id}
|
|
||||||
aria-pressed={query.originId === origin.id} onClick={() => change({ originId: query.originId === origin.id ? '' : origin.id })}
|
|
||||||
><strong>{origin.label}</strong></button>)}
|
|
||||||
</div>
|
|
||||||
{origins.length > 3 && <button type="button" className="client-traffic-devices-more" aria-expanded={devicesExpanded}
|
|
||||||
aria-controls="client-history-devices" onClick={() => setDevicesExpanded((value) => !value)}
|
|
||||||
>{devicesExpanded ? 'Свернуть устройства' : `Ещё ${origins.length - 3} устройств`}</button>}
|
|
||||||
{snapshot?.originsTruncated && <p className="client-traffic-notice">Показаны первые 256 устройств.</p>}
|
|
||||||
</section>}
|
|
||||||
<label className="client-traffic-search client-traffic-list-search">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6" /><path d="m15 15 5 5" /></svg>
|
|
||||||
<span className="client-live-region">Поиск истории</span>
|
|
||||||
<input type="search" value={query.search} aria-label="Найти сайт, IP или сервис в истории" placeholder="Найти сайт, IP или сервис"
|
|
||||||
onChange={(event) => change({ search: event.target.value })} />
|
|
||||||
</label>
|
|
||||||
<p className="client-traffic-history-status" role="status">{result.status === 'loading' ? 'Загружаем историю…'
|
|
||||||
: result.status === 'error' ? `История временно недоступна.${snapshot ? ' Показаны последние полученные данные.' : ''}`
|
|
||||||
: snapshot?.source === 'disabled' ? 'Сбор истории выключен; сохранённые данные доступны.'
|
|
||||||
: snapshot?.source === 'stopped' ? 'VPN остановлен; показана сохранённая история.'
|
|
||||||
: snapshot?.source === 'connecting' ? 'Сборщик подключается; показана сохранённая история.'
|
|
||||||
: snapshot?.coverage.partial ? 'В выбранном периоде есть пропуски или неточное распределение по времени.' : '\u00a0'}</p>
|
|
||||||
{snapshot && <>
|
{snapshot && <>
|
||||||
<div className="client-traffic-summary" aria-label="Расход за выбранный период">
|
<div className="client-traffic-summary" aria-label="Расход за выбранный период">
|
||||||
<span><b>Скачано</b>{formatByteString(snapshot.totals.downloadBytes)}</span>
|
<span>Скачано {formatByteString(snapshot.totals.downloadBytes)}</span>
|
||||||
<span><b>Отправлено</b>{formatByteString(snapshot.totals.uploadBytes)}</span>
|
<span>Отправлено {formatByteString(snapshot.totals.uploadBytes)}</span>
|
||||||
</div>
|
</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">
|
<p className="client-traffic-honesty">
|
||||||
Локальная история за 90 дней: завершённые минуты за последние 7 дней, ранее по часам.
|
{`Данные до ${new Date(snapshot.period.to).toLocaleString('ru-RU')}.`}
|
||||||
{` Данные до ${new Date(snapshot.period.to).toLocaleString('ru-RU')}.`}
|
|
||||||
{snapshot.period.availableFrom && ` Сбор с ${new Date(snapshot.period.availableFrom).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')}.`}
|
{snapshot.coverage.lastObservedAt && ` Обновлено ${new Date(snapshot.coverage.lastObservedAt).toLocaleString('ru-RU')}.`}
|
||||||
{' '}Внешний Prometheus не требуется. IP без домена не означает распознанный сайт.
|
{' '}IP без домена не означает распознанный сайт.
|
||||||
</p>
|
</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>
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user