Expose outbound traffic totals and simplify Direct chart breakdown
This commit is contained in:
@@ -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>}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user