diff --git a/web/package.json b/web/package.json
index c758ff38..69ff99b6 100644
--- a/web/package.json
+++ b/web/package.json
@@ -37,6 +37,7 @@
"nodemailer": "^7.0.12",
"nuqs": "^2.8.6",
"pg": "^8.16.3",
+ "qrcode": "^1.5.4",
"react": "19.2.7",
"react-dom": "19.2.7",
"recharts": "^3.8.1",
@@ -56,6 +57,7 @@
"@types/node": "^24",
"@types/nodemailer": "^7.0.5",
"@types/pg": "^8.16.0",
+ "@types/qrcode": "^1.5.6",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@types/validator": "^13.15.10",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 8daaa418..37609859 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -78,6 +78,9 @@ importers:
pg:
specifier: ^8.16.3
version: 8.21.0
+ qrcode:
+ specifier: ^1.5.4
+ version: 1.5.4
react:
specifier: 19.2.7
version: 19.2.7
@@ -130,6 +133,9 @@ importers:
'@types/pg':
specifier: ^8.16.0
version: 8.20.0
+ '@types/qrcode':
+ specifier: ^1.5.6
+ version: 1.5.6
'@types/react':
specifier: 19.2.17
version: 19.2.17
@@ -2602,6 +2608,9 @@ packages:
'@types/pg@8.20.0':
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
+ '@types/qrcode@1.5.6':
+ resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
+
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -3134,6 +3143,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
+ camelcase@5.3.1:
+ resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
+ engines: {node: '>=6'}
+
caniuse-lite@1.0.30001799:
resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
@@ -3176,6 +3189,9 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
+ cliui@6.0.0:
+ resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
+
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -3360,6 +3376,10 @@ packages:
supports-color:
optional: true
+ decamelize@1.2.0:
+ resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+ engines: {node: '>=0.10.0'}
+
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
@@ -3425,6 +3445,9 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
+ dijkstrajs@1.0.3:
+ resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
+
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -3903,6 +3926,10 @@ packages:
resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==}
engines: {node: '>=6'}
+ find-up@4.1.0:
+ resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
+ engines: {node: '>=8'}
+
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -4600,6 +4627,10 @@ packages:
resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==}
engines: {node: '>=6'}
+ locate-path@5.0.0:
+ resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
+ engines: {node: '>=8'}
+
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -4937,6 +4968,10 @@ packages:
resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==}
engines: {node: '>=6'}
+ p-locate@4.1.0:
+ resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
+ engines: {node: '>=8'}
+
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
@@ -5065,6 +5100,10 @@ packages:
resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==}
engines: {node: '>=8'}
+ pngjs@5.0.0:
+ resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
+ engines: {node: '>=10.13.0'}
+
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -5148,6 +5187,11 @@ packages:
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
engines: {node: '>=16.0.0'}
+ qrcode@1.5.4:
+ resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
+ engines: {node: '>=10.13.0'}
+ hasBin: true
+
qs@6.15.2:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'}
@@ -5234,6 +5278,9 @@ packages:
resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
+ require-main-filename@2.0.0:
+ resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
@@ -5333,6 +5380,9 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
+ set-blocking@2.0.0:
+ resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+
set-cookie-parser@3.1.0:
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
@@ -5847,6 +5897,9 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'}
+ which-module@2.0.1:
+ resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
+
which-typed-array@1.1.22:
resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
engines: {node: '>= 0.4'}
@@ -5870,6 +5923,10 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
+ wrap-ansi@6.2.0:
+ resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
+ engines: {node: '>=8'}
+
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -5893,6 +5950,9 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
+ y18n@4.0.3:
+ resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
+
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -5905,10 +5965,18 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
+ yargs-parser@18.1.3:
+ resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
+ engines: {node: '>=6'}
+
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
+ yargs@15.4.1:
+ resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
+ engines: {node: '>=8'}
+
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
@@ -8425,6 +8493,10 @@ snapshots:
pg-protocol: 1.14.0
pg-types: 2.2.0
+ '@types/qrcode@1.5.6':
+ dependencies:
+ '@types/node': 24.13.2
+
'@types/react-dom@19.2.3(@types/react@19.2.17)':
dependencies:
'@types/react': 19.2.17
@@ -8948,6 +9020,8 @@ snapshots:
callsites@3.1.0: {}
+ camelcase@5.3.1: {}
+
caniuse-lite@1.0.30001799: {}
canonicalize@1.0.8: {}
@@ -8979,6 +9053,12 @@ snapshots:
client-only@0.0.1: {}
+ cliui@6.0.0:
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 6.2.0
+
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -9140,6 +9220,8 @@ snapshots:
dependencies:
ms: 2.1.3
+ decamelize@1.2.0: {}
+
decimal.js-light@2.5.1: {}
dedent@1.7.2: {}
@@ -9183,6 +9265,8 @@ snapshots:
diff@8.0.4: {}
+ dijkstrajs@1.0.3: {}
+
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -9849,6 +9933,11 @@ snapshots:
dependencies:
locate-path: 3.0.0
+ find-up@4.1.0:
+ dependencies:
+ locate-path: 5.0.0
+ path-exists: 4.0.0
+
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -10493,6 +10582,10 @@ snapshots:
p-locate: 3.0.0
path-exists: 3.0.0
+ locate-path@5.0.0:
+ dependencies:
+ p-locate: 4.1.0
+
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -10811,6 +10904,10 @@ snapshots:
dependencies:
p-limit: 2.3.0
+ p-locate@4.1.0:
+ dependencies:
+ p-limit: 2.3.0
+
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
@@ -10915,6 +11012,8 @@ snapshots:
dependencies:
find-up: 3.0.0
+ pngjs@5.0.0: {}
+
possible-typed-array-names@1.1.0: {}
postcss-selector-parser@7.1.4:
@@ -10996,6 +11095,12 @@ snapshots:
pvutils@1.1.5: {}
+ qrcode@1.5.4:
+ dependencies:
+ dijkstrajs: 1.0.3
+ pngjs: 5.0.0
+ yargs: 15.4.1
+
qs@6.15.2:
dependencies:
side-channel: 1.1.1
@@ -11104,6 +11209,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ require-main-filename@2.0.0: {}
+
reselect@5.1.1: {}
reselect@5.2.0: {}
@@ -11238,6 +11345,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ set-blocking@2.0.0: {}
+
set-cookie-parser@3.1.0: {}
set-function-length@1.2.2:
@@ -11846,6 +11955,8 @@ snapshots:
is-weakmap: 2.0.2
is-weakset: 2.0.4
+ which-module@2.0.1: {}
+
which-typed-array@1.1.22:
dependencies:
available-typed-arrays: 1.0.7
@@ -11871,6 +11982,12 @@ snapshots:
word-wrap@1.2.5: {}
+ wrap-ansi@6.2.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -11894,14 +12011,35 @@ snapshots:
xtend@4.0.2: {}
+ y18n@4.0.3: {}
+
y18n@5.0.8: {}
yallist@3.1.1: {}
yaml@2.9.0: {}
+ yargs-parser@18.1.3:
+ dependencies:
+ camelcase: 5.3.1
+ decamelize: 1.2.0
+
yargs-parser@21.1.1: {}
+ yargs@15.4.1:
+ dependencies:
+ cliui: 6.0.0
+ decamelize: 1.2.0
+ find-up: 4.1.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ require-main-filename: 2.0.0
+ set-blocking: 2.0.0
+ string-width: 4.2.3
+ which-module: 2.0.1
+ y18n: 4.0.3
+ yargs-parser: 18.1.3
+
yargs@17.7.2:
dependencies:
cliui: 8.0.1
From f88b2c4a6e153b07f736cefe93c2ca97fd2fd4ce Mon Sep 17 00:00:00 2001
From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com>
Date: Thu, 9 Jul 2026 14:53:22 +1000
Subject: [PATCH 04/18] Reduce service metric label cardinality
---
agent/internal/metrics/victoria.go | 53 ++++++++++++++++--
agent/internal/metrics/victoria_test.go | 54 +++++++++++++++++++
web/lib/victoria-metrics.ts | 2 +-
.../victoria-metrics-service-metrics.test.ts | 3 ++
4 files changed, 106 insertions(+), 6 deletions(-)
diff --git a/agent/internal/metrics/victoria.go b/agent/internal/metrics/victoria.go
index d74fad37..c7c5c721 100644
--- a/agent/internal/metrics/victoria.go
+++ b/agent/internal/metrics/victoria.go
@@ -21,6 +21,15 @@ type VictoriaMetricsSender struct {
client *http.Client
}
+type serviceResourceStats struct {
+ ServiceID string
+ CPUUsagePercent float64
+ MemoryUsagePercent float64
+ MemoryUsedBytes float64
+ NetworkReceiveBytes float64
+ NetworkTransmitBytes float64
+}
+
func NewVictoriaMetricsSender(endpoint, serverID string) *VictoriaMetricsSender {
var username, password string
cleanEndpoint := strings.TrimRight(endpoint, "/")
@@ -85,16 +94,19 @@ func (v *VictoriaMetricsSender) SendContainerStats(stats []container.ResourceSta
return nil
}
+ aggregates := aggregateContainerStats(stats)
+ if len(aggregates) == 0 {
+ return nil
+ }
+
timestampMs := collectedAt.UnixMilli()
serverID := escapeLabelValue(v.serverID)
var buf bytes.Buffer
- for _, stat := range stats {
+ for _, stat := range aggregates {
labels := map[string]string{
- "server_id": serverID,
- "service_id": escapeLabelValue(stat.ServiceID),
- "deployment_id": escapeLabelValue(stat.DeploymentID),
- "container_id": escapeLabelValue(stat.ContainerID),
+ "server_id": serverID,
+ "service_id": escapeLabelValue(stat.ServiceID),
}
writeGaugeWithLabels(&buf, "techulus_service_cpu_usage_percent", labels, stat.CPUUsagePercent, timestampMs)
writeGaugeWithLabels(&buf, "techulus_service_memory_usage_percent", labels, stat.MemoryUsagePercent, timestampMs)
@@ -106,6 +118,37 @@ func (v *VictoriaMetricsSender) SendContainerStats(stats []container.ResourceSta
return v.postPrometheusImport(buf.Bytes(), nil)
}
+func aggregateContainerStats(stats []container.ResourceStats) []serviceResourceStats {
+ byService := make(map[string]*serviceResourceStats)
+ for _, stat := range stats {
+ if stat.ServiceID == "" {
+ continue
+ }
+ aggregate := byService[stat.ServiceID]
+ if aggregate == nil {
+ aggregate = &serviceResourceStats{ServiceID: stat.ServiceID}
+ byService[stat.ServiceID] = aggregate
+ }
+ aggregate.CPUUsagePercent += stat.CPUUsagePercent
+ aggregate.MemoryUsagePercent += stat.MemoryUsagePercent
+ aggregate.MemoryUsedBytes += stat.MemoryUsedBytes
+ aggregate.NetworkReceiveBytes += stat.NetworkReceiveBytes
+ aggregate.NetworkTransmitBytes += stat.NetworkTransmitBytes
+ }
+
+ serviceIDs := make([]string, 0, len(byService))
+ for serviceID := range byService {
+ serviceIDs = append(serviceIDs, serviceID)
+ }
+ sort.Strings(serviceIDs)
+
+ aggregates := make([]serviceResourceStats, 0, len(serviceIDs))
+ for _, serviceID := range serviceIDs {
+ aggregates = append(aggregates, *byService[serviceID])
+ }
+ return aggregates
+}
+
func (v *VictoriaMetricsSender) SendPrometheusMetrics(data []byte, extraLabels map[string]string) error {
if len(bytes.TrimSpace(data)) == 0 {
return nil
diff --git a/agent/internal/metrics/victoria_test.go b/agent/internal/metrics/victoria_test.go
index 5854f197..9e8792f7 100644
--- a/agent/internal/metrics/victoria_test.go
+++ b/agent/internal/metrics/victoria_test.go
@@ -4,7 +4,11 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
+ "time"
+
+ "techulus/cloud-agent/internal/container"
)
func TestSendPrometheusMetricsAddsExtraLabels(t *testing.T) {
@@ -39,3 +43,53 @@ func TestSendPrometheusMetricsAddsExtraLabels(t *testing.T) {
t.Fatalf("body = %q", gotBody)
}
}
+
+func TestSendContainerStatsAggregatesStableServiceLabels(t *testing.T) {
+ var gotBody string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ gotBody = string(body)
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+
+ sender := NewVictoriaMetricsSender(server.URL, "server-1")
+ err := sender.SendContainerStats([]container.ResourceStats{
+ {
+ ContainerID: "container-a",
+ ServiceID: "svc-a",
+ DeploymentID: "dep-a",
+ CPUUsagePercent: 10,
+ MemoryUsagePercent: 1.5,
+ MemoryUsedBytes: 1024,
+ NetworkReceiveBytes: 100,
+ NetworkTransmitBytes: 200,
+ },
+ {
+ ContainerID: "container-b",
+ ServiceID: "svc-a",
+ DeploymentID: "dep-b",
+ CPUUsagePercent: 20,
+ MemoryUsagePercent: 2.5,
+ MemoryUsedBytes: 2048,
+ NetworkReceiveBytes: 300,
+ NetworkTransmitBytes: 400,
+ },
+ }, time.UnixMilli(1_700_000_000_000))
+ if err != nil {
+ t.Fatalf("send container stats: %v", err)
+ }
+
+ if strings.Contains(gotBody, "container_id") || strings.Contains(gotBody, "deployment_id") {
+ t.Fatalf("unexpected churn labels in body:\n%s", gotBody)
+ }
+ if !strings.Contains(gotBody, `techulus_service_cpu_usage_percent{server_id="server-1",service_id="svc-a"} 30.000000 1700000000000`) {
+ t.Fatalf("missing aggregated CPU metric:\n%s", gotBody)
+ }
+ if !strings.Contains(gotBody, `techulus_service_memory_usage_percent{server_id="server-1",service_id="svc-a"} 4.000000 1700000000000`) {
+ t.Fatalf("missing aggregated memory percent metric:\n%s", gotBody)
+ }
+ if !strings.Contains(gotBody, `techulus_service_memory_used_bytes{server_id="server-1",service_id="svc-a"} 3072.000000 1700000000000`) {
+ t.Fatalf("missing aggregated memory bytes metric:\n%s", gotBody)
+ }
+}
diff --git a/web/lib/victoria-metrics.ts b/web/lib/victoria-metrics.ts
index 0fb62f7d..2e68ad3d 100644
--- a/web/lib/victoria-metrics.ts
+++ b/web/lib/victoria-metrics.ts
@@ -388,7 +388,7 @@ export async function queryServiceMetrics(options: {
stepSeconds: window.stepSeconds,
}).catch(() => []),
queryRangePromQL(endpoint, {
- query: `avg(avg_over_time(techulus_service_memory_usage_percent{service_id="${serviceId}"}[${rangeWindow}]))`,
+ query: `sum(avg_over_time(techulus_service_memory_usage_percent{service_id="${serviceId}"}[${rangeWindow}]))`,
start: queryStart,
end: window.end,
stepSeconds: window.stepSeconds,
diff --git a/web/tests/victoria-metrics-service-metrics.test.ts b/web/tests/victoria-metrics-service-metrics.test.ts
index 9e0f1fd1..5f971156 100644
--- a/web/tests/victoria-metrics-service-metrics.test.ts
+++ b/web/tests/victoria-metrics-service-metrics.test.ts
@@ -96,6 +96,9 @@ describe("VictoriaMetrics service metrics", () => {
expect(queries).toContain(
`sum(avg_over_time(techulus_service_cpu_usage_percent{service_id="${SERVICE_ID}"}[5m]))`,
);
+ expect(queries).toContain(
+ `sum(avg_over_time(techulus_service_memory_usage_percent{service_id="${SERVICE_ID}"}[5m]))`,
+ );
expect(
starts.every((start) => start === String(END_TS - 24 * 60 * 60 + 5 * 60)),
).toBe(true);
From 1ee14b0c24dd9fdc3237c131c5977fe6cfe82553 Mon Sep 17 00:00:00 2001
From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com>
Date: Thu, 9 Jul 2026 16:21:45 +1000
Subject: [PATCH 05/18] Add agent process resource metrics
---
agent/internal/agent/agent.go | 1 +
agent/internal/agent/reporting.go | 6 ++
agent/internal/health/health.go | 100 ++++++++++++++++++++++++
agent/internal/health/health_test.go | 67 ++++++++++++++++
agent/internal/metrics/victoria.go | 16 ++++
agent/internal/metrics/victoria_test.go | 31 ++++++++
6 files changed, 221 insertions(+)
create mode 100644 agent/internal/health/health_test.go
diff --git a/agent/internal/agent/agent.go b/agent/internal/agent/agent.go
index a1a4f7fb..fa85f72d 100644
--- a/agent/internal/agent/agent.go
+++ b/agent/internal/agent/agent.go
@@ -127,6 +127,7 @@ func NewAgent(
type MetricsSender interface {
SendSystemStats(stats *health.SystemStats, collectedAt time.Time) error
+ SendAgentStats(stats *health.AgentProcessStats, collectedAt time.Time) error
SendContainerStats(stats []container.ResourceStats, collectedAt time.Time) error
SendPrometheusMetrics(data []byte, extraLabels map[string]string) error
}
diff --git a/agent/internal/agent/reporting.go b/agent/internal/agent/reporting.go
index ab4c6574..70f75cc3 100644
--- a/agent/internal/agent/reporting.go
+++ b/agent/internal/agent/reporting.go
@@ -53,6 +53,12 @@ func (a *Agent) BuildStatusReport(includeResources bool) *agenthttp.StatusReport
if err := a.MetricsSender.SendSystemStats(systemStats, collectedAt); err != nil {
log.Printf("[metrics] failed to send system stats: %v", err)
}
+ agentStats, err := health.CollectAgentProcessStats()
+ if err != nil {
+ log.Printf("[metrics] failed to collect agent stats: %v", err)
+ } else if err := a.MetricsSender.SendAgentStats(agentStats, collectedAt); err != nil {
+ log.Printf("[metrics] failed to send agent stats: %v", err)
+ }
containerStats, err := container.CollectResourceStats()
if err != nil {
log.Printf("[metrics] failed to collect container stats: %v", err)
diff --git a/agent/internal/health/health.go b/agent/internal/health/health.go
index 3d140cdc..f23ce9d7 100644
--- a/agent/internal/health/health.go
+++ b/agent/internal/health/health.go
@@ -1,14 +1,19 @@
package health
import (
+ "math"
+ "os"
"os/exec"
+ "runtime"
"strconv"
"strings"
+ "sync"
"time"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/mem"
+ "github.com/shirou/gopsutil/v3/process"
)
type SystemStats struct {
@@ -19,6 +24,12 @@ type SystemStats struct {
DiskUsedGb int `json:"diskUsedGb"`
}
+type AgentProcessStats struct {
+ CPUUsagePercent float64
+ MemoryUsagePercent float64
+ MemoryUsedBytes uint64
+}
+
type NetworkPeerHealth struct {
ID string `json:"id"`
LastSeenSecs int `json:"lastSeenSecs"`
@@ -45,6 +56,12 @@ type AgentHealthInfo struct {
LastSyncAt string `json:"lastSyncAt"`
}
+var (
+ agentProcessCPUMu sync.Mutex
+ agentProcessLastCPUTimes *cpu.TimesStat
+ agentProcessLastCPUTime time.Time
+)
+
func CollectSystemStats() *SystemStats {
stats := &SystemStats{}
@@ -68,6 +85,89 @@ func CollectSystemStats() *SystemStats {
return stats
}
+func CollectAgentProcessStats() (*AgentProcessStats, error) {
+ proc, err := process.NewProcess(int32(os.Getpid()))
+ if err != nil {
+ return nil, err
+ }
+
+ stats := &AgentProcessStats{}
+
+ cpuPercent, err := collectAgentCPUPercent(proc)
+ if err != nil {
+ return nil, err
+ }
+ stats.CPUUsagePercent = cpuPercent
+
+ memInfo, err := proc.MemoryInfo()
+ if err != nil {
+ return nil, err
+ }
+ stats.MemoryUsedBytes = memInfo.RSS
+
+ memPercent, err := proc.MemoryPercent()
+ if err != nil {
+ return nil, err
+ }
+ stats.MemoryUsagePercent = float64(memPercent)
+
+ return stats, nil
+}
+
+func collectAgentCPUPercent(proc *process.Process) (float64, error) {
+ cpuTimes, err := proc.Times()
+ if err != nil {
+ return 0, err
+ }
+ now := time.Now()
+
+ agentProcessCPUMu.Lock()
+ defer agentProcessCPUMu.Unlock()
+
+ if agentProcessLastCPUTimes == nil || agentProcessLastCPUTime.IsZero() {
+ // Delta-based CPU metrics need one sample to establish the baseline.
+ agentProcessLastCPUTimes = cpuTimes
+ agentProcessLastCPUTime = now
+ return 0, nil
+ }
+
+ elapsedSeconds := now.Sub(agentProcessLastCPUTime).Seconds()
+ percent := calculateAgentCPUUsagePercent(
+ agentProcessLastCPUTimes,
+ cpuTimes,
+ elapsedSeconds,
+ runtime.NumCPU(),
+ )
+ agentProcessLastCPUTimes = cpuTimes
+ agentProcessLastCPUTime = now
+
+ return percent, nil
+}
+
+func calculateAgentCPUUsagePercent(previous, current *cpu.TimesStat, elapsedSeconds float64, cpuCount int) float64 {
+ if previous == nil || current == nil {
+ return 0
+ }
+
+ cpuDeltaSeconds := processCPUTotal(current) - processCPUTotal(previous)
+ if elapsedSeconds <= 0 || cpuDeltaSeconds < 0 {
+ return 0
+ }
+ if cpuCount <= 0 {
+ cpuCount = 1
+ }
+
+ percent := (cpuDeltaSeconds / elapsedSeconds) * 100 / float64(cpuCount)
+ if math.IsNaN(percent) || math.IsInf(percent, 0) {
+ return 0
+ }
+ return percent
+}
+
+func processCPUTotal(times *cpu.TimesStat) float64 {
+ return times.User + times.System
+}
+
func CollectNetworkHealth(interfaceName string) *NetworkHealth {
health := &NetworkHealth{
TunnelUp: false,
diff --git a/agent/internal/health/health_test.go b/agent/internal/health/health_test.go
new file mode 100644
index 00000000..1e169e9a
--- /dev/null
+++ b/agent/internal/health/health_test.go
@@ -0,0 +1,67 @@
+package health
+
+import (
+ "testing"
+
+ "github.com/shirou/gopsutil/v3/cpu"
+)
+
+func TestCalculateAgentCPUUsagePercentNormalizesByCPUCount(t *testing.T) {
+ previous := &cpu.TimesStat{User: 10, System: 5}
+ current := &cpu.TimesStat{User: 12, System: 7}
+
+ got := calculateAgentCPUUsagePercent(previous, current, 1, 4)
+ if got != 100 {
+ t.Fatalf("cpu percent = %f, want 100", got)
+ }
+}
+
+func TestCalculateAgentCPUUsagePercentWarmupOrInvalidInputs(t *testing.T) {
+ current := &cpu.TimesStat{User: 12, System: 7}
+
+ tests := map[string]struct {
+ previous *cpu.TimesStat
+ current *cpu.TimesStat
+ elapsedSeconds float64
+ cpuCount int
+ }{
+ "missing previous sample": {
+ previous: nil,
+ current: current,
+ elapsedSeconds: 1,
+ cpuCount: 4,
+ },
+ "missing current sample": {
+ previous: &cpu.TimesStat{User: 10, System: 5},
+ current: nil,
+ elapsedSeconds: 1,
+ cpuCount: 4,
+ },
+ "zero elapsed": {
+ previous: &cpu.TimesStat{User: 10, System: 5},
+ current: current,
+ elapsedSeconds: 0,
+ cpuCount: 4,
+ },
+ "negative counter delta": {
+ previous: current,
+ current: &cpu.TimesStat{User: 10, System: 5},
+ elapsedSeconds: 1,
+ cpuCount: 4,
+ },
+ }
+
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+ got := calculateAgentCPUUsagePercent(
+ test.previous,
+ test.current,
+ test.elapsedSeconds,
+ test.cpuCount,
+ )
+ if got != 0 {
+ t.Fatalf("cpu percent = %f, want 0", got)
+ }
+ })
+ }
+}
diff --git a/agent/internal/metrics/victoria.go b/agent/internal/metrics/victoria.go
index c7c5c721..1bedd144 100644
--- a/agent/internal/metrics/victoria.go
+++ b/agent/internal/metrics/victoria.go
@@ -89,6 +89,22 @@ func (v *VictoriaMetricsSender) SendSystemStats(stats *health.SystemStats, colle
return nil
}
+func (v *VictoriaMetricsSender) SendAgentStats(stats *health.AgentProcessStats, collectedAt time.Time) error {
+ if stats == nil {
+ return nil
+ }
+
+ timestampMs := collectedAt.UnixMilli()
+ serverID := escapeLabelValue(v.serverID)
+
+ var buf bytes.Buffer
+ writeGauge(&buf, "techulus_agent_cpu_usage_percent", serverID, stats.CPUUsagePercent, timestampMs)
+ writeGauge(&buf, "techulus_agent_memory_usage_percent", serverID, stats.MemoryUsagePercent, timestampMs)
+ writeGauge(&buf, "techulus_agent_memory_used_bytes", serverID, float64(stats.MemoryUsedBytes), timestampMs)
+
+ return v.postPrometheusImport(buf.Bytes(), nil)
+}
+
func (v *VictoriaMetricsSender) SendContainerStats(stats []container.ResourceStats, collectedAt time.Time) error {
if len(stats) == 0 {
return nil
diff --git a/agent/internal/metrics/victoria_test.go b/agent/internal/metrics/victoria_test.go
index 9e8792f7..ff691eb2 100644
--- a/agent/internal/metrics/victoria_test.go
+++ b/agent/internal/metrics/victoria_test.go
@@ -9,6 +9,7 @@ import (
"time"
"techulus/cloud-agent/internal/container"
+ "techulus/cloud-agent/internal/health"
)
func TestSendPrometheusMetricsAddsExtraLabels(t *testing.T) {
@@ -44,6 +45,36 @@ func TestSendPrometheusMetricsAddsExtraLabels(t *testing.T) {
}
}
+func TestSendAgentStatsWritesStableServerLabels(t *testing.T) {
+ var gotBody string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ gotBody = string(body)
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+
+ sender := NewVictoriaMetricsSender(server.URL, "server-1")
+ err := sender.SendAgentStats(&health.AgentProcessStats{
+ CPUUsagePercent: 1.25,
+ MemoryUsagePercent: 0.5,
+ MemoryUsedBytes: 64 * 1024 * 1024,
+ }, time.UnixMilli(1_700_000_000_000))
+ if err != nil {
+ t.Fatalf("send agent stats: %v", err)
+ }
+
+ if !strings.Contains(gotBody, `techulus_agent_cpu_usage_percent{server_id="server-1"} 1.250000 1700000000000`) {
+ t.Fatalf("missing agent CPU metric:\n%s", gotBody)
+ }
+ if !strings.Contains(gotBody, `techulus_agent_memory_usage_percent{server_id="server-1"} 0.500000 1700000000000`) {
+ t.Fatalf("missing agent memory percent metric:\n%s", gotBody)
+ }
+ if !strings.Contains(gotBody, `techulus_agent_memory_used_bytes{server_id="server-1"} 67108864.000000 1700000000000`) {
+ t.Fatalf("missing agent memory bytes metric:\n%s", gotBody)
+ }
+}
+
func TestSendContainerStatsAggregatesStableServiceLabels(t *testing.T) {
var gotBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
From 779abb289c24be480abd964994f23484d3528431 Mon Sep 17 00:00:00 2001
From: Arjun Komath
Date: Thu, 9 Jul 2026 18:29:43 +1000
Subject: [PATCH 06/18] Fix service metrics window alignment
---
web/lib/victoria-metrics.ts | 3 ++-
web/tests/victoria-metrics-service-metrics.test.ts | 5 +++--
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/web/lib/victoria-metrics.ts b/web/lib/victoria-metrics.ts
index 2e68ad3d..9fcafbb0 100644
--- a/web/lib/victoria-metrics.ts
+++ b/web/lib/victoria-metrics.ts
@@ -671,7 +671,8 @@ export function getMetricWindow(
stepSeconds: number;
} {
const option = METRIC_RANGE_OPTIONS[range];
- const end = new Date(Math.floor(now.getTime() / 1000) * 1000);
+ const stepMs = option.stepSeconds * 1000;
+ const end = new Date(Math.floor(now.getTime() / stepMs) * stepMs);
const start = new Date(end.getTime() - option.durationMs);
return {
start,
diff --git a/web/tests/victoria-metrics-service-metrics.test.ts b/web/tests/victoria-metrics-service-metrics.test.ts
index 5f971156..fc6464ea 100644
--- a/web/tests/victoria-metrics-service-metrics.test.ts
+++ b/web/tests/victoria-metrics-service-metrics.test.ts
@@ -7,7 +7,7 @@ import {
} from "@/lib/victoria-metrics";
const SERVICE_ID = "123e4567-e89b-42d3-a456-426614174000";
-const END_TS = Date.parse("2026-07-02T12:34:00Z") / 1000;
+const END_TS = Date.parse("2026-07-02T12:30:00Z") / 1000;
describe("VictoriaMetrics service metrics", () => {
afterEach(() => {
@@ -105,7 +105,8 @@ describe("VictoriaMetrics service metrics", () => {
expect(stats.totalRequests).toBe(3);
expect(stats.statusCodes).toEqual(["200"]);
expect(stats.buckets).toHaveLength(288);
- expect(stats.buckets[0]?.timestamp).toBe("2026-07-01T12:39:00.000Z");
+ expect(stats.windowEnd).toBe("2026-07-02T12:30:00.000Z");
+ expect(stats.buckets[0]?.timestamp).toBe("2026-07-01T12:35:00.000Z");
const lastBucket = stats.buckets.at(-1);
expect(lastBucket).toMatchObject({
From c2721b48a051e3fb505da796dac226dfddeceaa6 Mon Sep 17 00:00:00 2001
From: Arjun Komath
Date: Thu, 9 Jul 2026 18:36:00 +1000
Subject: [PATCH 07/18] Always show service metrics history
---
.../details/service-details-overview.tsx | 23 ++++---------------
1 file changed, 4 insertions(+), 19 deletions(-)
diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx
index 1d1f30ce..7b445074 100644
--- a/web/components/service/details/service-details-overview.tsx
+++ b/web/components/service/details/service-details-overview.tsx
@@ -160,10 +160,7 @@ export function ServiceDetailsOverview({ service }: { service: Service }) {
() => buildOverviewData(service, proxyDomain),
[service, proxyDomain],
);
- const serviceMetricsUrl =
- overview.runningDeployments > 0
- ? `/api/services/${service.id}/metrics?range=24h`
- : null;
+ const serviceMetricsUrl = `/api/services/${service.id}/metrics?range=24h`;
const {
data: serviceMetrics,
error: serviceMetricsError,
@@ -177,7 +174,6 @@ export function ServiceDetailsOverview({ service }: { service: Service }) {
0}
- hasRunningDeployments={overview.runningDeployments > 0}
stats={serviceMetrics}
error={serviceMetricsError}
isLoading={isServiceMetricsLoading}
@@ -191,13 +187,11 @@ export function ServiceDetailsOverview({ service }: { service: Service }) {
function ServiceMetricsPanel({
hasPublicHttp,
- hasRunningDeployments,
stats,
error,
isLoading,
}: {
hasPublicHttp: boolean;
- hasRunningDeployments: boolean;
stats?: ServiceMetricsResponse;
error?: unknown;
isLoading: boolean;
@@ -226,7 +220,7 @@ function ServiceMetricsPanel({
- ) : hasRunningDeployments ? (
+ ) : (
@@ -271,13 +265,6 @@ function ServiceMetricsPanel({
egress
- ) : (
- <>
-
- -
-
- not deployed
- >
)}