Expose outbound traffic totals and simplify Direct chart breakdown
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-12 22:43:40 +03:00
parent 9d43e74d97
commit 72c085a5b8
10 changed files with 173 additions and 69 deletions
@@ -149,6 +149,11 @@ interface OutboundTrafficSample {
unknownBytes: string;
}
interface OutboundTrafficTotal extends OutboundTrafficSample {
singboxObservedAt: string | null;
directIpv4ObservedAt: string | null;
}
interface OutboundTrafficCursor {
signature: string;
routeEpoch: string;
@@ -709,6 +714,7 @@ export function createDeviceInventoryService({
const trafficCursorByMac = new Map<string, TrafficCursor>();
const outboundTrafficHistoryByDeviceId = new Map<string, OutboundTrafficSample[]>();
const outboundTrafficCursorByDeviceId = new Map<string, OutboundTrafficCursor>();
const outboundTrafficByDeviceId = new Map<string, OutboundTrafficTotal>();
let globalTrafficHistory: TrafficSample[] = [];
let globalTrafficCursor: TrafficCursor | null = null;
const hostnameAttempts = new Map<string, number>();
@@ -832,6 +838,15 @@ export function createDeviceInventoryService({
const current: OutboundTrafficCursor = { signature, routeEpoch, directEpoch, ...total };
const previous = outboundTrafficCursorByDeviceId.get(device.id);
outboundTrafficCursorByDeviceId.set(device.id, current);
if (observedAt) outboundTrafficByDeviceId.set(device.id, {
observedAt,
singboxObservedAt: routeObservedAt || null,
directIpv4ObservedAt: directObservedAt || null,
vpnBytes: total.vpn.toString(),
directTrackedBytes: total.directTracked.toString(),
directIpv4Bytes: total.directIpv4.toString(),
unknownBytes: total.unknown.toString(),
});
if (!previous || previous.signature === signature || !observedAt) continue;
const routeDelta = (value: bigint, before: bigint) => (
routeEpoch && routeEpoch === previous.routeEpoch && value > before ? value - before : 0n
@@ -852,6 +867,7 @@ export function createDeviceInventoryService({
if (!knownIds.has(deviceId)) {
outboundTrafficCursorByDeviceId.delete(deviceId);
outboundTrafficHistoryByDeviceId.delete(deviceId);
outboundTrafficByDeviceId.delete(deviceId);
}
}
}
@@ -919,6 +935,7 @@ export function createDeviceInventoryService({
proxyDownloadBytes: proxyTraffic?.downloadBytes || '0',
proxyTrafficObservedAt: proxyTraffic?.observedAt || null,
trafficHistory: trafficHistoryByMac.get(device.mac) || [],
outboundTraffic: outboundTrafficByDeviceId.get(device.id) || null,
outboundTrafficHistory: outboundTrafficHistoryByDeviceId.get(device.id) || [],
desiredPolicy: policy.desired,
appliedPolicy: policy.applied,
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.25.3',
gatewayClient: '0.26.2',
gatewayBackend: '0.26.3',
macClient: '0.25.4',
gatewayClient: '0.26.3',
gatewayBackend: '0.26.4',
});
export interface ParsedVersion {
+29 -5
View File
@@ -337,7 +337,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
<h2 id="client-devices-title">Устройства</h2>
<div className="client-instructions-intro">
<p>Устройства, которые Gateway видит в локальной таблице соседей.</p>
<p>Учитывается только трафик, который прошёл через Harbor.</p>
<p>Вход накопленные Gateway/Proxy. Выход оценка VPN/Direct с запуска текущего учёта.</p>
</div>
</header>
@@ -403,6 +403,24 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const proxyTraffic = formatByteString(proxyTotal.toString());
const totalTraffic = formatByteString((gatewayTotal + proxyTotal).toString());
const hasProxyTraffic = proxyTotal > 0n;
const outboundAvailable = Boolean(
device.outboundTraffic?.singboxObservedAt || device.outboundTraffic?.directIpv4ObservedAt,
);
const vpnTotal = byteString(device.outboundTraffic?.vpnBytes);
const directTrackedTotal = byteString(device.outboundTraffic?.directTrackedBytes);
const directIpv4Total = byteString(device.outboundTraffic?.directIpv4Bytes);
const directTotal = directTrackedTotal + directIpv4Total;
const unknownTotal = byteString(device.outboundTraffic?.unknownBytes);
const outboundTotal = vpnTotal + directTotal + unknownTotal;
const displayedTotal = trafficView === 'outbound'
? outboundAvailable ? `${formatByteString(outboundTotal.toString())}` : '—'
: totalTraffic;
const trafficLabel = trafficView === 'outbound' ? 'Выход' : 'Вход';
const trafficAriaLabel = trafficView === 'outbound'
? outboundAvailable
? `Выход с запуска текущего учёта: примерно ${formatByteString(outboundTotal.toString())}. VPN ${formatByteString(vpnTotal.toString())}, Direct примерно ${formatByteString(directTotal.toString())}${unknownTotal > 0n ? `, неизвестно ${formatByteString(unknownTotal.toString())}` : ''}`
: 'Выход: ожидаем первые данные'
: `Вход накоплен: ${totalTraffic}. Gateway ${gatewayTraffic}${hasProxyTraffic ? `, Прокси ${proxyTraffic}` : ''}`;
const trafficDelta = trafficDeltas[device.id] || {};
const feedback = copyFeedback[device.id];
const policyBusy = device.policyStatus === 'applying';
@@ -538,14 +556,20 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
className="client-device-traffic"
role="group"
tabIndex={0}
aria-label={`Всего ${totalTraffic}. Gateway ${gatewayTraffic}${hasProxyTraffic ? `, Прокси ${proxyTraffic}` : ''}`}
aria-label={trafficAriaLabel}
>
<span className="client-device-traffic-total" aria-hidden="true">
<b>Всего</b><TrafficValue value={totalTraffic} delta={trafficDelta.total} />
<b>{trafficLabel}</b><TrafficValue value={displayedTotal} delta={trafficView === 'inbound' ? trafficDelta.total : undefined} />
</span>
<span className="client-device-traffic-breakdown" aria-hidden="true">
<span><b>Gateway</b><TrafficValue value={gatewayTraffic} delta={trafficDelta.gateway} /></span>
{hasProxyTraffic && <span className="is-proxy"><b>Прокси</b><TrafficValue value={proxyTraffic} delta={trafficDelta.proxy} /></span>}
{trafficView === 'outbound' ? <>
<span className="is-vpn"><b>VPN</b><TrafficValue value={outboundAvailable ? formatByteString(vpnTotal.toString()) : '—'} /></span>
<span className="is-direct-total"><b>Direct </b><TrafficValue value={outboundAvailable ? formatByteString(directTotal.toString()) : '—'} /></span>
{unknownTotal > 0n && <span className="is-unknown"><b>Неизв.</b><TrafficValue value={formatByteString(unknownTotal.toString())} /></span>}
</> : <>
<span><b>Gateway</b><TrafficValue value={gatewayTraffic} delta={trafficDelta.gateway} /></span>
{hasProxyTraffic && <span className="is-proxy"><b>Прокси</b><TrafficValue value={proxyTraffic} delta={trafficDelta.proxy} /></span>}
</>}
</span>
</span>}
+15 -19
View File
@@ -27,7 +27,7 @@ function chartTime(value: string) {
type ChartSample = TrafficSample | OutboundTrafficSample;
type ChartValueKey = 'gateway' | 'proxy' | 'directIpv4' | 'unknown';
type ChartYKey = 'gatewayY' | 'proxyY' | 'directIpv4Y' | 'unknownY';
type ChartYKey = 'gatewayY' | 'proxyY' | 'unknownY';
interface ChartPoint {
sample: ChartSample;
@@ -38,7 +38,6 @@ interface ChartPoint {
unknown: bigint;
gatewayY: number;
proxyY: number;
directIpv4Y: number;
unknownY: number;
}
@@ -68,13 +67,11 @@ function trafficPathAnimationSource(points: ChartPoint[], previousPoints: ChartP
x: source.x,
gatewayY: source.gatewayY,
proxyY: source.proxyY,
directIpv4Y: source.directIpv4Y,
unknownY: source.unknownY,
} : {
...point,
gatewayY: 100,
proxyY: 100,
directIpv4Y: 100,
unknownY: 100,
};
});
@@ -133,7 +130,7 @@ export function TrafficChart({
return {
sample,
gateway: byteString(outbound.vpnBytes),
proxy: byteString(outbound.directTrackedBytes),
proxy: byteString(outbound.directTrackedBytes) + byteString(outbound.directIpv4Bytes),
directIpv4: byteString(outbound.directIpv4Bytes),
unknown: byteString(outbound.unknownBytes),
};
@@ -148,8 +145,8 @@ export function TrafficChart({
unknown: 0n,
};
});
const max = trafficSeriesMax(values.map(({ gateway, proxy, directIpv4, unknown }) => ({
gateway, proxy, directIpv4, unknown,
const max = trafficSeriesMax(values.map(({ gateway, proxy, unknown }) => ({
gateway, proxy, directIpv4: 0n, unknown,
})));
const mid = trafficAxisMid(max, scale);
const firstSlot = capacity - visibleSamples.length;
@@ -163,7 +160,6 @@ export function TrafficChart({
unknown,
gatewayY: trafficChartY(trafficScaleRatio(gateway, max, scale)),
proxyY: trafficChartY(trafficScaleRatio(proxy, max, scale)),
directIpv4Y: trafficChartY(trafficScaleRatio(directIpv4, max, scale)),
unknownY: trafficChartY(trafficScaleRatio(unknown, max, scale)),
};
});
@@ -177,7 +173,6 @@ export function TrafficChart({
? [
{ valueKey: 'gateway', yKey: 'gatewayY' },
{ valueKey: 'proxy', yKey: 'proxyY' },
{ valueKey: 'directIpv4', yKey: 'directIpv4Y' },
{ valueKey: 'unknown', yKey: 'unknownY' },
]
: [
@@ -230,11 +225,13 @@ export function TrafficChart({
<span className={routeLabel === 'Gateway' ? 'is-gateway' : 'is-direct'}>{routeLabel} {formatByteString(hovered.sample.gatewayBytes)}</span>
{byteString(hovered.sample.proxyBytes) > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.sample.proxyBytes)}</span>}
</> : series === 'outbound' ? <>
{hovered.gateway > 0n && <strong className="is-vpn">VPN · sing-box {formatByteString(hovered.gateway)}</strong>}
{hovered.proxy > 0n && <span className="is-direct-tracked">Direct · sing-box {formatByteString(hovered.proxy)}</span>}
{hovered.directIpv4 > 0n && <span className="is-direct-ipv4">Direct · IPv4 {formatByteString(hovered.directIpv4)}</span>}
<strong className="is-total">Учтено {formatByteString(hovered.gateway + hovered.proxy + hovered.unknown)}</strong>
{hovered.gateway > 0n && <span className="is-vpn">VPN {formatByteString(hovered.gateway)}</span>}
{hovered.proxy > 0n && <span className="is-direct-total">Direct {formatByteString(hovered.proxy)}</span>}
{hovered.proxy - hovered.directIpv4 > 0n && <span className="is-direct-detail">через sing-box {formatByteString(hovered.proxy - hovered.directIpv4)}</span>}
{hovered.directIpv4 > 0n && <span className="is-direct-detail">мимо sing-box · IPv4 {formatByteString(hovered.directIpv4)}</span>}
{hovered.unknown > 0n && <span className="is-unknown">Неизвестно · sing-box {formatByteString(hovered.unknown)}</span>}
<span className="is-interval">Разные уровни учёта не складываются</span>
<span className="is-interval">Оценка за 15-секундный интервал</span>
</> : <>
<strong className="is-total">Всего {formatByteString(hovered.gateway + hovered.proxy)}</strong>
<span className={routeLabel === 'Gateway' ? 'is-gateway' : 'is-direct'}>{routeLabel} {formatByteString(hovered.gateway)}</span>
@@ -269,19 +266,19 @@ export function TrafficChart({
<line x1="0" x2="100" y1="100" y2="100" />
</g>}
<g className="client-device-traffic-lines">
{visibleLines.map((line) => previous.length > 0 && <path key={`old-${line.valueKey}`} className={line.valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line.valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-tracked' : series === 'speed' ? 'is-upload' : 'is-proxy' : line.valueKey === 'directIpv4' ? 'is-direct-ipv4' : 'is-unknown'} d={smoothTrafficPath(previous, line.yKey)}>
{visibleLines.map((line) => previous.length > 0 && <path key={`old-${line.valueKey}`} className={line.valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line.valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-total' : series === 'speed' ? 'is-upload' : 'is-proxy' : 'is-unknown'} d={smoothTrafficPath(previous, line.yKey)}>
{animatePaths && <animate key={`${line.valueKey}-${motionKey}`} attributeName="d" from={smoothTrafficPath(previousMotionFrom, line.yKey)} to={smoothTrafficPath(previous, line.yKey)} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
</path>)}
{visibleLines.map((line) => penultimate && newest && <path key={`new-${line.valueKey}`} className={line.valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line.valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-tracked' : series === 'speed' ? 'is-upload' : 'is-proxy' : line.valueKey === 'directIpv4' ? 'is-direct-ipv4' : 'is-unknown'} d={smoothTrafficPath([penultimate, newest], line.yKey)}>
{visibleLines.map((line) => penultimate && newest && <path key={`new-${line.valueKey}`} className={line.valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line.valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-total' : series === 'speed' ? 'is-upload' : 'is-proxy' : 'is-unknown'} d={smoothTrafficPath([penultimate, newest], line.yKey)}>
{animatePaths && <animate key={`${line.valueKey}-new-${motionKey}`} attributeName="d" from={smoothTrafficPath(newestMotionFrom, line.yKey)} to={smoothTrafficPath([penultimate, newest], line.yKey)} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
</path>)}
{visibleLines.map((line) => !penultimate && newest && <line key={`point-${line.valueKey}`} className={`${line.valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line.valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-tracked' : series === 'speed' ? 'is-upload' : 'is-proxy' : line.valueKey === 'directIpv4' ? 'is-direct-ipv4' : 'is-unknown'} is-point`} x1={newest.x} x2={newest.x} y1={newest[line.yKey]} y2={newest[line.yKey]}>
{visibleLines.map((line) => !penultimate && newest && <line key={`point-${line.valueKey}`} className={`${line.valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line.valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-total' : series === 'speed' ? 'is-upload' : 'is-proxy' : 'is-unknown'} is-point`} x1={newest.x} x2={newest.x} y1={newest[line.yKey]} y2={newest[line.yKey]}>
{animatePaths && <animate key={`${line.valueKey}-point-${motionKey}`} attributeName="opacity" from="0" to="1" dur="220ms" fill="freeze" />}
</line>)}
</g>
{hovered && <g className="client-device-traffic-cursor">
<line className="is-guide" x1={hovered.x} x2={hovered.x} y1={TRAFFIC_CHART_HEADROOM} y2="100" />
{visibleLines.map((line) => hovered[line.valueKey] > 0n && <line key={line.valueKey} className={`is-point ${line.valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line.valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-tracked' : series === 'speed' ? 'is-upload' : 'is-proxy' : line.valueKey === 'directIpv4' ? 'is-direct-ipv4' : 'is-unknown'}`} x1={hovered.x} x2={hovered.x} y1={hovered[line.yKey]} y2={hovered[line.yKey]} />)}
{visibleLines.map((line) => hovered[line.valueKey] > 0n && <line key={line.valueKey} className={`is-point ${line.valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line.valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-total' : series === 'speed' ? 'is-upload' : 'is-proxy' : 'is-unknown'}`} x1={hovered.x} x2={hovered.x} y1={hovered[line.yKey]} y2={hovered[line.yKey]} />)}
</g>}
</svg>
</span>
@@ -289,8 +286,7 @@ export function TrafficChart({
<time dateTime={visibleSamples[0].observedAt}>{chartTime(visibleSamples[0].observedAt)}</time>
{series === 'outbound' ? <span className="client-device-traffic-legend">
<span className="is-vpn">VPN</span>
<span className="is-direct-tracked">Direct · sing-box</span>
<span className="is-direct-ipv4">Direct · IPv4</span>
<span className="is-direct-total">Direct </span>
<span className="is-unknown">?</span>
</span> : <span>{series === 'speed' ? '↓ / ↑' : 'Вход'}</span>}
<time dateTime={visibleSamples[visibleSamples.length - 1].observedAt}>{chartTime(visibleSamples[visibleSamples.length - 1].observedAt)}</time>
@@ -21,6 +21,11 @@ export interface OutboundTrafficSample extends Record<string, unknown> {
unknownBytes: ByteValue;
}
export interface OutboundTrafficTotal extends OutboundTrafficSample {
singboxObservedAt: string | null;
directIpv4ObservedAt: string | null;
}
export interface Device extends Record<string, unknown> {
id: string;
alias: string | null;
@@ -41,6 +46,7 @@ export interface Device extends Record<string, unknown> {
appliedPolicy: DevicePolicy;
confidence: DeviceConfidence;
trafficHistory: TrafficSample[];
outboundTraffic?: OutboundTrafficTotal | null;
outboundTrafficHistory?: OutboundTrafficSample[];
}
@@ -130,6 +136,12 @@ function validOutboundHistory(value: unknown): value is OutboundTrafficSample[]
return Array.isArray(value) && value.every(validOutboundTrafficSample);
}
function validOutboundTraffic(value: unknown): value is OutboundTrafficTotal | null {
return value === null || (validOutboundTrafficSample(value)
&& nullableTimestamp(value.singboxObservedAt)
&& nullableTimestamp(value.directIpv4ObservedAt));
}
function validDevice(value: unknown): value is Device {
return record(value)
&& typeof value.id === 'string'
@@ -155,6 +167,7 @@ function validDevice(value: unknown): value is Device {
&& (value.appliedPolicy === 'vpn' || value.appliedPolicy === 'direct')
&& (value.confidence === 'high' || value.confidence === 'medium' || value.confidence === 'ambiguous')
&& validHistory(value.trafficHistory)
&& (value.outboundTraffic === undefined || validOutboundTraffic(value.outboundTraffic))
&& (value.outboundTrafficHistory === undefined || validOutboundHistory(value.outboundTrafficHistory));
}
+29 -19
View File
@@ -694,6 +694,10 @@
transition-delay: 45ms;
}
.client-device-traffic-breakdown > span:nth-child(3) {
transition-delay: 90ms;
}
.client-device-traffic:hover .client-device-traffic-breakdown,
.client-device-traffic:focus .client-device-traffic-breakdown {
visibility: visible;
@@ -764,6 +768,18 @@
color: color-mix(in oklch, var(--client-accent) 76%, var(--client-text));
}
.client-device-traffic-breakdown .is-vpn {
color: var(--harbor-connect);
}
.client-device-traffic-breakdown .is-direct-total {
color: var(--harbor-gateway);
}
.client-device-traffic-breakdown .is-unknown {
color: var(--client-muted);
}
.client-device-traffic-breakdown b {
color: var(--client-accent);
text-shadow: 0 0 3px var(--client-bg), 0 0 8px var(--client-bg);
@@ -773,6 +789,12 @@
color: color-mix(in oklch, var(--client-accent) 76%, var(--client-text));
}
.client-device-traffic-breakdown .is-vpn b,
.client-device-traffic-breakdown .is-direct-total b,
.client-device-traffic-breakdown .is-unknown b {
color: inherit;
}
.client-device-traffic:focus-visible {
border-radius: 3px;
outline: 2px solid var(--client-accent);
@@ -913,16 +935,11 @@
stroke: var(--harbor-connect);
}
.client-device-traffic-lines .is-direct-tracked {
.client-device-traffic-lines .is-direct-total {
stroke: var(--harbor-gateway);
stroke-dasharray: 5 4;
}
.client-device-traffic-lines .is-direct-ipv4 {
stroke: var(--harbor-word);
stroke-dasharray: 2 3;
}
.client-device-traffic-lines .is-unknown {
stroke: color-mix(in oklch, var(--client-text) 54%, var(--client-muted));
stroke-dasharray: 1 4;
@@ -967,14 +984,10 @@
stroke: var(--harbor-connect);
}
.client-device-traffic-cursor .is-point.is-direct-tracked {
.client-device-traffic-cursor .is-point.is-direct-total {
stroke: var(--harbor-gateway);
}
.client-device-traffic-cursor .is-point.is-direct-ipv4 {
stroke: var(--harbor-word);
}
.client-device-traffic-cursor .is-point.is-unknown {
stroke: color-mix(in oklch, var(--client-text) 54%, var(--client-muted));
}
@@ -1000,14 +1013,10 @@
color: var(--harbor-connect);
}
.client-device-traffic-legend .is-direct-tracked {
.client-device-traffic-legend .is-direct-total {
color: var(--harbor-gateway);
}
.client-device-traffic-legend .is-direct-ipv4 {
color: var(--harbor-word);
}
.client-device-traffic-legend .is-unknown {
color: color-mix(in oklch, var(--client-text) 54%, var(--client-muted));
}
@@ -1072,12 +1081,13 @@
color: oklch(0.75 0.1 185);
}
.client-device-traffic-point-tooltip .is-direct-tracked {
.client-device-traffic-point-tooltip .is-direct-total {
color: oklch(0.79 0.11 72);
}
.client-device-traffic-point-tooltip .is-direct-ipv4 {
color: oklch(0.78 0.07 232);
.client-device-traffic-point-tooltip .is-direct-detail {
padding-left: 8px;
color: oklch(0.68 0.012 145);
}
.client-device-traffic-point-tooltip .is-unknown {