From 6b17f1982b972763360d1a39ade174fe587bed3a Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sat, 8 Aug 2026 03:15:45 +0300 Subject: [PATCH] Improve device traffic dashboard and domain attribution metrics --- README.md | 6 +- monitoring/grafana/harbor-gateway.json | 126 ++++++++++++++---- src/server/prometheusMetrics.js | 15 +++ src/server/services/deviceInventoryService.js | 5 + src/server/services/domainTrafficService.js | 60 ++++++--- src/server/singbox.js | 18 ++- src/shared/versions.js | 6 +- src/web/instructions.js | 2 +- test/server/domain-traffic.test.js | 47 +++++++ test/server/prometheus-metrics.test.js | 9 ++ test/server/singbox-client-mode.test.js | 12 ++ test/server/singbox-gateway-mode.test.js | 7 +- test/server/state-contract.test.js | 8 +- test/web/prometheus-instructions.test.js | 37 +++-- 14 files changed, 289 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index f052124..d36b3ad 100644 --- a/README.md +++ b/README.md @@ -272,11 +272,11 @@ scrape_configs: - targets: [":3456"] ``` -`harbor_traffic_bytes_total` содержит общий накопленный объём по источникам Gateway/Proxy. `harbor_device_traffic_bytes_total` содержит upload/download по стабильному `device_id`; пользовательское название и текущий IP находятся в `harbor_device_info`. `harbor_device_domain_traffic_bytes_total` добавляет наблюдённые домен, сервис, источник и направление для каждого устройства. +`harbor_traffic_bytes_total` содержит общий накопленный объём по источникам Gateway/Proxy. `harbor_device_traffic_bytes_total` содержит upload/download по стабильному `device_id`; пользовательское название и текущий IP находятся в `harbor_device_info`. `harbor_device_domain_traffic_bytes_total` добавляет наблюдённые домен, сервис, источник и направление для каждого устройства. `harbor_domain_traffic_attribution_events_total{outcome}` помогает отличить нераспознанный hostname, неизвестное устройство и неподдерживаемый inbound без динамических high-cardinality labels. -Dashboard отделяет текущую скорость от значений за выбранный период и накопленных счётчиков. Top-10 устройств за период отсортирован по убыванию и служит переходом к детализации. Один общий фильтр по стабильному `device_id` управляет графиками устройства, сервисами и доменами; в списке он подписан как `name · ip`. Нулевые ranking-строки скрыты, а domain table показывает только сервис, домен и трафик. Автообновление настроено на 30 секунд; freshness предупреждает после 60 секунд и считает данные устаревшими после 120 секунд. +Dashboard отделяет текущую скорость от значений за выбранный период и накопленных счётчиков. Единый фильтр `Устройства` по умолчанию охватывает все устройства, но позволяет выбрать одно; список показывает `name · ip`, сохраняя стабильный `device_id` как значение. Он управляет графиками скорости, накопленным трафиком, сервисами и доменами. Отдельный график скорости по устройствам показывает одну суммарную линию на каждое активное устройство; нулевые устройства и source/direction series скрыты. Top-10 устройств за период отсортирован по убыванию и выбирает устройство в том же фильтре. Domain table показывает только сервис, домен и трафик. Автообновление настроено на 30 секунд; freshness предупреждает после 60 секунд и считает данные устаревшими после 120 секунд. -Domain counters снимаются с активных соединений sing-box раз в 2 секунды и хранятся в памяти dataplane до его перезапуска; историю и retention хранит Prometheus. YouTube и OpenAI / ChatGPT объединяются по известным связанным доменам в label `service`, остальные значения сохраняют домен как имя сервиса. Новые domain series сверх process limit складываются в `_other`. В метрики не входит физический трафик вне Harbor, устройства с policy Direct, ECH/IP-only соединения, соединения между двумя снимками и байты после последнего снимка перед закрытием или quota провайдера. +Domain counters снимаются с активных соединений sing-box раз в 2 секунды и хранятся в памяти dataplane до его перезапуска; историю и retention хранит Prometheus. Перед routing sing-box до 1 секунды распознаёт HTTP Host, TLS SNI и QUIC Server Name. YouTube и OpenAI / ChatGPT объединяются по известным связанным доменам в label `service`, остальные значения сохраняют домен как имя сервиса. Если устройство и Harbor source известны, но hostname недоступен (например, ECH или IP-only), трафик попадает в `domain="_unknown",service="Не распознано"` и не теряется. Новые domain series сверх process limit складываются в `_other`. В метрики не входит физический трафик вне Harbor, устройства с policy Direct, соединения между двумя снимками и байты после последнего снимка перед закрытием или quota провайдера. Готовый dashboard: [`monitoring/grafana/harbor-gateway.json`](monitoring/grafana/harbor-gateway.json). При импорте Grafana попросит выбрать Prometheus data source. Та же конфигурация и dashboard доступны для копирования в Gateway drawer «Как использовать» → «Prometheus и Grafana». diff --git a/monitoring/grafana/harbor-gateway.json b/monitoring/grafana/harbor-gateway.json index c2136f9..ffd5182 100644 --- a/monitoring/grafana/harbor-gateway.json +++ b/monitoring/grafana/harbor-gateway.json @@ -195,14 +195,14 @@ "targets": [ { "editorMode": "code", - "expr": "sum(rate(harbor_device_traffic_bytes_total{direction=\"download\"}[5m]))", + "expr": "sum(rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"download\"}[5m])) > 0", "legendFormat": "Скачивание", "range": true, "refId": "A" }, { "editorMode": "code", - "expr": "sum(rate(harbor_device_traffic_bytes_total{direction=\"upload\"}[5m]))", + "expr": "sum(rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"upload\"}[5m])) > 0", "legendFormat": "Отправка", "range": true, "refId": "B" @@ -337,7 +337,7 @@ }, "id": 11, "panels": [], - "title": "Устройство", + "title": "Устройства", "type": "row" }, { @@ -345,7 +345,72 @@ "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "description": "Скорость одного выбранного устройства по Gateway/Proxy и upload/download, средняя за последние 5 минут.", + "description": "Суммарная скорость Gateway/Proxy и upload/download для каждого активного устройства из фильтра, средняя за последние 5 минут. Нулевые устройства скрыты.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 6, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 4, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + } + }, + "decimals": 1, + "noValue": "Нет активного трафика", + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 17 + }, + "id": 14, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "editorMode": "code", + "expr": "(sum by (device_id) (rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\"}[5m])) > 0) * on (device_id) group_left (name, ip) max by (device_id, name, ip) (harbor_device_info)", + "legendFormat": "{{name}} · {{ip}}", + "range": true, + "refId": "A" + } + ], + "title": "Скорость по устройствам сейчас", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "Скорость выбранного scope по Gateway/Proxy и upload/download, средняя за последние 5 минут. При выборе «Все устройства» остаётся максимум четыре суммарные series; нулевые series скрыты.", "fieldConfig": { "defaults": { "color": { @@ -374,7 +439,7 @@ "h": 9, "w": 24, "x": 0, - "y": 17 + "y": 26 }, "id": 6, "options": { @@ -396,13 +461,13 @@ "targets": [ { "editorMode": "code", - "expr": "sum by (source, direction) (rate(harbor_device_traffic_bytes_total{device_id=\"$device_id\"}[5m]))", + "expr": "sum by (source, direction) (rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\"}[5m])) > 0", "legendFormat": "{{source}} · {{direction}}", "range": true, "refId": "A" } ], - "title": "${device_id:text}: скорость сейчас", + "title": "Скорость по источникам и направлениям сейчас", "type": "timeseries" }, { @@ -410,7 +475,7 @@ "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "description": "Накопленные счётчики выбранного устройства за всё время хранения Harbor. Нулевые серии скрыты.", + "description": "Накопленные счётчики выбранного scope за всё время хранения Harbor. При «Все устройства» счётчики суммируются; нулевые series скрыты.", "fieldConfig": { "defaults": { "color": { @@ -426,7 +491,7 @@ "h": 7, "w": 24, "x": 0, - "y": 26 + "y": 35 }, "id": 7, "options": { @@ -450,14 +515,14 @@ "targets": [ { "editorMode": "code", - "expr": "sum by (source, direction) (harbor_device_traffic_bytes_total{device_id=\"$device_id\"}) > 0", + "expr": "sum by (source, direction) (harbor_device_traffic_bytes_total{device_id=~\"$device_id\"}) > 0", "instant": true, "legendFormat": "{{source}} · {{direction}}", "range": false, "refId": "A" } ], - "title": "${device_id:text}: накоплено", + "title": "Накоплено по источникам и направлениям", "type": "bargauge" }, { @@ -466,7 +531,7 @@ "h": 1, "w": 24, "x": 0, - "y": 33 + "y": 42 }, "id": 12, "panels": [], @@ -478,7 +543,7 @@ "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "description": "Сервисы выбранного устройства по объёму domain traffic в текущем диапазоне времени. Нулевые серии скрыты.", + "description": "Сервисы всех устройств или одного устройства из единого фильтра «Устройства» по объёму traffic в текущем диапазоне времени. Нулевые серии скрыты.", "fieldConfig": { "defaults": { "color": { @@ -494,7 +559,7 @@ "h": 10, "w": 10, "x": 0, - "y": 34 + "y": 43 }, "id": 8, "options": { @@ -518,14 +583,14 @@ "targets": [ { "editorMode": "code", - "expr": "topk(10, sum by (service) (increase(harbor_device_domain_traffic_bytes_total{device_id=\"$device_id\"}[$__range])) > 0)", + "expr": "topk(10, sum by (service) (increase(harbor_device_domain_traffic_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0)", "instant": true, "legendFormat": "{{service}}", "range": false, "refId": "A" } ], - "title": "${device_id:text}: сервисы за период", + "title": "Сервисы за выбранный период", "type": "bargauge" }, { @@ -533,7 +598,7 @@ "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "description": "Домены выбранного устройства в текущем диапазоне времени. Устройство и IP не повторяются в строках.", + "description": "Домены всех устройств или одного устройства из единого фильтра «Устройства» в текущем диапазоне времени. При общем scope одинаковые домены суммируются; нулевые строки скрыты.", "fieldConfig": { "defaults": { "color": { @@ -556,7 +621,7 @@ "h": 10, "w": 14, "x": 10, - "y": 34 + "y": 43 }, "id": 9, "options": { @@ -581,7 +646,7 @@ "targets": [ { "editorMode": "code", - "expr": "topk(15, sum by (service, domain) (increase(harbor_device_domain_traffic_bytes_total{device_id=\"$device_id\"}[$__range])) > 0)", + "expr": "topk(15, sum by (service, domain) (increase(harbor_device_domain_traffic_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0)", "format": "table", "instant": true, "legendFormat": "{{service}} · {{domain}}", @@ -589,7 +654,7 @@ "refId": "A" } ], - "title": "${device_id:text}: домены за период", + "title": "Домены за выбранный период", "transformations": [ { "id": "organize", @@ -618,7 +683,7 @@ "h": 1, "w": 24, "x": 0, - "y": 44 + "y": 53 }, "id": 13, "panels": [], @@ -646,7 +711,7 @@ "h": 6, "w": 8, "x": 0, - "y": 45 + "y": 54 }, "id": 1, "options": { @@ -729,7 +794,7 @@ "h": 6, "w": 16, "x": 8, - "y": 45 + "y": 54 }, "id": 4, "options": { @@ -774,16 +839,21 @@ "templating": { "list": [ { - "current": {}, + "allValue": ".*", + "current": { + "selected": true, + "text": "Все устройства", + "value": "$__all" + }, "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "definition": "query_result(label_join(max by (device_id, name, ip) (harbor_device_info), \"display\", \" · \", \"name\", \"ip\"))", - "description": "Одно устройство для графиков, сервисов и доменов ниже. Значение фильтра — стабильный device_id.", + "description": "Единый scope для скорости, накопленного трафика, сервисов и доменов: все устройства или одно устройство по стабильному device_id.", "hide": 0, - "includeAll": false, - "label": "Устройство", + "includeAll": true, + "label": "Устройства", "multi": false, "name": "device_id", "options": [], @@ -816,6 +886,6 @@ "timezone": "browser", "title": "Harbor Gateway: трафик", "uid": "harbor-gateway-traffic", - "version": 3, + "version": 5, "weekStart": "monday" } diff --git a/src/server/prometheusMetrics.js b/src/server/prometheusMetrics.js index 94a3193..852e3d8 100644 --- a/src/server/prometheusMetrics.js +++ b/src/server/prometheusMetrics.js @@ -136,6 +136,21 @@ export function renderPrometheusMetrics(snapshot) { ); lines.push(`harbor_domain_traffic_overflow_connections_total ${counter(domainTraffic.overflowConnections)}`); } + const attributionEvents = domainTraffic?.attributionEvents; + if (attributionEvents) { + lines.push( + '# HELP harbor_domain_traffic_attribution_events_total Connections with incomplete Harbor domain attribution.', + '# TYPE harbor_domain_traffic_attribution_events_total counter', + ); + for (const outcome of ['unresolved_host', 'unknown_device', 'unsupported_source']) { + metric( + lines, + 'harbor_domain_traffic_attribution_events_total', + { outcome }, + counter(attributionEvents[outcome]), + ); + } + } return `${lines.join('\n')}\n`; } diff --git a/src/server/services/deviceInventoryService.js b/src/server/services/deviceInventoryService.js index 0f05114..fed55b0 100644 --- a/src/server/services/deviceInventoryService.js +++ b/src/server/services/deviceInventoryService.js @@ -431,6 +431,11 @@ export function createDeviceInventoryService({ observedAt: null, source: { error: null }, overflowConnections: '0', + attributionEvents: { + unresolved_host: '0', + unknown_device: '0', + unsupported_source: '0', + }, series: [], }; diff --git a/src/server/services/domainTrafficService.js b/src/server/services/domainTrafficService.js index d7a5c8c..e0361c2 100644 --- a/src/server/services/domainTrafficService.js +++ b/src/server/services/domainTrafficService.js @@ -6,6 +6,8 @@ import { deviceId } from './deviceInventoryService.js'; const MAX_RESPONSE_BYTES = 4 * 1024 * 1024; const DEFAULT_MAX_SERIES = 4096; +const UNKNOWN_DOMAIN = { domain: '_unknown', service: 'Не распознано' }; +const ATTRIBUTION_OUTCOMES = ['unresolved_host', 'unknown_device', 'unsupported_source']; const SERVICE_DOMAINS = [ ['YouTube', ['youtube.com', 'youtube-nocookie.com', 'youtu.be', 'googlevideo.com', 'ytimg.com']], ['OpenAI / ChatGPT', ['chatgpt.com', 'openai.com', 'oaistatic.com', 'oaiusercontent.com']], @@ -35,23 +37,29 @@ function sourceFor(type) { function parseConnection(connection, devicesByIp) { const id = String(connection?.id || ''); const metadata = connection?.metadata; - const source = sourceFor(String(metadata?.type || '')); - const domain = classifyDomain(metadata?.host); - const currentDeviceId = devicesByIp.get(String(metadata?.sourceIP || '')); const upload = connection?.upload; const download = connection?.download; if (!id || !Number.isSafeInteger(upload) || upload < 0 || !Number.isSafeInteger(download) || download < 0) { throw new Error('Sing-box вернул невалидный domain traffic counter'); } - if (!source || !domain || !currentDeviceId) return null; - return { + const parsed = { id, + upload: BigInt(upload), + download: BigInt(download), + }; + const source = sourceFor(String(metadata?.type || '')); + if (!source) return { ...parsed, outcome: 'unsupported_source' }; + const currentDeviceId = devicesByIp.get(String(metadata?.sourceIP || '')); + if (!currentDeviceId) return { ...parsed, outcome: 'unknown_device' }; + const classifiedDomain = classifyDomain(metadata?.host); + const domain = classifiedDomain || UNKNOWN_DOMAIN; + return { + ...parsed, + outcome: classifiedDomain ? 'classified' : 'unresolved_host', deviceId: currentDeviceId, ...domain, source, - upload: BigInt(upload), - download: BigInt(download), }; } @@ -102,12 +110,14 @@ export function createDomainTrafficService({ let normalSeries = 0; let previousConnections = new Map(); let overflowConnections = 0n; + const attributionEvents = Object.fromEntries(ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, 0n])); let refreshPromise = null; let current = { epoch, observedAt: null, source: { error: null }, overflowConnections: '0', + attributionEvents: Object.fromEntries(ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, '0'])), series: [], }; @@ -117,6 +127,9 @@ export function createDomainTrafficService({ observedAt: current.observedAt, source: { error }, overflowConnections: overflowConnections.toString(), + attributionEvents: Object.fromEntries( + ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]), + ), series: [...totals.values()] .map((entry) => ({ ...entry, @@ -144,13 +157,22 @@ export function createDomainTrafficService({ if (!net.isIPv4(ip) || !id) continue; devicesByIp.set(ip, devicesByIp.has(ip) ? null : id); } - const parsed = response.connections - .map((connection) => parseConnection(connection, devicesByIp)) - .filter(Boolean); const activeConnections = new Map(); - for (const connection of parsed) { - const requestedKey = `${connection.deviceId}\0${connection.domain}\0${connection.source}`; + for (const rawConnection of response.connections) { + const connection = parseConnection(rawConnection, devicesByIp); const previous = previousConnections.get(connection.id); + if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) { + attributionEvents[connection.outcome] += 1n; + } + if (connection.outcome === 'unknown_device' || connection.outcome === 'unsupported_source') { + activeConnections.set(connection.id, { + outcome: connection.outcome, + countedUpload: previous?.countedUpload ?? null, + countedDownload: previous?.countedDownload ?? null, + }); + continue; + } + const requestedKey = `${connection.deviceId}\0${connection.domain}\0${connection.source}`; let key = previous?.requestedKey === requestedKey ? previous.key : requestedKey; let domain = connection.domain; let service = connection.service; @@ -165,12 +187,11 @@ export function createDomainTrafficService({ } else if (!totals.has(key)) { normalSeries += 1; } - const sameSeries = previous?.key === key; - const uploadDelta = sameSeries && connection.upload >= previous.upload - ? connection.upload - previous.upload + const uploadDelta = previous?.countedUpload != null && connection.upload >= previous.countedUpload + ? connection.upload - previous.countedUpload : connection.upload; - const downloadDelta = sameSeries && connection.download >= previous.download - ? connection.download - previous.download + const downloadDelta = previous?.countedDownload != null && connection.download >= previous.countedDownload + ? connection.download - previous.countedDownload : connection.download; const total = totals.get(key) || { deviceId: key === requestedKey ? connection.deviceId : '_other', @@ -184,10 +205,11 @@ export function createDomainTrafficService({ total.downloadBytes += downloadDelta; totals.set(key, total); activeConnections.set(connection.id, { + outcome: connection.outcome, key, requestedKey, - upload: connection.upload, - download: connection.download, + countedUpload: connection.upload, + countedDownload: connection.download, }); } previousConnections = activeConnections; diff --git a/src/server/singbox.js b/src/server/singbox.js index 4360eba..38ad0ef 100644 --- a/src/server/singbox.js +++ b/src/server/singbox.js @@ -8,6 +8,8 @@ const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteri const MIXED_INBOUND = 'mixed-in'; const TPROXY_INBOUND = 'tproxy-in'; const DIAGNOSTICS_INBOUND = 'diagnostics-vpn-in'; +const SNIFF_TIMEOUT = '1s'; +const SNIFFERS = ['http', 'tls', 'quic']; function findOutbound(subscriptionConfig, selectedTag) { const outbounds = Array.isArray(subscriptionConfig?.outbounds) @@ -39,15 +41,12 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, { tag: TPROXY_INBOUND, listen: '::', listen_port: settings.tproxyPort, - sniff: true, - sniff_override_destination: true, }] : []), { type: 'mixed', tag: MIXED_INBOUND, listen: settings.bindIp, listen_port: settings.proxyPort, - sniff: true, set_system_proxy: false, }, { @@ -55,7 +54,6 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, { tag: DIAGNOSTICS_INBOUND, listen: '127.0.0.1', listen_port: settings.diagnosticsProxyPort, - sniff: true, set_system_proxy: false, }, ]; @@ -64,11 +62,23 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, { .map((rule) => ({ [rule.type]: [rule.value], outbound: 'direct' })); const rules = clientMode ? [ + { + inbound: [MIXED_INBOUND, DIAGNOSTICS_INBOUND], + action: 'sniff', + sniffer: SNIFFERS, + timeout: SNIFF_TIMEOUT, + }, { inbound: [DIAGNOSTICS_INBOUND], outbound: vpnOutbound.tag }, ...directRules, { inbound: [MIXED_INBOUND], outbound: outboundTag }, ] : [ + { + inbound: [TPROXY_INBOUND, MIXED_INBOUND, DIAGNOSTICS_INBOUND], + action: 'sniff', + sniffer: SNIFFERS, + timeout: SNIFF_TIMEOUT, + }, { inbound: [DIAGNOSTICS_INBOUND], outbound: outboundTag }, ...directRules, { inbound: [TPROXY_INBOUND], outbound: outboundTag }, diff --git a/src/shared/versions.js b/src/shared/versions.js index 1aee71f..8e6bedb 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.20.1', - gatewayClient: '0.21.1', - gatewayBackend: '0.21.0', + macClient: '0.20.4', + gatewayClient: '0.21.3', + gatewayBackend: '0.21.1', }); export function parseVersion(value) { diff --git a/src/web/instructions.js b/src/web/instructions.js index 2f2ae6c..418be86 100644 --- a/src/web/instructions.js +++ b/src/web/instructions.js @@ -92,7 +92,7 @@ export function instructionBlocks({ isGateway, host, port, controlHost }) { paragraphs: [ `Prometheus забирает накопленные Harbor counters с http://${controlHost}/metrics. Ручка читает готовый snapshot и не запускает новый сбор трафика.`, 'Harbor обновляет traffic snapshot раз в 15 секунд, поэтому начальный scrape interval и refresh dashboard в 30 секунд не создают лишних одинаковых выборок.', - 'Dashboard показывает свежесть и текущую скорость, Top-10 устройств за период и детализацию одного выбранного устройства по стабильному ID. Сервисы и домены используют тот же фильтр; накопленные счётчики вынесены в техническую секцию.', + 'Единый фильтр «Устройства» управляет скоростью, накопленным трафиком, сервисами и доменами для всех или одного устройства. Отдельный график показывает текущую скорость каждого активного устройства; нулевые series скрыты.', ], steps: [ 'Добавьте блок ниже в prometheus.yml и перезагрузите Prometheus.', diff --git a/test/server/domain-traffic.test.js b/test/server/domain-traffic.test.js index 985c638..b001779 100644 --- a/test/server/domain-traffic.test.js +++ b/test/server/domain-traffic.test.js @@ -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({ diff --git a/test/server/prometheus-metrics.test.js b/test/server/prometheus-metrics.test.js index 79d35f6..72ceef2 100644 --- a/test/server/prometheus-metrics.test.js +++ b/test/server/prometheus-metrics.test.js @@ -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); }); diff --git a/test/server/singbox-client-mode.test.js b/test/server/singbox-client-mode.test.js index 3b0bce0..791d81e 100644 --- a/test/server/singbox-client-mode.test.js +++ b/test/server/singbox-client-mode.test.js @@ -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' }, diff --git a/test/server/singbox-gateway-mode.test.js b/test/server/singbox-gateway-mode.test.js index 66381ef..00b4333 100644 --- a/test/server/singbox-gateway-mode.test.js +++ b/test/server/singbox-gateway-mode.test.js @@ -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' }, diff --git a/test/server/state-contract.test.js b/test/server/state-contract.test.js index 06be5be..3a8e1c6 100644 --- a/test/server/state-contract.test.js +++ b/test/server/state-contract.test.js @@ -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' }, diff --git a/test/web/prometheus-instructions.test.js b/test/web/prometheus-instructions.test.js index cc02eae..b34ece2 100644 --- a/test/web/prometheus-instructions.test.js +++ b/test/web/prometheus-instructions.test.js @@ -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, //); assert.match(deviceVariable.regex, //); + 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'))); });