Improve device traffic dashboard and domain attribution metrics
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 14s

This commit is contained in:
2026-08-08 03:15:45 +03:00
parent 6e701d4fc6
commit 6b17f1982b
14 changed files with 289 additions and 69 deletions
+47
View File
@@ -21,6 +21,7 @@ test('domain traffic accumulates connection deltas by device, service and source
let response = { connections: [
connection('youtube', 'tproxy/tproxy-in', 'r1.googlevideo.com', 10, 100),
connection('chatgpt', 'mixed/mixed-in', 'www.chatgpt.com.', 20, 200),
connection('hostless', 'tproxy/tproxy-in', '', 5, 50),
connection('diagnostics', 'mixed/diagnostics-vpn-in', 'example.com', 30, 300),
connection('unknown-device', 'tproxy/tproxy-in', 'example.net', 40, 400, '192.168.50.99'),
] };
@@ -34,6 +35,7 @@ test('domain traffic accumulates connection deltas by device, service and source
response = { connections: [
connection('youtube', 'tproxy/tproxy-in', 'r1.googlevideo.com', 15, 130),
connection('chatgpt', 'mixed/mixed-in', 'www.chatgpt.com.', 22, 260),
connection('hostless', 'tproxy/tproxy-in', '', 7, 70),
] };
observedAt = new Date('2026-08-08T10:00:02.000Z');
await service.refresh();
@@ -56,7 +58,20 @@ test('domain traffic accumulates connection deltas by device, service and source
uploadBytes: '15',
downloadBytes: '130',
},
{
deviceId: id,
domain: '_unknown',
service: 'Не распознано',
source: 'gateway',
uploadBytes: '7',
downloadBytes: '70',
},
]);
assert.deepEqual(service.snapshot().attributionEvents, {
unresolved_host: '1',
unknown_device: '1',
unsupported_source: '1',
});
assert.equal(service.snapshot().observedAt, observedAt.toISOString());
assert.equal(service.snapshot().source.error, null);
@@ -65,6 +80,38 @@ test('domain traffic accumulates connection deltas by device, service and source
assert.equal(service.snapshot().series[1].downloadBytes, '130');
});
test('a hostless connection can become classified without counting its bytes twice', async () => {
let response = { connections: [
connection('video', 'tproxy/tproxy-in', '', 10, 100),
] };
const service = createDomainTrafficService({
observe: async () => response,
devices: () => [device],
});
await service.refresh();
response = { connections: [
connection('video', 'tproxy/tproxy-in', 'r2.googlevideo.com', 15, 130),
] };
await service.refresh();
assert.deepEqual(
Object.fromEntries(service.snapshot().series.map((series) => [series.domain, {
uploadBytes: series.uploadBytes,
downloadBytes: series.downloadBytes,
}])),
{
_unknown: { uploadBytes: '10', downloadBytes: '100' },
'googlevideo.com': { uploadBytes: '5', downloadBytes: '30' },
},
);
assert.deepEqual(service.snapshot().attributionEvents, {
unresolved_host: '1',
unknown_device: '0',
unsupported_source: '0',
});
});
test('domain traffic is bounded and keeps the last good snapshot on source failure', async () => {
let fail = false;
const service = createDomainTrafficService({
+9
View File
@@ -29,6 +29,11 @@ const snapshot = {
domainTraffic: {
observedAt,
overflowConnections: '2',
attributionEvents: {
unresolved_host: '3',
unknown_device: '4',
unsupported_source: '5',
},
series: [{
deviceId: 'dev_0011223344556677',
domain: 'chatgpt.com',
@@ -54,6 +59,10 @@ test('Prometheus exposition keeps exact counters, stable identity and escaped na
assert.match(output, /harbor_device_domain_traffic_bytes_total\{device_id="dev_0011223344556677",domain="chatgpt\.com",service="OpenAI \/ ChatGPT",source="proxy",direction="download"\} 345/);
assert.match(output, /harbor_domain_traffic_last_observed_timestamp_seconds 1786183200/);
assert.match(output, /harbor_domain_traffic_overflow_connections_total 2/);
assert.match(output, /# TYPE harbor_domain_traffic_attribution_events_total counter/);
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unresolved_host"\} 3/);
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unknown_device"\} 4/);
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unsupported_source"\} 5/);
assert.doesNotMatch(output, /harbor_device_domain_traffic_bytes_total\{[^\n]*name=/);
assert.equal(output.endsWith('\n'), true);
});
+12
View File
@@ -37,6 +37,12 @@ test('client exposes one local proxy and routes local exceptions before the sele
assert.equal(config.inbounds[0].listen_port, 8082);
assert.equal(config.inbounds[1].listen_port, 18080);
assert.deepEqual(config.route.rules, [
{
inbound: ['mixed-in', 'diagnostics-vpn-in'],
action: 'sniff',
sniffer: ['http', 'tls', 'quic'],
timeout: '1s',
},
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
{ domain_suffix: ['ru'], outbound: 'direct' },
{ domain: ['example.com'], outbound: 'direct' },
@@ -53,6 +59,12 @@ test('client keeps its local proxy but routes directly when Harbor Gateway is ah
});
assert.deepEqual(config.route.rules, [
{
inbound: ['mixed-in', 'diagnostics-vpn-in'],
action: 'sniff',
sniffer: ['http', 'tls', 'quic'],
timeout: '1s',
},
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
{ domain_suffix: ['ru'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: 'direct' },
+6 -1
View File
@@ -37,10 +37,15 @@ test('gateway routes .ru domains directly and other traffic through the selected
tag: 'diagnostics-vpn-in',
listen: '127.0.0.1',
listen_port: 18080,
sniff: true,
set_system_proxy: false,
});
assert.deepEqual(config.route.rules, [
{
inbound: ['tproxy-in', 'mixed-in', 'diagnostics-vpn-in'],
action: 'sniff',
sniffer: ['http', 'tls', 'quic'],
timeout: '1s',
},
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
{ domain_suffix: ['ru'], outbound: 'direct' },
{ inbound: ['tproxy-in'], outbound: 'test-vpn' },
+7 -1
View File
@@ -432,7 +432,13 @@ setInterval(() => {}, 60_000);
]);
assert.equal(routed.state.route.localRulesPendingRestart, false);
assert.deepEqual(routed.state.route.activeLocalRules, routed.state.route.localRules);
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 3), [
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 4), [
{
inbound: ['mixed-in', 'diagnostics-vpn-in'],
action: 'sniff',
sniffer: ['http', 'tls', 'quic'],
timeout: '1s',
},
{ inbound: ['diagnostics-vpn-in'], outbound: testServerId },
{ domain: ['example.com'], outbound: 'direct' },
{ domain_suffix: ['example.org'], outbound: 'direct' },
+28 -9
View File
@@ -36,42 +36,61 @@ test('metrics route reads the current snapshot before static fallback', () => {
assert.doesNotMatch(server.slice(metricsRoute, staticFallback), /deviceInventory\.refresh\(/);
});
test('Grafana dashboard is organized around period ranking and one stable device selection', () => {
test('Grafana dashboard uses one all-or-one device scope and shows active device speed', () => {
const expressions = dashboard.panels.flatMap((panel) => panel.targets || []).map(({ expr }) => expr).filter(Boolean);
const titles = dashboard.panels.map(({ title }) => title);
const deviceVariable = dashboard.templating.list[0];
const freshness = dashboard.panels.find(({ id }) => id === 2);
const globalSpeed = dashboard.panels.find(({ id }) => id === 3);
const topDevices = dashboard.panels.find(({ id }) => id === 5);
const sourceSpeed = dashboard.panels.find(({ id }) => id === 6);
const deviceSpeed = dashboard.panels.find(({ id }) => id === 14);
const services = dashboard.panels.find(({ id }) => id === 8);
const domains = dashboard.panels.find(({ id }) => id === 9);
assert.equal(dashboard.title, 'Harbor Gateway: трафик');
assert.equal(dashboard.refresh, '30s');
assert.deepEqual(titles.filter((title) => ['Обзор', 'Устройство', 'Сервисы и домены', 'Техническая детализация'].includes(title)), [
assert.deepEqual(titles.filter((title) => ['Обзор', 'Устройства', 'Сервисы и домены', 'Техническая детализация'].includes(title)), [
'Обзор',
'Устройство',
'Устройства',
'Сервисы и домены',
'Техническая детализация',
]);
assert.ok(titles.includes('Скорость сейчас, средняя за 5 минут'));
assert.ok(titles.includes('Скорость по устройствам сейчас'));
assert.ok(titles.includes('Скорость по источникам и направлениям сейчас'));
assert.ok(titles.includes('Топ-10 устройств за выбранный период'));
assert.ok(titles.includes('${device_id:text}: накоплено'));
assert.ok(titles.includes('${device_id:text}: сервисы за период'));
assert.ok(titles.includes('${device_id:text}: домены за период'));
assert.ok(titles.includes('Накоплено по источникам и направлениям'));
assert.ok(titles.includes('Сервисы за выбранный период'));
assert.ok(titles.includes('Домены за выбранный период'));
assert.ok(expressions.some((expression) => expression.includes('harbor_traffic_bytes_total')));
assert.ok(expressions.some((expression) => expression.includes('harbor_device_traffic_bytes_total')));
assert.ok(expressions.some((expression) => expression.includes('harbor_device_domain_traffic_bytes_total{device_id="$device_id"}[$__range]')));
assert.ok(expressions.some((expression) => expression.includes('harbor_device_domain_traffic_bytes_total{device_id=~"$device_id"}[$__range]')));
assert.ok(expressions.some((expression) => expression.includes('harbor_domain_traffic_last_observed_timestamp_seconds')));
assert.match(topDevices.targets[0].expr, /^topk\(10,[\s\S]*increase\(harbor_device_traffic_bytes_total\[\$__range\]\)[\s\S]*> 0[\s\S]*group_left \(name, ip\)/);
assert.equal(topDevices.options.sortBy[0].desc, true);
assert.match(topDevices.fieldConfig.overrides[1].properties[0].value[0].url, /var-device_id=\$\{__data\.fields/);
assert.doesNotMatch(topDevices.fieldConfig.overrides[1].properties[0].value[0].url, /domain_device_id/);
assert.deepEqual(freshness.fieldConfig.defaults.thresholds.steps.map(({ value }) => value), [null, 60, 120]);
assert.equal(freshness.fieldConfig.defaults.noValue, 'Нет данных');
assert.equal(deviceVariable.name, 'device_id');
assert.equal(deviceVariable.multi, false);
assert.equal(deviceVariable.includeAll, false);
assert.equal(deviceVariable.includeAll, true);
assert.equal(deviceVariable.allValue, '.*');
assert.equal(deviceVariable.current.value, '$__all');
assert.equal(dashboard.templating.list.length, 1);
assert.match(deviceVariable.query.query, /query_result\(label_join\(/);
assert.match(deviceVariable.regex, /<text>/);
assert.match(deviceVariable.regex, /<value>/);
assert.ok(globalSpeed.targets.every(({ expr }) => expr.includes('device_id=~"$device_id"') && expr.endsWith('> 0')));
assert.match(deviceSpeed.targets[0].expr, /sum by \(device_id\)[\s\S]*device_id=~"\$device_id"[\s\S]*> 0[\s\S]*group_left \(name, ip\)/);
assert.match(deviceSpeed.targets[0].legendFormat, /\{\{name\}\} · \{\{ip\}\}/);
assert.match(sourceSpeed.targets[0].expr, /device_id=~"\$device_id"[\s\S]*> 0$/);
assert.match(services.targets[0].expr, /device_id=~"\$device_id"[\s\S]*> 0\)$/);
assert.deepEqual(domains.transformations[0].options.indexByName, { service: 0, domain: 1, Value: 2 });
assert.match(domains.targets[0].expr, /^topk\(15,[\s\S]*> 0\)$/);
assert.match(domains.targets[0].expr, /^topk\(15,[\s\S]*device_id=~"\$device_id"[\s\S]*> 0\)$/);
assert.ok(dashboard.panels
.filter(({ type }) => ['bargauge', 'table'].includes(type))
.flatMap(({ targets }) => targets || [])
.every(({ expr }) => expr.includes('> 0')));
});