Clarify VPN and Direct traffic metrics in Grafana dashboard
Build and Deploy Gateway / build-and-push (push) Successful in 31s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-09-01 07:01:26 +03:00
parent 76a99f098a
commit ab14fc979e
8 changed files with 316 additions and 91 deletions
+3 -3
View File
@@ -302,17 +302,17 @@ scrape_configs:
- targets: ["<gateway>:3456"] - targets: ["<gateway>: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 выход. Фактический выход экспортируется отдельно. `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`. В `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. Состояние 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». Готовый dashboard: [`monitoring/grafana/harbor-gateway.json`](monitoring/grafana/harbor-gateway.json). При импорте Grafana попросит выбрать Prometheus data source. Та же конфигурация и dashboard доступны для копирования в Gateway drawer «Как использовать» → «Prometheus и Grafana».
+206 -65
View File
@@ -196,14 +196,14 @@
"type": "prometheus", "type": "prometheus",
"uid": "${DS_PROMETHEUS}" "uid": "${DS_PROMETHEUS}"
}, },
"description": "Наблюдённые sing-box байты для выбранных устройств по фактически выбранному выходу. Direct IPv4 мимо sing-box учитывается отдельно в технической детализации.", "description": "Фактический выход выбранных устройств за период. VPN включает трафик, вошедший через Gateway и явно настроенный Proxy. Direct приблизительно объединяет логические байты sing-box и IPv4 L3 packet bytes Linux; точная разбивка остаётся в технической детализации.",
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"color": { "color": {
"mode": "palette-classic" "mode": "palette-classic"
}, },
"decimals": 1, "decimals": 1,
"noValue": "Нет трафика", "noValue": "Нет данных",
"unit": "bytes" "unit": "bytes"
}, },
"overrides": [ "overrides": [
@@ -290,7 +290,7 @@
}, },
{ {
"editorMode": "code", "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, "instant": true,
"legendFormat": "Direct", "legendFormat": "Direct",
"range": false, "range": false,
@@ -382,7 +382,73 @@
"type": "prometheus", "type": "prometheus",
"uid": "${DS_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": { "fieldConfig": {
"defaults": { "defaults": {
"color": { "color": {
@@ -414,14 +480,14 @@
"overrides": [ "overrides": [
{ {
"matcher": { "matcher": {
"id": "byName", "id": "byRegexp",
"options": "Скачивание" "options": "^VPN"
}, },
"properties": [ "properties": [
{ {
"id": "color", "id": "color",
"value": { "value": {
"fixedColor": "blue", "fixedColor": "green",
"mode": "fixed" "mode": "fixed"
} }
} }
@@ -429,8 +495,8 @@
}, },
{ {
"matcher": { "matcher": {
"id": "byName", "id": "byRegexp",
"options": "Отправка" "options": "^Direct"
}, },
"properties": [ "properties": [
{ {
@@ -442,6 +508,21 @@
} }
] ]
}, },
{
"matcher": {
"id": "byRegexp",
"options": "^Не определено"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "gray",
"mode": "fixed"
}
}
]
},
{ {
"matcher": { "matcher": {
"id": "byFrameRefID", "id": "byFrameRefID",
@@ -453,6 +534,30 @@
"value": "negative-Y" "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, "h": 12,
"w": 16, "w": 16,
"x": 0, "x": 0,
"y": 5 "y": 9
}, },
"id": 3, "id": 3,
"options": { "options": {
@@ -482,20 +587,48 @@
"targets": [ "targets": [
{ {
"editorMode": "code", "editorMode": "code",
"expr": "sum(rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"download\"}[$__rate_interval])) > 0", "expr": "sum(rate(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"vpn\", direction=\"download\"}[$__rate_interval])) > 0",
"legendFormat": "Скачивание", "legendFormat": "VPN · скачивание",
"range": true, "range": true,
"refId": "A" "refId": "A"
}, },
{ {
"editorMode": "code", "editorMode": "code",
"expr": "sum(rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"upload\"}[$__rate_interval])) > 0", "expr": "sum(rate(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"vpn\", direction=\"upload\"}[$__rate_interval])) > 0",
"legendFormat": "Отправка", "legendFormat": "VPN · отправка",
"range": true, "range": true,
"refId": "B" "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" "type": "timeseries"
}, },
{ {
@@ -582,7 +715,7 @@
] ]
}, },
"gridPos": { "gridPos": {
"h": 12, "h": 16,
"w": 8, "w": 8,
"x": 16, "x": 16,
"y": 5 "y": 5
@@ -648,7 +781,7 @@
"h": 1, "h": 1,
"w": 24, "w": 24,
"x": 0, "x": 0,
"y": 17 "y": 21
}, },
"id": 12, "id": 12,
"panels": [], "panels": [],
@@ -676,7 +809,7 @@
"h": 15, "h": 15,
"w": 9, "w": 9,
"x": 0, "x": 0,
"y": 18 "y": 22
}, },
"id": 8, "id": 8,
"options": { "options": {
@@ -775,7 +908,7 @@
"h": 15, "h": 15,
"w": 15, "w": 15,
"x": 9, "x": 9,
"y": 18 "y": 22
}, },
"id": 9, "id": 9,
"options": { "options": {
@@ -837,7 +970,7 @@
"h": 1, "h": 1,
"w": 24, "w": 24,
"x": 0, "x": 0,
"y": 33 "y": 37
}, },
"id": 13, "id": 13,
"panels": [ "panels": [
@@ -846,7 +979,7 @@
"type": "prometheus", "type": "prometheus",
"uid": "${DS_PROMETHEUS}" "uid": "${DS_PROMETHEUS}"
}, },
"description": "Где выбранный трафик вошёл в Harbor: через Gateway или Proxy. Это техническая точка входа, а не выбор VPN / Direct.", "description": "Как sing-box увидел трафик выбранных устройств: точка входа gateway или proxy и фактически выбранный outbound. Например, компьютер с policy Direct и браузерным Proxy появляется как proxy · vpn.",
"fieldConfig": { "fieldConfig": {
"defaults": { "defaults": {
"color": { "color": {
@@ -856,44 +989,13 @@
"noValue": "Нет данных", "noValue": "Нет данных",
"unit": "bytes" "unit": "bytes"
}, },
"overrides": [ "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"
}
}
]
}
]
}, },
"gridPos": { "gridPos": {
"h": 7, "h": 7,
"w": 12, "w": 12,
"x": 0, "x": 0,
"y": 34 "y": 38
}, },
"id": 4, "id": 4,
"options": { "options": {
@@ -917,14 +1019,14 @@
"targets": [ "targets": [
{ {
"editorMode": "code", "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, "instant": true,
"legendFormat": "{{source}}", "legendFormat": "{{source}} · {{outbound}}",
"range": false, "range": false,
"refId": "A" "refId": "A"
} }
], ],
"title": "Точки входа Gateway / Proxy", "title": "Вход × выход sing-box",
"type": "bargauge" "type": "bargauge"
}, },
{ {
@@ -932,7 +1034,7 @@
"type": "prometheus", "type": "prometheus",
"uid": "${DS_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": { "fieldConfig": {
"defaults": { "defaults": {
"color": { "color": {
@@ -942,13 +1044,44 @@
"noValue": "Нет данных", "noValue": "Нет данных",
"unit": "bytes" "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": { "gridPos": {
"h": 7, "h": 7,
"w": 12, "w": 12,
"x": 12, "x": 12,
"y": 34 "y": 38
}, },
"id": 16, "id": 16,
"options": { "options": {
@@ -972,14 +1105,22 @@
"targets": [ "targets": [
{ {
"editorMode": "code", "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, "instant": true,
"legendFormat": "{{direction}}", "legendFormat": "Через sing-box",
"range": false, "range": false,
"refId": "A" "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" "type": "bargauge"
} }
], ],
@@ -1008,7 +1149,7 @@
"uid": "${DS_PROMETHEUS}" "uid": "${DS_PROMETHEUS}"
}, },
"definition": "query_result(label_join(max by (device_id, name, ip) (harbor_device_info), \"display\", \" · \", \"name\", \"ip\"))", "definition": "query_result(label_join(max by (device_id, name, ip) (harbor_device_info), \"display\", \" · \", \"name\", \"ip\"))",
"description": "Выберите все устройства или одно устройство. Фильтр управляет скоростью, общим трафиком, маршрутами, сервисами, доменами и технической детализацией.", "description": "Выберите все устройства или одно устройство. Фильтр управляет применённым режимом, фактическим VPN/Direct, общим трафиком, сервисами, доменами и технической детализацией.",
"hide": 0, "hide": 0,
"includeAll": true, "includeAll": true,
"label": "Устройства", "label": "Устройства",
@@ -1044,6 +1185,6 @@
"timezone": "browser", "timezone": "browser",
"title": "Harbor Gateway: трафик", "title": "Harbor Gateway: трафик",
"uid": "harbor-gateway-traffic", "uid": "harbor-gateway-traffic",
"version": 9, "version": 10,
"weekStart": "monday" "weekStart": "monday"
} }
+15
View File
@@ -4,6 +4,7 @@ const COUNTER_PATTERN = /^\d+$/;
const SIGNED_DECIMAL_PATTERN = /^-?\d+$/; const SIGNED_DECIMAL_PATTERN = /^-?\d+$/;
const COLLECTOR_MODES = new Set(['snapshot', 'shadow', 'native']); const COLLECTOR_MODES = new Set(['snapshot', 'shadow', 'native']);
const COLLECTOR_WRITERS = new Set(['snapshot', 'native']); const COLLECTOR_WRITERS = new Set(['snapshot', 'native']);
const APPLIED_POLICY_VALUES: Readonly<Record<string, string>> = Object.freeze({ direct: '0', vpn: '1' });
const COLLECTOR_STATES = new Set([ const COLLECTOR_STATES = new Set([
'connecting', 'live', 'degraded', 'stale', 'stopped', 'incompatible', 'disabled', 'connecting', 'live', 'degraded', 'stale', 'stopped', 'incompatible', 'disabled',
]); ]);
@@ -93,6 +94,20 @@ export function renderPrometheusMetrics(value: unknown) {
ip: device.ip || '', ip: device.ip || '',
}, '1'); }, '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) => [ const deviceTraffic = devices.flatMap((device) => [
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.36.0', macClient: '0.36.1',
gatewayClient: '0.38.0', gatewayClient: '0.38.1',
gatewayBackend: '0.38.0', gatewayBackend: '0.38.1',
}); });
export interface ParsedVersion { export interface ParsedVersion {
@@ -97,7 +97,7 @@ export function instructionBlocks({ isGateway, host, port, controlHost }: {
paragraphs: [ paragraphs: [
`Prometheus забирает накопленные Harbor counters с http://${controlHost}/metrics. Ручка читает готовый snapshot и не запускает новый сбор трафика.`, `Prometheus забирает накопленные Harbor counters с http://${controlHost}/metrics. Ручка читает готовый snapshot и не запускает новый сбор трафика.`,
'Harbor обновляет traffic snapshot раз в 15 секунд, поэтому начальный scrape interval и refresh dashboard в 30 секунд не создают лишних одинаковых выборок.', 'Harbor обновляет traffic snapshot раз в 15 секунд, поэтому начальный scrape interval и refresh dashboard в 30 секунд не создают лишних одинаковых выборок.',
'Единый фильтр «Устройства» управляет скоростью, накопленным трафиком, сервисами и доменами для всех или одного устройства. Отдельный график показывает текущую скорость каждого активного устройства; нулевые series скрыты.', 'Единый фильтр «Устройства» управляет applied policy, фактическим VPN/Direct, общим трафиком, сервисами и доменами. Policy Direct не запрещает VPN через явно настроенный Harbor Proxy: режим останется Direct, а proxy-байты появятся в VPN.',
], ],
steps: [ steps: [
'Добавьте блок ниже в prometheus.yml и перезагрузите Prometheus.', 'Добавьте блок ниже в prometheus.yml и перезагрузите Prometheus.',
@@ -110,7 +110,7 @@ export function instructionBlocks({ isGateway, host, port, controlHost }: {
{ id: 'prometheus-config', label: 'prometheus.yml', text: prometheusScrapeConfig(controlHost) }, { id: 'prometheus-config', label: 'prometheus.yml', text: prometheusScrapeConfig(controlHost) },
{ id: 'grafana-dashboard', label: 'Grafana dashboard', text: grafanaDashboardJson }, { 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 отсутствует.',
}] : []), }] : []),
]; ];
} }
+16
View File
@@ -136,6 +136,22 @@ test('domain traffic accumulates connection deltas by device, service and source
downloadBytes: '70', 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, { assert.deepEqual(service.snapshot().attributionEvents, {
unresolved_host: '1', unresolved_host: '1',
unknown_device: '1', unknown_device: '1',
+23
View File
@@ -27,6 +27,7 @@ const snapshot = {
proxyUploadBytes: '0', proxyUploadBytes: '0',
proxyDownloadBytes: '0', proxyDownloadBytes: '0',
proxyTrafficObservedAt: null, proxyTrafficObservedAt: null,
appliedPolicy: 'direct',
}], }],
directTraffic: { directTraffic: {
observedAt, observedAt,
@@ -80,6 +81,12 @@ const snapshot = {
outbound: 'vpn', outbound: 'vpn',
uploadBytes: '55', uploadBytes: '55',
downloadBytes: '666', downloadBytes: '666',
}, {
deviceId: 'dev_0011223344556677',
source: 'proxy',
outbound: 'vpn',
uploadBytes: '12',
downloadBytes: '345',
}], }],
series: [{ series: [{
deviceId: 'dev_0011223344556677', 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, /# TYPE harbor_traffic_bytes_total counter/);
assert.match(output, /harbor_traffic_bytes_total\{source="gateway"\} 9007199254740993/); 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_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.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]*name=/);
assert.doesNotMatch(output, /harbor_device_traffic_bytes_total\{[^\n]*source="proxy"/); 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, /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, /# 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="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_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_last_observed_timestamp_seconds 1786183200/);
assert.match(output, /harbor_domain_traffic_overflow_connections_total 2/); 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); const invalidShadow = structuredClone(snapshot);
invalidShadow.domainTraffic.source.shadow.uploadDifferenceBytes = '1.5'; invalidShadow.domainTraffic.source.shadow.uploadDifferenceBytes = '1.5';
assert.throws(() => renderPrometheusMetrics(invalidShadow), /Invalid Prometheus gauge/); 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() { function routeResponse() {
+48 -18
View File
@@ -43,15 +43,16 @@ test('Grafana dashboard uses a compact user-first layout and one device scope',
const services = byId.get(8); const services = byId.get(8);
const domains = byId.get(9); const domains = byId.get(9);
const totalTraffic = byId.get(1); const totalTraffic = byId.get(1);
const trafficBySource = byId.get(4); const sourceOutbounds = byId.get(4);
const singboxOutbounds = byId.get(15); 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); const technical = byId.get(13);
assert.equal(dashboard.title, 'Harbor Gateway: трафик'); assert.equal(dashboard.title, 'Harbor Gateway: трафик');
assert.equal(dashboard.refresh, '30s'); assert.equal(dashboard.refresh, '30s');
assert.equal(dashboard.version, 9); assert.equal(dashboard.version, 10);
assert.deepEqual(dashboard.panels.map(({ id }) => id), [10, 6, 7, 1, 15, 2, 3, 5, 12, 8, 9, 13]); 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), [ 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(11), false);
assert.equal(byId.has(14), false); assert.equal(byId.has(14), false);
assert.deepEqual(speed.gridPos, { h: 12, w: 16, x: 0, y: 5 }); assert.deepEqual(appliedPolicy.gridPos, { h: 4, w: 16, x: 0, y: 5 });
assert.deepEqual(devices.gridPos, { h: 12, w: 8, x: 16, y: 5 }); assert.deepEqual(speed.gridPos, { h: 12, w: 16, x: 0, y: 9 });
assert.deepEqual(services.gridPos, { h: 15, w: 9, x: 0, y: 18 }); assert.deepEqual(devices.gridPos, { h: 16, w: 8, x: 16, y: 5 });
assert.deepEqual(domains.gridPos, { h: 15, w: 15, x: 9, y: 18 }); assert.deepEqual(services.gridPos, { h: 15, w: 9, x: 0, y: 22 });
assert.deepEqual(technical.gridPos, { h: 1, w: 24, x: 0, y: 33 }); 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(devices.options.footer.enablePagination, false);
assert.equal(domains.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, log: 2,
type: 'symlog', 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.targets.every(({ expr }) => expr.includes('device_id=~"$device_id"') && expr.includes('[$__rate_interval]') && !expr.includes('[5m]')));
assert.ok(speed.fieldConfig.overrides.some((override) => ( assert.ok(speed.targets.filter(({ refId }) => ['A', 'B', 'E', 'F'].includes(refId))
override.matcher.id === 'byFrameRefID' .every(({ expr }) => expr.includes('harbor_device_singbox_tracked_bytes_total')));
&& override.matcher.options === 'B' assert.ok(speed.targets.every(({ expr }) => !expr.includes('source=')));
&& override.properties.some(({ id, value }) => id === 'custom.transform' && value === 'negative-Y') 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.equal(speed.options.legend.placement, 'right');
assert.deepEqual(speed.options.legend.calcs, ['lastNotNull', 'mean', 'max']); 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.deepEqual(singboxOutbounds.targets.map(({ legendFormat }) => legendFormat), ['VPN', 'Direct', 'Не определено']);
assert.ok(singboxOutbounds.targets.every(({ expr }) => ( assert.ok(singboxOutbounds.targets.every(({ expr }) => (
expr.includes('harbor_device_singbox_tracked_bytes_total') 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]') && expr.includes('[$__range]')
))); )));
assert.match(singboxOutbounds.targets[0].expr, /outbound="vpn"/); 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(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.equal(singboxOutbounds.fieldConfig.defaults.noValue, 'Нет данных');
assert.match(directPackets.description, /нельзя складывать с sing-box tracker bytes/); assert.equal(sourceOutbounds.title, 'Вход × выход sing-box');
assert.match(trafficBySource.targets[0].expr, /harbor_device_traffic_bytes_total\{device_id=~"\$device_id"\}\[\$__range\]/); 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.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/); assert.doesNotMatch(devices.targets[0].expr, /topk/);