Add outbound traffic breakdown to device charts
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 14s

This commit is contained in:
2026-08-12 22:18:08 +03:00
parent 9e52ccc24d
commit 9d43e74d97
10 changed files with 403 additions and 69 deletions
@@ -141,6 +141,24 @@ interface TrafficCursor {
download: bigint;
}
interface OutboundTrafficSample {
observedAt: string;
vpnBytes: string;
directTrackedBytes: string;
directIpv4Bytes: string;
unknownBytes: string;
}
interface OutboundTrafficCursor {
signature: string;
routeEpoch: string;
directEpoch: string;
vpn: bigint;
directTracked: bigint;
directIpv4: bigint;
unknown: bigint;
}
interface DeviceObservation {
mac: string;
ip: string;
@@ -689,6 +707,8 @@ export function createDeviceInventoryService({
let policyQueue: Promise<unknown> = Promise.resolve();
const trafficHistoryByMac = new Map<string, TrafficSample[]>();
const trafficCursorByMac = new Map<string, TrafficCursor>();
const outboundTrafficHistoryByDeviceId = new Map<string, OutboundTrafficSample[]>();
const outboundTrafficCursorByDeviceId = new Map<string, OutboundTrafficCursor>();
let globalTrafficHistory: TrafficSample[] = [];
let globalTrafficCursor: TrafficCursor | null = null;
const hostnameAttempts = new Map<string, number>();
@@ -763,6 +783,79 @@ export function createDeviceInventoryService({
}
}
function captureOutboundTrafficHistory(state: InventoryState) {
const routeEpoch = typeof domainTrafficSnapshot.epoch === 'string' ? domainTrafficSnapshot.epoch : '';
const directEpoch = typeof directTrafficSnapshot.epoch === 'string' ? directTrafficSnapshot.epoch : '';
const routeObservedAt = validTimestamp(domainTrafficSnapshot.observedAt)
? String(domainTrafficSnapshot.observedAt)
: '';
const directObservedAt = validTimestamp(directTrafficSnapshot.observedAt)
? String(directTrafficSnapshot.observedAt)
: '';
const signature = `${routeEpoch}|${routeObservedAt}|${directEpoch}|${directObservedAt}`;
if (signature === '|||') return;
const totals = new Map<string, Omit<OutboundTrafficCursor, 'signature' | 'routeEpoch' | 'directEpoch'>>();
const totalFor = (deviceId: string) => {
const existing = totals.get(deviceId) || { vpn: 0n, directTracked: 0n, directIpv4: 0n, unknown: 0n };
totals.set(deviceId, existing);
return existing;
};
for (const value of Array.isArray(domainTrafficSnapshot.routes) ? domainTrafficSnapshot.routes : []) {
const row = record(value);
const deviceId = String(row.deviceId || '');
const outbound = String(row.outbound || '');
const uploadBytes = String(row.uploadBytes || '');
const downloadBytes = String(row.downloadBytes || '');
if (!DEVICE_ID_PATTERN.test(deviceId) || !['vpn', 'direct', 'unknown'].includes(outbound)
|| !COUNTER_PATTERN.test(uploadBytes) || !COUNTER_PATTERN.test(downloadBytes)) continue;
const amount = BigInt(uploadBytes) + BigInt(downloadBytes);
const total = totalFor(deviceId);
if (outbound === 'vpn') total.vpn += amount;
else if (outbound === 'direct') total.directTracked += amount;
else total.unknown += amount;
}
for (const value of Array.isArray(directTrafficSnapshot.series) ? directTrafficSnapshot.series : []) {
const row = record(value);
const deviceId = String(row.deviceId || '');
const uploadBytes = String(row.uploadBytes || '');
const downloadBytes = String(row.downloadBytes || '');
if (!DEVICE_ID_PATTERN.test(deviceId)
|| !COUNTER_PATTERN.test(uploadBytes) || !COUNTER_PATTERN.test(downloadBytes)) continue;
totalFor(deviceId).directIpv4 += BigInt(uploadBytes) + BigInt(downloadBytes);
}
const knownIds = new Set(state.devices.map(({ id }) => id));
const observedAt = [routeObservedAt, directObservedAt].filter(Boolean).sort().at(-1) || '';
for (const device of state.devices) {
const total = totalFor(device.id);
const current: OutboundTrafficCursor = { signature, routeEpoch, directEpoch, ...total };
const previous = outboundTrafficCursorByDeviceId.get(device.id);
outboundTrafficCursorByDeviceId.set(device.id, current);
if (!previous || previous.signature === signature || !observedAt) continue;
const routeDelta = (value: bigint, before: bigint) => (
routeEpoch && routeEpoch === previous.routeEpoch && value > before ? value - before : 0n
);
const directDelta = directEpoch && directEpoch === previous.directEpoch && total.directIpv4 > previous.directIpv4
? total.directIpv4 - previous.directIpv4
: 0n;
const samples = outboundTrafficHistoryByDeviceId.get(device.id) || [];
outboundTrafficHistoryByDeviceId.set(device.id, [...samples, {
observedAt,
vpnBytes: routeDelta(total.vpn, previous.vpn).toString(),
directTrackedBytes: routeDelta(total.directTracked, previous.directTracked).toString(),
directIpv4Bytes: directDelta.toString(),
unknownBytes: routeDelta(total.unknown, previous.unknown).toString(),
}].slice(-TRAFFIC_HISTORY_LIMIT));
}
for (const deviceId of outboundTrafficCursorByDeviceId.keys()) {
if (!knownIds.has(deviceId)) {
outboundTrafficCursorByDeviceId.delete(deviceId);
outboundTrafficHistoryByDeviceId.delete(deviceId);
}
}
}
function serializePolicy<T>(action: () => Promise<T> | T): Promise<T> {
const result = policyQueue.then(() => action(), () => action());
policyQueue = result.catch(() => {});
@@ -826,6 +919,7 @@ export function createDeviceInventoryService({
proxyDownloadBytes: proxyTraffic?.downloadBytes || '0',
proxyTrafficObservedAt: proxyTraffic?.observedAt || null,
trafficHistory: trafficHistoryByMac.get(device.mac) || [],
outboundTrafficHistory: outboundTrafficHistoryByDeviceId.get(device.id) || [],
desiredPolicy: policy.desired,
appliedPolicy: policy.applied,
policyStatus: policy.status,
@@ -1375,6 +1469,7 @@ export function createDeviceInventoryService({
};
});
captureTrafficHistory(nextState);
captureOutboundTrafficHistory(nextState);
if (typeof policyResult?.transportError === 'string') {
commitPolicyFailure(new Error(policyResult.transportError));
}