Improve device traffic dashboard and domain attribution metrics
This commit is contained in:
@@ -272,11 +272,11 @@ 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_traffic_bytes_total` содержит общий накопленный объём по источникам Gateway/Proxy. `harbor_device_traffic_bytes_total` содержит upload/download по стабильному `device_id`; пользовательское название и текущий IP находятся в `harbor_device_info`. `harbor_device_domain_traffic_bytes_total` добавляет наблюдённые домен, сервис, источник и направление для каждого устройства. `harbor_domain_traffic_attribution_events_total{outcome}` помогает отличить нераспознанный hostname, неизвестное устройство и неподдерживаемый inbound без динамических high-cardinality labels.
|
||||||
|
|
||||||
Dashboard отделяет текущую скорость от значений за выбранный период и накопленных счётчиков. Top-10 устройств за период отсортирован по убыванию и служит переходом к детализации. Один общий фильтр по стабильному `device_id` управляет графиками устройства, сервисами и доменами; в списке он подписан как `name · ip`. Нулевые ranking-строки скрыты, а domain table показывает только сервис, домен и трафик. Автообновление настроено на 30 секунд; freshness предупреждает после 60 секунд и считает данные устаревшими после 120 секунд.
|
Dashboard отделяет текущую скорость от значений за выбранный период и накопленных счётчиков. Единый фильтр `Устройства` по умолчанию охватывает все устройства, но позволяет выбрать одно; список показывает `name · ip`, сохраняя стабильный `device_id` как значение. Он управляет графиками скорости, накопленным трафиком, сервисами и доменами. Отдельный график скорости по устройствам показывает одну суммарную линию на каждое активное устройство; нулевые устройства и source/direction series скрыты. Top-10 устройств за период отсортирован по убыванию и выбирает устройство в том же фильтре. Domain table показывает только сервис, домен и трафик. Автообновление настроено на 30 секунд; freshness предупреждает после 60 секунд и считает данные устаревшими после 120 секунд.
|
||||||
|
|
||||||
Domain counters снимаются с активных соединений sing-box раз в 2 секунды и хранятся в памяти dataplane до его перезапуска; историю и retention хранит Prometheus. YouTube и OpenAI / ChatGPT объединяются по известным связанным доменам в label `service`, остальные значения сохраняют домен как имя сервиса. Новые domain series сверх process limit складываются в `_other`. В метрики не входит физический трафик вне Harbor, устройства с policy Direct, ECH/IP-only соединения, соединения между двумя снимками и байты после последнего снимка перед закрытием или quota провайдера.
|
Domain counters снимаются с активных соединений sing-box раз в 2 секунды и хранятся в памяти dataplane до его перезапуска; историю и retention хранит Prometheus. Перед routing sing-box до 1 секунды распознаёт HTTP Host, TLS SNI и QUIC Server Name. YouTube и OpenAI / ChatGPT объединяются по известным связанным доменам в label `service`, остальные значения сохраняют домен как имя сервиса. Если устройство и Harbor source известны, но hostname недоступен (например, ECH или IP-only), трафик попадает в `domain="_unknown",service="Не распознано"` и не теряется. Новые domain series сверх process limit складываются в `_other`. В метрики не входит физический трафик вне Harbor, устройства с policy Direct, соединения между двумя снимками и байты после последнего снимка перед закрытием или quota провайдера.
|
||||||
|
|
||||||
Готовый dashboard: [`monitoring/grafana/harbor-gateway.json`](monitoring/grafana/harbor-gateway.json). При импорте Grafana попросит выбрать Prometheus data source. Та же конфигурация и dashboard доступны для копирования в Gateway drawer «Как использовать» → «Prometheus и Grafana».
|
Готовый dashboard: [`monitoring/grafana/harbor-gateway.json`](monitoring/grafana/harbor-gateway.json). При импорте Grafana попросит выбрать Prometheus data source. Та же конфигурация и dashboard доступны для копирования в Gateway drawer «Как использовать» → «Prometheus и Grafana».
|
||||||
|
|
||||||
|
|||||||
@@ -195,14 +195,14 @@
|
|||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(rate(harbor_device_traffic_bytes_total{direction=\"download\"}[5m]))",
|
"expr": "sum(rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"download\"}[5m])) > 0",
|
||||||
"legendFormat": "Скачивание",
|
"legendFormat": "Скачивание",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(rate(harbor_device_traffic_bytes_total{direction=\"upload\"}[5m]))",
|
"expr": "sum(rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"upload\"}[5m])) > 0",
|
||||||
"legendFormat": "Отправка",
|
"legendFormat": "Отправка",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "B"
|
"refId": "B"
|
||||||
@@ -337,7 +337,7 @@
|
|||||||
},
|
},
|
||||||
"id": 11,
|
"id": 11,
|
||||||
"panels": [],
|
"panels": [],
|
||||||
"title": "Устройство",
|
"title": "Устройства",
|
||||||
"type": "row"
|
"type": "row"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -345,7 +345,72 @@
|
|||||||
"type": "prometheus",
|
"type": "prometheus",
|
||||||
"uid": "${DS_PROMETHEUS}"
|
"uid": "${DS_PROMETHEUS}"
|
||||||
},
|
},
|
||||||
"description": "Скорость одного выбранного устройства по Gateway/Proxy и upload/download, средняя за последние 5 минут.",
|
"description": "Суммарная скорость Gateway/Proxy и upload/download для каждого активного устройства из фильтра, средняя за последние 5 минут. Нулевые устройства скрыты.",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 6,
|
||||||
|
"lineInterpolation": "smooth",
|
||||||
|
"lineWidth": 2,
|
||||||
|
"pointSize": 4,
|
||||||
|
"showPoints": "never",
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"decimals": 1,
|
||||||
|
"noValue": "Нет активного трафика",
|
||||||
|
"unit": "Bps"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 9,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 17
|
||||||
|
},
|
||||||
|
"id": 14,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull",
|
||||||
|
"mean",
|
||||||
|
"max"
|
||||||
|
],
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "(sum by (device_id) (rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\"}[5m])) > 0) * on (device_id) group_left (name, ip) max by (device_id, name, ip) (harbor_device_info)",
|
||||||
|
"legendFormat": "{{name}} · {{ip}}",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Скорость по устройствам сейчас",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"description": "Скорость выбранного scope по Gateway/Proxy и upload/download, средняя за последние 5 минут. При выборе «Все устройства» остаётся максимум четыре суммарные series; нулевые series скрыты.",
|
||||||
"fieldConfig": {
|
"fieldConfig": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"color": {
|
"color": {
|
||||||
@@ -374,7 +439,7 @@
|
|||||||
"h": 9,
|
"h": 9,
|
||||||
"w": 24,
|
"w": 24,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 17
|
"y": 26
|
||||||
},
|
},
|
||||||
"id": 6,
|
"id": 6,
|
||||||
"options": {
|
"options": {
|
||||||
@@ -396,13 +461,13 @@
|
|||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum by (source, direction) (rate(harbor_device_traffic_bytes_total{device_id=\"$device_id\"}[5m]))",
|
"expr": "sum by (source, direction) (rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\"}[5m])) > 0",
|
||||||
"legendFormat": "{{source}} · {{direction}}",
|
"legendFormat": "{{source}} · {{direction}}",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "${device_id:text}: скорость сейчас",
|
"title": "Скорость по источникам и направлениям сейчас",
|
||||||
"type": "timeseries"
|
"type": "timeseries"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -410,7 +475,7 @@
|
|||||||
"type": "prometheus",
|
"type": "prometheus",
|
||||||
"uid": "${DS_PROMETHEUS}"
|
"uid": "${DS_PROMETHEUS}"
|
||||||
},
|
},
|
||||||
"description": "Накопленные счётчики выбранного устройства за всё время хранения Harbor. Нулевые серии скрыты.",
|
"description": "Накопленные счётчики выбранного scope за всё время хранения Harbor. При «Все устройства» счётчики суммируются; нулевые series скрыты.",
|
||||||
"fieldConfig": {
|
"fieldConfig": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"color": {
|
"color": {
|
||||||
@@ -426,7 +491,7 @@
|
|||||||
"h": 7,
|
"h": 7,
|
||||||
"w": 24,
|
"w": 24,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 26
|
"y": 35
|
||||||
},
|
},
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"options": {
|
"options": {
|
||||||
@@ -450,14 +515,14 @@
|
|||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum by (source, direction) (harbor_device_traffic_bytes_total{device_id=\"$device_id\"}) > 0",
|
"expr": "sum by (source, direction) (harbor_device_traffic_bytes_total{device_id=~\"$device_id\"}) > 0",
|
||||||
"instant": true,
|
"instant": true,
|
||||||
"legendFormat": "{{source}} · {{direction}}",
|
"legendFormat": "{{source}} · {{direction}}",
|
||||||
"range": false,
|
"range": false,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "${device_id:text}: накоплено",
|
"title": "Накоплено по источникам и направлениям",
|
||||||
"type": "bargauge"
|
"type": "bargauge"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -466,7 +531,7 @@
|
|||||||
"h": 1,
|
"h": 1,
|
||||||
"w": 24,
|
"w": 24,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 33
|
"y": 42
|
||||||
},
|
},
|
||||||
"id": 12,
|
"id": 12,
|
||||||
"panels": [],
|
"panels": [],
|
||||||
@@ -478,7 +543,7 @@
|
|||||||
"type": "prometheus",
|
"type": "prometheus",
|
||||||
"uid": "${DS_PROMETHEUS}"
|
"uid": "${DS_PROMETHEUS}"
|
||||||
},
|
},
|
||||||
"description": "Сервисы выбранного устройства по объёму domain traffic в текущем диапазоне времени. Нулевые серии скрыты.",
|
"description": "Сервисы всех устройств или одного устройства из единого фильтра «Устройства» по объёму traffic в текущем диапазоне времени. Нулевые серии скрыты.",
|
||||||
"fieldConfig": {
|
"fieldConfig": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"color": {
|
"color": {
|
||||||
@@ -494,7 +559,7 @@
|
|||||||
"h": 10,
|
"h": 10,
|
||||||
"w": 10,
|
"w": 10,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 34
|
"y": 43
|
||||||
},
|
},
|
||||||
"id": 8,
|
"id": 8,
|
||||||
"options": {
|
"options": {
|
||||||
@@ -518,14 +583,14 @@
|
|||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "topk(10, sum by (service) (increase(harbor_device_domain_traffic_bytes_total{device_id=\"$device_id\"}[$__range])) > 0)",
|
"expr": "topk(10, sum by (service) (increase(harbor_device_domain_traffic_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0)",
|
||||||
"instant": true,
|
"instant": true,
|
||||||
"legendFormat": "{{service}}",
|
"legendFormat": "{{service}}",
|
||||||
"range": false,
|
"range": false,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "${device_id:text}: сервисы за период",
|
"title": "Сервисы за выбранный период",
|
||||||
"type": "bargauge"
|
"type": "bargauge"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -533,7 +598,7 @@
|
|||||||
"type": "prometheus",
|
"type": "prometheus",
|
||||||
"uid": "${DS_PROMETHEUS}"
|
"uid": "${DS_PROMETHEUS}"
|
||||||
},
|
},
|
||||||
"description": "Домены выбранного устройства в текущем диапазоне времени. Устройство и IP не повторяются в строках.",
|
"description": "Домены всех устройств или одного устройства из единого фильтра «Устройства» в текущем диапазоне времени. При общем scope одинаковые домены суммируются; нулевые строки скрыты.",
|
||||||
"fieldConfig": {
|
"fieldConfig": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"color": {
|
"color": {
|
||||||
@@ -556,7 +621,7 @@
|
|||||||
"h": 10,
|
"h": 10,
|
||||||
"w": 14,
|
"w": 14,
|
||||||
"x": 10,
|
"x": 10,
|
||||||
"y": 34
|
"y": 43
|
||||||
},
|
},
|
||||||
"id": 9,
|
"id": 9,
|
||||||
"options": {
|
"options": {
|
||||||
@@ -581,7 +646,7 @@
|
|||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "topk(15, sum by (service, domain) (increase(harbor_device_domain_traffic_bytes_total{device_id=\"$device_id\"}[$__range])) > 0)",
|
"expr": "topk(15, sum by (service, domain) (increase(harbor_device_domain_traffic_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0)",
|
||||||
"format": "table",
|
"format": "table",
|
||||||
"instant": true,
|
"instant": true,
|
||||||
"legendFormat": "{{service}} · {{domain}}",
|
"legendFormat": "{{service}} · {{domain}}",
|
||||||
@@ -589,7 +654,7 @@
|
|||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "${device_id:text}: домены за период",
|
"title": "Домены за выбранный период",
|
||||||
"transformations": [
|
"transformations": [
|
||||||
{
|
{
|
||||||
"id": "organize",
|
"id": "organize",
|
||||||
@@ -618,7 +683,7 @@
|
|||||||
"h": 1,
|
"h": 1,
|
||||||
"w": 24,
|
"w": 24,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 44
|
"y": 53
|
||||||
},
|
},
|
||||||
"id": 13,
|
"id": 13,
|
||||||
"panels": [],
|
"panels": [],
|
||||||
@@ -646,7 +711,7 @@
|
|||||||
"h": 6,
|
"h": 6,
|
||||||
"w": 8,
|
"w": 8,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 45
|
"y": 54
|
||||||
},
|
},
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"options": {
|
"options": {
|
||||||
@@ -729,7 +794,7 @@
|
|||||||
"h": 6,
|
"h": 6,
|
||||||
"w": 16,
|
"w": 16,
|
||||||
"x": 8,
|
"x": 8,
|
||||||
"y": 45
|
"y": 54
|
||||||
},
|
},
|
||||||
"id": 4,
|
"id": 4,
|
||||||
"options": {
|
"options": {
|
||||||
@@ -774,16 +839,21 @@
|
|||||||
"templating": {
|
"templating": {
|
||||||
"list": [
|
"list": [
|
||||||
{
|
{
|
||||||
"current": {},
|
"allValue": ".*",
|
||||||
|
"current": {
|
||||||
|
"selected": true,
|
||||||
|
"text": "Все устройства",
|
||||||
|
"value": "$__all"
|
||||||
|
},
|
||||||
"datasource": {
|
"datasource": {
|
||||||
"type": "prometheus",
|
"type": "prometheus",
|
||||||
"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": "Одно устройство для графиков, сервисов и доменов ниже. Значение фильтра — стабильный device_id.",
|
"description": "Единый scope для скорости, накопленного трафика, сервисов и доменов: все устройства или одно устройство по стабильному device_id.",
|
||||||
"hide": 0,
|
"hide": 0,
|
||||||
"includeAll": false,
|
"includeAll": true,
|
||||||
"label": "Устройство",
|
"label": "Устройства",
|
||||||
"multi": false,
|
"multi": false,
|
||||||
"name": "device_id",
|
"name": "device_id",
|
||||||
"options": [],
|
"options": [],
|
||||||
@@ -816,6 +886,6 @@
|
|||||||
"timezone": "browser",
|
"timezone": "browser",
|
||||||
"title": "Harbor Gateway: трафик",
|
"title": "Harbor Gateway: трафик",
|
||||||
"uid": "harbor-gateway-traffic",
|
"uid": "harbor-gateway-traffic",
|
||||||
"version": 3,
|
"version": 5,
|
||||||
"weekStart": "monday"
|
"weekStart": "monday"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,21 @@ export function renderPrometheusMetrics(snapshot) {
|
|||||||
);
|
);
|
||||||
lines.push(`harbor_domain_traffic_overflow_connections_total ${counter(domainTraffic.overflowConnections)}`);
|
lines.push(`harbor_domain_traffic_overflow_connections_total ${counter(domainTraffic.overflowConnections)}`);
|
||||||
}
|
}
|
||||||
|
const attributionEvents = domainTraffic?.attributionEvents;
|
||||||
|
if (attributionEvents) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_domain_traffic_attribution_events_total Connections with incomplete Harbor domain attribution.',
|
||||||
|
'# TYPE harbor_domain_traffic_attribution_events_total counter',
|
||||||
|
);
|
||||||
|
for (const outcome of ['unresolved_host', 'unknown_device', 'unsupported_source']) {
|
||||||
|
metric(
|
||||||
|
lines,
|
||||||
|
'harbor_domain_traffic_attribution_events_total',
|
||||||
|
{ outcome },
|
||||||
|
counter(attributionEvents[outcome]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return `${lines.join('\n')}\n`;
|
return `${lines.join('\n')}\n`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -431,6 +431,11 @@ export function createDeviceInventoryService({
|
|||||||
observedAt: null,
|
observedAt: null,
|
||||||
source: { error: null },
|
source: { error: null },
|
||||||
overflowConnections: '0',
|
overflowConnections: '0',
|
||||||
|
attributionEvents: {
|
||||||
|
unresolved_host: '0',
|
||||||
|
unknown_device: '0',
|
||||||
|
unsupported_source: '0',
|
||||||
|
},
|
||||||
series: [],
|
series: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { deviceId } from './deviceInventoryService.js';
|
|||||||
|
|
||||||
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||||
const DEFAULT_MAX_SERIES = 4096;
|
const DEFAULT_MAX_SERIES = 4096;
|
||||||
|
const UNKNOWN_DOMAIN = { domain: '_unknown', service: 'Не распознано' };
|
||||||
|
const ATTRIBUTION_OUTCOMES = ['unresolved_host', 'unknown_device', 'unsupported_source'];
|
||||||
const SERVICE_DOMAINS = [
|
const SERVICE_DOMAINS = [
|
||||||
['YouTube', ['youtube.com', 'youtube-nocookie.com', 'youtu.be', 'googlevideo.com', 'ytimg.com']],
|
['YouTube', ['youtube.com', 'youtube-nocookie.com', 'youtu.be', 'googlevideo.com', 'ytimg.com']],
|
||||||
['OpenAI / ChatGPT', ['chatgpt.com', 'openai.com', 'oaistatic.com', 'oaiusercontent.com']],
|
['OpenAI / ChatGPT', ['chatgpt.com', 'openai.com', 'oaistatic.com', 'oaiusercontent.com']],
|
||||||
@@ -35,23 +37,29 @@ function sourceFor(type) {
|
|||||||
function parseConnection(connection, devicesByIp) {
|
function parseConnection(connection, devicesByIp) {
|
||||||
const id = String(connection?.id || '');
|
const id = String(connection?.id || '');
|
||||||
const metadata = connection?.metadata;
|
const metadata = connection?.metadata;
|
||||||
const source = sourceFor(String(metadata?.type || ''));
|
|
||||||
const domain = classifyDomain(metadata?.host);
|
|
||||||
const currentDeviceId = devicesByIp.get(String(metadata?.sourceIP || ''));
|
|
||||||
const upload = connection?.upload;
|
const upload = connection?.upload;
|
||||||
const download = connection?.download;
|
const download = connection?.download;
|
||||||
if (!id || !Number.isSafeInteger(upload) || upload < 0
|
if (!id || !Number.isSafeInteger(upload) || upload < 0
|
||||||
|| !Number.isSafeInteger(download) || download < 0) {
|
|| !Number.isSafeInteger(download) || download < 0) {
|
||||||
throw new Error('Sing-box вернул невалидный domain traffic counter');
|
throw new Error('Sing-box вернул невалидный domain traffic counter');
|
||||||
}
|
}
|
||||||
if (!source || !domain || !currentDeviceId) return null;
|
const parsed = {
|
||||||
return {
|
|
||||||
id,
|
id,
|
||||||
|
upload: BigInt(upload),
|
||||||
|
download: BigInt(download),
|
||||||
|
};
|
||||||
|
const source = sourceFor(String(metadata?.type || ''));
|
||||||
|
if (!source) return { ...parsed, outcome: 'unsupported_source' };
|
||||||
|
const currentDeviceId = devicesByIp.get(String(metadata?.sourceIP || ''));
|
||||||
|
if (!currentDeviceId) return { ...parsed, outcome: 'unknown_device' };
|
||||||
|
const classifiedDomain = classifyDomain(metadata?.host);
|
||||||
|
const domain = classifiedDomain || UNKNOWN_DOMAIN;
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
outcome: classifiedDomain ? 'classified' : 'unresolved_host',
|
||||||
deviceId: currentDeviceId,
|
deviceId: currentDeviceId,
|
||||||
...domain,
|
...domain,
|
||||||
source,
|
source,
|
||||||
upload: BigInt(upload),
|
|
||||||
download: BigInt(download),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,12 +110,14 @@ export function createDomainTrafficService({
|
|||||||
let normalSeries = 0;
|
let normalSeries = 0;
|
||||||
let previousConnections = new Map();
|
let previousConnections = new Map();
|
||||||
let overflowConnections = 0n;
|
let overflowConnections = 0n;
|
||||||
|
const attributionEvents = Object.fromEntries(ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, 0n]));
|
||||||
let refreshPromise = null;
|
let refreshPromise = null;
|
||||||
let current = {
|
let current = {
|
||||||
epoch,
|
epoch,
|
||||||
observedAt: null,
|
observedAt: null,
|
||||||
source: { error: null },
|
source: { error: null },
|
||||||
overflowConnections: '0',
|
overflowConnections: '0',
|
||||||
|
attributionEvents: Object.fromEntries(ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, '0'])),
|
||||||
series: [],
|
series: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,6 +127,9 @@ export function createDomainTrafficService({
|
|||||||
observedAt: current.observedAt,
|
observedAt: current.observedAt,
|
||||||
source: { error },
|
source: { error },
|
||||||
overflowConnections: overflowConnections.toString(),
|
overflowConnections: overflowConnections.toString(),
|
||||||
|
attributionEvents: Object.fromEntries(
|
||||||
|
ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]),
|
||||||
|
),
|
||||||
series: [...totals.values()]
|
series: [...totals.values()]
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
@@ -144,13 +157,22 @@ export function createDomainTrafficService({
|
|||||||
if (!net.isIPv4(ip) || !id) continue;
|
if (!net.isIPv4(ip) || !id) continue;
|
||||||
devicesByIp.set(ip, devicesByIp.has(ip) ? null : id);
|
devicesByIp.set(ip, devicesByIp.has(ip) ? null : id);
|
||||||
}
|
}
|
||||||
const parsed = response.connections
|
|
||||||
.map((connection) => parseConnection(connection, devicesByIp))
|
|
||||||
.filter(Boolean);
|
|
||||||
const activeConnections = new Map();
|
const activeConnections = new Map();
|
||||||
for (const connection of parsed) {
|
for (const rawConnection of response.connections) {
|
||||||
const requestedKey = `${connection.deviceId}\0${connection.domain}\0${connection.source}`;
|
const connection = parseConnection(rawConnection, devicesByIp);
|
||||||
const previous = previousConnections.get(connection.id);
|
const previous = previousConnections.get(connection.id);
|
||||||
|
if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) {
|
||||||
|
attributionEvents[connection.outcome] += 1n;
|
||||||
|
}
|
||||||
|
if (connection.outcome === 'unknown_device' || connection.outcome === 'unsupported_source') {
|
||||||
|
activeConnections.set(connection.id, {
|
||||||
|
outcome: connection.outcome,
|
||||||
|
countedUpload: previous?.countedUpload ?? null,
|
||||||
|
countedDownload: previous?.countedDownload ?? null,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const requestedKey = `${connection.deviceId}\0${connection.domain}\0${connection.source}`;
|
||||||
let key = previous?.requestedKey === requestedKey ? previous.key : requestedKey;
|
let key = previous?.requestedKey === requestedKey ? previous.key : requestedKey;
|
||||||
let domain = connection.domain;
|
let domain = connection.domain;
|
||||||
let service = connection.service;
|
let service = connection.service;
|
||||||
@@ -165,12 +187,11 @@ export function createDomainTrafficService({
|
|||||||
} else if (!totals.has(key)) {
|
} else if (!totals.has(key)) {
|
||||||
normalSeries += 1;
|
normalSeries += 1;
|
||||||
}
|
}
|
||||||
const sameSeries = previous?.key === key;
|
const uploadDelta = previous?.countedUpload != null && connection.upload >= previous.countedUpload
|
||||||
const uploadDelta = sameSeries && connection.upload >= previous.upload
|
? connection.upload - previous.countedUpload
|
||||||
? connection.upload - previous.upload
|
|
||||||
: connection.upload;
|
: connection.upload;
|
||||||
const downloadDelta = sameSeries && connection.download >= previous.download
|
const downloadDelta = previous?.countedDownload != null && connection.download >= previous.countedDownload
|
||||||
? connection.download - previous.download
|
? connection.download - previous.countedDownload
|
||||||
: connection.download;
|
: connection.download;
|
||||||
const total = totals.get(key) || {
|
const total = totals.get(key) || {
|
||||||
deviceId: key === requestedKey ? connection.deviceId : '_other',
|
deviceId: key === requestedKey ? connection.deviceId : '_other',
|
||||||
@@ -184,10 +205,11 @@ export function createDomainTrafficService({
|
|||||||
total.downloadBytes += downloadDelta;
|
total.downloadBytes += downloadDelta;
|
||||||
totals.set(key, total);
|
totals.set(key, total);
|
||||||
activeConnections.set(connection.id, {
|
activeConnections.set(connection.id, {
|
||||||
|
outcome: connection.outcome,
|
||||||
key,
|
key,
|
||||||
requestedKey,
|
requestedKey,
|
||||||
upload: connection.upload,
|
countedUpload: connection.upload,
|
||||||
download: connection.download,
|
countedDownload: connection.download,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
previousConnections = activeConnections;
|
previousConnections = activeConnections;
|
||||||
|
|||||||
+14
-4
@@ -8,6 +8,8 @@ const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteri
|
|||||||
const MIXED_INBOUND = 'mixed-in';
|
const MIXED_INBOUND = 'mixed-in';
|
||||||
const TPROXY_INBOUND = 'tproxy-in';
|
const TPROXY_INBOUND = 'tproxy-in';
|
||||||
const DIAGNOSTICS_INBOUND = 'diagnostics-vpn-in';
|
const DIAGNOSTICS_INBOUND = 'diagnostics-vpn-in';
|
||||||
|
const SNIFF_TIMEOUT = '1s';
|
||||||
|
const SNIFFERS = ['http', 'tls', 'quic'];
|
||||||
|
|
||||||
function findOutbound(subscriptionConfig, selectedTag) {
|
function findOutbound(subscriptionConfig, selectedTag) {
|
||||||
const outbounds = Array.isArray(subscriptionConfig?.outbounds)
|
const outbounds = Array.isArray(subscriptionConfig?.outbounds)
|
||||||
@@ -39,15 +41,12 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, {
|
|||||||
tag: TPROXY_INBOUND,
|
tag: TPROXY_INBOUND,
|
||||||
listen: '::',
|
listen: '::',
|
||||||
listen_port: settings.tproxyPort,
|
listen_port: settings.tproxyPort,
|
||||||
sniff: true,
|
|
||||||
sniff_override_destination: true,
|
|
||||||
}] : []),
|
}] : []),
|
||||||
{
|
{
|
||||||
type: 'mixed',
|
type: 'mixed',
|
||||||
tag: MIXED_INBOUND,
|
tag: MIXED_INBOUND,
|
||||||
listen: settings.bindIp,
|
listen: settings.bindIp,
|
||||||
listen_port: settings.proxyPort,
|
listen_port: settings.proxyPort,
|
||||||
sniff: true,
|
|
||||||
set_system_proxy: false,
|
set_system_proxy: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -55,7 +54,6 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, {
|
|||||||
tag: DIAGNOSTICS_INBOUND,
|
tag: DIAGNOSTICS_INBOUND,
|
||||||
listen: '127.0.0.1',
|
listen: '127.0.0.1',
|
||||||
listen_port: settings.diagnosticsProxyPort,
|
listen_port: settings.diagnosticsProxyPort,
|
||||||
sniff: true,
|
|
||||||
set_system_proxy: false,
|
set_system_proxy: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -64,11 +62,23 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, {
|
|||||||
.map((rule) => ({ [rule.type]: [rule.value], outbound: 'direct' }));
|
.map((rule) => ({ [rule.type]: [rule.value], outbound: 'direct' }));
|
||||||
const rules = clientMode
|
const rules = clientMode
|
||||||
? [
|
? [
|
||||||
|
{
|
||||||
|
inbound: [MIXED_INBOUND, DIAGNOSTICS_INBOUND],
|
||||||
|
action: 'sniff',
|
||||||
|
sniffer: SNIFFERS,
|
||||||
|
timeout: SNIFF_TIMEOUT,
|
||||||
|
},
|
||||||
{ inbound: [DIAGNOSTICS_INBOUND], outbound: vpnOutbound.tag },
|
{ inbound: [DIAGNOSTICS_INBOUND], outbound: vpnOutbound.tag },
|
||||||
...directRules,
|
...directRules,
|
||||||
{ inbound: [MIXED_INBOUND], outbound: outboundTag },
|
{ inbound: [MIXED_INBOUND], outbound: outboundTag },
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
|
{
|
||||||
|
inbound: [TPROXY_INBOUND, MIXED_INBOUND, DIAGNOSTICS_INBOUND],
|
||||||
|
action: 'sniff',
|
||||||
|
sniffer: SNIFFERS,
|
||||||
|
timeout: SNIFF_TIMEOUT,
|
||||||
|
},
|
||||||
{ inbound: [DIAGNOSTICS_INBOUND], outbound: outboundTag },
|
{ inbound: [DIAGNOSTICS_INBOUND], outbound: outboundTag },
|
||||||
...directRules,
|
...directRules,
|
||||||
{ inbound: [TPROXY_INBOUND], outbound: outboundTag },
|
{ inbound: [TPROXY_INBOUND], outbound: outboundTag },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.20.1',
|
macClient: '0.20.4',
|
||||||
gatewayClient: '0.21.1',
|
gatewayClient: '0.21.3',
|
||||||
gatewayBackend: '0.21.0',
|
gatewayBackend: '0.21.1',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function parseVersion(value) {
|
export function parseVersion(value) {
|
||||||
|
|||||||
@@ -92,7 +92,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 секунд не создают лишних одинаковых выборок.',
|
||||||
'Dashboard показывает свежесть и текущую скорость, Top-10 устройств за период и детализацию одного выбранного устройства по стабильному ID. Сервисы и домены используют тот же фильтр; накопленные счётчики вынесены в техническую секцию.',
|
'Единый фильтр «Устройства» управляет скоростью, накопленным трафиком, сервисами и доменами для всех или одного устройства. Отдельный график показывает текущую скорость каждого активного устройства; нулевые series скрыты.',
|
||||||
],
|
],
|
||||||
steps: [
|
steps: [
|
||||||
'Добавьте блок ниже в prometheus.yml и перезагрузите Prometheus.',
|
'Добавьте блок ниже в prometheus.yml и перезагрузите Prometheus.',
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ test('domain traffic accumulates connection deltas by device, service and source
|
|||||||
let response = { connections: [
|
let response = { connections: [
|
||||||
connection('youtube', 'tproxy/tproxy-in', 'r1.googlevideo.com', 10, 100),
|
connection('youtube', 'tproxy/tproxy-in', 'r1.googlevideo.com', 10, 100),
|
||||||
connection('chatgpt', 'mixed/mixed-in', 'www.chatgpt.com.', 20, 200),
|
connection('chatgpt', 'mixed/mixed-in', 'www.chatgpt.com.', 20, 200),
|
||||||
|
connection('hostless', 'tproxy/tproxy-in', '', 5, 50),
|
||||||
connection('diagnostics', 'mixed/diagnostics-vpn-in', 'example.com', 30, 300),
|
connection('diagnostics', 'mixed/diagnostics-vpn-in', 'example.com', 30, 300),
|
||||||
connection('unknown-device', 'tproxy/tproxy-in', 'example.net', 40, 400, '192.168.50.99'),
|
connection('unknown-device', 'tproxy/tproxy-in', 'example.net', 40, 400, '192.168.50.99'),
|
||||||
] };
|
] };
|
||||||
@@ -34,6 +35,7 @@ test('domain traffic accumulates connection deltas by device, service and source
|
|||||||
response = { connections: [
|
response = { connections: [
|
||||||
connection('youtube', 'tproxy/tproxy-in', 'r1.googlevideo.com', 15, 130),
|
connection('youtube', 'tproxy/tproxy-in', 'r1.googlevideo.com', 15, 130),
|
||||||
connection('chatgpt', 'mixed/mixed-in', 'www.chatgpt.com.', 22, 260),
|
connection('chatgpt', 'mixed/mixed-in', 'www.chatgpt.com.', 22, 260),
|
||||||
|
connection('hostless', 'tproxy/tproxy-in', '', 7, 70),
|
||||||
] };
|
] };
|
||||||
observedAt = new Date('2026-08-08T10:00:02.000Z');
|
observedAt = new Date('2026-08-08T10:00:02.000Z');
|
||||||
await service.refresh();
|
await service.refresh();
|
||||||
@@ -56,7 +58,20 @@ test('domain traffic accumulates connection deltas by device, service and source
|
|||||||
uploadBytes: '15',
|
uploadBytes: '15',
|
||||||
downloadBytes: '130',
|
downloadBytes: '130',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
deviceId: id,
|
||||||
|
domain: '_unknown',
|
||||||
|
service: 'Не распознано',
|
||||||
|
source: 'gateway',
|
||||||
|
uploadBytes: '7',
|
||||||
|
downloadBytes: '70',
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
assert.deepEqual(service.snapshot().attributionEvents, {
|
||||||
|
unresolved_host: '1',
|
||||||
|
unknown_device: '1',
|
||||||
|
unsupported_source: '1',
|
||||||
|
});
|
||||||
assert.equal(service.snapshot().observedAt, observedAt.toISOString());
|
assert.equal(service.snapshot().observedAt, observedAt.toISOString());
|
||||||
assert.equal(service.snapshot().source.error, null);
|
assert.equal(service.snapshot().source.error, null);
|
||||||
|
|
||||||
@@ -65,6 +80,38 @@ test('domain traffic accumulates connection deltas by device, service and source
|
|||||||
assert.equal(service.snapshot().series[1].downloadBytes, '130');
|
assert.equal(service.snapshot().series[1].downloadBytes, '130');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a hostless connection can become classified without counting its bytes twice', async () => {
|
||||||
|
let response = { connections: [
|
||||||
|
connection('video', 'tproxy/tproxy-in', '', 10, 100),
|
||||||
|
] };
|
||||||
|
const service = createDomainTrafficService({
|
||||||
|
observe: async () => response,
|
||||||
|
devices: () => [device],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.refresh();
|
||||||
|
response = { connections: [
|
||||||
|
connection('video', 'tproxy/tproxy-in', 'r2.googlevideo.com', 15, 130),
|
||||||
|
] };
|
||||||
|
await service.refresh();
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.fromEntries(service.snapshot().series.map((series) => [series.domain, {
|
||||||
|
uploadBytes: series.uploadBytes,
|
||||||
|
downloadBytes: series.downloadBytes,
|
||||||
|
}])),
|
||||||
|
{
|
||||||
|
_unknown: { uploadBytes: '10', downloadBytes: '100' },
|
||||||
|
'googlevideo.com': { uploadBytes: '5', downloadBytes: '30' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.deepEqual(service.snapshot().attributionEvents, {
|
||||||
|
unresolved_host: '1',
|
||||||
|
unknown_device: '0',
|
||||||
|
unsupported_source: '0',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('domain traffic is bounded and keeps the last good snapshot on source failure', async () => {
|
test('domain traffic is bounded and keeps the last good snapshot on source failure', async () => {
|
||||||
let fail = false;
|
let fail = false;
|
||||||
const service = createDomainTrafficService({
|
const service = createDomainTrafficService({
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ const snapshot = {
|
|||||||
domainTraffic: {
|
domainTraffic: {
|
||||||
observedAt,
|
observedAt,
|
||||||
overflowConnections: '2',
|
overflowConnections: '2',
|
||||||
|
attributionEvents: {
|
||||||
|
unresolved_host: '3',
|
||||||
|
unknown_device: '4',
|
||||||
|
unsupported_source: '5',
|
||||||
|
},
|
||||||
series: [{
|
series: [{
|
||||||
deviceId: 'dev_0011223344556677',
|
deviceId: 'dev_0011223344556677',
|
||||||
domain: 'chatgpt.com',
|
domain: 'chatgpt.com',
|
||||||
@@ -54,6 +59,10 @@ test('Prometheus exposition keeps exact counters, stable identity and escaped na
|
|||||||
assert.match(output, /harbor_device_domain_traffic_bytes_total\{device_id="dev_0011223344556677",domain="chatgpt\.com",service="OpenAI \/ ChatGPT",source="proxy",direction="download"\} 345/);
|
assert.match(output, /harbor_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/);
|
||||||
|
assert.match(output, /# TYPE harbor_domain_traffic_attribution_events_total counter/);
|
||||||
|
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unresolved_host"\} 3/);
|
||||||
|
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unknown_device"\} 4/);
|
||||||
|
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unsupported_source"\} 5/);
|
||||||
assert.doesNotMatch(output, /harbor_device_domain_traffic_bytes_total\{[^\n]*name=/);
|
assert.doesNotMatch(output, /harbor_device_domain_traffic_bytes_total\{[^\n]*name=/);
|
||||||
assert.equal(output.endsWith('\n'), true);
|
assert.equal(output.endsWith('\n'), true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ test('client exposes one local proxy and routes local exceptions before the sele
|
|||||||
assert.equal(config.inbounds[0].listen_port, 8082);
|
assert.equal(config.inbounds[0].listen_port, 8082);
|
||||||
assert.equal(config.inbounds[1].listen_port, 18080);
|
assert.equal(config.inbounds[1].listen_port, 18080);
|
||||||
assert.deepEqual(config.route.rules, [
|
assert.deepEqual(config.route.rules, [
|
||||||
|
{
|
||||||
|
inbound: ['mixed-in', 'diagnostics-vpn-in'],
|
||||||
|
action: 'sniff',
|
||||||
|
sniffer: ['http', 'tls', 'quic'],
|
||||||
|
timeout: '1s',
|
||||||
|
},
|
||||||
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
|
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
|
||||||
{ domain_suffix: ['ru'], outbound: 'direct' },
|
{ domain_suffix: ['ru'], outbound: 'direct' },
|
||||||
{ domain: ['example.com'], outbound: 'direct' },
|
{ domain: ['example.com'], outbound: 'direct' },
|
||||||
@@ -53,6 +59,12 @@ test('client keeps its local proxy but routes directly when Harbor Gateway is ah
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(config.route.rules, [
|
assert.deepEqual(config.route.rules, [
|
||||||
|
{
|
||||||
|
inbound: ['mixed-in', 'diagnostics-vpn-in'],
|
||||||
|
action: 'sniff',
|
||||||
|
sniffer: ['http', 'tls', 'quic'],
|
||||||
|
timeout: '1s',
|
||||||
|
},
|
||||||
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
|
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
|
||||||
{ domain_suffix: ['ru'], outbound: 'direct' },
|
{ domain_suffix: ['ru'], outbound: 'direct' },
|
||||||
{ inbound: ['mixed-in'], outbound: 'direct' },
|
{ inbound: ['mixed-in'], outbound: 'direct' },
|
||||||
|
|||||||
@@ -37,10 +37,15 @@ test('gateway routes .ru domains directly and other traffic through the selected
|
|||||||
tag: 'diagnostics-vpn-in',
|
tag: 'diagnostics-vpn-in',
|
||||||
listen: '127.0.0.1',
|
listen: '127.0.0.1',
|
||||||
listen_port: 18080,
|
listen_port: 18080,
|
||||||
sniff: true,
|
|
||||||
set_system_proxy: false,
|
set_system_proxy: false,
|
||||||
});
|
});
|
||||||
assert.deepEqual(config.route.rules, [
|
assert.deepEqual(config.route.rules, [
|
||||||
|
{
|
||||||
|
inbound: ['tproxy-in', 'mixed-in', 'diagnostics-vpn-in'],
|
||||||
|
action: 'sniff',
|
||||||
|
sniffer: ['http', 'tls', 'quic'],
|
||||||
|
timeout: '1s',
|
||||||
|
},
|
||||||
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
|
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
|
||||||
{ domain_suffix: ['ru'], outbound: 'direct' },
|
{ domain_suffix: ['ru'], outbound: 'direct' },
|
||||||
{ inbound: ['tproxy-in'], outbound: 'test-vpn' },
|
{ inbound: ['tproxy-in'], outbound: 'test-vpn' },
|
||||||
|
|||||||
@@ -432,7 +432,13 @@ setInterval(() => {}, 60_000);
|
|||||||
]);
|
]);
|
||||||
assert.equal(routed.state.route.localRulesPendingRestart, false);
|
assert.equal(routed.state.route.localRulesPendingRestart, false);
|
||||||
assert.deepEqual(routed.state.route.activeLocalRules, routed.state.route.localRules);
|
assert.deepEqual(routed.state.route.activeLocalRules, routed.state.route.localRules);
|
||||||
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 3), [
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 4), [
|
||||||
|
{
|
||||||
|
inbound: ['mixed-in', 'diagnostics-vpn-in'],
|
||||||
|
action: 'sniff',
|
||||||
|
sniffer: ['http', 'tls', 'quic'],
|
||||||
|
timeout: '1s',
|
||||||
|
},
|
||||||
{ inbound: ['diagnostics-vpn-in'], outbound: testServerId },
|
{ inbound: ['diagnostics-vpn-in'], outbound: testServerId },
|
||||||
{ domain: ['example.com'], outbound: 'direct' },
|
{ domain: ['example.com'], outbound: 'direct' },
|
||||||
{ domain_suffix: ['example.org'], outbound: 'direct' },
|
{ domain_suffix: ['example.org'], outbound: 'direct' },
|
||||||
|
|||||||
@@ -36,42 +36,61 @@ test('metrics route reads the current snapshot before static fallback', () => {
|
|||||||
assert.doesNotMatch(server.slice(metricsRoute, staticFallback), /deviceInventory\.refresh\(/);
|
assert.doesNotMatch(server.slice(metricsRoute, staticFallback), /deviceInventory\.refresh\(/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Grafana dashboard is organized around period ranking and one stable device selection', () => {
|
test('Grafana dashboard uses one all-or-one device scope and shows active device speed', () => {
|
||||||
const expressions = dashboard.panels.flatMap((panel) => panel.targets || []).map(({ expr }) => expr).filter(Boolean);
|
const expressions = dashboard.panels.flatMap((panel) => panel.targets || []).map(({ expr }) => expr).filter(Boolean);
|
||||||
const titles = dashboard.panels.map(({ title }) => title);
|
const titles = dashboard.panels.map(({ title }) => title);
|
||||||
const deviceVariable = dashboard.templating.list[0];
|
const deviceVariable = dashboard.templating.list[0];
|
||||||
const freshness = dashboard.panels.find(({ id }) => id === 2);
|
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 topDevices = dashboard.panels.find(({ id }) => id === 5);
|
||||||
|
const sourceSpeed = dashboard.panels.find(({ id }) => id === 6);
|
||||||
|
const deviceSpeed = dashboard.panels.find(({ id }) => id === 14);
|
||||||
|
const services = dashboard.panels.find(({ id }) => id === 8);
|
||||||
const domains = dashboard.panels.find(({ id }) => id === 9);
|
const domains = dashboard.panels.find(({ id }) => id === 9);
|
||||||
|
|
||||||
assert.equal(dashboard.title, 'Harbor Gateway: трафик');
|
assert.equal(dashboard.title, 'Harbor Gateway: трафик');
|
||||||
assert.equal(dashboard.refresh, '30s');
|
assert.equal(dashboard.refresh, '30s');
|
||||||
assert.deepEqual(titles.filter((title) => ['Обзор', 'Устройство', 'Сервисы и домены', 'Техническая детализация'].includes(title)), [
|
assert.deepEqual(titles.filter((title) => ['Обзор', 'Устройства', 'Сервисы и домены', 'Техническая детализация'].includes(title)), [
|
||||||
'Обзор',
|
'Обзор',
|
||||||
'Устройство',
|
'Устройства',
|
||||||
'Сервисы и домены',
|
'Сервисы и домены',
|
||||||
'Техническая детализация',
|
'Техническая детализация',
|
||||||
]);
|
]);
|
||||||
assert.ok(titles.includes('Скорость сейчас, средняя за 5 минут'));
|
assert.ok(titles.includes('Скорость сейчас, средняя за 5 минут'));
|
||||||
|
assert.ok(titles.includes('Скорость по устройствам сейчас'));
|
||||||
|
assert.ok(titles.includes('Скорость по источникам и направлениям сейчас'));
|
||||||
assert.ok(titles.includes('Топ-10 устройств за выбранный период'));
|
assert.ok(titles.includes('Топ-10 устройств за выбранный период'));
|
||||||
assert.ok(titles.includes('${device_id:text}: накоплено'));
|
assert.ok(titles.includes('Накоплено по источникам и направлениям'));
|
||||||
assert.ok(titles.includes('${device_id:text}: сервисы за период'));
|
assert.ok(titles.includes('Сервисы за выбранный период'));
|
||||||
assert.ok(titles.includes('${device_id:text}: домены за период'));
|
assert.ok(titles.includes('Домены за выбранный период'));
|
||||||
assert.ok(expressions.some((expression) => expression.includes('harbor_traffic_bytes_total')));
|
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_traffic_bytes_total')));
|
||||||
assert.ok(expressions.some((expression) => expression.includes('harbor_device_domain_traffic_bytes_total{device_id="$device_id"}[$__range]')));
|
assert.ok(expressions.some((expression) => expression.includes('harbor_device_domain_traffic_bytes_total{device_id=~"$device_id"}[$__range]')));
|
||||||
assert.ok(expressions.some((expression) => expression.includes('harbor_domain_traffic_last_observed_timestamp_seconds')));
|
assert.ok(expressions.some((expression) => expression.includes('harbor_domain_traffic_last_observed_timestamp_seconds')));
|
||||||
assert.match(topDevices.targets[0].expr, /^topk\(10,[\s\S]*increase\(harbor_device_traffic_bytes_total\[\$__range\]\)[\s\S]*> 0[\s\S]*group_left \(name, ip\)/);
|
assert.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.equal(topDevices.options.sortBy[0].desc, true);
|
||||||
assert.match(topDevices.fieldConfig.overrides[1].properties[0].value[0].url, /var-device_id=\$\{__data\.fields/);
|
assert.match(topDevices.fieldConfig.overrides[1].properties[0].value[0].url, /var-device_id=\$\{__data\.fields/);
|
||||||
|
assert.doesNotMatch(topDevices.fieldConfig.overrides[1].properties[0].value[0].url, /domain_device_id/);
|
||||||
assert.deepEqual(freshness.fieldConfig.defaults.thresholds.steps.map(({ value }) => value), [null, 60, 120]);
|
assert.deepEqual(freshness.fieldConfig.defaults.thresholds.steps.map(({ value }) => value), [null, 60, 120]);
|
||||||
assert.equal(freshness.fieldConfig.defaults.noValue, 'Нет данных');
|
assert.equal(freshness.fieldConfig.defaults.noValue, 'Нет данных');
|
||||||
assert.equal(deviceVariable.name, 'device_id');
|
assert.equal(deviceVariable.name, 'device_id');
|
||||||
assert.equal(deviceVariable.multi, false);
|
assert.equal(deviceVariable.multi, false);
|
||||||
assert.equal(deviceVariable.includeAll, false);
|
assert.equal(deviceVariable.includeAll, true);
|
||||||
|
assert.equal(deviceVariable.allValue, '.*');
|
||||||
|
assert.equal(deviceVariable.current.value, '$__all');
|
||||||
|
assert.equal(dashboard.templating.list.length, 1);
|
||||||
assert.match(deviceVariable.query.query, /query_result\(label_join\(/);
|
assert.match(deviceVariable.query.query, /query_result\(label_join\(/);
|
||||||
assert.match(deviceVariable.regex, /<text>/);
|
assert.match(deviceVariable.regex, /<text>/);
|
||||||
assert.match(deviceVariable.regex, /<value>/);
|
assert.match(deviceVariable.regex, /<value>/);
|
||||||
|
assert.ok(globalSpeed.targets.every(({ expr }) => expr.includes('device_id=~"$device_id"') && expr.endsWith('> 0')));
|
||||||
|
assert.match(deviceSpeed.targets[0].expr, /sum by \(device_id\)[\s\S]*device_id=~"\$device_id"[\s\S]*> 0[\s\S]*group_left \(name, ip\)/);
|
||||||
|
assert.match(deviceSpeed.targets[0].legendFormat, /\{\{name\}\} · \{\{ip\}\}/);
|
||||||
|
assert.match(sourceSpeed.targets[0].expr, /device_id=~"\$device_id"[\s\S]*> 0$/);
|
||||||
|
assert.match(services.targets[0].expr, /device_id=~"\$device_id"[\s\S]*> 0\)$/);
|
||||||
assert.deepEqual(domains.transformations[0].options.indexByName, { service: 0, domain: 1, Value: 2 });
|
assert.deepEqual(domains.transformations[0].options.indexByName, { service: 0, domain: 1, Value: 2 });
|
||||||
assert.match(domains.targets[0].expr, /^topk\(15,[\s\S]*> 0\)$/);
|
assert.match(domains.targets[0].expr, /^topk\(15,[\s\S]*device_id=~"\$device_id"[\s\S]*> 0\)$/);
|
||||||
|
assert.ok(dashboard.panels
|
||||||
|
.filter(({ type }) => ['bargauge', 'table'].includes(type))
|
||||||
|
.flatMap(({ targets }) => targets || [])
|
||||||
|
.every(({ expr }) => expr.includes('> 0')));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user