Add Prometheus and Grafana monitoring instructions
This commit is contained in:
@@ -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', () => {
|
||||
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/);
|
||||
});
|
||||
|
||||
@@ -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