From 10888ac0128e9a2404381339e706f79c164d7da4 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sat, 8 Aug 2026 01:28:53 +0300 Subject: [PATCH] Add Prometheus and Grafana monitoring instructions --- README.md | 18 + monitoring/grafana/harbor-gateway.json | 497 ++++++++++++++++++++++ src/server/index.js | 8 +- src/server/prometheusMetrics.js | 109 +++++ src/shared/versions.js | 6 +- src/web/components/ClientOverviewPage.jsx | 43 +- src/web/instructions.js | 26 +- src/web/prometheus.js | 13 + src/web/styles.css | 50 +++ test/server/prometheus-metrics.test.js | 75 ++++ test/web/client-controls.test.js | 18 +- test/web/prometheus-instructions.test.js | 52 +++ 12 files changed, 906 insertions(+), 9 deletions(-) create mode 100644 monitoring/grafana/harbor-gateway.json create mode 100644 src/server/prometheusMetrics.js create mode 100644 src/web/prometheus.js create mode 100644 test/server/prometheus-metrics.test.js create mode 100644 test/web/prometheus-instructions.test.js diff --git a/README.md b/README.md index 43c0f6d..577a8e6 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,24 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh | После изменения `.env` пересоздайте контейнер командой `up -d` — обычного `restart` недостаточно. +## Prometheus и Grafana + +Gateway публикует уже накопленные Harbor traffic counters по адресу `http://: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: [":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». + ## Если что-то не работает ### Интерфейс не открывается diff --git a/monitoring/grafana/harbor-gateway.json b/monitoring/grafana/harbor-gateway.json new file mode 100644 index 0000000..9f5993d --- /dev/null +++ b/monitoring/grafana/harbor-gateway.json @@ -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" +} diff --git a/src/server/index.js b/src/server/index.js index 925fae1..864e92c 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -48,6 +48,7 @@ import { } from './services/deviceInventoryService.js'; import { buildGatewayVersionInfo, buildVersionInfo } from './version.js'; import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js'; +import { sendPrometheusMetrics } from './prometheusMetrics.js'; const MAX_BODY_BYTES = 1_000_000; const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000; @@ -860,7 +861,12 @@ function serveStatic(req, res) { const server = http.createServer(async (req, res) => { 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) : serveStatic(req, res); } catch (error) { diff --git a/src/server/prometheusMetrics.js b/src/server/prometheusMetrics.js new file mode 100644 index 0000000..20fe40f --- /dev/null +++ b/src/server/prometheusMetrics.js @@ -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); +} diff --git a/src/shared/versions.js b/src/shared/versions.js index 4b00b42..9f11722 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.18.2', - gatewayClient: '0.19.2', - gatewayBackend: '0.19.0', + macClient: '0.19.0', + gatewayClient: '0.20.0', + gatewayBackend: '0.20.0', }); export function parseVersion(value) { diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 586c190..dff025a 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -174,6 +174,22 @@ function InstructionStep({ step }) { } 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 (
)} - {block.code && {block.code}} + {block.code && (block.multilineCode + ?
{block.code}
+ : {block.code})} + {block.copies &&
+ {block.copies.map((action) => { + const feedback = copyFeedback?.id === action.id ? copyFeedback : null; + return
+ {action.label} + +
; + })} + + {copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''} + +
} {block.note &&

{block.note}

} @@ -685,6 +724,7 @@ export function ClientOverviewPage({ const previousHasSubscriptionRef = useRef(hasSubscription); confirmingDeleteRef.current = confirmingDelete; const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1'; + const controlHost = window.location.host || `${gatewayAddress}:3456`; const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress); const usage = subscriptionUsage(state?.userInfo); const duration = connectionDurationParts(state?.singboxStartedAt, now); @@ -704,6 +744,7 @@ export function ClientOverviewPage({ isGateway, host: gatewayAddress, port: state?.proxyPort || (isGateway ? 8080 : 8082), + controlHost, }); const [instructionsIntro, ...instructionGuides] = instructions; const openInstruction = instructionGuides.find((block) => block.id === openInstructionId); diff --git a/src/web/instructions.js b/src/web/instructions.js index 7f60a4c..425d893 100644 --- a/src/web/instructions.js +++ b/src/web/instructions.js @@ -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 socksProxy = `socks5://${host}:${port}`; @@ -82,6 +84,28 @@ export function instructionBlocks({ isGateway, host, port }) { `Для отката верните в это поле локальный адрес самого роутера вместо ${host}.`, ], 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.', }] : []), ]; } diff --git a/src/web/prometheus.js b/src/web/prometheus.js new file mode 100644 index 0000000..024288e --- /dev/null +++ b/src/web/prometheus.js @@ -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}"]`; +} diff --git a/src/web/styles.css b/src/web/styles.css index 91a35f2..bc137e3 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -2577,6 +2577,56 @@ p { 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 { padding: 11px 13px; border-radius: 12px; diff --git a/test/server/prometheus-metrics.test.js b/test/server/prometheus-metrics.test.js new file mode 100644 index 0000000..99eeb31 --- /dev/null +++ b/test/server/prometheus-metrics.test.js @@ -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/, + ); +}); diff --git a/test/web/client-controls.test.js b/test/web/client-controls.test.js index 3762cae..7b1016b 100644 --- a/test/web/client-controls.test.js +++ b/test/web/client-controls.test.js @@ -112,10 +112,22 @@ test('subscription expiry uses Russian day forms', () => { }); test('gateway receives router instructions while local client does not', () => { - const gateway = instructionBlocks({ isGateway: true, host: '192.168.1.20', port: 8080 }); - const client = instructionBlocks({ isGateway: false, host: '127.0.0.1', port: 8082 }); + const gateway = instructionBlocks({ + 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 === 'prometheus'), false); assert.match(client.find((block) => block.id === 'vscode').code, /127\.0\.0\.1:8082/); }); diff --git a/test/web/prometheus-instructions.test.js b/test/web/prometheus-instructions.test.js new file mode 100644 index 0000000..a88e8b5 --- /dev/null +++ b/test/web/prometheus-instructions.test.js @@ -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)'); +});