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"]
```
`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».
+206 -65
View File
@@ -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"
}
+15
View File
@@ -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<Record<string, string>> = 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) => [
+3 -3
View File
@@ -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 {
@@ -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 отсутствует.',
}] : []),
];
}
+16
View File
@@ -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',
+23
View File
@@ -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() {
+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 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/);