Improve device traffic dashboard and domain attribution metrics
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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')));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user