Add native traffic inspection to Harbor Connect and Gateway
This commit is contained in:
@@ -29,6 +29,7 @@ const componentActions = {
|
||||
pingServers: api.servers.ping,
|
||||
runConnectivityDiagnostics: api.diagnostics.connectivity,
|
||||
loadActivityJournal: api.activityJournal.page,
|
||||
loadLiveTraffic: api.traffic.live,
|
||||
};
|
||||
|
||||
interface UiError {
|
||||
|
||||
@@ -230,6 +230,9 @@ export const api = {
|
||||
activityJournal: {
|
||||
page: (cursor: string | null = null) => request(`/api/activity-journal?limit=50${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`),
|
||||
},
|
||||
traffic: {
|
||||
live: () => request('/api/traffic/live'),
|
||||
},
|
||||
singbox: {
|
||||
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
||||
restart: () => request('/api/singbox/restart', { method: 'POST' }),
|
||||
|
||||
@@ -47,6 +47,11 @@ import {
|
||||
InstructionsToggle,
|
||||
useInstructionsFeature,
|
||||
} from '../features/instructions/index.js';
|
||||
import {
|
||||
TrafficPanel,
|
||||
TrafficToggle,
|
||||
useTrafficFeature,
|
||||
} from '../features/traffic/index.js';
|
||||
import { FailoverPanel, FailoverToggle, useFailoverFeature } from '../features/failover/index.js';
|
||||
import {
|
||||
ActivityJournalPanel,
|
||||
@@ -72,7 +77,7 @@ const VERSION_PARTS = [
|
||||
] as const;
|
||||
|
||||
const DRAWER_SWITCH_MS = 620;
|
||||
const DRAWER_ORDER = ['subscription', 'failover', 'instructions', 'devices', 'diagnostics', 'routing', 'journal'] as const;
|
||||
const DRAWER_ORDER = ['subscription', 'failover', 'instructions', 'devices', 'traffic', 'diagnostics', 'routing', 'journal'] as const;
|
||||
type DrawerKey = typeof DRAWER_ORDER[number];
|
||||
|
||||
const failoverReasonLabel = (reason: string | null) => ({
|
||||
@@ -128,6 +133,7 @@ interface ComponentActions {
|
||||
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
|
||||
runConnectivityDiagnostics: (target?: unknown) => Promise<unknown>;
|
||||
loadActivityJournal: (cursor?: string | null) => Promise<unknown>;
|
||||
loadLiveTraffic: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface ClientViewState extends StateSnapshot {
|
||||
@@ -586,6 +592,11 @@ export function ClientOverviewPage({
|
||||
});
|
||||
const failoverFeature = useFailoverFeature();
|
||||
const activityJournalFeature = useActivityJournalFeature();
|
||||
const trafficFeature = useTrafficFeature({
|
||||
enabled: true,
|
||||
isGateway,
|
||||
loadLiveTraffic: actions.loadLiveTraffic,
|
||||
});
|
||||
const diagnosticsAvailable = hasSubscription;
|
||||
const drawerControls = {
|
||||
subscription: {
|
||||
@@ -612,6 +623,12 @@ export function ClientOverviewPage({
|
||||
show: devicesFeature.toggle,
|
||||
close: devicesFeature.close,
|
||||
},
|
||||
traffic: {
|
||||
isOpen: trafficFeature.isOpen,
|
||||
panelRef: trafficFeature.panelRef,
|
||||
show: trafficFeature.toggle,
|
||||
close: trafficFeature.close,
|
||||
},
|
||||
diagnostics: {
|
||||
isOpen: diagnosticsFeature.isOpen,
|
||||
panelRef: diagnosticsFeature.panelRef,
|
||||
@@ -654,8 +671,9 @@ export function ClientOverviewPage({
|
||||
diagnosticsFeature.close();
|
||||
failoverFeature.close();
|
||||
activityJournalFeature.close();
|
||||
trafficFeature.close();
|
||||
}
|
||||
}, [hasSubscription, isGateway]);
|
||||
}, [hasSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!diagnosticsAvailable) diagnosticsFeature.close();
|
||||
@@ -851,6 +869,11 @@ export function ClientOverviewPage({
|
||||
open={activeRailDrawer === 'devices'}
|
||||
onToggle={() => switchDrawer('devices')}
|
||||
/>}
|
||||
<TrafficToggle
|
||||
feature={trafficFeature}
|
||||
open={activeRailDrawer === 'traffic'}
|
||||
onToggle={() => switchDrawer('traffic')}
|
||||
/>
|
||||
<DiagnosticsToggle
|
||||
feature={diagnosticsFeature}
|
||||
open={activeRailDrawer === 'diagnostics'}
|
||||
@@ -936,6 +959,8 @@ export function ClientOverviewPage({
|
||||
|
||||
{isGateway && hasSubscription && <DevicesPanel feature={devicesFeature} />}
|
||||
|
||||
{hasSubscription && <TrafficPanel feature={trafficFeature} />}
|
||||
|
||||
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
|
||||
feature={diagnosticsFeature}
|
||||
runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
assertLiveTrafficSnapshot,
|
||||
type LiveTrafficConnection,
|
||||
type LiveTrafficSnapshot,
|
||||
} from '../../../shared/liveTraffic.js';
|
||||
import { Drawer } from '../../ui/Drawer.js';
|
||||
import { RailAction } from '../../ui/RailAction.js';
|
||||
import { formatByteString } from '../../utils/format.js';
|
||||
import {
|
||||
groupTrafficConnections,
|
||||
reconcileTrafficGroups,
|
||||
trafficGroupMatches,
|
||||
type DisplayedTrafficGroup,
|
||||
type TrafficConnectionGroup,
|
||||
type TrafficQualityFilter,
|
||||
type TrafficRouteFilter,
|
||||
} from './trafficRows.js';
|
||||
|
||||
const POLL_MS = 1_000;
|
||||
const RETENTION_STORAGE_KEY = 'harbor:traffic-retention-seconds';
|
||||
const RETENTION_OPTIONS = [5, 10, 30] as const;
|
||||
|
||||
type RequestState = 'idle' | 'loading' | 'ready' | 'error';
|
||||
type RetentionSeconds = typeof RETENTION_OPTIONS[number];
|
||||
|
||||
interface TrafficFeatureOptions {
|
||||
enabled: boolean;
|
||||
isGateway: boolean;
|
||||
loadLiveTraffic: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
const routeLabels: Record<LiveTrafficConnection['route']['kind'], string> = {
|
||||
vpn: 'VPN',
|
||||
direct: 'Direct',
|
||||
other: 'Другое',
|
||||
};
|
||||
|
||||
function storedRetentionSeconds(): RetentionSeconds {
|
||||
try {
|
||||
const value = Number(localStorage.getItem(RETENTION_STORAGE_KEY));
|
||||
return RETENTION_OPTIONS.includes(value as RetentionSeconds) ? value as RetentionSeconds : 10;
|
||||
} catch {
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
|
||||
function address(ip: string | null, port: number | null) {
|
||||
if (!ip) return '—';
|
||||
return port === null ? ip : `${ip}:${port}`;
|
||||
}
|
||||
|
||||
function updatedAt(value: string | null | undefined) {
|
||||
if (!value) return 'обновлений ещё нет';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime())
|
||||
? 'время неизвестно'
|
||||
: `обновлено ${new Intl.DateTimeFormat('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).format(date)}`;
|
||||
}
|
||||
|
||||
export function useTrafficFeature({ enabled, isGateway, loadLiveTraffic }: TrafficFeatureOptions) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [snapshot, setSnapshot] = useState<LiveTrafficSnapshot | null>(null);
|
||||
const [requestState, setRequestState] = useState<RequestState>('idle');
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) return;
|
||||
setIsOpen(false);
|
||||
setPaused(false);
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !isOpen || paused) return undefined;
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
setRequestState((current) => current === 'idle' ? 'loading' : current);
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const next = assertLiveTrafficSnapshot(await loadLiveTraffic());
|
||||
if (!cancelled) {
|
||||
setSnapshot(next);
|
||||
setRequestState('ready');
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setRequestState('error');
|
||||
} finally {
|
||||
if (!cancelled) timer = setTimeout(poll, POLL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [enabled, isOpen, paused, loadLiveTraffic]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
||||
const closeTraffic = (event: PointerEvent | KeyboardEvent) => {
|
||||
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
|
||||
if (event.type !== 'keydown' && (
|
||||
panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)
|
||||
)) return;
|
||||
setIsOpen(false);
|
||||
setPaused(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', closeTraffic);
|
||||
document.addEventListener('keydown', closeTraffic);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
document.removeEventListener('pointerdown', closeTraffic);
|
||||
document.removeEventListener('keydown', closeTraffic);
|
||||
requestAnimationFrame(() => {
|
||||
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
|
||||
});
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
function close() {
|
||||
setIsOpen(false);
|
||||
setPaused(false);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (isOpen) close();
|
||||
else if (enabled) setIsOpen(true);
|
||||
}
|
||||
|
||||
return {
|
||||
isGateway,
|
||||
isOpen,
|
||||
paused,
|
||||
snapshot,
|
||||
requestState,
|
||||
panelRef,
|
||||
toggleRef,
|
||||
closeRef,
|
||||
close,
|
||||
toggle,
|
||||
togglePause: () => setPaused((current) => !current),
|
||||
};
|
||||
}
|
||||
|
||||
export type TrafficFeature = ReturnType<typeof useTrafficFeature>;
|
||||
|
||||
export function TrafficToggle({
|
||||
feature,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
feature: TrafficFeature;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return <RailAction
|
||||
buttonRef={feature.toggleRef}
|
||||
className="client-traffic-toggle"
|
||||
open={open}
|
||||
controls="client-traffic"
|
||||
ariaLabel={open ? 'Закрыть трафик' : 'Открыть трафик'}
|
||||
label="Трафик"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3.5 19.5h17" />
|
||||
<path d="m5 16 4-4 3 2 6-7" />
|
||||
<circle cx="5" cy="16" r=".7" />
|
||||
<circle cx="9" cy="12" r=".7" />
|
||||
<circle cx="12" cy="14" r=".7" />
|
||||
<circle cx="18" cy="7" r=".7" />
|
||||
</svg>
|
||||
</RailAction>;
|
||||
}
|
||||
|
||||
function groupStatus(group: TrafficConnectionGroup) {
|
||||
if (group.connections.length === 1) {
|
||||
return group.activeCount > 0 ? group.protocol : `Завершено · ${group.protocol}`;
|
||||
}
|
||||
const states = [];
|
||||
if (group.activeCount > 0) states.push(`Активно: ${group.activeCount}`);
|
||||
if (group.recentCount > 0) {
|
||||
states.push(`${group.activeCount > 0 ? 'завершено' : 'Завершено'}: ${group.recentCount}`);
|
||||
}
|
||||
states.push(group.protocol);
|
||||
return states.join(' · ');
|
||||
}
|
||||
|
||||
function groupDestination(group: TrafficConnectionGroup) {
|
||||
const { domain, ip, port } = group.destination;
|
||||
if (!domain) return address(ip, port);
|
||||
if (group.destinationIps.length === 1) return `${domain} · ${address(group.destinationIps[0], port)}`;
|
||||
if (group.destinationIps.length > 1) {
|
||||
return `${domain} · IP: ${group.destinationIps.length}${port === null ? '' : ` · порт ${port}`}`;
|
||||
}
|
||||
return port === null ? domain : `${domain} · порт ${port}`;
|
||||
}
|
||||
|
||||
function TrafficGroupRow({
|
||||
group,
|
||||
expanded,
|
||||
exiting,
|
||||
onExited,
|
||||
onToggle,
|
||||
}: {
|
||||
group: TrafficConnectionGroup;
|
||||
expanded: boolean;
|
||||
exiting: boolean;
|
||||
onExited: () => void;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const detailsId = useId();
|
||||
const onlyConnection = group.connections.length === 1 ? group.connections[0] : null;
|
||||
const source = onlyConnection
|
||||
? `${group.origin.label} · ${address(onlyConnection.source.ip, onlyConnection.source.port)}`
|
||||
: `${group.origin.label} · соединений: ${group.connections.length}`;
|
||||
const chain = group.route.chain.length
|
||||
? group.route.chain.join(' → ')
|
||||
: group.route.outbound || '—';
|
||||
|
||||
return <div
|
||||
className={`client-traffic-connection${exiting ? ' is-exiting' : ''}`}
|
||||
role="listitem"
|
||||
inert={exiting || undefined}
|
||||
aria-hidden={exiting || undefined}
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.target === event.currentTarget && event.animationName === 'client-traffic-connection-out') {
|
||||
onExited();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="client-traffic-connection-summary"
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={detailsId}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className="client-traffic-identity">
|
||||
<strong aria-label={group.connections.length > 1
|
||||
? `${group.label}, соединений: ${group.connections.length}`
|
||||
: undefined}
|
||||
>{group.label}{group.connections.length > 1 ? ` ×${group.connections.length}` : ''}</strong>
|
||||
<small>{groupStatus(group)}</small>
|
||||
</span>
|
||||
<span className="client-traffic-route" data-route={group.route.kind}>
|
||||
{routeLabels[group.route.kind]}
|
||||
</span>
|
||||
<span className="client-traffic-values">
|
||||
{group.activeCount > 0 && <strong>
|
||||
<span>↓ {formatByteString(group.traffic.downloadBytesPerSecond)}/с</span>
|
||||
<span>↑ {formatByteString(group.traffic.uploadBytesPerSecond)}/с</span>
|
||||
</strong>}
|
||||
<small>
|
||||
<span>↓ {formatByteString(group.traffic.downloadBytes)}</span>
|
||||
<span>↑ {formatByteString(group.traffic.uploadBytes)}</span>
|
||||
</small>
|
||||
</span>
|
||||
<svg className="client-traffic-chevron" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="m5 6 3 3 3-3" />
|
||||
</svg>
|
||||
</button>
|
||||
{expanded && <dl id={detailsId} className="client-traffic-details">
|
||||
<div><dt>Источник</dt><dd>{source}</dd></div>
|
||||
<div><dt>Назначение</dt><dd>{groupDestination(group)}</dd></div>
|
||||
<div><dt>Правило</dt><dd>{group.route.rule || '—'}</dd></div>
|
||||
<div><dt>Цепочка</dt><dd>{chain}</dd></div>
|
||||
</dl>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function TrafficState({ feature }: { feature: TrafficFeature }) {
|
||||
const { snapshot, requestState } = feature;
|
||||
const sourceState = snapshot?.source.state;
|
||||
if (!snapshot && (requestState === 'idle' || requestState === 'loading')) {
|
||||
return <div className="client-traffic-skeleton" role="status" aria-label="Загружаем трафик">
|
||||
{[0, 1, 2, 3].map((item) => <span key={item} />)}
|
||||
</div>;
|
||||
}
|
||||
if (!snapshot) return <p className="client-traffic-state" role="status">Инспектор трафика временно недоступен.</p>;
|
||||
if (sourceState === 'disabled') {
|
||||
return <p className="client-traffic-state" role="status">{feature.isGateway
|
||||
? 'Инспектор трафика выключен в настройках Harbor Gateway.'
|
||||
: 'Инспектор трафика выключен в настройках Harbor Connect.'}</p>;
|
||||
}
|
||||
if (sourceState === 'incompatible') {
|
||||
return <p className="client-traffic-state" role="status">Эта версия sing-box не поддерживает инспектор трафика.</p>;
|
||||
}
|
||||
if (sourceState === 'stopped') {
|
||||
return <p className="client-traffic-state" role="status">VPN остановлен. Данные появятся после запуска.</p>;
|
||||
}
|
||||
if (sourceState === 'connecting' && snapshot.connections.length === 0) {
|
||||
return <p className="client-traffic-state" role="status">Подключаем инспектор трафика…</p>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [routeFilter, setRouteFilter] = useState<TrafficRouteFilter>('all');
|
||||
const [qualityFilter, setQualityFilter] = useState<TrafficQualityFilter>('all');
|
||||
const [retentionSeconds, setRetentionSeconds] = useState<RetentionSeconds>(storedRetentionSeconds);
|
||||
const [displayedGroups, setDisplayedGroups] = useState<DisplayedTrafficGroup[]>([]);
|
||||
const [reducedMotion, setReducedMotion] = useState(() => (
|
||||
matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
));
|
||||
const [expandedId, setExpandedId] = useState('');
|
||||
const snapshot = feature.snapshot;
|
||||
const sourceState = snapshot?.source.state;
|
||||
const snapshotTime = snapshot?.observedAt ? Date.parse(snapshot.observedAt) : Number.NaN;
|
||||
const retainedConnections = useMemo(() => (snapshot?.connections || []).filter((connection) => {
|
||||
if (connection.closedAt === null || !Number.isFinite(snapshotTime)) return true;
|
||||
return snapshotTime - Date.parse(connection.closedAt) < retentionSeconds * 1_000;
|
||||
}), [snapshot, snapshotTime, retentionSeconds]);
|
||||
const trafficGroups = useMemo(() => groupTrafficConnections(retainedConnections), [retainedConnections]);
|
||||
const groups = useMemo(() => trafficGroups.filter((group) => (
|
||||
trafficGroupMatches(group, query, routeFilter, qualityFilter)
|
||||
)), [trafficGroups, query, routeFilter, qualityFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
const media = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const update = () => setReducedMotion(media.matches);
|
||||
media.addEventListener('change', update);
|
||||
return () => media.removeEventListener('change', update);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const immediate = reducedMotion
|
||||
|| !snapshot
|
||||
|| ['disabled', 'incompatible', 'stopped'].includes(sourceState || '');
|
||||
if (immediate) {
|
||||
const desiredIds = new Set(groups.map((group) => group.id));
|
||||
setExpandedId((current) => desiredIds.has(current) ? current : '');
|
||||
}
|
||||
setDisplayedGroups((current) => reconcileTrafficGroups(current, groups, immediate));
|
||||
}, [groups, reducedMotion, snapshot, sourceState]);
|
||||
|
||||
function selectRetention(seconds: RetentionSeconds) {
|
||||
setRetentionSeconds(seconds);
|
||||
try {
|
||||
localStorage.setItem(RETENTION_STORAGE_KEY, String(seconds));
|
||||
} catch {
|
||||
// The setting remains available for this session when storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function finishExit(id: string) {
|
||||
setDisplayedGroups((current) => current.filter((row) => (
|
||||
row.group.id !== id || !row.exiting
|
||||
)));
|
||||
setExpandedId((current) => current === id ? '' : current);
|
||||
}
|
||||
|
||||
const canShowList = snapshot
|
||||
&& !['disabled', 'incompatible', 'stopped'].includes(sourceState || '')
|
||||
&& (sourceState !== 'connecting' || retainedConnections.length > 0);
|
||||
const stale = feature.requestState === 'error' || sourceState === 'stale';
|
||||
const degraded = sourceState === 'degraded';
|
||||
|
||||
return <Drawer
|
||||
panelRef={feature.panelRef}
|
||||
closeRef={feature.closeRef}
|
||||
id="client-traffic"
|
||||
className="client-traffic"
|
||||
sheetClassName="client-traffic-sheet"
|
||||
open={feature.isOpen}
|
||||
labelledBy="client-traffic-title"
|
||||
closeLabel="Закрыть трафик"
|
||||
onClose={feature.close}
|
||||
>
|
||||
<header className="client-traffic-header">
|
||||
<div className="client-traffic-meta">
|
||||
<span>{feature.isGateway ? 'GATEWAY' : 'MAC'} · {snapshot?.summary.active || 0} АКТИВНЫХ</span>
|
||||
<time dateTime={snapshot?.observedAt || undefined}>{updatedAt(snapshot?.observedAt)}</time>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={feature.paused}
|
||||
onClick={feature.togglePause}
|
||||
>{feature.paused ? 'Продолжить' : 'Пауза'}</button>
|
||||
</div>
|
||||
<h2 id="client-traffic-title">Трафик</h2>
|
||||
<p>Соединения сгруппированы по назначению, протоколу и маршруту.</p>
|
||||
</header>
|
||||
|
||||
{snapshot && <div className="client-traffic-summary" aria-label="Качество распознавания трафика">
|
||||
<span><b>Активных распознано</b> {snapshot.summary.recognized}</span>
|
||||
<span><b>Активных требует внимания</b> {snapshot.summary.unresolved}</span>
|
||||
</div>}
|
||||
|
||||
<div className="client-traffic-tools">
|
||||
<label className="client-traffic-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>
|
||||
<div className="client-traffic-filters" role="group" aria-label="Фильтр по маршруту">
|
||||
{([
|
||||
['all', 'Все'],
|
||||
['vpn', 'VPN'],
|
||||
['direct', 'Direct'],
|
||||
['other', 'Другое'],
|
||||
] as const).map(([value, label]) => <button
|
||||
type="button"
|
||||
key={value}
|
||||
aria-pressed={routeFilter === value}
|
||||
onClick={() => setRouteFilter(value)}
|
||||
>{label}</button>)}
|
||||
</div>
|
||||
<div className="client-traffic-filters" role="group" aria-label="Фильтр по качеству распознавания">
|
||||
{([
|
||||
['all', 'Все'],
|
||||
['recognized', 'Распознано'],
|
||||
['attention', 'Требует внимания'],
|
||||
] as const).map(([value, label]) => <button
|
||||
type="button"
|
||||
key={value}
|
||||
aria-pressed={qualityFilter === value}
|
||||
onClick={() => setQualityFilter(value)}
|
||||
>{label}</button>)}
|
||||
</div>
|
||||
<div className="client-traffic-retention">
|
||||
<span>Показывать завершённые</span>
|
||||
<div className="client-traffic-filters" role="group" aria-label="Время показа завершённых соединений">
|
||||
{RETENTION_OPTIONS.map((seconds) => <button
|
||||
type="button"
|
||||
key={seconds}
|
||||
aria-pressed={retentionSeconds === seconds}
|
||||
onClick={() => selectRetention(seconds)}
|
||||
>{seconds} с</button>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{feature.paused && snapshot && <p className="client-traffic-notice" role="status">
|
||||
Пауза · показаны данные на {updatedAt(snapshot.observedAt).replace('обновлено ', '')}.
|
||||
</p>}
|
||||
{!feature.paused && stale && snapshot && <p className="client-traffic-notice is-warning" role="status">
|
||||
Данные временно не обновляются. Показан последний полученный снимок.
|
||||
</p>}
|
||||
{!feature.paused && !stale && snapshot && sourceState === 'connecting' && snapshot.connections.length > 0 && <p
|
||||
className="client-traffic-notice"
|
||||
role="status"
|
||||
>
|
||||
Инспектор переподключается. Показан последний полученный снимок.
|
||||
</p>}
|
||||
{degraded && snapshot && <p className="client-traffic-notice is-warning" role="status">
|
||||
Часть трафика не удалось сопоставить с соединениями: ↓ {formatByteString(snapshot.source.unattributedDownloadBytes)} · ↑ {formatByteString(snapshot.source.unattributedUploadBytes)}.
|
||||
</p>}
|
||||
|
||||
<TrafficState feature={feature} />
|
||||
|
||||
{canShowList && retainedConnections.length === 0 && displayedGroups.length === 0 && <p className="client-traffic-state">
|
||||
Активных соединений пока нет.
|
||||
</p>}
|
||||
{canShowList && retainedConnections.length > 0 && groups.length === 0 && displayedGroups.length === 0 && <p className="client-traffic-state">
|
||||
По выбранным фильтрам ничего не найдено.
|
||||
</p>}
|
||||
{canShowList && displayedGroups.length > 0 && <div
|
||||
className="client-traffic-list"
|
||||
role="list"
|
||||
aria-label="Группы активных и недавно завершённых соединений"
|
||||
aria-busy={feature.requestState === 'loading'}
|
||||
>
|
||||
{displayedGroups.map((row) => <TrafficGroupRow
|
||||
key={row.group.id}
|
||||
group={row.group}
|
||||
expanded={expandedId === row.group.id}
|
||||
exiting={row.exiting}
|
||||
onExited={() => finishExit(row.group.id)}
|
||||
onToggle={() => setExpandedId((current) => current === row.group.id ? '' : row.group.id)}
|
||||
/>)}
|
||||
</div>}
|
||||
|
||||
{snapshot?.summary.truncated && <p className="client-traffic-truncated" role="status">
|
||||
Снимок ограничен 256 соединениями; активные показаны первыми.
|
||||
</p>}
|
||||
<p className="client-traffic-honesty">
|
||||
{feature.isGateway
|
||||
? 'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.'
|
||||
: 'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.'}
|
||||
</p>
|
||||
</Drawer>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
TrafficPanel,
|
||||
TrafficToggle,
|
||||
useTrafficFeature,
|
||||
type TrafficFeature,
|
||||
} from './TrafficFeature.js';
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { LiveTrafficConnection } from '../../../shared/liveTraffic.js';
|
||||
import { byteString } from '../../utils/format.js';
|
||||
|
||||
export type TrafficRouteFilter = 'all' | 'vpn' | 'direct' | 'other';
|
||||
export type TrafficQualityFilter = 'all' | 'recognized' | 'attention';
|
||||
|
||||
export interface TrafficConnectionGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
connections: LiveTrafficConnection[];
|
||||
activeCount: number;
|
||||
recentCount: number;
|
||||
protocol: string;
|
||||
route: LiveTrafficConnection['route'];
|
||||
origin: LiveTrafficConnection['origin'];
|
||||
destination: LiveTrafficConnection['destination'];
|
||||
destinationIps: string[];
|
||||
traffic: LiveTrafficConnection['traffic'];
|
||||
}
|
||||
|
||||
export interface DisplayedTrafficGroup {
|
||||
group: TrafficConnectionGroup;
|
||||
exiting: boolean;
|
||||
}
|
||||
|
||||
function normalizedDestination(connection: LiveTrafficConnection) {
|
||||
const domain = connection.destination.domain?.trim().toLowerCase() || null;
|
||||
const ip = connection.destination.ip?.trim().toLowerCase() || null;
|
||||
return { domain, ip };
|
||||
}
|
||||
|
||||
function trafficGroupId(connection: LiveTrafficConnection) {
|
||||
const { domain, ip } = normalizedDestination(connection);
|
||||
const destination = domain ? ['domain', domain] : ip ? ['ip', ip] : ['unknown', connection.id];
|
||||
return JSON.stringify([
|
||||
destination,
|
||||
connection.destination.port,
|
||||
connection.network,
|
||||
connection.protocol?.trim().toLowerCase() || null,
|
||||
[connection.origin.kind, connection.origin.id, connection.origin.label, connection.origin.provenance],
|
||||
[
|
||||
connection.route.kind,
|
||||
connection.route.scope,
|
||||
connection.route.outbound,
|
||||
connection.route.outboundType,
|
||||
connection.route.chain,
|
||||
connection.route.rule,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
function buildTrafficGroup(id: string, connections: LiveTrafficConnection[]): TrafficConnectionGroup {
|
||||
const first = connections[0];
|
||||
const { domain, ip } = normalizedDestination(first);
|
||||
const active = connections.filter(({ closedAt }) => closedAt === null);
|
||||
const sum = (field: keyof LiveTrafficConnection['traffic'], values = connections) => values
|
||||
.reduce((total, connection) => total + byteString(connection.traffic[field]), 0n)
|
||||
.toString();
|
||||
return {
|
||||
id,
|
||||
label: domain || ip || 'Назначение не определено',
|
||||
connections,
|
||||
activeCount: active.length,
|
||||
recentCount: connections.length - active.length,
|
||||
protocol: first.protocol || first.network.toUpperCase(),
|
||||
route: first.route,
|
||||
origin: first.origin,
|
||||
destination: { ...first.destination, domain, ip },
|
||||
destinationIps: [...new Set(connections.flatMap(({ destination }) => {
|
||||
const address = destination.ip?.trim().toLowerCase();
|
||||
return address ? [address] : [];
|
||||
}))].sort((left, right) => left.localeCompare(right)),
|
||||
traffic: {
|
||||
uploadBytes: sum('uploadBytes'),
|
||||
downloadBytes: sum('downloadBytes'),
|
||||
uploadBytesPerSecond: sum('uploadBytesPerSecond', active),
|
||||
downloadBytesPerSecond: sum('downloadBytesPerSecond', active),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function groupTrafficConnections(connections: LiveTrafficConnection[]) {
|
||||
const grouped = new Map<string, LiveTrafficConnection[]>();
|
||||
for (const connection of connections) {
|
||||
const id = trafficGroupId(connection);
|
||||
const members = grouped.get(id);
|
||||
if (members) members.push(connection);
|
||||
else grouped.set(id, [connection]);
|
||||
}
|
||||
return [...grouped].map(([id, members]) => buildTrafficGroup(id, members));
|
||||
}
|
||||
|
||||
export function trafficGroupMatches(
|
||||
group: TrafficConnectionGroup,
|
||||
query: string,
|
||||
route: TrafficRouteFilter,
|
||||
quality: TrafficQualityFilter,
|
||||
) {
|
||||
if (route !== 'all' && group.route.kind !== route) return false;
|
||||
const recognized = group.destination.domain !== null;
|
||||
if (quality === 'recognized' && !recognized) return false;
|
||||
if (quality === 'attention' && recognized) return false;
|
||||
const needle = query.trim().toLocaleLowerCase('ru-RU');
|
||||
if (!needle) return true;
|
||||
return group.connections.some((connection) => [
|
||||
connection.destination.domain,
|
||||
connection.destination.ip,
|
||||
connection.source.ip,
|
||||
connection.protocol,
|
||||
connection.inbound.tag,
|
||||
connection.inbound.type,
|
||||
connection.route.outbound,
|
||||
connection.route.outboundType,
|
||||
...connection.route.chain,
|
||||
].some((value) => String(value || '').toLocaleLowerCase('ru-RU').includes(needle)));
|
||||
}
|
||||
|
||||
export function reconcileTrafficGroups(
|
||||
current: DisplayedTrafficGroup[],
|
||||
desired: TrafficConnectionGroup[],
|
||||
immediate: boolean,
|
||||
) {
|
||||
const next = desired.map((group) => ({ group, exiting: false }));
|
||||
if (immediate) return next;
|
||||
|
||||
const desiredIds = new Set(desired.map((group) => group.id));
|
||||
current.forEach((row, index) => {
|
||||
if (!desiredIds.has(row.group.id)) {
|
||||
next.splice(Math.min(index, next.length), 0, { ...row, exiting: true });
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
.client-traffic-toggle svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.client-traffic-sheet {
|
||||
padding: 54px 72px 72px 34px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.client-traffic {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
.client-traffic-header {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
margin: 0 8px 28px;
|
||||
}
|
||||
|
||||
.client-traffic-meta {
|
||||
min-height: 28px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
.client-traffic-meta > span,
|
||||
.client-traffic-meta time {
|
||||
color: var(--client-muted);
|
||||
font: var(--type-label);
|
||||
letter-spacing: var(--type-label-tracking);
|
||||
text-transform: var(--type-label-transform);
|
||||
font-variant-numeric: var(--numeric-tabular);
|
||||
}
|
||||
|
||||
.client-traffic-meta time {
|
||||
text-transform: var(--type-label-transform);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-traffic-meta button {
|
||||
width: 96px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--client-text);
|
||||
font: var(--type-control);
|
||||
letter-spacing: var(--type-control-tracking);
|
||||
text-transform: var(--type-control-transform);
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.client-traffic-meta button:hover,
|
||||
.client-traffic-meta button:focus-visible,
|
||||
.client-traffic-meta button[aria-pressed='true'] {
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-traffic-meta button:focus-visible {
|
||||
outline: 2px solid var(--client-accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.client-traffic-header h2 {
|
||||
margin: 4px 0 0;
|
||||
font: var(--type-drawer-title);
|
||||
letter-spacing: var(--type-drawer-title-tracking);
|
||||
text-transform: var(--type-drawer-title-transform);
|
||||
}
|
||||
|
||||
.client-traffic-header p {
|
||||
max-width: 44ch;
|
||||
color: var(--client-muted);
|
||||
font: var(--type-body);
|
||||
letter-spacing: var(--type-body-tracking);
|
||||
text-transform: var(--type-body-transform);
|
||||
}
|
||||
|
||||
.client-traffic-summary {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0 8px 24px;
|
||||
color: var(--client-text);
|
||||
font: var(--type-data);
|
||||
letter-spacing: var(--type-data-tracking);
|
||||
text-transform: var(--type-data-transform);
|
||||
font-variant-numeric: var(--numeric-tabular);
|
||||
}
|
||||
|
||||
.client-traffic-summary span {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.client-traffic-summary b {
|
||||
min-width: 112px;
|
||||
color: var(--client-muted);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.client-traffic-tools {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0 8px 22px;
|
||||
}
|
||||
|
||||
.client-traffic-search {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--client-border);
|
||||
color: var(--client-muted);
|
||||
}
|
||||
|
||||
.client-traffic-search:focus-within {
|
||||
border-bottom-color: var(--client-accent);
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-traffic-search svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.7;
|
||||
}
|
||||
|
||||
.client-traffic-search input {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--client-text);
|
||||
font: var(--type-body);
|
||||
letter-spacing: var(--type-body-tracking);
|
||||
text-transform: var(--type-body-transform);
|
||||
}
|
||||
|
||||
.client-traffic-search input::placeholder {
|
||||
color: var(--client-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.client-traffic-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 18px;
|
||||
}
|
||||
|
||||
.client-traffic-filters button {
|
||||
min-height: 36px;
|
||||
padding: 4px 0 2px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--client-muted);
|
||||
font: var(--type-control);
|
||||
letter-spacing: var(--type-control-tracking);
|
||||
text-transform: var(--type-control-transform);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.client-traffic-filters button:hover,
|
||||
.client-traffic-filters button:focus-visible,
|
||||
.client-traffic-filters button[aria-pressed='true'] {
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-traffic-filters button[aria-pressed='true'] {
|
||||
border-bottom-color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-traffic-filters button:focus-visible {
|
||||
outline: 2px solid var(--client-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.client-traffic-retention {
|
||||
min-height: 36px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.client-traffic-retention > span {
|
||||
color: var(--client-muted);
|
||||
font: var(--type-control);
|
||||
letter-spacing: var(--type-control-tracking);
|
||||
text-transform: var(--type-control-transform);
|
||||
}
|
||||
|
||||
.client-traffic-notice,
|
||||
.client-traffic-state,
|
||||
.client-traffic-truncated,
|
||||
.client-traffic-honesty {
|
||||
margin: 0 8px;
|
||||
color: var(--client-muted);
|
||||
font: var(--type-control);
|
||||
letter-spacing: var(--type-control-tracking);
|
||||
text-transform: var(--type-control-transform);
|
||||
}
|
||||
|
||||
.client-traffic-notice {
|
||||
margin-bottom: 14px;
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-traffic-notice.is-warning {
|
||||
color: oklch(0.68 0.14 72);
|
||||
}
|
||||
|
||||
.client-traffic-state {
|
||||
min-height: 118px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-traffic-skeleton {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.client-traffic-skeleton span {
|
||||
height: 58px;
|
||||
background: color-mix(in oklch, var(--client-border) 24%, transparent);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.client-traffic-list {
|
||||
display: grid;
|
||||
margin: 0 8px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.client-traffic-connection {
|
||||
min-width: 0;
|
||||
box-shadow: inset 0 -1px 0 color-mix(in oklch, var(--client-border) 58%, transparent);
|
||||
animation: client-traffic-connection-in 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.client-traffic-connection.is-exiting {
|
||||
pointer-events: none;
|
||||
animation: client-traffic-connection-out 240ms cubic-bezier(0.4, 0, 1, 1) both;
|
||||
}
|
||||
|
||||
.client-traffic-connection-summary {
|
||||
width: 100%;
|
||||
min-height: 68px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 54px 154px 18px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--client-text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.client-traffic-connection-summary:hover,
|
||||
.client-traffic-connection-summary:focus-visible {
|
||||
outline: 0;
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-traffic-connection-summary:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--client-accent);
|
||||
}
|
||||
|
||||
.client-traffic-identity,
|
||||
.client-traffic-values {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.client-traffic-identity strong {
|
||||
overflow: hidden;
|
||||
font: var(--type-item-title);
|
||||
letter-spacing: var(--type-item-title-tracking);
|
||||
text-overflow: ellipsis;
|
||||
text-transform: var(--type-item-title-transform);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-traffic-identity small {
|
||||
overflow: hidden;
|
||||
color: var(--client-muted);
|
||||
font: var(--type-control);
|
||||
letter-spacing: var(--type-control-tracking);
|
||||
text-overflow: ellipsis;
|
||||
text-transform: var(--type-control-transform);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-traffic-route {
|
||||
justify-self: start;
|
||||
color: var(--client-muted);
|
||||
font: var(--type-control);
|
||||
letter-spacing: var(--type-control-tracking);
|
||||
text-transform: var(--type-control-transform);
|
||||
}
|
||||
|
||||
.client-traffic-route[data-route='vpn'] {
|
||||
color: var(--harbor-connect);
|
||||
}
|
||||
|
||||
.client-traffic-route[data-route='direct'] {
|
||||
color: var(--harbor-gateway);
|
||||
}
|
||||
|
||||
.client-traffic-values {
|
||||
justify-items: end;
|
||||
font-variant-numeric: var(--numeric-tabular);
|
||||
}
|
||||
|
||||
.client-traffic-values strong,
|
||||
.client-traffic-values small {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-traffic-values strong {
|
||||
font: var(--type-control);
|
||||
letter-spacing: var(--type-control-tracking);
|
||||
text-transform: var(--type-control-transform);
|
||||
}
|
||||
|
||||
.client-traffic-values small {
|
||||
color: var(--client-muted);
|
||||
font: var(--type-micro);
|
||||
letter-spacing: var(--type-micro-tracking);
|
||||
text-transform: var(--type-micro-transform);
|
||||
}
|
||||
|
||||
.client-traffic-values span:first-child {
|
||||
color: var(--harbor-connect);
|
||||
}
|
||||
|
||||
.client-traffic-values span:last-child {
|
||||
color: var(--harbor-gateway);
|
||||
}
|
||||
|
||||
.client-traffic-chevron {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
|
||||
.client-traffic-details {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
padding: 4px 0 16px;
|
||||
animation: client-traffic-details-in 180ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.client-traffic-details > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 82px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.client-traffic-details dt,
|
||||
.client-traffic-details dd {
|
||||
margin: 0;
|
||||
font: var(--type-control);
|
||||
letter-spacing: var(--type-control-tracking);
|
||||
text-transform: var(--type-control-transform);
|
||||
}
|
||||
|
||||
.client-traffic-details dt {
|
||||
color: var(--client-muted);
|
||||
}
|
||||
|
||||
.client-traffic-details dd {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--client-text);
|
||||
}
|
||||
|
||||
.client-traffic-truncated {
|
||||
padding-top: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-traffic-honesty {
|
||||
margin-top: 26px;
|
||||
padding-top: 16px;
|
||||
box-shadow: inset 0 1px 0 color-mix(in oklch, var(--client-border) 46%, transparent);
|
||||
}
|
||||
|
||||
@keyframes client-traffic-details-in {
|
||||
from { opacity: 0; transform: translateY(-5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes client-traffic-connection-in {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes client-traffic-connection-out {
|
||||
from { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 0; transform: translateY(4px); }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.client-traffic-sheet {
|
||||
padding: 40px 58px 60px 18px;
|
||||
}
|
||||
|
||||
.client-traffic-meta {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.client-traffic-meta > span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.client-traffic-connection-summary {
|
||||
grid-template-columns: minmax(0, 1fr) auto 18px;
|
||||
}
|
||||
|
||||
.client-traffic-values {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.client-traffic-values strong,
|
||||
.client-traffic-values small {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.client-traffic-details > div {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.client-traffic-connection,
|
||||
.client-traffic-details {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -10,5 +10,6 @@
|
||||
@import './features/diagnostics.css';
|
||||
@import './features/failover.css';
|
||||
@import './features/activity-journal.css';
|
||||
@import './features/traffic.css';
|
||||
@import './layout.css';
|
||||
@import './themes.css';
|
||||
|
||||
Reference in New Issue
Block a user