diff --git a/README.md b/README.md index f9767df..55c0a91 100644 --- a/README.md +++ b/README.md @@ -302,17 +302,17 @@ 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_domain_traffic_attribution_events_total{outcome}` помогает отличить нераспознанный hostname, неизвестное устройство и неподдерживаемый inbound без динамических high-cardinality labels. +`harbor_traffic_bytes_total` содержит общий накопленный объём по источникам Gateway/Proxy. `harbor_device_traffic_bytes_total` содержит upload/download по стабильному `device_id`; пользовательское название и текущий IP находятся в `harbor_device_info`. Gauge `harbor_device_applied_policy{device_id}` показывает последнюю применённую policy: `0` для Direct и `1` для VPN. История начинается с первого Prometheus scrape после обновления и не восстанавливается задним числом. `harbor_device_domain_traffic_bytes_total` добавляет наблюдённые домен, сервис, источник и направление для каждого устройства. `harbor_domain_traffic_attribution_events_total{outcome}` помогает отличить нераспознанный hostname, неизвестное устройство и неподдерживаемый inbound без динамических high-cardinality labels. Фактический выход экспортируется отдельно. `harbor_singbox_tracked_bytes_total{source,outbound,direction}` показывает наблюдённые sing-box байты с `outbound="vpn|direct|unknown"`; вариант с префиксом `harbor_device_...` добавляет `device_id`. `harbor_direct_ipv4_packet_bytes_total{direction}` считает IPv4-пакеты, которые Gateway направил напрямую вместо sing-box, включая policy Direct и работу при остановленном VPN runtime; вариант `harbor_device_...` содержит атрибутированную детализацию. `source="gateway|proxy"` по-прежнему означает место входа, а `outbound` — выбранный sing-box выход. -Dashboard начинает со скорости скачивания и отправки в конце выбранного периода, общего трафика и VPN / Direct внутри sing-box за этот период. Для стандартного диапазона, который заканчивается сейчас, карточки скорости показывают текущее значение. Единый фильтр `Устройства` по умолчанию охватывает все устройства, но позволяет выбрать одно; он управляет скоростью, общим трафиком, маршрутами, сервисами, доменами и технической детализацией. Таблица «Все устройства за период» намеренно остаётся общей: она показывает все устройства с ненулевым трафиком, сортируется в обе стороны и выбирает устройство в том же фильтре. Блок «Куда уходит трафик» показывает основные назначения и Top-15 доменов без пагинации. Свёрнутая техническая детализация отдельно показывает точки входа Gateway / Proxy и Direct IPv4 мимо sing-box. Автообновление настроено на 30 секунд; индикатор показывает возраст самого старого из контуров общего, domain / sing-box и Direct IPv4 трафика, предупреждает после 60 секунд и считает данные устаревшими после 120 секунд. +Dashboard начинает со скорости скачивания и отправки в конце выбранного периода, общего трафика и фактического VPN / Direct за этот период. Под обзором полоса `Применённый режим` показывает applied policy устройства, а график `Фактический VPN / Direct` независимо показывает маршрут наблюдённых байтов. Поэтому компьютер с policy Direct, браузер которого использует Harbor Proxy, остаётся Direct на полосе режима, но его proxy-соединения учитываются в VPN. Единый фильтр `Устройства` управляет режимом, скоростью, общим трафиком, сервисами, доменами и технической детализацией. Таблица «Все устройства за период» намеренно остаётся общей и выбирает устройство в том же фильтре. Блок «Куда уходит трафик» показывает основные назначения и Top-15 доменов без пагинации. Свёрнутая техническая детализация показывает `source × outbound`, включая `proxy · vpn`, и раздельные Direct-пути через sing-box и Linux мимо sing-box. Автообновление настроено на 30 секунд; индикатор предупреждает после 60 секунд и считает данные устаревшими после 120 секунд. В `snapshot` и `shadow` domain и sing-box outbound counters снимаются с активных соединений раз в 2 секунды. В `native` dataplane получает полный lifecycle, включая короткие соединения и финальный хвост; данные всё равно хранятся в памяти только до перезапуска, а историю и 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`. Состояние collector и сравнение `shadow` экспортируются отдельными bounded gauges `harbor_traffic_collector_*` и `harbor_traffic_shadow_*`. Они не содержат UUID, IP, домены или пользовательские имена и не заменяют canonical traffic counters. -Direct IPv4 считает L3 packet bytes с IP-заголовками и retransmit, а sing-box tracker — логические TCP/UDP bytes без tunnel overhead. Эти семейства нельзя складывать в один «точный общий трафик». Snapshot polling может пропустить короткие соединения и финальный хвост; native lifecycle закрывает этот разрыв только для трафика, вошедшего в sing-box. IPv6, трафик вне Gateway, назначения из `BYPASS_CIDRS` и quota провайдера не входят в новый route split. +Direct IPv4 считает L3 packet bytes с IP-заголовками и retransmit, а sing-box tracker считает логические TCP/UDP bytes без tunnel overhead. Основные Grafana panels складывают их только как приблизительную пользовательскую оценку непересекающихся Direct-путей; техническая секция сохраняет значения раздельными. Эту сумму нельзя считать точным provider или wire total. Snapshot polling может пропустить короткие соединения и финальный хвост; native lifecycle закрывает этот разрыв только для трафика, вошедшего в sing-box. IPv6, трафик вне Gateway, назначения из `BYPASS_CIDRS` и quota провайдера не входят в route split. Готовый 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 534a989..61657a1 100644 --- a/monitoring/grafana/harbor-gateway.json +++ b/monitoring/grafana/harbor-gateway.json @@ -196,14 +196,14 @@ "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "description": "Наблюдённые sing-box байты для выбранных устройств по фактически выбранному выходу. Direct IPv4 мимо sing-box учитывается отдельно в технической детализации.", + "description": "Фактический выход выбранных устройств за период. VPN включает трафик, вошедший через Gateway и явно настроенный Proxy. Direct приблизительно объединяет логические байты sing-box и IPv4 L3 packet bytes Linux; точная разбивка остаётся в технической детализации.", "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "decimals": 1, - "noValue": "Нет трафика", + "noValue": "Нет данных", "unit": "bytes" }, "overrides": [ @@ -290,7 +290,7 @@ }, { "editorMode": "code", - "expr": "sum(increase(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"direct\"}[$__range])) > 0", + "expr": "((sum(increase(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"direct\"}[$__range])) or vector(0)) + (sum(increase(harbor_device_direct_ipv4_packet_bytes_total{device_id=~\"$device_id\"}[$__range])) or vector(0))) > 0", "instant": true, "legendFormat": "Direct", "range": false, @@ -382,7 +382,73 @@ "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "description": "Скорость трафика выбранных устройств. Окно усреднения адаптируется к масштабу времени; скачивание показано вверх, отправка — вниз. Это наблюдаемый Harbor трафик, а не скорость интернет-канала.", + "description": "Последняя применённая policy устройства по данным Prometheus: Direct или VPN. Это не маршрут каждого байта: устройство в Direct может одновременно использовать VPN через явно настроенный Harbor Proxy.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "0": { + "color": "orange", + "index": 0, + "text": "Direct" + }, + "1": { + "color": "green", + "index": 1, + "text": "VPN" + } + }, + "type": "value" + } + ], + "noValue": "Нет данных" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 16, + "x": 0, + "y": 5 + }, + "id": 17, + "options": { + "alignValue": "left", + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "mergeValues": true, + "rowHeight": 0.9, + "showValue": "always", + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "editorMode": "code", + "expr": "max by (device_id) (harbor_device_applied_policy{device_id=~\"$device_id\"}) * 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": "state-timeline" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "Фактическая скорость по выходу выбранных устройств. VPN суммирует Gateway и Proxy. Direct приблизительно объединяет непересекающиеся sing-box и Linux IPv4 пути с разной byte semantics. Скачивание показано вверх, отправка вниз.", "fieldConfig": { "defaults": { "color": { @@ -414,14 +480,14 @@ "overrides": [ { "matcher": { - "id": "byName", - "options": "Скачивание" + "id": "byRegexp", + "options": "^VPN" }, "properties": [ { "id": "color", "value": { - "fixedColor": "blue", + "fixedColor": "green", "mode": "fixed" } } @@ -429,8 +495,8 @@ }, { "matcher": { - "id": "byName", - "options": "Отправка" + "id": "byRegexp", + "options": "^Direct" }, "properties": [ { @@ -442,6 +508,21 @@ } ] }, + { + "matcher": { + "id": "byRegexp", + "options": "^Не определено" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "gray", + "mode": "fixed" + } + } + ] + }, { "matcher": { "id": "byFrameRefID", @@ -453,6 +534,30 @@ "value": "negative-Y" } ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "D" + }, + "properties": [ + { + "id": "custom.transform", + "value": "negative-Y" + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "F" + }, + "properties": [ + { + "id": "custom.transform", + "value": "negative-Y" + } + ] } ] }, @@ -460,7 +565,7 @@ "h": 12, "w": 16, "x": 0, - "y": 5 + "y": 9 }, "id": 3, "options": { @@ -482,20 +587,48 @@ "targets": [ { "editorMode": "code", - "expr": "sum(rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"download\"}[$__rate_interval])) > 0", - "legendFormat": "Скачивание", + "expr": "sum(rate(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"vpn\", direction=\"download\"}[$__rate_interval])) > 0", + "legendFormat": "VPN · скачивание", "range": true, "refId": "A" }, { "editorMode": "code", - "expr": "sum(rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"upload\"}[$__rate_interval])) > 0", - "legendFormat": "Отправка", + "expr": "sum(rate(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"vpn\", direction=\"upload\"}[$__rate_interval])) > 0", + "legendFormat": "VPN · отправка", "range": true, "refId": "B" + }, + { + "editorMode": "code", + "expr": "((sum(rate(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"direct\", direction=\"download\"}[$__rate_interval])) or vector(0)) + (sum(rate(harbor_device_direct_ipv4_packet_bytes_total{device_id=~\"$device_id\", direction=\"download\"}[$__rate_interval])) or vector(0))) > 0", + "legendFormat": "Direct · скачивание", + "range": true, + "refId": "C" + }, + { + "editorMode": "code", + "expr": "((sum(rate(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"direct\", direction=\"upload\"}[$__rate_interval])) or vector(0)) + (sum(rate(harbor_device_direct_ipv4_packet_bytes_total{device_id=~\"$device_id\", direction=\"upload\"}[$__rate_interval])) or vector(0))) > 0", + "legendFormat": "Direct · отправка", + "range": true, + "refId": "D" + }, + { + "editorMode": "code", + "expr": "sum(rate(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"unknown\", direction=\"download\"}[$__rate_interval])) > 0", + "legendFormat": "Не определено · скачивание", + "range": true, + "refId": "E" + }, + { + "editorMode": "code", + "expr": "sum(rate(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"unknown\", direction=\"upload\"}[$__rate_interval])) > 0", + "legendFormat": "Не определено · отправка", + "range": true, + "refId": "F" } ], - "title": "Скорость трафика", + "title": "Фактический VPN / Direct", "type": "timeseries" }, { @@ -582,7 +715,7 @@ ] }, "gridPos": { - "h": 12, + "h": 16, "w": 8, "x": 16, "y": 5 @@ -648,7 +781,7 @@ "h": 1, "w": 24, "x": 0, - "y": 17 + "y": 21 }, "id": 12, "panels": [], @@ -676,7 +809,7 @@ "h": 15, "w": 9, "x": 0, - "y": 18 + "y": 22 }, "id": 8, "options": { @@ -775,7 +908,7 @@ "h": 15, "w": 15, "x": 9, - "y": 18 + "y": 22 }, "id": 9, "options": { @@ -837,7 +970,7 @@ "h": 1, "w": 24, "x": 0, - "y": 33 + "y": 37 }, "id": 13, "panels": [ @@ -846,7 +979,7 @@ "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "description": "Где выбранный трафик вошёл в Harbor: через Gateway или Proxy. Это техническая точка входа, а не выбор VPN / Direct.", + "description": "Как sing-box увидел трафик выбранных устройств: точка входа gateway или proxy и фактически выбранный outbound. Например, компьютер с policy Direct и браузерным Proxy появляется как proxy · vpn.", "fieldConfig": { "defaults": { "color": { @@ -856,44 +989,13 @@ "noValue": "Нет данных", "unit": "bytes" }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "gateway" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "blue", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "proxy" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "purple", - "mode": "fixed" - } - } - ] - } - ] + "overrides": [] }, "gridPos": { "h": 7, "w": 12, "x": 0, - "y": 34 + "y": 38 }, "id": 4, "options": { @@ -917,14 +1019,14 @@ "targets": [ { "editorMode": "code", - "expr": "sum by (source) (increase(harbor_device_traffic_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0", + "expr": "sum by (source, outbound) (increase(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0", "instant": true, - "legendFormat": "{{source}}", + "legendFormat": "{{source}} · {{outbound}}", "range": false, "refId": "A" } ], - "title": "Точки входа Gateway / Proxy", + "title": "Вход × выход sing-box", "type": "bargauge" }, { @@ -932,7 +1034,7 @@ "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "description": "IPv4-пакеты выбранных устройств, которые Gateway направил напрямую вместо sing-box: policy Direct или работа при остановленном VPN runtime. Это L3 packet bytes с заголовками и retransmit; их нельзя складывать с sing-box tracker bytes.", + "description": "Два непересекающихся Direct-пути выбранных устройств. Через sing-box показаны логические TCP/UDP bytes; Linux мимо sing-box показывает IPv4 L3 packet bytes с заголовками и retransmit. Их сумма в основных панелях является только приблизительной оценкой.", "fieldConfig": { "defaults": { "color": { @@ -942,13 +1044,44 @@ "noValue": "Нет данных", "unit": "bytes" }, - "overrides": [] + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "A" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + } + ] }, "gridPos": { "h": 7, "w": 12, "x": 12, - "y": 34 + "y": 38 }, "id": 16, "options": { @@ -972,14 +1105,22 @@ "targets": [ { "editorMode": "code", - "expr": "sum by (direction) (increase(harbor_device_direct_ipv4_packet_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0", + "expr": "sum(increase(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"direct\"}[$__range])) > 0", "instant": true, - "legendFormat": "{{direction}}", + "legendFormat": "Через sing-box", "range": false, "refId": "A" + }, + { + "editorMode": "code", + "expr": "sum(increase(harbor_device_direct_ipv4_packet_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0", + "instant": true, + "legendFormat": "Linux мимо sing-box", + "range": false, + "refId": "B" } ], - "title": "Direct IPv4 мимо sing-box", + "title": "Direct раздельно", "type": "bargauge" } ], @@ -1008,7 +1149,7 @@ "uid": "${DS_PROMETHEUS}" }, "definition": "query_result(label_join(max by (device_id, name, ip) (harbor_device_info), \"display\", \" · \", \"name\", \"ip\"))", - "description": "Выберите все устройства или одно устройство. Фильтр управляет скоростью, общим трафиком, маршрутами, сервисами, доменами и технической детализацией.", + "description": "Выберите все устройства или одно устройство. Фильтр управляет применённым режимом, фактическим VPN/Direct, общим трафиком, сервисами, доменами и технической детализацией.", "hide": 0, "includeAll": true, "label": "Устройства", @@ -1044,6 +1185,6 @@ "timezone": "browser", "title": "Harbor Gateway: трафик", "uid": "harbor-gateway-traffic", - "version": 9, + "version": 10, "weekStart": "monday" } diff --git a/src/server/prometheusMetrics.ts b/src/server/prometheusMetrics.ts index 7c2686f..57e05c7 100644 --- a/src/server/prometheusMetrics.ts +++ b/src/server/prometheusMetrics.ts @@ -4,6 +4,7 @@ const COUNTER_PATTERN = /^\d+$/; const SIGNED_DECIMAL_PATTERN = /^-?\d+$/; const COLLECTOR_MODES = new Set(['snapshot', 'shadow', 'native']); const COLLECTOR_WRITERS = new Set(['snapshot', 'native']); +const APPLIED_POLICY_VALUES: Readonly> = Object.freeze({ direct: '0', vpn: '1' }); const COLLECTOR_STATES = new Set([ 'connecting', 'live', 'degraded', 'stale', 'stopped', 'incompatible', 'disabled', ]); @@ -93,6 +94,20 @@ export function renderPrometheusMetrics(value: unknown) { ip: device.ip || '', }, '1'); } + + lines.push( + '# HELP harbor_device_applied_policy Current applied device policy: 0 Direct, 1 VPN.', + '# TYPE harbor_device_applied_policy gauge', + ); + for (const device of devices) { + const appliedPolicy = String(device.appliedPolicy || ''); + if (!Object.hasOwn(APPLIED_POLICY_VALUES, appliedPolicy)) { + throw new Error('Invalid applied device policy'); + } + metric(lines, 'harbor_device_applied_policy', { + device_id: device.id, + }, APPLIED_POLICY_VALUES[appliedPolicy]); + } } const deviceTraffic = devices.flatMap((device) => [ diff --git a/src/shared/versions.ts b/src/shared/versions.ts index f357f18..45abf09 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.36.0', - gatewayClient: '0.38.0', - gatewayBackend: '0.38.0', + macClient: '0.36.1', + gatewayClient: '0.38.1', + gatewayBackend: '0.38.1', }); export interface ParsedVersion { diff --git a/src/web/features/instructions/instructionBlocks.ts b/src/web/features/instructions/instructionBlocks.ts index 0ebb4dc..5ef7d27 100644 --- a/src/web/features/instructions/instructionBlocks.ts +++ b/src/web/features/instructions/instructionBlocks.ts @@ -97,7 +97,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 секунд не создают лишних одинаковых выборок.', - 'Единый фильтр «Устройства» управляет скоростью, накопленным трафиком, сервисами и доменами для всех или одного устройства. Отдельный график показывает текущую скорость каждого активного устройства; нулевые series скрыты.', + 'Единый фильтр «Устройства» управляет applied policy, фактическим VPN/Direct, общим трафиком, сервисами и доменами. Policy Direct не запрещает VPN через явно настроенный Harbor Proxy: режим останется Direct, а proxy-байты появятся в VPN.', ], steps: [ 'Добавьте блок ниже в prometheus.yml и перезагрузите Prometheus.', @@ -110,7 +110,7 @@ export function instructionBlocks({ isGateway, host, port, controlHost }: { { id: 'prometheus-config', label: 'prometheus.yml', text: prometheusScrapeConfig(controlHost) }, { id: 'grafana-dashboard', label: 'Grafana dashboard', text: grafanaDashboardJson }, ], - note: 'Domain counters снимаются с активных соединений sing-box раз в 2 секунды. Историю хранит Prometheus; соединения между снимками могут быть пропущены, неизвестный домен записывается как _unknown, а policy Direct виден только в отдельной Direct IPv4 metric.', + note: 'Историю хранит Prometheus. Основной Direct приблизительно объединяет sing-box и Linux IPv4 bytes с разной семантикой; точные значения остаются раздельными в технической секции. До первого scrape история applied policy отсутствует.', }] : []), ]; } diff --git a/test/server/domain-traffic.test.js b/test/server/domain-traffic.test.js index 0814e61..cf582d3 100644 --- a/test/server/domain-traffic.test.js +++ b/test/server/domain-traffic.test.js @@ -136,6 +136,22 @@ test('domain traffic accumulates connection deltas by device, service and source downloadBytes: '70', }, ]); + assert.deepEqual(service.snapshot().routes, [ + { + deviceId: id, + source: 'gateway', + outbound: 'vpn', + uploadBytes: '22', + downloadBytes: '200', + }, + { + deviceId: id, + source: 'proxy', + outbound: 'vpn', + uploadBytes: '22', + downloadBytes: '260', + }, + ]); assert.deepEqual(service.snapshot().attributionEvents, { unresolved_host: '1', unknown_device: '1', diff --git a/test/server/prometheus-metrics.test.js b/test/server/prometheus-metrics.test.js index 8c09155..5c5ae3d 100644 --- a/test/server/prometheus-metrics.test.js +++ b/test/server/prometheus-metrics.test.js @@ -27,6 +27,7 @@ const snapshot = { proxyUploadBytes: '0', proxyDownloadBytes: '0', proxyTrafficObservedAt: null, + appliedPolicy: 'direct', }], directTraffic: { observedAt, @@ -80,6 +81,12 @@ const snapshot = { outbound: 'vpn', uploadBytes: '55', downloadBytes: '666', + }, { + deviceId: 'dev_0011223344556677', + source: 'proxy', + outbound: 'vpn', + uploadBytes: '12', + downloadBytes: '345', }], series: [{ deviceId: 'dev_0011223344556677', @@ -98,6 +105,8 @@ test('Prometheus exposition keeps exact counters, stable identity and escaped na assert.match(output, /# TYPE harbor_traffic_bytes_total counter/); assert.match(output, /harbor_traffic_bytes_total\{source="gateway"\} 9007199254740993/); assert.match(output, /harbor_device_info\{device_id="dev_0011223344556677",name="ТВ \\"Зал\\"\\\\основной\\nэкран",ip="192\.168\.50\.7"\} 1/); + assert.match(output, /harbor_device_applied_policy\{device_id="dev_0011223344556677"\} 0/); + assert.doesNotMatch(output, /harbor_device_applied_policy\{[^\n]*(?:name|ip|mac|desired|status)=/); assert.match(output, /harbor_device_traffic_bytes_total\{device_id="dev_0011223344556677",source="gateway",direction="upload"\} 9007199254740993/); assert.doesNotMatch(output, /harbor_device_traffic_bytes_total\{[^\n]*name=/); assert.doesNotMatch(output, /harbor_device_traffic_bytes_total\{[^\n]*source="proxy"/); @@ -112,6 +121,7 @@ test('Prometheus exposition keeps exact counters, stable identity and escaped na assert.match(output, /harbor_singbox_tracked_bytes_total\{source="gateway",outbound="vpn",direction="download"\} 777/); assert.match(output, /# TYPE harbor_device_singbox_tracked_bytes_total counter/); assert.match(output, /harbor_device_singbox_tracked_bytes_total\{device_id="dev_0011223344556677",source="gateway",outbound="vpn",direction="upload"\} 55/); + assert.match(output, /harbor_device_singbox_tracked_bytes_total\{device_id="dev_0011223344556677",source="proxy",outbound="vpn",direction="download"\} 345/); 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/); @@ -181,6 +191,19 @@ test('invalid canonical counters fail the scrape instead of publishing corrupt v const invalidShadow = structuredClone(snapshot); invalidShadow.domainTraffic.source.shadow.uploadDifferenceBytes = '1.5'; assert.throws(() => renderPrometheusMetrics(invalidShadow), /Invalid Prometheus gauge/); + const invalidPolicy = structuredClone(snapshot); + invalidPolicy.devices[0].appliedPolicy = 'future'; + assert.throws(() => renderPrometheusMetrics(invalidPolicy), /Invalid applied device policy/); +}); + +test('Prometheus applied policy gauge reports VPN without changing series identity', () => { + const vpn = structuredClone(snapshot); + vpn.devices[0].appliedPolicy = 'vpn'; + + assert.match( + renderPrometheusMetrics(vpn), + /harbor_device_applied_policy\{device_id="dev_0011223344556677"\} 1/, + ); }); function routeResponse() { diff --git a/test/web/prometheus-instructions.test.js b/test/web/prometheus-instructions.test.js index 0b7d31e..4d7d90f 100644 --- a/test/web/prometheus-instructions.test.js +++ b/test/web/prometheus-instructions.test.js @@ -43,15 +43,16 @@ test('Grafana dashboard uses a compact user-first layout and one device scope', const services = byId.get(8); const domains = byId.get(9); const totalTraffic = byId.get(1); - const trafficBySource = byId.get(4); + const sourceOutbounds = byId.get(4); const singboxOutbounds = byId.get(15); - const directPackets = byId.get(16); + const directPaths = byId.get(16); + const appliedPolicy = byId.get(17); const technical = byId.get(13); assert.equal(dashboard.title, 'Harbor Gateway: трафик'); assert.equal(dashboard.refresh, '30s'); - assert.equal(dashboard.version, 9); - assert.deepEqual(dashboard.panels.map(({ id }) => id), [10, 6, 7, 1, 15, 2, 3, 5, 12, 8, 9, 13]); + assert.equal(dashboard.version, 10); + assert.deepEqual(dashboard.panels.map(({ id }) => id), [10, 6, 7, 1, 15, 2, 17, 3, 5, 12, 8, 9, 13]); assert.deepEqual(dashboard.panels.filter(({ type }) => type === 'row').map(({ title }) => title), [ 'Обзор', 'Куда уходит трафик', @@ -62,11 +63,12 @@ test('Grafana dashboard uses a compact user-first layout and one device scope', assert.equal(byId.has(11), false); assert.equal(byId.has(14), false); - assert.deepEqual(speed.gridPos, { h: 12, w: 16, x: 0, y: 5 }); - assert.deepEqual(devices.gridPos, { h: 12, w: 8, x: 16, y: 5 }); - assert.deepEqual(services.gridPos, { h: 15, w: 9, x: 0, y: 18 }); - assert.deepEqual(domains.gridPos, { h: 15, w: 15, x: 9, y: 18 }); - assert.deepEqual(technical.gridPos, { h: 1, w: 24, x: 0, y: 33 }); + assert.deepEqual(appliedPolicy.gridPos, { h: 4, w: 16, x: 0, y: 5 }); + assert.deepEqual(speed.gridPos, { h: 12, w: 16, x: 0, y: 9 }); + assert.deepEqual(devices.gridPos, { h: 16, w: 8, x: 16, y: 5 }); + assert.deepEqual(services.gridPos, { h: 15, w: 9, x: 0, y: 22 }); + assert.deepEqual(domains.gridPos, { h: 15, w: 15, x: 9, y: 22 }); + assert.deepEqual(technical.gridPos, { h: 1, w: 24, x: 0, y: 37 }); assert.equal(devices.options.footer.enablePagination, false); assert.equal(domains.options.footer.enablePagination, false); @@ -88,16 +90,35 @@ test('Grafana dashboard uses a compact user-first layout and one device scope', log: 2, type: 'symlog', }); - assert.deepEqual(speed.targets.map(({ refId }) => refId), ['A', 'B']); + assert.equal(speed.title, 'Фактический VPN / Direct'); + assert.deepEqual(speed.targets.map(({ refId }) => refId), ['A', 'B', 'C', 'D', 'E', 'F']); assert.ok(speed.targets.every(({ expr }) => expr.includes('device_id=~"$device_id"') && expr.includes('[$__rate_interval]') && !expr.includes('[5m]'))); - assert.ok(speed.fieldConfig.overrides.some((override) => ( - override.matcher.id === 'byFrameRefID' - && override.matcher.options === 'B' - && override.properties.some(({ id, value }) => id === 'custom.transform' && value === 'negative-Y') + assert.ok(speed.targets.filter(({ refId }) => ['A', 'B', 'E', 'F'].includes(refId)) + .every(({ expr }) => expr.includes('harbor_device_singbox_tracked_bytes_total'))); + assert.ok(speed.targets.every(({ expr }) => !expr.includes('source='))); + assert.ok(speed.targets.filter(({ refId }) => ['C', 'D'].includes(refId)).every(({ expr }) => ( + expr.includes('harbor_device_singbox_tracked_bytes_total') + && expr.includes('harbor_device_direct_ipv4_packet_bytes_total') + && expr.includes('or vector(0)') ))); + assert.deepEqual(speed.fieldConfig.overrides.filter((override) => ( + override.matcher.id === 'byFrameRefID' + && override.properties.some(({ id, value }) => id === 'custom.transform' && value === 'negative-Y') + )).map(({ matcher }) => matcher.options), ['B', 'D', 'F']); assert.equal(speed.options.legend.placement, 'right'); assert.deepEqual(speed.options.legend.calcs, ['lastNotNull', 'mean', 'max']); + assert.equal(appliedPolicy.type, 'state-timeline'); + assert.equal(appliedPolicy.title, 'Применённый режим'); + assert.match(appliedPolicy.description, /Direct[\s\S]*VPN через явно настроенный Harbor Proxy/); + assert.match(appliedPolicy.targets[0].expr, /harbor_device_applied_policy\{device_id=~"\$device_id"\}/); + assert.match(appliedPolicy.targets[0].expr, /group_left \(name, ip\)[\s\S]*harbor_device_info/); + assert.deepEqual(appliedPolicy.fieldConfig.defaults.mappings[0].options, { + 0: { color: 'orange', index: 0, text: 'Direct' }, + 1: { color: 'green', index: 1, text: 'VPN' }, + }); + assert.equal(appliedPolicy.fieldConfig.defaults.noValue, 'Нет данных'); + assert.deepEqual(singboxOutbounds.targets.map(({ legendFormat }) => legendFormat), ['VPN', 'Direct', 'Не определено']); assert.ok(singboxOutbounds.targets.every(({ expr }) => ( expr.includes('harbor_device_singbox_tracked_bytes_total') @@ -105,11 +126,20 @@ test('Grafana dashboard uses a compact user-first layout and one device scope', && expr.includes('[$__range]') ))); assert.match(singboxOutbounds.targets[0].expr, /outbound="vpn"/); - assert.match(singboxOutbounds.targets[1].expr, /outbound="direct"/); + assert.doesNotMatch(singboxOutbounds.targets[0].expr, /source=/); + assert.match(singboxOutbounds.targets[1].expr, /harbor_device_singbox_tracked_bytes_total[\s\S]*outbound="direct"/); + assert.match(singboxOutbounds.targets[1].expr, /harbor_device_direct_ipv4_packet_bytes_total/); + assert.match(singboxOutbounds.targets[1].expr, /or vector\(0\)/); assert.match(singboxOutbounds.targets[2].expr, /outbound="unknown"/); - assert.match(directPackets.targets[0].expr, /harbor_device_direct_ipv4_packet_bytes_total\{device_id=~"\$device_id"\}\[\$__range\]/); - assert.match(directPackets.description, /нельзя складывать с sing-box tracker bytes/); - assert.match(trafficBySource.targets[0].expr, /harbor_device_traffic_bytes_total\{device_id=~"\$device_id"\}\[\$__range\]/); + assert.equal(singboxOutbounds.fieldConfig.defaults.noValue, 'Нет данных'); + assert.equal(sourceOutbounds.title, 'Вход × выход sing-box'); + assert.match(sourceOutbounds.targets[0].expr, /sum by \(source, outbound\)[\s\S]*harbor_device_singbox_tracked_bytes_total/); + assert.equal(sourceOutbounds.targets[0].legendFormat, '{{source}} · {{outbound}}'); + assert.match(sourceOutbounds.description, /proxy · vpn/); + assert.deepEqual(directPaths.targets.map(({ legendFormat }) => legendFormat), ['Через sing-box', 'Linux мимо sing-box']); + assert.match(directPaths.targets[0].expr, /harbor_device_singbox_tracked_bytes_total[\s\S]*outbound="direct"/); + assert.match(directPaths.targets[1].expr, /harbor_device_direct_ipv4_packet_bytes_total/); + assert.match(directPaths.description, /приблизительной оценкой/); assert.match(devices.targets[0].expr, /^\(sum by \(device_id\)[\s\S]*increase\(harbor_device_traffic_bytes_total\[\$__range\]\)[\s\S]*> 0[\s\S]*group_left \(name, ip\)/); assert.doesNotMatch(devices.targets[0].expr, /topk/);