From 0b39211fbd9bfbcaeb9f1ff742869db6a054b522 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Mon, 17 Aug 2026 12:19:26 +0300 Subject: [PATCH] Refactor Harbor client connection flow --- README.md | 2 +- monitoring/grafana/harbor-gateway.json | 1090 +++++++++++----------- src/shared/versions.ts | 4 +- test/web/prometheus-instructions.test.js | 160 ++-- 4 files changed, 625 insertions(+), 631 deletions(-) diff --git a/README.md b/README.md index 999707c..a73e8a1 100644 --- a/README.md +++ b/README.md @@ -279,7 +279,7 @@ scrape_configs: Фактический выход экспортируется отдельно. `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 отделяет текущую скорость от значений за выбранный период и накопленных счётчиков. Единый фильтр `Устройства` по умолчанию охватывает все устройства, но позволяет выбрать одно; список показывает `name · ip`, сохраняя стабильный `device_id` как значение. Он управляет графиками скорости, накопленным трафиком, сервисами и доменами. Отдельный график скорости по устройствам показывает одну суммарную линию на каждое активное устройство; нулевые устройства и source/direction series скрыты. Top-10 устройств за период отсортирован по убыванию и выбирает устройство в том же фильтре. Domain table показывает только сервис, домен и трафик. В технической детализации VPN/Direct внутри sing-box и Direct IPv4-пакеты показаны на разных панелях. Автообновление настроено на 30 секунд; freshness предупреждает после 60 секунд и считает данные устаревшими после 120 секунд. +Dashboard начинает со скорости скачивания и отправки в конце выбранного периода, общего трафика и VPN / Direct внутри sing-box за этот период. Для стандартного диапазона, который заканчивается сейчас, карточки скорости показывают текущее значение. Единый фильтр `Устройства` по умолчанию охватывает все устройства, но позволяет выбрать одно; он управляет скоростью, общим трафиком, маршрутами, сервисами, доменами и технической детализацией. Таблица «Все устройства за период» намеренно остаётся общей: она показывает все устройства с ненулевым трафиком, сортируется в обе стороны и выбирает устройство в том же фильтре. Блок «Куда уходит трафик» показывает основные назначения и Top-15 доменов без пагинации. Свёрнутая техническая детализация отдельно показывает точки входа Gateway / Proxy и Direct IPv4 мимо sing-box. Автообновление настроено на 30 секунд; индикатор показывает возраст самого старого из контуров общего, domain / sing-box и Direct IPv4 трафика, предупреждает после 60 секунд и считает данные устаревшими после 120 секунд. Domain и sing-box outbound counters снимаются с активных соединений раз в 2 секунды и хранятся в памяти dataplane до его перезапуска; историю и retention хранит Prometheus. Перед routing sing-box до 1 секунды распознаёт HTTP Host, TLS SNI и QUIC Server Name. YouTube и OpenAI / ChatGPT объединяются по известным связанным доменам в label `service`, остальные значения сохраняют домен как имя сервиса. Если устройство и Harbor source известны, но hostname недоступен (например, ECH или IP-only), трафик попадает в `domain="_unknown",service="Не распознано"` и не теряется. Новые domain series сверх process limit складываются в `_other`. diff --git a/monitoring/grafana/harbor-gateway.json b/monitoring/grafana/harbor-gateway.json index 201eaa1..534a989 100644 --- a/monitoring/grafana/harbor-gateway.json +++ b/monitoring/grafana/harbor-gateway.json @@ -12,7 +12,7 @@ "annotations": { "list": [] }, - "description": "Трафик, учтённый Harbor Gateway и Proxy. Физический и Direct-трафик вне Harbor не включён.", + "description": "Трафик, который Harbor учёл для всех устройств или выбранного устройства. Физическая пропускная способность канала и трафик вне Gateway не измеряются.", "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 1, @@ -37,7 +37,283 @@ "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "description": "Сколько секунд прошло с последнего успешного обновления к концу выбранного периода. До 60 секунд — штатно, 60–119 — задержка, от 120 — данные устарели.", + "description": "Скорость скачивания в конце выбранного периода с адаптивным окном усреднения. Если диапазон заканчивается сейчас, это текущая скорость. Это наблюдаемый Harbor трафик, а не пропускная способность интернет-канала.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "blue", + "mode": "fixed" + }, + "decimals": 1, + "noValue": "Нет активности", + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 0, + "y": 1 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value", + "wideLayout": true + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum(rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"download\"}[$__rate_interval]))", + "instant": true, + "legendFormat": "Скачивание", + "range": false, + "refId": "A" + } + ], + "title": "Скачивание в конце", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "Скорость отправки в конце выбранного периода с адаптивным окном усреднения. Если диапазон заканчивается сейчас, это текущая скорость. Это наблюдаемый Harbor трафик, а не пропускная способность интернет-канала.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "orange", + "mode": "fixed" + }, + "decimals": 1, + "noValue": "Нет активности", + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 4, + "y": 1 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value", + "wideLayout": true + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum(rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"upload\"}[$__rate_interval]))", + "instant": true, + "legendFormat": "Отправка", + "range": false, + "refId": "A" + } + ], + "title": "Отправка в конце", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "Скачивание и отправка, учтённые Harbor для выбранных устройств в текущем диапазоне времени.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "green", + "mode": "fixed" + }, + "decimals": 1, + "noValue": "Нет данных", + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 8, + "y": 1 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value", + "wideLayout": true + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum(increase(harbor_device_traffic_bytes_total{device_id=~\"$device_id\"}[$__range]))", + "instant": true, + "legendFormat": "Учтено Harbor", + "range": false, + "refId": "A" + } + ], + "title": "Трафик за период", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "Наблюдённые sing-box байты для выбранных устройств по фактически выбранному выходу. Direct IPv4 мимо sing-box учитывается отдельно в технической детализации.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "decimals": 1, + "noValue": "Нет трафика", + "unit": "bytes" + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "A" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "C" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "gray", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 4, + "w": 8, + "x": 12, + "y": 1 + }, + "id": 15, + "options": { + "displayMode": "basic", + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "sizing": "auto", + "valueMode": "text" + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum(increase(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"vpn\"}[$__range])) > 0", + "instant": true, + "legendFormat": "VPN", + "range": false, + "refId": "A" + }, + { + "editorMode": "code", + "expr": "sum(increase(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"direct\"}[$__range])) > 0", + "instant": true, + "legendFormat": "Direct", + "range": false, + "refId": "B" + }, + { + "editorMode": "code", + "expr": "sum(increase(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"unknown\"}[$__range])) > 0", + "instant": true, + "legendFormat": "Не определено", + "range": false, + "refId": "C" + } + ], + "title": "VPN / Direct за период", + "type": "bargauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "Возраст самых старых данных среди общего трафика, сайтов / sing-box и Direct IPv4 к концу выбранного периода. До 60 секунд — штатно, 60–119 — задержка, от 120 — данные устарели.", "fieldConfig": { "defaults": { "color": { @@ -67,9 +343,9 @@ "overrides": [] }, "gridPos": { - "h": 6, - "w": 6, - "x": 0, + "h": 4, + "w": 4, + "x": 20, "y": 1 }, "id": 2, @@ -85,36 +361,20 @@ "fields": "", "values": false }, - "textMode": "auto", - "wideLayout": true + "textMode": "value", + "wideLayout": false }, "targets": [ { "editorMode": "code", - "expr": "time() - min(harbor_traffic_last_observed_timestamp_seconds)", + "expr": "max(label_replace(time() - min(harbor_traffic_last_observed_timestamp_seconds), \"freshness_source\", \"traffic\", \"__name__\", \".*\") or label_replace(time() - harbor_domain_traffic_last_observed_timestamp_seconds, \"freshness_source\", \"domains\", \"__name__\", \".*\") or label_replace(time() - harbor_direct_ipv4_packet_last_observed_timestamp_seconds, \"freshness_source\", \"direct\", \"__name__\", \".*\"))", "instant": true, - "legendFormat": "Общий трафик", + "legendFormat": "Секунд назад", "range": false, "refId": "A" - }, - { - "editorMode": "code", - "expr": "time() - harbor_domain_traffic_last_observed_timestamp_seconds", - "instant": true, - "legendFormat": "Домены", - "range": false, - "refId": "B" - }, - { - "editorMode": "code", - "expr": "time() - harbor_direct_ipv4_packet_last_observed_timestamp_seconds", - "instant": true, - "legendFormat": "Gateway Direct", - "range": false, - "refId": "C" } ], - "title": "Свежесть данных в конце выбранного периода", + "title": "Обновление данных", "type": "stat" }, { @@ -122,7 +382,7 @@ "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "description": "Суммарная скорость всех устройств в выбранном периоде. Окно усреднения автоматически адаптируется к масштабу времени; скачивание показано вверх, отправка — вниз.", + "description": "Скорость трафика выбранных устройств. Окно усреднения адаптируется к масштабу времени; скачивание показано вверх, отправка — вниз. Это наблюдаемый Harbor трафик, а не скорость интернет-канала.", "fieldConfig": { "defaults": { "color": { @@ -197,20 +457,21 @@ ] }, "gridPos": { - "h": 6, - "w": 18, - "x": 6, - "y": 1 + "h": 12, + "w": 16, + "x": 0, + "y": 5 }, "id": 3, "options": { "legend": { "calcs": [ "lastNotNull", - "mean" + "mean", + "max" ], "displayMode": "table", - "placement": "bottom", + "placement": "right", "showLegend": true }, "tooltip": { @@ -234,7 +495,7 @@ "refId": "B" } ], - "title": "Скорость за выбранный период", + "title": "Скорость трафика", "type": "timeseries" }, { @@ -242,7 +503,7 @@ "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "description": "Устройства с наибольшим учтённым трафиком в текущем диапазоне времени. Нажмите название, чтобы выбрать устройство в детализации ниже.", + "description": "Все устройства с учтённым трафиком в текущем диапазоне времени. Сортируйте столбец «За период», чтобы увидеть самые активные или самые экономные; нажмите название, чтобы применить фильтр к остальным панелям.", "fieldConfig": { "defaults": { "color": { @@ -284,26 +545,54 @@ { "targetBlank": false, "title": "Выбрать устройство", - "url": "${__url_time_range}&var-device_id=${__data.fields[\"device_id\"]}" + "url": "?${__url_time_range}&var-device_id=${__data.fields[\"device_id\"]}" } ] + }, + { + "id": "custom.width", + "value": 180 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "ip" + }, + "properties": [ + { + "id": "custom.width", + "value": 110 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Value" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 } ] } ] }, "gridPos": { - "h": 9, - "w": 24, - "x": 0, - "y": 7 + "h": 12, + "w": 8, + "x": 16, + "y": 5 }, "id": 5, "options": { "cellHeight": "sm", "footer": { "countRows": false, - "enablePagination": true, + "enablePagination": false, "fields": "", "reducer": [ "sum" @@ -321,7 +610,7 @@ "targets": [ { "editorMode": "code", - "expr": "topk(10, (sum by (device_id) (increase(harbor_device_traffic_bytes_total[$__range])) > 0) * on (device_id) group_left (name, ip) max by (device_id, name, ip) (harbor_device_info))", + "expr": "(sum by (device_id) (increase(harbor_device_traffic_bytes_total[$__range])) > 0) * on (device_id) group_left (name, ip) max by (device_id, name, ip) (harbor_device_info)", "format": "table", "instant": true, "legendFormat": "{{name}} · {{ip}}", @@ -329,7 +618,7 @@ "refId": "A" } ], - "title": "Топ-10 устройств за выбранный период", + "title": "Все устройства за период", "transformations": [ { "id": "organize", @@ -359,261 +648,11 @@ "h": 1, "w": 24, "x": 0, - "y": 16 - }, - "id": 11, - "panels": [], - "title": "Устройства", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "description": "Скорость скачивания и отправки каждого активного устройства в выбранном периоде. Окно усреднения адаптируется к масштабу времени; скачивание показано вверх, отправка — вниз. Нулевые series скрыты.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": true, - "drawStyle": "line", - "fillOpacity": 6, - "lineInterpolation": "smooth", - "lineWidth": 2, - "pointSize": 4, - "scaleDistribution": { - "linearThreshold": 1048576, - "log": 2, - "type": "symlog" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - } - }, - "decimals": 1, - "noValue": "Нет активного трафика", - "unit": "Bps" - }, - "overrides": [ - { - "matcher": { - "id": "byFrameRefID", - "options": "B" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 24, - "x": 0, "y": 17 }, - "id": 14, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "mean", - "max" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "targets": [ - { - "editorMode": "code", - "expr": "(sum by (device_id) (rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"download\"}[$__rate_interval])) > 0) * on (device_id) group_left (name, ip) max by (device_id, name, ip) (harbor_device_info)", - "legendFormat": "{{name}} · {{ip}} · скачивание", - "range": true, - "refId": "A" - }, - { - "editorMode": "code", - "expr": "(sum by (device_id) (rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"upload\"}[$__rate_interval])) > 0) * on (device_id) group_left (name, ip) max by (device_id, name, ip) (harbor_device_info)", - "legendFormat": "{{name}} · {{ip}} · отправка", - "range": true, - "refId": "B" - } - ], - "title": "Скорость по устройствам", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "description": "Скорость выбранного scope по Gateway/Proxy и направлениям в выбранном периоде. Окно усреднения адаптируется к масштабу времени; скачивание показано вверх, отправка — вниз. Нулевые series скрыты.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": true, - "drawStyle": "line", - "fillOpacity": 8, - "lineInterpolation": "smooth", - "lineWidth": 2, - "pointSize": 4, - "scaleDistribution": { - "linearThreshold": 1048576, - "log": 2, - "type": "symlog" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - } - }, - "decimals": 1, - "noValue": "Нет данных за период", - "unit": "Bps" - }, - "overrides": [ - { - "matcher": { - "id": "byFrameRefID", - "options": "B" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 24, - "x": 0, - "y": 26 - }, - "id": 6, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "mean", - "max" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "targets": [ - { - "editorMode": "code", - "expr": "sum by (source) (rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"download\"}[$__rate_interval])) > 0", - "legendFormat": "{{source}} · скачивание", - "range": true, - "refId": "A" - }, - { - "editorMode": "code", - "expr": "sum by (source) (rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"upload\"}[$__rate_interval])) > 0", - "legendFormat": "{{source}} · отправка", - "range": true, - "refId": "B" - } - ], - "title": "Скорость по источникам и направлениям", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "description": "Трафик выбранного scope в текущем диапазоне времени. При «Все устройства» значения суммируются; нулевые series скрыты.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "decimals": 1, - "noValue": "Трафик не учтён", - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 24, - "x": 0, - "y": 35 - }, - "id": 7, - "options": { - "displayMode": "basic", - "maxVizHeight": 300, - "minVizHeight": 16, - "minVizWidth": 8, - "namePlacement": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showUnfilled": false, - "sizing": "auto", - "valueMode": "text" - }, - "targets": [ - { - "editorMode": "code", - "expr": "sum by (source, direction) (increase(harbor_device_traffic_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0", - "instant": true, - "legendFormat": "{{source}} · {{direction}}", - "range": false, - "refId": "A" - } - ], - "title": "Трафик по источникам и направлениям за выбранный период", - "type": "bargauge" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 42 - }, "id": 12, "panels": [], - "title": "Сервисы и домены", + "title": "Куда уходит трафик", "type": "row" }, { @@ -621,7 +660,7 @@ "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "description": "Сервисы всех устройств или одного устройства из единого фильтра «Устройства» по объёму traffic в текущем диапазоне времени. Нулевые серии скрыты.", + "description": "Основные назначения выбранных устройств по объёму трафика. Известные связанные домены объединены в сервисы; остальные сохраняются под своим доменным именем.", "fieldConfig": { "defaults": { "color": { @@ -634,10 +673,10 @@ "overrides": [] }, "gridPos": { - "h": 10, - "w": 10, + "h": 15, + "w": 9, "x": 0, - "y": 43 + "y": 18 }, "id": 8, "options": { @@ -668,7 +707,7 @@ "refId": "A" } ], - "title": "Сервисы за выбранный период", + "title": "Основные назначения", "type": "bargauge" }, { @@ -676,7 +715,7 @@ "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "description": "Домены всех устройств или одного устройства из единого фильтра «Устройства» в текущем диапазоне времени. При общем scope одинаковые домены суммируются; нулевые строки скрыты.", + "description": "Самые активные сайты и домены выбранных устройств в текущем диапазоне времени. При выборе всех устройств одинаковые домены суммируются.", "fieldConfig": { "defaults": { "color": { @@ -693,20 +732,57 @@ "noValue": "Нет данных за период", "unit": "bytes" }, - "overrides": [] + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "service" + }, + "properties": [ + { + "id": "custom.width", + "value": 180 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "domain" + }, + "properties": [ + { + "id": "custom.width", + "value": 300 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Value" + }, + "properties": [ + { + "id": "custom.width", + "value": 130 + } + ] + } + ] }, "gridPos": { - "h": 10, - "w": 14, - "x": 10, - "y": 43 + "h": 15, + "w": 15, + "x": 9, + "y": 18 }, "id": 9, "options": { "cellHeight": "sm", "footer": { "countRows": false, - "enablePagination": true, + "enablePagination": false, "fields": "", "reducer": [ "sum" @@ -732,7 +808,7 @@ "refId": "A" } ], - "title": "Домены за выбранный период", + "title": "Сайты и домены", "transformations": [ { "id": "organize", @@ -756,265 +832,159 @@ "type": "table" }, { - "collapsed": false, + "collapsed": true, "gridPos": { "h": 1, "w": 24, "x": 0, - "y": 53 + "y": 33 }, "id": 13, - "panels": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "Где выбранный трафик вошёл в Harbor: через Gateway или Proxy. Это техническая точка входа, а не выбор VPN / Direct.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "decimals": 1, + "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" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 34 + }, + "id": 4, + "options": { + "displayMode": "basic", + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "sizing": "auto", + "valueMode": "text" + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum by (source) (increase(harbor_device_traffic_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0", + "instant": true, + "legendFormat": "{{source}}", + "range": false, + "refId": "A" + } + ], + "title": "Точки входа Gateway / Proxy", + "type": "bargauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "IPv4-пакеты выбранных устройств, которые Gateway направил напрямую вместо sing-box: policy Direct или работа при остановленном VPN runtime. Это L3 packet bytes с заголовками и retransmit; их нельзя складывать с sing-box tracker bytes.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "decimals": 1, + "noValue": "Нет данных", + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 34 + }, + "id": 16, + "options": { + "displayMode": "basic", + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "sizing": "auto", + "valueMode": "text" + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum by (direction) (increase(harbor_device_direct_ipv4_packet_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0", + "instant": true, + "legendFormat": "{{direction}}", + "range": false, + "refId": "A" + } + ], + "title": "Direct IPv4 мимо sing-box", + "type": "bargauge" + } + ], "title": "Техническая детализация", "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "description": "Объём всех счётчиков Harbor в выбранном диапазоне времени.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "noValue": "Нет данных", - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 8, - "x": 0, - "y": 54 - }, - "id": 1, - "options": { - "colorMode": "none", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "auto", - "wideLayout": true - }, - "targets": [ - { - "editorMode": "code", - "expr": "sum(increase(harbor_traffic_bytes_total[$__range]))", - "instant": true, - "legendFormat": "Учтено Harbor", - "range": false, - "refId": "A" - } - ], - "title": "Весь Harbor за выбранный период", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "description": "Объём трафика Gateway и Proxy в выбранном диапазоне времени.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "decimals": 1, - "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" - } - } - ] - } - ] - }, - "gridPos": { - "h": 6, - "w": 16, - "x": 8, - "y": 54 - }, - "id": 4, - "options": { - "displayMode": "basic", - "maxVizHeight": 300, - "minVizHeight": 16, - "minVizWidth": 8, - "namePlacement": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showUnfilled": false, - "sizing": "auto", - "valueMode": "text" - }, - "targets": [ - { - "editorMode": "code", - "expr": "sum by (source) (increase(harbor_traffic_bytes_total[$__range])) > 0", - "instant": true, - "legendFormat": "{{source}}", - "range": false, - "refId": "A" - } - ], - "title": "Источники за выбранный период", - "type": "bargauge" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "description": "Наблюдённые sing-box байты по фактически выбранному outbound и источнику входа. Короткие соединения и хвост после последнего snapshot могут отсутствовать; это не физический трафик туннеля.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "decimals": 1, - "noValue": "Нет данных", - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 14, - "x": 0, - "y": 60 - }, - "id": 15, - "options": { - "displayMode": "basic", - "maxVizHeight": 300, - "minVizHeight": 16, - "minVizWidth": 8, - "namePlacement": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showUnfilled": false, - "sizing": "auto", - "valueMode": "text" - }, - "targets": [ - { - "editorMode": "code", - "expr": "sum by (outbound, source) (increase(harbor_singbox_tracked_bytes_total[$__range])) > 0", - "instant": true, - "legendFormat": "{{outbound}} · {{source}}", - "range": false, - "refId": "A" - } - ], - "title": "sing-box: VPN / Direct за выбранный период", - "type": "bargauge" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "description": "IPv4-пакеты, которые Gateway направил напрямую вместо sing-box: policy Direct или работа при остановленном VPN runtime. Это L3 packet bytes с заголовками и retransmit; их нельзя складывать с sing-box tracker bytes.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "decimals": 1, - "noValue": "Нет данных", - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 10, - "x": 14, - "y": 60 - }, - "id": 16, - "options": { - "displayMode": "basic", - "maxVizHeight": 300, - "minVizHeight": 16, - "minVizWidth": 8, - "namePlacement": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showUnfilled": false, - "sizing": "auto", - "valueMode": "text" - }, - "targets": [ - { - "editorMode": "code", - "expr": "sum by (direction) (increase(harbor_direct_ipv4_packet_bytes_total[$__range])) > 0", - "instant": true, - "legendFormat": "{{direction}}", - "range": false, - "refId": "A" - } - ], - "title": "Gateway Direct: IPv4-пакеты за выбранный период", - "type": "bargauge" } ], "refresh": "30s", @@ -1038,7 +1008,7 @@ "uid": "${DS_PROMETHEUS}" }, "definition": "query_result(label_join(max by (device_id, name, ip) (harbor_device_info), \"display\", \" · \", \"name\", \"ip\"))", - "description": "Единый scope для скорости, накопленного трафика, сервисов и доменов: все устройства или одно устройство по стабильному device_id.", + "description": "Выберите все устройства или одно устройство. Фильтр управляет скоростью, общим трафиком, маршрутами, сервисами, доменами и технической детализацией.", "hide": 0, "includeAll": true, "label": "Устройства", @@ -1074,6 +1044,6 @@ "timezone": "browser", "title": "Harbor Gateway: трафик", "uid": "harbor-gateway-traffic", - "version": 8, + "version": 9, "weekStart": "monday" } diff --git a/src/shared/versions.ts b/src/shared/versions.ts index d2c005b..b024597 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,6 +1,6 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.25.15', - gatewayClient: '0.26.14', + macClient: '0.25.16', + gatewayClient: '0.26.15', gatewayBackend: '0.26.5', }); diff --git a/test/web/prometheus-instructions.test.js b/test/web/prometheus-instructions.test.js index 564211d..0b7d31e 100644 --- a/test/web/prometheus-instructions.test.js +++ b/test/web/prometheus-instructions.test.js @@ -29,60 +29,104 @@ test('Gateway info drawer contains copyable Prometheus and Grafana instructions' assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-copy-feedback/); }); -test('Grafana dashboard uses one all-or-one device scope and shows active device speed', () => { - const expressions = dashboard.panels.flatMap((panel) => panel.targets || []).map(({ expr }) => expr).filter(Boolean); - const titles = dashboard.panels.map(({ title }) => title); +test('Grafana dashboard uses a compact user-first layout and one device scope', () => { + const flattenPanels = (items) => items.flatMap((item) => [item, ...flattenPanels(item.panels || [])]); + const panels = flattenPanels(dashboard.panels); + const byId = new Map(panels.map((panel) => [panel.id, panel])); + const expressions = panels.flatMap((panel) => panel.targets || []).map(({ expr }) => expr).filter(Boolean); const deviceVariable = dashboard.templating.list[0]; - const freshness = dashboard.panels.find(({ id }) => id === 2); - const globalSpeed = dashboard.panels.find(({ id }) => id === 3); - const topDevices = dashboard.panels.find(({ id }) => id === 5); - const sourceSpeed = dashboard.panels.find(({ id }) => id === 6); - const deviceSpeed = dashboard.panels.find(({ id }) => id === 14); - const sourceTraffic = dashboard.panels.find(({ id }) => id === 7); - const services = dashboard.panels.find(({ id }) => id === 8); - const domains = dashboard.panels.find(({ id }) => id === 9); - const totalTraffic = dashboard.panels.find(({ id }) => id === 1); - const trafficBySource = dashboard.panels.find(({ id }) => id === 4); - const singboxOutbounds = dashboard.panels.find(({ id }) => id === 15); - const directPackets = dashboard.panels.find(({ id }) => id === 16); - const speedPanels = [globalSpeed, deviceSpeed, sourceSpeed]; + const freshness = byId.get(2); + const speed = byId.get(3); + const devices = byId.get(5); + const currentDownload = byId.get(6); + const currentUpload = byId.get(7); + const services = byId.get(8); + const domains = byId.get(9); + const totalTraffic = byId.get(1); + const trafficBySource = byId.get(4); + const singboxOutbounds = byId.get(15); + const directPackets = byId.get(16); + const technical = byId.get(13); assert.equal(dashboard.title, 'Harbor Gateway: трафик'); assert.equal(dashboard.refresh, '30s'); - assert.equal(dashboard.version, 8); - assert.deepEqual(titles.filter((title) => ['Обзор', 'Устройства', 'Сервисы и домены', 'Техническая детализация'].includes(title)), [ + 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.deepEqual(dashboard.panels.filter(({ type }) => type === 'row').map(({ title }) => title), [ 'Обзор', - 'Устройства', - 'Сервисы и домены', + 'Куда уходит трафик', 'Техническая детализация', ]); - assert.ok(titles.includes('Скорость за выбранный период')); - assert.ok(titles.includes('Скорость по устройствам')); - assert.ok(titles.includes('Скорость по источникам и направлениям')); - assert.ok(titles.includes('Топ-10 устройств за выбранный период')); - assert.ok(titles.includes('Трафик по источникам и направлениям за выбранный период')); - assert.ok(titles.includes('Сервисы за выбранный период')); - assert.ok(titles.includes('Домены за выбранный период')); - assert.ok(titles.includes('Весь Harbor за выбранный период')); - assert.ok(titles.includes('Источники за выбранный период')); - assert.ok(titles.includes('sing-box: VPN / Direct за выбранный период')); - assert.ok(titles.includes('Gateway Direct: IPv4-пакеты за выбранный период')); - assert.ok(expressions.some((expression) => expression.includes('harbor_traffic_bytes_total'))); - assert.ok(expressions.some((expression) => expression.includes('harbor_device_traffic_bytes_total'))); - assert.ok(expressions.some((expression) => expression.includes('harbor_device_domain_traffic_bytes_total{device_id=~"$device_id"}[$__range]'))); - assert.ok(expressions.some((expression) => expression.includes('harbor_domain_traffic_last_observed_timestamp_seconds'))); - assert.ok(expressions.some((expression) => expression.includes('harbor_direct_ipv4_packet_last_observed_timestamp_seconds'))); - assert.match(singboxOutbounds.targets[0].expr, /sum by \(outbound, source\).*harbor_singbox_tracked_bytes_total\[\$__range\]/); - assert.match(directPackets.targets[0].expr, /sum by \(direction\).*harbor_direct_ipv4_packet_bytes_total\[\$__range\]/); + assert.equal(technical.collapsed, true); + assert.deepEqual(technical.panels.map(({ id }) => id), [4, 16]); + 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.equal(devices.options.footer.enablePagination, false); + assert.equal(domains.options.footer.enablePagination, false); + + assert.equal(currentDownload.type, 'stat'); + assert.equal(currentUpload.type, 'stat'); + assert.equal(currentDownload.targets[0].instant, true); + assert.equal(currentUpload.targets[0].instant, true); + assert.match(currentDownload.title, /в конце/); + assert.match(currentUpload.title, /в конце/); + assert.match(currentDownload.description, /адаптивным окном усреднения/); + assert.match(currentUpload.description, /адаптивным окном усреднения/); + assert.match(currentDownload.targets[0].expr, /device_id=~"\$device_id"[\s\S]*direction="download"[\s\S]*\[\$__rate_interval\]/); + assert.match(currentUpload.targets[0].expr, /device_id=~"\$device_id"[\s\S]*direction="upload"[\s\S]*\[\$__rate_interval\]/); + assert.equal(totalTraffic.targets[0].expr, 'sum(increase(harbor_device_traffic_bytes_total{device_id=~"$device_id"}[$__range]))'); + + assert.equal(speed.fieldConfig.defaults.custom.axisCenteredZero, true); + assert.deepEqual(speed.fieldConfig.defaults.custom.scaleDistribution, { + linearThreshold: 1048576, + log: 2, + type: 'symlog', + }); + assert.deepEqual(speed.targets.map(({ refId }) => refId), ['A', 'B']); + 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.equal(speed.options.legend.placement, 'right'); + assert.deepEqual(speed.options.legend.calcs, ['lastNotNull', 'mean', 'max']); + + assert.deepEqual(singboxOutbounds.targets.map(({ legendFormat }) => legendFormat), ['VPN', 'Direct', 'Не определено']); + assert.ok(singboxOutbounds.targets.every(({ expr }) => ( + expr.includes('harbor_device_singbox_tracked_bytes_total') + && expr.includes('device_id=~"$device_id"') + && expr.includes('[$__range]') + ))); + assert.match(singboxOutbounds.targets[0].expr, /outbound="vpn"/); + assert.match(singboxOutbounds.targets[1].expr, /outbound="direct"/); + 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(topDevices.targets[0].expr, /^topk\(10,[\s\S]*increase\(harbor_device_traffic_bytes_total\[\$__range\]\)[\s\S]*> 0[\s\S]*group_left \(name, ip\)/); - assert.equal(topDevices.options.sortBy[0].desc, true); - assert.match(topDevices.fieldConfig.overrides[1].properties[0].value[0].url, /var-device_id=\$\{__data\.fields/); - assert.doesNotMatch(topDevices.fieldConfig.overrides[1].properties[0].value[0].url, /domain_device_id/); + assert.match(trafficBySource.targets[0].expr, /harbor_device_traffic_bytes_total\{device_id=~"\$device_id"\}\[\$__range\]/); + + 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.equal(devices.options.sortBy[0].desc, true); + const deviceNameOverride = devices.fieldConfig.overrides.find(({ matcher }) => matcher.options === 'name'); + const deviceLink = deviceNameOverride.properties.find(({ id }) => id === 'links').value[0]; + assert.equal(deviceLink.url, '?' + '$' + '{__url_time_range}&var-device_id=' + '$' + '{__data.fields["device_id"]}'); + assert.ok(devices.fieldConfig.overrides.some(({ properties }) => properties.some(({ id }) => id === 'custom.width'))); + assert.deepEqual(freshness.fieldConfig.defaults.thresholds.steps.map(({ value }) => value), [null, 60, 120]); assert.equal(freshness.fieldConfig.defaults.noValue, 'Нет данных'); - assert.equal(freshness.title, 'Свежесть данных в конце выбранного периода'); - assert.equal(freshness.targets.find(({ refId }) => refId === 'C').legendFormat, 'Gateway Direct'); + assert.equal(freshness.title, 'Обновление данных'); + assert.equal(freshness.targets.length, 1); + assert.match(freshness.targets[0].expr, /harbor_traffic_last_observed_timestamp_seconds/); + assert.match(freshness.targets[0].expr, /harbor_domain_traffic_last_observed_timestamp_seconds/); + assert.match(freshness.targets[0].expr, /harbor_direct_ipv4_packet_last_observed_timestamp_seconds/); + assert.equal(deviceVariable.name, 'device_id'); assert.equal(deviceVariable.multi, false); assert.equal(deviceVariable.includeAll, true); @@ -93,34 +137,14 @@ test('Grafana dashboard uses one all-or-one device scope and shows active device assert.match(deviceVariable.query.query, /query_result\(label_join\(/); assert.match(deviceVariable.regex, //); assert.match(deviceVariable.regex, //); - for (const panel of speedPanels) { - assert.equal(panel.fieldConfig.defaults.custom.axisCenteredZero, true); - assert.deepEqual(panel.fieldConfig.defaults.custom.scaleDistribution, { - linearThreshold: 1048576, - log: 2, - type: 'symlog', - }); - assert.deepEqual(panel.targets.map(({ refId }) => refId), ['A', 'B']); - assert.ok(panel.targets.every(({ expr }) => expr.includes('[$__rate_interval]') && !expr.includes('[5m]'))); - assert.ok(panel.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(globalSpeed.targets.every(({ expr }) => expr.includes('device_id=~"$device_id"') && expr.endsWith('> 0'))); - assert.ok(deviceSpeed.targets.every(({ expr }) => /sum by \(device_id\)[\s\S]*device_id=~"\$device_id"[\s\S]*> 0[\s\S]*group_left \(name, ip\)/.test(expr))); - assert.ok(deviceSpeed.targets.every(({ legendFormat }) => /\{\{name\}\} · \{\{ip\}\}/.test(legendFormat))); - assert.ok(sourceSpeed.targets.every(({ expr }) => /device_id=~"\$device_id"[\s\S]*> 0$/.test(expr))); - assert.equal(totalTraffic.targets[0].expr, 'sum(increase(harbor_traffic_bytes_total[$__range]))'); - assert.equal(trafficBySource.targets[0].expr, 'sum by (source) (increase(harbor_traffic_bytes_total[$__range])) > 0'); - assert.match(sourceTraffic.targets[0].expr, /increase\(harbor_device_traffic_bytes_total\{device_id=~"\$device_id"\}\[\$__range\]\)/); - assert.ok(dashboard.panels.every(({ title = '', description = '' }) => !/Накоплено|за всё время/.test(`${title} ${description}`))); + assert.match(services.targets[0].expr, /device_id=~"\$device_id"[\s\S]*> 0\)$/); assert.deepEqual(domains.transformations[0].options.indexByName, { service: 0, domain: 1, Value: 2 }); assert.match(domains.targets[0].expr, /^topk\(15,[\s\S]*device_id=~"\$device_id"[\s\S]*> 0\)$/); - assert.ok(dashboard.panels + assert.ok(panels.every(({ title = '', description = '' }) => !/Накоплено|за всё время/.test([title, description].join(' ')))); + assert.ok(panels .filter(({ type }) => ['bargauge', 'table'].includes(type)) .flatMap(({ targets }) => targets || []) .every(({ expr }) => expr.includes('> 0'))); + assert.ok(expressions.every((expression) => !expression.includes('harbor_singbox_tracked_bytes_total'))); });