Add Prometheus and Grafana monitoring instructions
This commit is contained in:
@@ -258,6 +258,24 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh |
|
|||||||
|
|
||||||
После изменения `.env` пересоздайте контейнер командой `up -d` — обычного `restart` недостаточно.
|
После изменения `.env` пересоздайте контейнер командой `up -d` — обычного `restart` недостаточно.
|
||||||
|
|
||||||
|
## Prometheus и Grafana
|
||||||
|
|
||||||
|
Gateway публикует уже накопленные Harbor traffic counters по адресу `http://<gateway>:3456/metrics`. Scrape не запускает дополнительный netfilter read и не меняет сохранённое состояние. Harbor обновляет snapshot раз в 15 секунд, поэтому рекомендуемый начальный scrape interval и refresh dashboard — 30 секунд:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: harbor_gateway
|
||||||
|
scrape_interval: 30s
|
||||||
|
scrape_timeout: 3s
|
||||||
|
metrics_path: /metrics
|
||||||
|
static_configs:
|
||||||
|
- targets: ["<gateway>:3456"]
|
||||||
|
```
|
||||||
|
|
||||||
|
`harbor_traffic_bytes_total` содержит общий накопленный объём по источникам Gateway/Proxy. `harbor_device_traffic_bytes_total` содержит upload/download по стабильному `device_id`; пользовательское название и текущий IP находятся в `harbor_device_info`. В метрики не входит direct/физический трафик вне Harbor или quota провайдера.
|
||||||
|
|
||||||
|
Готовый dashboard: [`monitoring/grafana/harbor-gateway.json`](monitoring/grafana/harbor-gateway.json). При импорте Grafana попросит выбрать Prometheus data source. Та же конфигурация и dashboard доступны для копирования в Gateway drawer «Как использовать» → «Prometheus и Grafana».
|
||||||
|
|
||||||
## Если что-то не работает
|
## Если что-то не работает
|
||||||
|
|
||||||
### Интерфейс не открывается
|
### Интерфейс не открывается
|
||||||
|
|||||||
@@ -0,0 +1,497 @@
|
|||||||
|
{
|
||||||
|
"__inputs": [
|
||||||
|
{
|
||||||
|
"name": "DS_PROMETHEUS",
|
||||||
|
"label": "Prometheus",
|
||||||
|
"description": "Prometheus data source with the Harbor Gateway target",
|
||||||
|
"type": "datasource",
|
||||||
|
"pluginId": "prometheus",
|
||||||
|
"pluginName": "Prometheus"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"annotations": {
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"description": "Harbor-accounted Gateway and Proxy traffic. Direct and physical device traffic outside Harbor is not included.",
|
||||||
|
"editable": true,
|
||||||
|
"fiscalYearStartMonth": 0,
|
||||||
|
"graphTooltip": 1,
|
||||||
|
"id": null,
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "continuous-GrYlRd"
|
||||||
|
},
|
||||||
|
"decimals": 1,
|
||||||
|
"unit": "bytes"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 6,
|
||||||
|
"w": 6,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 1,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"textMode": "auto",
|
||||||
|
"wideLayout": true
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum(harbor_traffic_bytes_total)",
|
||||||
|
"instant": true,
|
||||||
|
"legendFormat": "Учтено Harbor",
|
||||||
|
"range": false,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Всего учтено Harbor",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
},
|
||||||
|
"decimals": 0,
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "orange",
|
||||||
|
"value": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 120
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unit": "s"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 6,
|
||||||
|
"w": 6,
|
||||||
|
"x": 6,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 2,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"textMode": "auto",
|
||||||
|
"wideLayout": true
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "time() - min(harbor_traffic_last_observed_timestamp_seconds)",
|
||||||
|
"instant": true,
|
||||||
|
"legendFormat": "Возраст данных",
|
||||||
|
"range": false,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Свежесть счётчиков",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 12,
|
||||||
|
"lineInterpolation": "smooth",
|
||||||
|
"lineWidth": 2,
|
||||||
|
"pointSize": 4,
|
||||||
|
"showPoints": "never",
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"decimals": 1,
|
||||||
|
"unit": "Bps"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 9,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 3,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull",
|
||||||
|
"mean"
|
||||||
|
],
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum by (source) (rate(harbor_traffic_bytes_total[5m]))",
|
||||||
|
"legendFormat": "{{source}}",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Общая скорость за 5 минут",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"decimals": 1,
|
||||||
|
"unit": "bytes"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 7,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 9
|
||||||
|
},
|
||||||
|
"id": 4,
|
||||||
|
"options": {
|
||||||
|
"displayMode": "gradient",
|
||||||
|
"maxVizHeight": 300,
|
||||||
|
"minVizHeight": 16,
|
||||||
|
"minVizWidth": 8,
|
||||||
|
"namePlacement": "auto",
|
||||||
|
"orientation": "horizontal",
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"showUnfilled": true,
|
||||||
|
"sizing": "auto",
|
||||||
|
"valueMode": "color"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum by (source) (harbor_traffic_bytes_total)",
|
||||||
|
"instant": true,
|
||||||
|
"legendFormat": "{{source}}",
|
||||||
|
"range": false,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Объём по источникам",
|
||||||
|
"type": "bargauge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"align": "auto",
|
||||||
|
"cellOptions": {
|
||||||
|
"type": "auto"
|
||||||
|
},
|
||||||
|
"inspect": false
|
||||||
|
},
|
||||||
|
"decimals": 1,
|
||||||
|
"unit": "bytes"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 7,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 9
|
||||||
|
},
|
||||||
|
"id": 5,
|
||||||
|
"options": {
|
||||||
|
"cellHeight": "sm",
|
||||||
|
"footer": {
|
||||||
|
"countRows": false,
|
||||||
|
"enablePagination": true,
|
||||||
|
"fields": "",
|
||||||
|
"reducer": [
|
||||||
|
"sum"
|
||||||
|
],
|
||||||
|
"show": false
|
||||||
|
},
|
||||||
|
"showHeader": true
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum by (device_id) (harbor_device_traffic_bytes_total) * on (device_id) group_left (name, ip) max by (device_id, name, ip) (harbor_device_info)",
|
||||||
|
"format": "table",
|
||||||
|
"instant": true,
|
||||||
|
"legendFormat": "{{name}} · {{ip}}",
|
||||||
|
"range": false,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Устройства по объёму",
|
||||||
|
"transformations": [
|
||||||
|
{
|
||||||
|
"id": "organize",
|
||||||
|
"options": {
|
||||||
|
"excludeByName": {
|
||||||
|
"Time": true,
|
||||||
|
"device_id": true
|
||||||
|
},
|
||||||
|
"indexByName": {
|
||||||
|
"name": 0,
|
||||||
|
"ip": 1,
|
||||||
|
"Value": 2
|
||||||
|
},
|
||||||
|
"renameByName": {
|
||||||
|
"Value": "Учтено",
|
||||||
|
"ip": "IP",
|
||||||
|
"name": "Устройство"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 8,
|
||||||
|
"lineInterpolation": "smooth",
|
||||||
|
"lineWidth": 2,
|
||||||
|
"pointSize": 4,
|
||||||
|
"showPoints": "never",
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"decimals": 1,
|
||||||
|
"unit": "Bps"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 9,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 16
|
||||||
|
},
|
||||||
|
"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 (device_id, source, direction) (rate(harbor_device_traffic_bytes_total[5m])) * on (device_id) group_left (name, ip) max by (device_id, name, ip) (harbor_device_info{name=~\"$device\"})",
|
||||||
|
"legendFormat": "{{name}} · {{source}} · {{direction}}",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Скорость выбранных устройств",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"decimals": 1,
|
||||||
|
"unit": "bytes"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 7,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 25
|
||||||
|
},
|
||||||
|
"id": 7,
|
||||||
|
"options": {
|
||||||
|
"displayMode": "gradient",
|
||||||
|
"maxVizHeight": 300,
|
||||||
|
"minVizHeight": 16,
|
||||||
|
"minVizWidth": 8,
|
||||||
|
"namePlacement": "auto",
|
||||||
|
"orientation": "horizontal",
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"showUnfilled": true,
|
||||||
|
"sizing": "auto",
|
||||||
|
"valueMode": "color"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum by (device_id, source, direction) (harbor_device_traffic_bytes_total) * on (device_id) group_left (name, ip) max by (device_id, name, ip) (harbor_device_info{name=~\"$device\"})",
|
||||||
|
"instant": true,
|
||||||
|
"legendFormat": "{{name}} · {{source}} · {{direction}}",
|
||||||
|
"range": false,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Объём выбранных устройств",
|
||||||
|
"type": "bargauge"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "30s",
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"tags": [
|
||||||
|
"harbor",
|
||||||
|
"gateway",
|
||||||
|
"traffic"
|
||||||
|
],
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"allValue": ".*",
|
||||||
|
"current": {
|
||||||
|
"selected": true,
|
||||||
|
"text": "Все",
|
||||||
|
"value": "$__all"
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"definition": "label_values(harbor_device_info, name)",
|
||||||
|
"hide": 0,
|
||||||
|
"includeAll": true,
|
||||||
|
"label": "Устройство",
|
||||||
|
"multi": true,
|
||||||
|
"name": "device",
|
||||||
|
"options": [],
|
||||||
|
"query": {
|
||||||
|
"query": "label_values(harbor_device_info, name)",
|
||||||
|
"refId": "PrometheusVariableQueryEditor-VariableQuery"
|
||||||
|
},
|
||||||
|
"refresh": 1,
|
||||||
|
"regex": "",
|
||||||
|
"skipUrlSync": false,
|
||||||
|
"sort": 1,
|
||||||
|
"type": "query"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"from": "now-24h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"timepicker": {},
|
||||||
|
"timezone": "browser",
|
||||||
|
"title": "Harbor Gateway Traffic",
|
||||||
|
"uid": "harbor-gateway-traffic",
|
||||||
|
"version": 1,
|
||||||
|
"weekStart": "monday"
|
||||||
|
}
|
||||||
+7
-1
@@ -48,6 +48,7 @@ import {
|
|||||||
} from './services/deviceInventoryService.js';
|
} from './services/deviceInventoryService.js';
|
||||||
import { buildGatewayVersionInfo, buildVersionInfo } from './version.js';
|
import { buildGatewayVersionInfo, buildVersionInfo } from './version.js';
|
||||||
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
|
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
|
||||||
|
import { sendPrometheusMetrics } from './prometheusMetrics.js';
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 1_000_000;
|
const MAX_BODY_BYTES = 1_000_000;
|
||||||
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
||||||
@@ -860,7 +861,12 @@ function serveStatic(req, res) {
|
|||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
return req.url?.startsWith('/api/')
|
const requestUrl = new URL(req.url, `http://localhost:${settings.port}`);
|
||||||
|
if (requestUrl.pathname === '/metrics') {
|
||||||
|
if (!deviceInventory || req.method !== 'GET') throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
return sendPrometheusMetrics(res, deviceInventory.snapshot());
|
||||||
|
}
|
||||||
|
return requestUrl.pathname.startsWith('/api/')
|
||||||
? await handleApi(req, res)
|
? await handleApi(req, res)
|
||||||
: serveStatic(req, res);
|
: serveStatic(req, res);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
const COUNTER_PATTERN = /^\d+$/;
|
||||||
|
|
||||||
|
const labelValue = (value) => String(value ?? '')
|
||||||
|
.replaceAll('\\', '\\\\')
|
||||||
|
.replaceAll('\n', '\\n')
|
||||||
|
.replaceAll('"', '\\"');
|
||||||
|
|
||||||
|
const labels = (values) => Object.entries(values)
|
||||||
|
.map(([key, value]) => `${key}="${labelValue(value)}"`)
|
||||||
|
.join(',');
|
||||||
|
|
||||||
|
function counter(value) {
|
||||||
|
const decimal = String(value ?? '');
|
||||||
|
if (!COUNTER_PATTERN.test(decimal)) throw new Error(`Invalid Prometheus counter: ${decimal}`);
|
||||||
|
return decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestamp(value) {
|
||||||
|
const milliseconds = Date.parse(value);
|
||||||
|
return Number.isFinite(milliseconds) ? String(milliseconds / 1000) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function metric(lines, name, metricLabels, value) {
|
||||||
|
lines.push(`${name}{${labels(metricLabels)}} ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPrometheusMetrics(snapshot) {
|
||||||
|
const lines = [
|
||||||
|
'# HELP harbor_traffic_bytes_total Total traffic accounted by Harbor.',
|
||||||
|
'# TYPE harbor_traffic_bytes_total counter',
|
||||||
|
];
|
||||||
|
metric(lines, 'harbor_traffic_bytes_total', { source: 'gateway' }, counter(snapshot?.traffic?.gatewayBytes));
|
||||||
|
metric(lines, 'harbor_traffic_bytes_total', { source: 'proxy' }, counter(snapshot?.traffic?.proxyBytes));
|
||||||
|
|
||||||
|
const globalFreshness = [
|
||||||
|
['gateway', snapshot?.traffic?.gatewayObservedAt],
|
||||||
|
['proxy', snapshot?.traffic?.proxyObservedAt],
|
||||||
|
].map(([source, observedAt]) => [source, timestamp(observedAt)]).filter(([, observedAt]) => observedAt);
|
||||||
|
if (globalFreshness.length) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful Harbor traffic observation.',
|
||||||
|
'# TYPE harbor_traffic_last_observed_timestamp_seconds gauge',
|
||||||
|
);
|
||||||
|
for (const [source, observedAt] of globalFreshness) {
|
||||||
|
metric(lines, 'harbor_traffic_last_observed_timestamp_seconds', { source }, observedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = Array.isArray(snapshot?.devices) ? snapshot.devices : [];
|
||||||
|
if (devices.length) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_device_info Current Harbor device identity metadata.',
|
||||||
|
'# TYPE harbor_device_info gauge',
|
||||||
|
);
|
||||||
|
for (const device of devices) {
|
||||||
|
metric(lines, 'harbor_device_info', {
|
||||||
|
device_id: device.id,
|
||||||
|
name: device.alias || device.hostname || device.ip || device.id,
|
||||||
|
ip: device.ip || '',
|
||||||
|
}, '1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceTraffic = devices.flatMap((device) => [
|
||||||
|
timestamp(device.trafficObservedAt)
|
||||||
|
? [device, 'gateway', timestamp(device.trafficObservedAt), device.uploadBytes, device.downloadBytes]
|
||||||
|
: null,
|
||||||
|
timestamp(device.proxyTrafficObservedAt)
|
||||||
|
? [device, 'proxy', timestamp(device.proxyTrafficObservedAt), device.proxyUploadBytes, device.proxyDownloadBytes]
|
||||||
|
: null,
|
||||||
|
].filter(Boolean));
|
||||||
|
if (deviceTraffic.length) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_device_traffic_bytes_total Total traffic accounted by Harbor for a device.',
|
||||||
|
'# TYPE harbor_device_traffic_bytes_total counter',
|
||||||
|
);
|
||||||
|
for (const [device, source, , uploadBytes, downloadBytes] of deviceTraffic) {
|
||||||
|
metric(lines, 'harbor_device_traffic_bytes_total', {
|
||||||
|
device_id: device.id,
|
||||||
|
source,
|
||||||
|
direction: 'download',
|
||||||
|
}, counter(downloadBytes));
|
||||||
|
metric(lines, 'harbor_device_traffic_bytes_total', {
|
||||||
|
device_id: device.id,
|
||||||
|
source,
|
||||||
|
direction: 'upload',
|
||||||
|
}, counter(uploadBytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_device_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful device traffic observation.',
|
||||||
|
'# TYPE harbor_device_traffic_last_observed_timestamp_seconds gauge',
|
||||||
|
);
|
||||||
|
for (const [device, source, observedAt] of deviceTraffic) {
|
||||||
|
metric(lines, 'harbor_device_traffic_last_observed_timestamp_seconds', {
|
||||||
|
device_id: device.id,
|
||||||
|
source,
|
||||||
|
}, observedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendPrometheusMetrics(res, snapshot) {
|
||||||
|
const body = renderPrometheusMetrics(snapshot);
|
||||||
|
res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' });
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.18.2',
|
macClient: '0.19.0',
|
||||||
gatewayClient: '0.19.2',
|
gatewayClient: '0.20.0',
|
||||||
gatewayBackend: '0.19.0',
|
gatewayBackend: '0.20.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function parseVersion(value) {
|
export function parseVersion(value) {
|
||||||
|
|||||||
@@ -174,6 +174,22 @@ function InstructionStep({ step }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function InstructionBlock({ block, open, onToggle }) {
|
function InstructionBlock({ block, open, onToggle }) {
|
||||||
|
const [copyFeedback, setCopyFeedback] = useState(null);
|
||||||
|
const copyTimer = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => () => clearTimeout(copyTimer.current), []);
|
||||||
|
|
||||||
|
async function copyInstruction(action) {
|
||||||
|
clearTimeout(copyTimer.current);
|
||||||
|
try {
|
||||||
|
await copyText(action.text);
|
||||||
|
setCopyFeedback({ id: action.id, failed: false });
|
||||||
|
} catch {
|
||||||
|
setCopyFeedback({ id: action.id, failed: true });
|
||||||
|
}
|
||||||
|
copyTimer.current = setTimeout(() => setCopyFeedback(null), 800);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`client-instruction-block${open ? ' is-open' : ''}`}
|
className={`client-instruction-block${open ? ' is-open' : ''}`}
|
||||||
@@ -202,7 +218,30 @@ function InstructionBlock({ block, open, onToggle }) {
|
|||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
)}
|
)}
|
||||||
{block.code && <code>{block.code}</code>}
|
{block.code && (block.multilineCode
|
||||||
|
? <pre className="client-instruction-code"><code>{block.code}</code></pre>
|
||||||
|
: <code>{block.code}</code>)}
|
||||||
|
{block.copies && <div className="client-instruction-copies">
|
||||||
|
{block.copies.map((action) => {
|
||||||
|
const feedback = copyFeedback?.id === action.id ? copyFeedback : null;
|
||||||
|
return <div className="client-instruction-copy" key={action.id}>
|
||||||
|
<span>{action.label}</span>
|
||||||
|
<button
|
||||||
|
className={`client-copy-button client-instruction-copy-button${feedback ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyInstruction(action)}
|
||||||
|
>
|
||||||
|
<span className="client-copy-label">Скопировать</span>
|
||||||
|
{feedback && <span className="client-copy-feedback" aria-hidden="true">
|
||||||
|
{feedback.failed ? 'Ошибка' : 'Скопировано'}
|
||||||
|
</span>}
|
||||||
|
</button>
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
<span className="client-live-region" role="status" aria-live="polite">
|
||||||
|
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
|
||||||
|
</span>
|
||||||
|
</div>}
|
||||||
{block.note && <p className="client-instruction-note">{block.note}</p>}
|
{block.note && <p className="client-instruction-note">{block.note}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -685,6 +724,7 @@ export function ClientOverviewPage({
|
|||||||
const previousHasSubscriptionRef = useRef(hasSubscription);
|
const previousHasSubscriptionRef = useRef(hasSubscription);
|
||||||
confirmingDeleteRef.current = confirmingDelete;
|
confirmingDeleteRef.current = confirmingDelete;
|
||||||
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
||||||
|
const controlHost = window.location.host || `${gatewayAddress}:3456`;
|
||||||
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
||||||
const usage = subscriptionUsage(state?.userInfo);
|
const usage = subscriptionUsage(state?.userInfo);
|
||||||
const duration = connectionDurationParts(state?.singboxStartedAt, now);
|
const duration = connectionDurationParts(state?.singboxStartedAt, now);
|
||||||
@@ -704,6 +744,7 @@ export function ClientOverviewPage({
|
|||||||
isGateway,
|
isGateway,
|
||||||
host: gatewayAddress,
|
host: gatewayAddress,
|
||||||
port: state?.proxyPort || (isGateway ? 8080 : 8082),
|
port: state?.proxyPort || (isGateway ? 8080 : 8082),
|
||||||
|
controlHost,
|
||||||
});
|
});
|
||||||
const [instructionsIntro, ...instructionGuides] = instructions;
|
const [instructionsIntro, ...instructionGuides] = instructions;
|
||||||
const openInstruction = instructionGuides.find((block) => block.id === openInstructionId);
|
const openInstruction = instructionGuides.find((block) => block.id === openInstructionId);
|
||||||
|
|||||||
+25
-1
@@ -1,4 +1,6 @@
|
|||||||
export function instructionBlocks({ isGateway, host, port }) {
|
import { grafanaDashboardJson, prometheusScrapeConfig } from './prometheus.js';
|
||||||
|
|
||||||
|
export function instructionBlocks({ isGateway, host, port, controlHost }) {
|
||||||
const httpProxy = `http://${host}:${port}`;
|
const httpProxy = `http://${host}:${port}`;
|
||||||
const socksProxy = `socks5://${host}:${port}`;
|
const socksProxy = `socks5://${host}:${port}`;
|
||||||
|
|
||||||
@@ -82,6 +84,28 @@ export function instructionBlocks({ isGateway, host, port }) {
|
|||||||
`Для отката верните в это поле локальный адрес самого роутера вместо ${host}.`,
|
`Для отката верните в это поле локальный адрес самого роутера вместо ${host}.`,
|
||||||
],
|
],
|
||||||
note: 'Gateway и устройства должны находиться в одной локальной сети. Сначала проверьте настройку на одном устройстве вручную.',
|
note: 'Gateway и устройства должны находиться в одной локальной сети. Сначала проверьте настройку на одном устройстве вручную.',
|
||||||
|
}, {
|
||||||
|
id: 'prometheus',
|
||||||
|
label: 'Мониторинг',
|
||||||
|
title: 'Prometheus и Grafana',
|
||||||
|
summary: 'Готовые traffic metrics и dashboard для Gateway и отдельных устройств.',
|
||||||
|
paragraphs: [
|
||||||
|
`Prometheus забирает накопленные Harbor counters с http://${controlHost}/metrics. Ручка читает готовый snapshot и не запускает новый сбор трафика.`,
|
||||||
|
'Harbor обновляет traffic snapshot раз в 15 секунд, поэтому начальный scrape interval и refresh dashboard в 30 секунд не создают лишних одинаковых выборок.',
|
||||||
|
'Dashboard показывает общий объём и скорость, источники Gateway/Proxy, список устройств и выбранных клиентов по имени. В статистику входит только трафик, учтённый Harbor.',
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
'Добавьте блок ниже в prometheus.yml и перезагрузите Prometheus.',
|
||||||
|
'В Grafana добавьте этот Prometheus как data source.',
|
||||||
|
'Скопируйте dashboard JSON, откройте Dashboards → New → Import и вставьте его.',
|
||||||
|
],
|
||||||
|
code: prometheusScrapeConfig(controlHost),
|
||||||
|
multilineCode: true,
|
||||||
|
copies: [
|
||||||
|
{ id: 'prometheus-config', label: 'prometheus.yml', text: prometheusScrapeConfig(controlHost) },
|
||||||
|
{ id: 'grafana-dashboard', label: 'Grafana dashboard', text: grafanaDashboardJson },
|
||||||
|
],
|
||||||
|
note: 'Имя устройства берётся из заданного вами названия, затем из hostname или IP. Переименование не сбрасывает traffic series: счётчик привязан к стабильному device ID.',
|
||||||
}] : []),
|
}] : []),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import dashboard from '../../monitoring/grafana/harbor-gateway.json' with { type: 'json' };
|
||||||
|
|
||||||
|
export const grafanaDashboardJson = JSON.stringify(dashboard, null, 2);
|
||||||
|
|
||||||
|
export function prometheusScrapeConfig(controlHost) {
|
||||||
|
return `scrape_configs:
|
||||||
|
- job_name: harbor_gateway
|
||||||
|
scrape_interval: 30s
|
||||||
|
scrape_timeout: 3s
|
||||||
|
metrics_path: /metrics
|
||||||
|
static_configs:
|
||||||
|
- targets: ["${controlHost}"]`;
|
||||||
|
}
|
||||||
@@ -2577,6 +2577,56 @@ p {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-instruction-code {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in oklch, var(--client-control) 84%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-code code {
|
||||||
|
overflow: visible;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-copies {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-copy {
|
||||||
|
min-height: 34px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-copy > span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--client-text);
|
||||||
|
font-size: 10px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-copy-button {
|
||||||
|
width: 104px;
|
||||||
|
min-width: 104px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--client-accent);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.client-instruction-body .client-instruction-note {
|
.client-instruction-body .client-instruction-note {
|
||||||
padding: 11px 13px;
|
padding: 11px 13px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
renderPrometheusMetrics,
|
||||||
|
sendPrometheusMetrics,
|
||||||
|
} from '../../src/server/prometheusMetrics.js';
|
||||||
|
|
||||||
|
const observedAt = '2026-08-08T10:00:00.000Z';
|
||||||
|
const snapshot = {
|
||||||
|
traffic: {
|
||||||
|
gatewayBytes: '9007199254740993',
|
||||||
|
proxyBytes: '3000',
|
||||||
|
gatewayObservedAt: observedAt,
|
||||||
|
proxyObservedAt: observedAt,
|
||||||
|
},
|
||||||
|
devices: [{
|
||||||
|
id: 'dev_0011223344556677',
|
||||||
|
alias: 'ТВ "Зал"\\основной\nэкран',
|
||||||
|
hostname: 'tv.local',
|
||||||
|
ip: '192.168.50.7',
|
||||||
|
mac: '00:11:22:33:44:55',
|
||||||
|
uploadBytes: '9007199254740993',
|
||||||
|
downloadBytes: '100',
|
||||||
|
trafficObservedAt: observedAt,
|
||||||
|
proxyUploadBytes: '0',
|
||||||
|
proxyDownloadBytes: '0',
|
||||||
|
proxyTrafficObservedAt: null,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('Prometheus exposition keeps exact counters, stable identity and escaped names', () => {
|
||||||
|
const output = renderPrometheusMetrics(snapshot);
|
||||||
|
|
||||||
|
assert.match(output, /# TYPE harbor_traffic_bytes_total counter/);
|
||||||
|
assert.match(output, /harbor_traffic_bytes_total\{source="gateway"\} 9007199254740993/);
|
||||||
|
assert.match(output, /harbor_device_info\{device_id="dev_0011223344556677",name="ТВ \\"Зал\\"\\\\основной\\nэкран",ip="192\.168\.50\.7"\} 1/);
|
||||||
|
assert.match(output, /harbor_device_traffic_bytes_total\{device_id="dev_0011223344556677",source="gateway",direction="upload"\} 9007199254740993/);
|
||||||
|
assert.doesNotMatch(output, /harbor_device_traffic_bytes_total\{[^\n]*name=/);
|
||||||
|
assert.doesNotMatch(output, /harbor_device_traffic_bytes_total\{[^\n]*source="proxy"/);
|
||||||
|
assert.doesNotMatch(output, /00:11:22:33:44:55/);
|
||||||
|
assert.match(output, /harbor_device_traffic_last_observed_timestamp_seconds\{device_id="dev_0011223344556677",source="gateway"\} 1786183200/);
|
||||||
|
assert.equal(output.endsWith('\n'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Prometheus response uses the negotiated legacy text contract without mutating the snapshot', () => {
|
||||||
|
const before = structuredClone(snapshot);
|
||||||
|
const response = {
|
||||||
|
writeHead(status, headers) {
|
||||||
|
this.status = status;
|
||||||
|
this.headers = headers;
|
||||||
|
},
|
||||||
|
end(body) {
|
||||||
|
this.body = body;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
sendPrometheusMetrics(response, snapshot);
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(response.headers['content-type'], 'text/plain; version=0.0.4; charset=utf-8');
|
||||||
|
assert.match(response.body, /harbor_device_info/);
|
||||||
|
assert.deepEqual(snapshot, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid canonical counters fail the scrape instead of publishing corrupt values', () => {
|
||||||
|
const invalid = { traffic: { gatewayBytes: 'broken', proxyBytes: '0' }, devices: [] };
|
||||||
|
assert.throws(
|
||||||
|
() => renderPrometheusMetrics(invalid),
|
||||||
|
/Invalid Prometheus counter/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => sendPrometheusMetrics({ writeHead() { throw new Error('headers sent'); } }, invalid),
|
||||||
|
/Invalid Prometheus counter/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -112,10 +112,22 @@ test('subscription expiry uses Russian day forms', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('gateway receives router instructions while local client does not', () => {
|
test('gateway receives router instructions while local client does not', () => {
|
||||||
const gateway = instructionBlocks({ isGateway: true, host: '192.168.1.20', port: 8080 });
|
const gateway = instructionBlocks({
|
||||||
const client = instructionBlocks({ isGateway: false, host: '127.0.0.1', port: 8082 });
|
isGateway: true,
|
||||||
|
host: '192.168.1.20',
|
||||||
|
port: 8080,
|
||||||
|
controlHost: '192.168.1.20:3456',
|
||||||
|
});
|
||||||
|
const client = instructionBlocks({
|
||||||
|
isGateway: false,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 8082,
|
||||||
|
controlHost: '127.0.0.1:3456',
|
||||||
|
});
|
||||||
|
|
||||||
assert.equal(gateway.at(-1).id, 'router');
|
assert.equal(gateway.some((block) => block.id === 'router'), true);
|
||||||
|
assert.equal(gateway.some((block) => block.id === 'prometheus'), true);
|
||||||
assert.equal(client.some((block) => block.id === 'router'), false);
|
assert.equal(client.some((block) => block.id === 'router'), false);
|
||||||
|
assert.equal(client.some((block) => block.id === 'prometheus'), false);
|
||||||
assert.match(client.find((block) => block.id === 'vscode').code, /127\.0\.0\.1:8082/);
|
assert.match(client.find((block) => block.id === 'vscode').code, /127\.0\.0\.1:8082/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '../..');
|
||||||
|
const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
||||||
|
const instructions = fs.readFileSync(path.join(root, 'src/web/instructions.js'), 'utf8');
|
||||||
|
const prometheus = fs.readFileSync(path.join(root, 'src/web/prometheus.js'), 'utf8');
|
||||||
|
const server = fs.readFileSync(path.join(root, 'src/server/index.js'), 'utf8');
|
||||||
|
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
|
||||||
|
const dashboard = JSON.parse(fs.readFileSync(path.join(root, 'monitoring/grafana/harbor-gateway.json'), 'utf8'));
|
||||||
|
|
||||||
|
test('Gateway info drawer contains copyable Prometheus and Grafana instructions', () => {
|
||||||
|
assert.match(instructions, /\.\.\.\(isGateway \? \[\{/);
|
||||||
|
assert.match(instructions, /id: 'prometheus'/);
|
||||||
|
assert.match(instructions, /title: 'Prometheus и Grafana'/);
|
||||||
|
assert.match(instructions, /prometheusScrapeConfig\(controlHost\)/);
|
||||||
|
assert.match(instructions, /label: 'prometheus\.yml'/);
|
||||||
|
assert.match(instructions, /label: 'Grafana dashboard'/);
|
||||||
|
assert.match(prometheus, /monitoring\/grafana\/harbor-gateway\.json/);
|
||||||
|
assert.match(prometheus, /scrape_interval: 30s[\s\S]*metrics_path: \/metrics/);
|
||||||
|
assert.match(overview, /const controlHost = window\.location\.host/);
|
||||||
|
assert.match(overview, /client-copy-label">Скопировать/);
|
||||||
|
assert.doesNotMatch(overview, /prometheus-toggle|Prometheus<\/span><\/button>/);
|
||||||
|
assert.match(styles, /\.client-instruction-copy-button \{[\s\S]*width: 104px;[\s\S]*min-width: 104px/);
|
||||||
|
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-copy-feedback/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('metrics route reads the current snapshot before static fallback', () => {
|
||||||
|
const metricsRoute = server.indexOf("requestUrl.pathname === '/metrics'");
|
||||||
|
const staticFallback = server.indexOf(': serveStatic(req, res)');
|
||||||
|
|
||||||
|
assert.ok(metricsRoute >= 0 && metricsRoute < staticFallback);
|
||||||
|
assert.match(server, /requestUrl\.pathname === '\/metrics'[\s\S]*deviceInventory\.snapshot\(\)/);
|
||||||
|
assert.doesNotMatch(server.slice(metricsRoute, staticFallback), /deviceInventory\.refresh\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Grafana dashboard covers global and named per-device traffic', () => {
|
||||||
|
const expressions = dashboard.panels.flatMap((panel) => panel.targets || []).map(({ expr }) => expr).filter(Boolean);
|
||||||
|
const titles = dashboard.panels.map(({ title }) => title);
|
||||||
|
|
||||||
|
assert.equal(dashboard.title, 'Harbor Gateway Traffic');
|
||||||
|
assert.ok(titles.includes('Всего учтено Harbor'));
|
||||||
|
assert.ok(titles.includes('Общая скорость за 5 минут'));
|
||||||
|
assert.ok(titles.includes('Устройства по объёму'));
|
||||||
|
assert.ok(titles.includes('Скорость выбранных устройств'));
|
||||||
|
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('group_left (name, ip)')));
|
||||||
|
assert.equal(dashboard.templating.list[0].query.query, 'label_values(harbor_device_info, name)');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user