Improve device traffic dashboard and domain attribution metrics
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 14s

This commit is contained in:
2026-08-08 03:15:45 +03:00
parent 6e701d4fc6
commit 6b17f1982b
14 changed files with 289 additions and 69 deletions
+15
View File
@@ -136,6 +136,21 @@ export function renderPrometheusMetrics(snapshot) {
);
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`;
}
@@ -431,6 +431,11 @@ export function createDeviceInventoryService({
observedAt: null,
source: { error: null },
overflowConnections: '0',
attributionEvents: {
unresolved_host: '0',
unknown_device: '0',
unsupported_source: '0',
},
series: [],
};
+41 -19
View File
@@ -6,6 +6,8 @@ import { deviceId } from './deviceInventoryService.js';
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
const DEFAULT_MAX_SERIES = 4096;
const UNKNOWN_DOMAIN = { domain: '_unknown', service: 'Не распознано' };
const ATTRIBUTION_OUTCOMES = ['unresolved_host', 'unknown_device', 'unsupported_source'];
const SERVICE_DOMAINS = [
['YouTube', ['youtube.com', 'youtube-nocookie.com', 'youtu.be', 'googlevideo.com', 'ytimg.com']],
['OpenAI / ChatGPT', ['chatgpt.com', 'openai.com', 'oaistatic.com', 'oaiusercontent.com']],
@@ -35,23 +37,29 @@ function sourceFor(type) {
function parseConnection(connection, devicesByIp) {
const id = String(connection?.id || '');
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 download = connection?.download;
if (!id || !Number.isSafeInteger(upload) || upload < 0
|| !Number.isSafeInteger(download) || download < 0) {
throw new Error('Sing-box вернул невалидный domain traffic counter');
}
if (!source || !domain || !currentDeviceId) return null;
return {
const parsed = {
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,
...domain,
source,
upload: BigInt(upload),
download: BigInt(download),
};
}
@@ -102,12 +110,14 @@ export function createDomainTrafficService({
let normalSeries = 0;
let previousConnections = new Map();
let overflowConnections = 0n;
const attributionEvents = Object.fromEntries(ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, 0n]));
let refreshPromise = null;
let current = {
epoch,
observedAt: null,
source: { error: null },
overflowConnections: '0',
attributionEvents: Object.fromEntries(ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, '0'])),
series: [],
};
@@ -117,6 +127,9 @@ export function createDomainTrafficService({
observedAt: current.observedAt,
source: { error },
overflowConnections: overflowConnections.toString(),
attributionEvents: Object.fromEntries(
ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]),
),
series: [...totals.values()]
.map((entry) => ({
...entry,
@@ -144,13 +157,22 @@ export function createDomainTrafficService({
if (!net.isIPv4(ip) || !id) continue;
devicesByIp.set(ip, devicesByIp.has(ip) ? null : id);
}
const parsed = response.connections
.map((connection) => parseConnection(connection, devicesByIp))
.filter(Boolean);
const activeConnections = new Map();
for (const connection of parsed) {
const requestedKey = `${connection.deviceId}\0${connection.domain}\0${connection.source}`;
for (const rawConnection of response.connections) {
const connection = parseConnection(rawConnection, devicesByIp);
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 domain = connection.domain;
let service = connection.service;
@@ -165,12 +187,11 @@ export function createDomainTrafficService({
} else if (!totals.has(key)) {
normalSeries += 1;
}
const sameSeries = previous?.key === key;
const uploadDelta = sameSeries && connection.upload >= previous.upload
? connection.upload - previous.upload
const uploadDelta = previous?.countedUpload != null && connection.upload >= previous.countedUpload
? connection.upload - previous.countedUpload
: connection.upload;
const downloadDelta = sameSeries && connection.download >= previous.download
? connection.download - previous.download
const downloadDelta = previous?.countedDownload != null && connection.download >= previous.countedDownload
? connection.download - previous.countedDownload
: connection.download;
const total = totals.get(key) || {
deviceId: key === requestedKey ? connection.deviceId : '_other',
@@ -184,10 +205,11 @@ export function createDomainTrafficService({
total.downloadBytes += downloadDelta;
totals.set(key, total);
activeConnections.set(connection.id, {
outcome: connection.outcome,
key,
requestedKey,
upload: connection.upload,
download: connection.download,
countedUpload: connection.upload,
countedDownload: connection.download,
});
}
previousConnections = activeConnections;
+14 -4
View File
@@ -8,6 +8,8 @@ const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteri
const MIXED_INBOUND = 'mixed-in';
const TPROXY_INBOUND = 'tproxy-in';
const DIAGNOSTICS_INBOUND = 'diagnostics-vpn-in';
const SNIFF_TIMEOUT = '1s';
const SNIFFERS = ['http', 'tls', 'quic'];
function findOutbound(subscriptionConfig, selectedTag) {
const outbounds = Array.isArray(subscriptionConfig?.outbounds)
@@ -39,15 +41,12 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, {
tag: TPROXY_INBOUND,
listen: '::',
listen_port: settings.tproxyPort,
sniff: true,
sniff_override_destination: true,
}] : []),
{
type: 'mixed',
tag: MIXED_INBOUND,
listen: settings.bindIp,
listen_port: settings.proxyPort,
sniff: true,
set_system_proxy: false,
},
{
@@ -55,7 +54,6 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, {
tag: DIAGNOSTICS_INBOUND,
listen: '127.0.0.1',
listen_port: settings.diagnosticsProxyPort,
sniff: true,
set_system_proxy: false,
},
];
@@ -64,11 +62,23 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, {
.map((rule) => ({ [rule.type]: [rule.value], outbound: 'direct' }));
const rules = clientMode
? [
{
inbound: [MIXED_INBOUND, DIAGNOSTICS_INBOUND],
action: 'sniff',
sniffer: SNIFFERS,
timeout: SNIFF_TIMEOUT,
},
{ inbound: [DIAGNOSTICS_INBOUND], outbound: vpnOutbound.tag },
...directRules,
{ inbound: [MIXED_INBOUND], outbound: outboundTag },
]
: [
{
inbound: [TPROXY_INBOUND, MIXED_INBOUND, DIAGNOSTICS_INBOUND],
action: 'sniff',
sniffer: SNIFFERS,
timeout: SNIFF_TIMEOUT,
},
{ inbound: [DIAGNOSTICS_INBOUND], outbound: outboundTag },
...directRules,
{ inbound: [TPROXY_INBOUND], outbound: outboundTag },