Add Prometheus and Grafana monitoring instructions
This commit is contained in:
+7
-1
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
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) {
|
||||
|
||||
@@ -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 (
|
||||
<section
|
||||
className={`client-instruction-block${open ? ' is-open' : ''}`}
|
||||
@@ -202,7 +218,30 @@ function InstructionBlock({ block, open, onToggle }) {
|
||||
))}
|
||||
</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>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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);
|
||||
|
||||
+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 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.',
|
||||
}] : []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
Reference in New Issue
Block a user