Docker Socket Updates attempts (Bare Safe)
This commit is contained in:
@@ -290,10 +290,11 @@ let catalogRefreshTimer = null
|
|||||||
|
|
||||||
function startCatalogRefresh() {
|
function startCatalogRefresh() {
|
||||||
if (catalogRefreshTimer) return
|
if (catalogRefreshTimer) return
|
||||||
|
// Refresh often enough that Docker/cgroup name enrichment appears without a reconnect
|
||||||
catalogRefreshTimer = setInterval(() => {
|
catalogRefreshTimer = setInterval(() => {
|
||||||
if (!manager.active?.connected) return
|
if (!manager.active?.connected) return
|
||||||
populateExploreCharts().catch(() => {})
|
populateExploreCharts().catch(() => {})
|
||||||
}, 30_000)
|
}, 10_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopCatalogRefresh() {
|
function stopCatalogRefresh() {
|
||||||
|
|||||||
+2
-2
@@ -70,8 +70,8 @@ Have `pipeline.js` start each enabled collector; all emit `samples` batches into
|
|||||||
1. Set `PEARDATA_DOCKER=1` on the agent (or re-run the installer when Docker is present).
|
1. Set `PEARDATA_DOCKER=1` on the agent (or re-run the installer when Docker is present).
|
||||||
2. Ensure `peardata` can read the socket (`usermod -aG docker peardata` + unit `SupplementaryGroups=… docker`).
|
2. Ensure `peardata` can read the socket (`usermod -aG docker peardata` + unit `SupplementaryGroups=… docker`).
|
||||||
3. Charts: `docker.containers`, `docker.cpu.<shortId>`, `docker.mem.<shortId>` (stable ids); **titles/families** use human names (`dozzle · CPU`).
|
3. Charts: `docker.containers`, `docker.cpu.<shortId>`, `docker.mem.<shortId>` (stable ids); **titles/families** use human names (`dozzle · CPU`).
|
||||||
4. Discovery: cgroup v2 `docker-*.scope` / `libpod-*.scope`; names from `fetchDockerNames` / Compose labels (`shared/container-names.js`).
|
4. Discovery: cgroup v2 `docker-*.scope` / `libpod-*.scope`; names via `loadContainerNameMap` (unix HTTP API → `docker ps` → filesystem) + Compose labels (`shared/container-names.js`).
|
||||||
5. Cgroups collector reuses the same socket map so `cgroup.*` cards are not bare hashes.
|
5. Cgroups collector reuses the same name map so `cgroup.*` cards are not bare hashes.
|
||||||
6. UI: [user-guide/containers.md](../user-guide/containers.md).
|
6. UI: [user-guide/containers.md](../user-guide/containers.md).
|
||||||
|
|
||||||
### Process top-N collector
|
### Process top-N collector
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
makeCgroupThrottleChart,
|
makeCgroupThrottleChart,
|
||||||
} from '../../../shared/metrics.js'
|
} from '../../../shared/metrics.js'
|
||||||
import { resolveContainerLabel } from '../../../shared/container-names.js'
|
import { resolveContainerLabel } from '../../../shared/container-names.js'
|
||||||
import { fetchDockerNames, resolveDockerSocket } from './docker.js'
|
import { loadContainerNameMap, resolveDockerSocket } from './docker.js'
|
||||||
import logger from '../../utils/logger.js'
|
import logger from '../../utils/logger.js'
|
||||||
|
|
||||||
const log = logger.child('cgroups')
|
const log = logger.child('cgroups')
|
||||||
@@ -217,13 +217,18 @@ export class CgroupsCollector extends EventEmitter {
|
|||||||
|
|
||||||
async _refreshNames() {
|
async _refreshNames() {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (now - this._nameRefreshAt < 30_000) return
|
const interval = this._names.size ? 30_000 : 5_000
|
||||||
|
if (now - this._nameRefreshAt < interval) return
|
||||||
this._nameRefreshAt = now
|
this._nameRefreshAt = now
|
||||||
try {
|
try {
|
||||||
this.socketPath = resolveDockerSocket(this.socketPath)
|
this.socketPath = resolveDockerSocket(this.socketPath)
|
||||||
if (fs.existsSync(this.socketPath)) {
|
const { map, source } = await loadContainerNameMap(this.socketPath)
|
||||||
const map = await fetchDockerNames(this.socketPath)
|
if (map.size) {
|
||||||
if (map.size) this._names = map
|
const prev = this._names.size
|
||||||
|
this._names = map
|
||||||
|
if (prev !== map.size) {
|
||||||
|
log.info('Container names loaded for cgroups', { count: map.size, source })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore — titles fall back to humanized hashes
|
// ignore — titles fall back to humanized hashes
|
||||||
|
|||||||
@@ -12,9 +12,10 @@
|
|||||||
*/
|
*/
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import http from 'http'
|
import net from 'net'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
|
import { execFile } from '../../utils/exec.js'
|
||||||
import {
|
import {
|
||||||
SAMPLE_INTERVAL_MS,
|
SAMPLE_INTERVAL_MS,
|
||||||
registerChart,
|
registerChart,
|
||||||
@@ -22,7 +23,11 @@ import {
|
|||||||
makeDockerCpuChart,
|
makeDockerCpuChart,
|
||||||
makeDockerMemChart,
|
makeDockerMemChart,
|
||||||
} from '../../../shared/metrics.js'
|
} from '../../../shared/metrics.js'
|
||||||
import { pickContainerDisplayName, resolveContainerLabel } from '../../../shared/container-names.js'
|
import {
|
||||||
|
pickContainerDisplayName,
|
||||||
|
resolveContainerLabel,
|
||||||
|
indexContainerName,
|
||||||
|
} from '../../../shared/container-names.js'
|
||||||
import logger from '../../utils/logger.js'
|
import logger from '../../utils/logger.js'
|
||||||
|
|
||||||
const log = logger.child('docker')
|
const log = logger.child('docker')
|
||||||
@@ -49,7 +54,8 @@ export function resolveDockerSocket(preferred) {
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return preferred || process.env.PEARDATA_DOCKER_SOCKET || '/var/run/docker.sock'
|
// Prefer a real default path when nothing exists yet (docker not started)
|
||||||
|
return process.env.PEARDATA_DOCKER_SOCKET || '/var/run/docker.sock'
|
||||||
}
|
}
|
||||||
|
|
||||||
function readFile(p) {
|
function readFile(p) {
|
||||||
@@ -189,8 +195,7 @@ export function mapDockerContainerNames(list) {
|
|||||||
const id = String(c.Id || '').toLowerCase()
|
const id = String(c.Id || '').toLowerCase()
|
||||||
if (!id) continue
|
if (!id) continue
|
||||||
const name = pickContainerDisplayName(c)
|
const name = pickContainerDisplayName(c)
|
||||||
map.set(id, name)
|
indexContainerName(map, id, name)
|
||||||
if (id.length >= 12) map.set(id.slice(0, 12), name)
|
|
||||||
for (const n of c.Names || []) {
|
for (const n of c.Names || []) {
|
||||||
const bare = String(n || '')
|
const bare = String(n || '')
|
||||||
.replace(/^\//, '')
|
.replace(/^\//, '')
|
||||||
@@ -203,41 +208,233 @@ export function mapDockerContainerNames(list) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* GET over a Unix domain socket (works under Node + Bare; bare-http1 has no socketPath).
|
||||||
* @param {string} socketPath
|
* @param {string} socketPath
|
||||||
* @returns {Promise<Map<string, string>>} id → display name
|
* @param {string} urlPath
|
||||||
|
* @param {number} [timeoutMs]
|
||||||
|
* @returns {Promise<string>} response body
|
||||||
*/
|
*/
|
||||||
export function fetchDockerNames(socketPath) {
|
export function httpGetUnix(socketPath, urlPath, timeoutMs = 3000) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = http.request(
|
// String form is reliable for bare-net → bare-pipe unix sockets
|
||||||
{
|
const socket = net.connect(socketPath)
|
||||||
socketPath,
|
let buf = ''
|
||||||
path: '/containers/json?all=1',
|
let settled = false
|
||||||
method: 'GET',
|
const finish = (err, body) => {
|
||||||
timeout: 2000,
|
if (settled) return
|
||||||
},
|
settled = true
|
||||||
(res) => {
|
clearTimeout(timer)
|
||||||
let body = ''
|
try {
|
||||||
res.on('data', (c) => {
|
socket.destroy()
|
||||||
body += c
|
} catch {
|
||||||
})
|
// ignore
|
||||||
res.on('end', () => {
|
|
||||||
try {
|
|
||||||
resolve(mapDockerContainerNames(JSON.parse(body)))
|
|
||||||
} catch {
|
|
||||||
resolve(new Map())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
)
|
if (err) reject(err)
|
||||||
req.on('error', () => resolve(new Map()))
|
else resolve(body)
|
||||||
req.on('timeout', () => {
|
}
|
||||||
req.destroy()
|
const timer = setTimeout(() => {
|
||||||
resolve(new Map())
|
finish(new Error(`unix http timeout after ${timeoutMs}ms`))
|
||||||
|
}, timeoutMs)
|
||||||
|
socket.on('connect', () => {
|
||||||
|
socket.write(
|
||||||
|
`GET ${urlPath} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nAccept: application/json\r\n\r\n`
|
||||||
|
)
|
||||||
})
|
})
|
||||||
req.end()
|
socket.on('data', (chunk) => {
|
||||||
|
buf += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
|
||||||
|
})
|
||||||
|
socket.on('end', () => {
|
||||||
|
try {
|
||||||
|
finish(null, parseHttpBody(buf))
|
||||||
|
} catch (err) {
|
||||||
|
finish(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
socket.on('error', (err) => finish(err))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} raw
|
||||||
|
*/
|
||||||
|
export function parseHttpBody(raw) {
|
||||||
|
let sep = raw.indexOf('\r\n\r\n')
|
||||||
|
let hdrEnd = 4
|
||||||
|
if (sep < 0) {
|
||||||
|
sep = raw.indexOf('\n\n')
|
||||||
|
hdrEnd = 2
|
||||||
|
}
|
||||||
|
if (sep < 0) throw new Error('invalid http response')
|
||||||
|
const header = raw.slice(0, sep)
|
||||||
|
let body = raw.slice(sep + hdrEnd)
|
||||||
|
const status = header.split(/\r?\n/)[0] || ''
|
||||||
|
if (!/\s200(\s|$)/.test(status)) {
|
||||||
|
throw new Error(`http ${status || 'error'}`)
|
||||||
|
}
|
||||||
|
if (/transfer-encoding:\s*chunked/i.test(header)) {
|
||||||
|
body = decodeChunked(body)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeChunked(body) {
|
||||||
|
let out = ''
|
||||||
|
let i = 0
|
||||||
|
while (i < body.length) {
|
||||||
|
const nl = body.indexOf('\r\n', i)
|
||||||
|
if (nl < 0) break
|
||||||
|
const size = parseInt(body.slice(i, nl), 16)
|
||||||
|
if (!Number.isFinite(size) || size <= 0) break
|
||||||
|
const start = nl + 2
|
||||||
|
out += body.slice(start, start + size)
|
||||||
|
i = start + size + 2
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Docker Engine API via unix socket.
|
||||||
|
* @param {string} socketPath
|
||||||
|
* @returns {Promise<Map<string, string>>}
|
||||||
|
*/
|
||||||
|
export async function fetchDockerNames(socketPath) {
|
||||||
|
const sock = resolveDockerSocket(socketPath)
|
||||||
|
const paths = [
|
||||||
|
'/containers/json?all=true',
|
||||||
|
'/v1.41/containers/json?all=true',
|
||||||
|
'/v1.44/containers/json?all=true',
|
||||||
|
]
|
||||||
|
let lastErr = null
|
||||||
|
for (const urlPath of paths) {
|
||||||
|
try {
|
||||||
|
const body = await httpGetUnix(sock, urlPath)
|
||||||
|
const map = mapDockerContainerNames(JSON.parse(body))
|
||||||
|
if (map.size) return map
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastErr) throw lastErr
|
||||||
|
return new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `docker ps` TSV lines into a name map. Exported for tests.
|
||||||
|
* @param {string} stdout
|
||||||
|
* @returns {Map<string, string>}
|
||||||
|
*/
|
||||||
|
export function parseDockerPsNames(stdout) {
|
||||||
|
/** @type {Map<string, string>} */
|
||||||
|
const map = new Map()
|
||||||
|
for (const line of String(stdout || '').split('\n')) {
|
||||||
|
const tab = line.indexOf('\t')
|
||||||
|
if (tab < 0) continue
|
||||||
|
const id = line.slice(0, tab).trim().toLowerCase()
|
||||||
|
const names = line
|
||||||
|
.slice(tab + 1)
|
||||||
|
.trim()
|
||||||
|
.split(',')
|
||||||
|
.map((n) => n.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
if (!id || !names.length) continue
|
||||||
|
const name = pickContainerDisplayName({ Id: id, Names: names })
|
||||||
|
indexContainerName(map, id, name)
|
||||||
|
for (const n of names) map.set(n.replace(/^\//, '').toLowerCase(), name)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `docker ps` CLI fallback (Bare-safe via server/utils/exec.js).
|
||||||
|
* @returns {Promise<Map<string, string>>}
|
||||||
|
*/
|
||||||
|
export async function fetchDockerNamesCli() {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFile(
|
||||||
|
'docker',
|
||||||
|
['ps', '-a', '--no-trunc', '--format', '{{.ID}}\t{{.Names}}'],
|
||||||
|
{ timeout: 5000, maxBuffer: 8 * 1024 * 1024 }
|
||||||
|
)
|
||||||
|
return parseDockerPsNames(stdout)
|
||||||
|
} catch {
|
||||||
|
return new Map()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read Names from Docker's on-disk config.v2.json (no socket needed if readable).
|
||||||
|
* @returns {Map<string, string>}
|
||||||
|
*/
|
||||||
|
export function readDockerNamesFromFs() {
|
||||||
|
/** @type {Map<string, string>} */
|
||||||
|
const map = new Map()
|
||||||
|
const roots = [
|
||||||
|
process.env.PEARDATA_DOCKER_ROOT,
|
||||||
|
'/var/lib/docker/containers',
|
||||||
|
path.join(os.homedir(), '.local/share/docker/containers'),
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
for (const root of roots) {
|
||||||
|
let ents
|
||||||
|
try {
|
||||||
|
ents = fs.readdirSync(root)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const id of ents) {
|
||||||
|
if (!/^[0-9a-f]{64}$/i.test(id)) continue
|
||||||
|
const raw = readFile(path.join(root, id, 'config.v2.json'))
|
||||||
|
if (!raw) continue
|
||||||
|
try {
|
||||||
|
const cfg = JSON.parse(raw)
|
||||||
|
const labels = cfg.Config?.Labels || cfg.Labels || {}
|
||||||
|
const name = pickContainerDisplayName({
|
||||||
|
Id: id,
|
||||||
|
Names: cfg.Name ? [cfg.Name] : [],
|
||||||
|
Labels: labels,
|
||||||
|
Image: cfg.Config?.Image || cfg.Image || '',
|
||||||
|
})
|
||||||
|
indexContainerName(map, id, name)
|
||||||
|
if (cfg.Name) {
|
||||||
|
map.set(String(cfg.Name).replace(/^\//, '').toLowerCase(), name)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore bad configs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort name map: Engine API → docker CLI → filesystem.
|
||||||
|
* @param {string} [socketPath]
|
||||||
|
* @returns {Promise<{ map: Map<string, string>, source: string }>}
|
||||||
|
*/
|
||||||
|
export async function loadContainerNameMap(socketPath) {
|
||||||
|
const sock = resolveDockerSocket(socketPath)
|
||||||
|
if (fs.existsSync(sock)) {
|
||||||
|
try {
|
||||||
|
const map = await fetchDockerNames(sock)
|
||||||
|
if (map.size) return { map, source: 'docker-api' }
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('Docker API name fetch failed', { socket: sock, error: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const map = await fetchDockerNamesCli()
|
||||||
|
if (map.size) return { map, source: 'docker-cli' }
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('docker CLI name fetch failed', { error: err.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
const fsMap = readDockerNamesFromFs()
|
||||||
|
if (fsMap.size) return { map: fsMap, source: 'docker-fs' }
|
||||||
|
|
||||||
|
return { map: new Map(), source: 'none' }
|
||||||
|
}
|
||||||
|
|
||||||
export class DockerCollector extends EventEmitter {
|
export class DockerCollector extends EventEmitter {
|
||||||
constructor(opts = {}) {
|
constructor(opts = {}) {
|
||||||
super()
|
super()
|
||||||
@@ -270,23 +467,28 @@ export class DockerCollector extends EventEmitter {
|
|||||||
|
|
||||||
async _refreshNames() {
|
async _refreshNames() {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (now - this._nameRefreshAt < 30_000) return
|
// Retry sooner while empty so a late socket/group fix shows names quickly
|
||||||
|
const interval = this._names.size ? 30_000 : 5_000
|
||||||
|
if (now - this._nameRefreshAt < interval) return
|
||||||
this._nameRefreshAt = now
|
this._nameRefreshAt = now
|
||||||
try {
|
try {
|
||||||
this.socketPath = resolveDockerSocket(this.socketPath)
|
this.socketPath = resolveDockerSocket(this.socketPath)
|
||||||
if (fs.existsSync(this.socketPath)) {
|
const { map, source } = await loadContainerNameMap(this.socketPath)
|
||||||
const map = await fetchDockerNames(this.socketPath)
|
if (map.size) {
|
||||||
if (map.size) {
|
const prev = this._names.size
|
||||||
const prev = this._names.size
|
this._names = map
|
||||||
this._names = map
|
if (prev !== map.size) {
|
||||||
if (prev !== map.size) {
|
log.info('Docker container names loaded', {
|
||||||
log.info('Docker container names loaded', { count: map.size, socket: this.socketPath })
|
count: map.size,
|
||||||
}
|
source,
|
||||||
} else if (!this._names.size) {
|
|
||||||
log.warn('Docker socket reachable but no container names (check peardata ∈ docker group)', {
|
|
||||||
socket: this.socketPath,
|
socket: this.socketPath,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
} else if (!this._names.size) {
|
||||||
|
log.warn(
|
||||||
|
'No Docker container names yet — check peardata ∈ docker group, PEARDATA_DOCKER=1, and socket path',
|
||||||
|
{ socket: this.socketPath, source }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn('Docker name refresh failed', { error: err.message, socket: this.socketPath })
|
log.warn('Docker name refresh failed', { error: err.message, socket: this.socketPath })
|
||||||
|
|||||||
@@ -90,6 +90,21 @@ function stripTrailingReplica(name) {
|
|||||||
return m[2] === '1' ? m[1] : `${m[1]} · ${m[2]}`
|
return m[2] === '1' ? m[1] : `${m[1]} · ${m[2]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index a container id under full + short prefixes for lookup.
|
||||||
|
* @param {Map<string, string>} map
|
||||||
|
* @param {string} id
|
||||||
|
* @param {string} name
|
||||||
|
*/
|
||||||
|
export function indexContainerName(map, id, name) {
|
||||||
|
const full = String(id || '').toLowerCase()
|
||||||
|
const label = String(name || '').trim()
|
||||||
|
if (!full || !label || !map) return
|
||||||
|
map.set(full, label)
|
||||||
|
const max = Math.min(64, full.length)
|
||||||
|
for (let n = 12; n <= max; n++) map.set(full.slice(0, n), label)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Look up a display name from id/shortId/cgroup title via a name map.
|
* Look up a display name from id/shortId/cgroup title via a name map.
|
||||||
* @param {string} idOrTitle
|
* @param {string} idOrTitle
|
||||||
@@ -106,15 +121,23 @@ export function resolveContainerLabel(idOrTitle, nameMap) {
|
|||||||
tryKeys.push(extracted, extracted.slice(0, 12))
|
tryKeys.push(extracted, extracted.slice(0, 12))
|
||||||
}
|
}
|
||||||
if (isHexId(raw, 12)) {
|
if (isHexId(raw, 12)) {
|
||||||
tryKeys.push(raw.toLowerCase().slice(0, 12))
|
tryKeys.push(raw.toLowerCase(), raw.toLowerCase().slice(0, 12))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nameMap?.size) {
|
if (nameMap?.size) {
|
||||||
for (const k of tryKeys) {
|
for (const k of tryKeys) {
|
||||||
const hit = nameMap.get(k)
|
const hit = nameMap.get(k)
|
||||||
if (hit && !isHexId(hit, 12)) return hit
|
if (hit && !looksLikeHashLabel(hit)) return hit
|
||||||
if (hit) return hit
|
if (hit) return hit
|
||||||
}
|
}
|
||||||
|
// Prefix match: cgroup id vs API id length differences
|
||||||
|
const needle = (extracted || (isHexId(raw, 12) ? raw.toLowerCase() : '')).slice(0, 12)
|
||||||
|
if (needle.length >= 12) {
|
||||||
|
for (const [k, v] of nameMap) {
|
||||||
|
if (k.length < 12 || looksLikeHashLabel(v)) continue
|
||||||
|
if (k.startsWith(needle) || needle.startsWith(k.slice(0, 12))) return v
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (extracted) return `container ${extracted.slice(0, 12)}`
|
if (extracted) return `container ${extracted.slice(0, 12)}`
|
||||||
@@ -129,6 +152,12 @@ export function resolveContainerLabel(idOrTitle, nameMap) {
|
|||||||
return stripped.slice(0, 64)
|
return stripped.slice(0, 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function looksLikeHashLabel(name) {
|
||||||
|
const s = String(name || '')
|
||||||
|
if (s.startsWith('container ')) return true
|
||||||
|
return isHexId(s.replace(/^container\s+/i, ''), 12)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card / catalog title: "nginx · CPU"
|
* Card / catalog title: "nginx · CPU"
|
||||||
* @param {string} metric e.g. CPU, memory, I/O
|
* @param {string} metric e.g. CPU, memory, I/O
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import {
|
|||||||
chartOptionLabel,
|
chartOptionLabel,
|
||||||
isHexId,
|
isHexId,
|
||||||
} from '../shared/container-names.js'
|
} from '../shared/container-names.js'
|
||||||
import { mapDockerContainerNames } from '../server/services/collectors/docker.js'
|
import {
|
||||||
|
mapDockerContainerNames,
|
||||||
|
parseHttpBody,
|
||||||
|
parseDockerPsNames,
|
||||||
|
} from '../server/services/collectors/docker.js'
|
||||||
import { makeDockerCpuChart, makeDockerMemChart } from '../shared/metrics.js'
|
import { makeDockerCpuChart, makeDockerMemChart } from '../shared/metrics.js'
|
||||||
|
|
||||||
test('pickContainerDisplayName prefers compose service', (t) => {
|
test('pickContainerDisplayName prefers compose service', (t) => {
|
||||||
@@ -68,6 +72,29 @@ test('mapDockerContainerNames indexes full and short ids', (t) => {
|
|||||||
t.is(map.get(id.slice(0, 12)), 'nginx')
|
t.is(map.get(id.slice(0, 12)), 'nginx')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('parseHttpBody handles Content-Length and chunked Docker replies', (t) => {
|
||||||
|
const json = '[{"Id":"aa","Names":["/x"]}]'
|
||||||
|
const plain =
|
||||||
|
`HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${json.length}\r\n\r\n` +
|
||||||
|
json
|
||||||
|
t.is(parseHttpBody(plain), json)
|
||||||
|
|
||||||
|
const chunked =
|
||||||
|
'HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n' +
|
||||||
|
Number(json.length).toString(16) +
|
||||||
|
'\r\n' +
|
||||||
|
json +
|
||||||
|
'\r\n0\r\n\r\n'
|
||||||
|
t.is(parseHttpBody(chunked), json)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseDockerPsNames maps id to Names', (t) => {
|
||||||
|
const id = '45b3a7631c68aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||||
|
const map = parseDockerPsNames(`${id}\tdozzle\n`)
|
||||||
|
t.is(map.get(id.slice(0, 12)), 'dozzle')
|
||||||
|
t.is(map.get('dozzle'), 'dozzle')
|
||||||
|
})
|
||||||
|
|
||||||
test('resolveContainerLabel humanizes cgroup docker scopes', (t) => {
|
test('resolveContainerLabel humanizes cgroup docker scopes', (t) => {
|
||||||
const id = 'a1b2c3d4e5f6789012345678abcdef01'
|
const id = 'a1b2c3d4e5f6789012345678abcdef01'
|
||||||
const map = new Map([
|
const map = new Map([
|
||||||
|
|||||||
@@ -49,12 +49,22 @@ You should see a line like `Docker container names loaded` with a count.
|
|||||||
|
|
||||||
Chart **ids** remain `docker.cpu.<shortId>` / `docker.mem.<shortId>` so history stays stable when a container is recreated with the same id prefix.
|
Chart **ids** remain `docker.cpu.<shortId>` / `docker.mem.<shortId>` so history stays stable when a container is recreated with the same id prefix.
|
||||||
|
|
||||||
## Name resolution order
|
## How names are loaded (agent)
|
||||||
|
|
||||||
|
PearData tries, in order:
|
||||||
|
|
||||||
|
1. **Docker Engine API** over the unix socket (raw HTTP — works under Bare)
|
||||||
|
2. **`docker ps`** CLI
|
||||||
|
3. On-disk `config.v2.json` under `/var/lib/docker/containers` (if readable)
|
||||||
|
|
||||||
|
Then each container label prefers:
|
||||||
|
|
||||||
1. Compose / Swarm / Kubernetes service labels (`web`, `web · 2`)
|
1. Compose / Swarm / Kubernetes service labels (`web`, `web · 2`)
|
||||||
2. Docker `Names` (e.g. `/dozzle` → `dozzle`)
|
2. Docker `Names` (e.g. `/dozzle` → `dozzle`)
|
||||||
3. Image short name
|
3. Image short name
|
||||||
4. Fallback `container <12-char id>` if the socket is unavailable
|
4. Fallback `container <12-char id>` if none of the sources worked
|
||||||
|
|
||||||
|
If the UI still shows `container <hash>`, the agent could not reach Docker. Check the journal for `Docker container names loaded` / `Container names loaded for cgroups`, then fix group/socket and restart.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user