Docker work
CI / test (push) Successful in 56s
Release rolling / release (push) Successful in 7m4s

This commit is contained in:
Raven Scott
2026-07-19 13:13:45 -04:00
parent 4b8d194b21
commit 1458915d78
14 changed files with 447 additions and 64 deletions
+2 -1
View File
@@ -33,6 +33,7 @@ import {
import { createLogsView } from './ui/logs.js' import { createLogsView } from './ui/logs.js'
import { createDataManager } from './ui/data-manager.js' import { createDataManager } from './ui/data-manager.js'
import { formatMib, formatKilobitsPerSec } from './shared/format.js' import { formatMib, formatKilobitsPerSec } from './shared/format.js'
import { chartOptionLabel } from './shared/container-names.js'
const $ = (id) => document.getElementById(id) const $ = (id) => document.getElementById(id)
@@ -1054,7 +1055,7 @@ async function populateExploreCharts() {
for (const id of options) { for (const id of options) {
const opt = document.createElement('option') const opt = document.createElement('option')
opt.value = id opt.value = id
opt.textContent = id opt.textContent = chartOptionLabel(id, chartCatalog[id] || {})
els.exploreChart.appendChild(opt) els.exploreChart.appendChild(opt)
} }
if (options.includes(prev)) { if (options.includes(prev)) {
+35 -9
View File
@@ -18,6 +18,8 @@ import {
makeCgroupMemDetailChart, makeCgroupMemDetailChart,
makeCgroupThrottleChart, makeCgroupThrottleChart,
} from '../../../shared/metrics.js' } from '../../../shared/metrics.js'
import { resolveContainerLabel } from '../../../shared/container-names.js'
import { fetchDockerNames } 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')
@@ -164,13 +166,14 @@ function parsePressureSome10(dir, kind) {
function makeCgroupPressureChart(id, title, kind) { function makeCgroupPressureChart(id, title, kind) {
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96) const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
const display = title || safe
return { return {
id: `cgroup.pressure.${kind}.${safe}`, id: `cgroup.pressure.${kind}.${safe}`,
name: `cgroup.pressure.${kind}.${safe}`, name: `cgroup.pressure.${kind}.${safe}`,
context: 'cgroup.pressure', context: 'cgroup.pressure',
title: `Cgroup ${kind} pressure ${title || safe}`, title: `${display} · ${kind} pressure`,
units: 'percentage', units: 'percentage',
family: title || safe, family: display,
chartType: 'line', chartType: 'line',
priority: 6120, priority: 6120,
plugin: 'cgroups', plugin: 'cgroups',
@@ -182,11 +185,15 @@ export class CgroupsCollector extends EventEmitter {
constructor(opts = {}) { constructor(opts = {}) {
super() super()
this.intervalMs = opts.intervalMs ?? (Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS) this.intervalMs = opts.intervalMs ?? (Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS)
this.socketPath = opts.socketPath || process.env.PEARDATA_DOCKER_SOCKET || '/var/run/docker.sock'
this.timer = null this.timer = null
this.running = false this.running = false
/** @type {Map<string, { usage: number, user: number, system: number, rbytes: number, wbytes: number, throttledUsec: number }>} */ /** @type {Map<string, { usage: number, user: number, system: number, rbytes: number, wbytes: number, throttledUsec: number }>} */
this.prev = new Map() this.prev = new Map()
this.lastTs = 0 this.lastTs = 0
/** @type {Map<string, string>} */
this._names = new Map()
this._nameRefreshAt = 0
} }
start() { start() {
@@ -208,7 +215,25 @@ export class CgroupsCollector extends EventEmitter {
this.timer = null this.timer = null
} }
_tick() { async _refreshNames() {
const now = Date.now()
if (now - this._nameRefreshAt < 30_000) return
this._nameRefreshAt = now
try {
if (fs.existsSync(this.socketPath)) {
this._names = await fetchDockerNames(this.socketPath)
}
} catch {
// ignore — titles fall back to humanized hashes
}
}
async _tick() {
try {
await this._refreshNames()
} catch {
// ignore
}
const ts = Date.now() const ts = Date.now()
const dtSec = this.lastTs ? (ts - this.lastTs) / 1000 : this.intervalMs / 1000 const dtSec = this.lastTs ? (ts - this.lastTs) / 1000 : this.intervalMs / 1000
this.lastTs = ts this.lastTs = ts
@@ -217,11 +242,12 @@ export class CgroupsCollector extends EventEmitter {
const batch = [] const batch = []
for (const cg of discoverCgroups()) { for (const cg of discoverCgroups()) {
const cpuDef = makeCgroupCpuChart(cg.id, cg.title) const title = resolveContainerLabel(cg.title, this._names)
const memDef = makeCgroupMemChart(cg.id, cg.title) const cpuDef = makeCgroupCpuChart(cg.id, title)
const ioDef = makeCgroupIoChart(cg.id, cg.title) const memDef = makeCgroupMemChart(cg.id, title)
const memDetailDef = makeCgroupMemDetailChart(cg.id, cg.title) const ioDef = makeCgroupIoChart(cg.id, title)
const throttleDef = makeCgroupThrottleChart(cg.id, cg.title) const memDetailDef = makeCgroupMemDetailChart(cg.id, title)
const throttleDef = makeCgroupThrottleChart(cg.id, title)
registerChart(cpuDef) registerChart(cpuDef)
registerChart(memDef) registerChart(memDef)
registerChart(ioDef) registerChart(ioDef)
@@ -311,7 +337,7 @@ export class CgroupsCollector extends EventEmitter {
for (const kind of ['cpu', 'memory', 'io']) { for (const kind of ['cpu', 'memory', 'io']) {
const some10 = parsePressureSome10(cg.path, kind) const some10 = parsePressureSome10(cg.path, kind)
if (some10 == null) continue if (some10 == null) continue
const pressureDef = makeCgroupPressureChart(cg.id, cg.title, kind) const pressureDef = makeCgroupPressureChart(cg.id, title, kind)
registerChart(pressureDef) registerChart(pressureDef)
batch.push({ batch.push({
chart: pressureDef.id, chart: pressureDef.id,
+31 -15
View File
@@ -22,6 +22,7 @@ import {
makeDockerCpuChart, makeDockerCpuChart,
makeDockerMemChart, makeDockerMemChart,
} from '../../../shared/metrics.js' } from '../../../shared/metrics.js'
import { pickContainerDisplayName, resolveContainerLabel } 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')
@@ -154,9 +155,36 @@ export function readCgroupMemory(cgroupPath) {
return null return null
} }
/**
* Parse Docker Engine `/containers/json` payload into id → display name.
* Exported for tests.
* @param {any[]} list
* @returns {Map<string, string>}
*/
export function mapDockerContainerNames(list) {
/** @type {Map<string, string>} */
const map = new Map()
if (!Array.isArray(list)) return map
for (const c of list) {
const id = String(c.Id || '').toLowerCase()
if (!id) continue
const name = pickContainerDisplayName(c)
map.set(id, name)
if (id.length >= 12) map.set(id.slice(0, 12), name)
for (const n of c.Names || []) {
const bare = String(n || '')
.replace(/^\//, '')
.trim()
.toLowerCase()
if (bare) map.set(bare, name)
}
}
return map
}
/** /**
* @param {string} socketPath * @param {string} socketPath
* @returns {Promise<Map<string, string>>} id → name * @returns {Promise<Map<string, string>>} id → display name
*/ */
export function fetchDockerNames(socketPath) { export function fetchDockerNames(socketPath) {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -174,18 +202,7 @@ export function fetchDockerNames(socketPath) {
}) })
res.on('end', () => { res.on('end', () => {
try { try {
const list = JSON.parse(body) resolve(mapDockerContainerNames(JSON.parse(body)))
/** @type {Map<string, string>} */
const map = new Map()
for (const c of list) {
const id = String(c.Id || '').toLowerCase()
const name = String((c.Names && c.Names[0]) || id)
.replace(/^\//, '')
.slice(0, 64)
if (id) map.set(id, name)
if (id.length >= 12) map.set(id.slice(0, 12), name)
}
resolve(map)
} catch { } catch {
resolve(new Map()) resolve(new Map())
} }
@@ -261,8 +278,7 @@ export class DockerCollector extends EventEmitter {
] ]
for (const c of containers) { for (const c of containers) {
const display = const display = resolveContainerLabel(c.id, this._names)
this._names.get(c.id) || this._names.get(c.shortId) || c.name || c.shortId
const cpuDef = makeDockerCpuChart(c.shortId, display) const cpuDef = makeDockerCpuChart(c.shortId, display)
const memDef = makeDockerMemChart(c.shortId, display) const memDef = makeDockerMemChart(c.shortId, display)
registerChart(cpuDef) registerChart(cpuDef)
+38 -7
View File
@@ -16,6 +16,7 @@ import { EventEmitter } from 'events'
import { PearDataConnection } from '../../../client/connection.js' import { PearDataConnection } from '../../../client/connection.js'
import { Methods } from '../../../shared/protocol.js' import { Methods } from '../../../shared/protocol.js'
import { registerChart } from '../../../shared/metrics.js' import { registerChart } from '../../../shared/metrics.js'
import { isHexId, resolveContainerLabel } from '../../../shared/container-names.js'
import logger from '../../utils/logger.js' import logger from '../../utils/logger.js'
const log = logger.child('peardock') const log = logger.child('peardock')
@@ -68,7 +69,7 @@ export function remapDockChart(chartId) {
export function extractDockCharts(metrics) { export function extractDockCharts(metrics) {
const root = metrics?.body || metrics const root = metrics?.body || metrics
const charts = root?.charts || {} const charts = root?.charts || {}
/** @type {Array<{ chart: string, context: string, values: Record<string, number|null>, sourceChart: string }>} */ /** @type {Array<{ chart: string, context: string, values: Record<string, number|null>, sourceChart: string, title?: string, family?: string }>} */
const out = [] const out = []
for (const [chartId, point] of Object.entries(charts)) { for (const [chartId, point] of Object.entries(charts)) {
const mapped = remapDockChart(chartId) const mapped = remapDockChart(chartId)
@@ -87,25 +88,47 @@ export function extractDockCharts(metrics) {
context, context,
values: nums, values: nums,
sourceChart: chartId, sourceChart: chartId,
title: point?.title || point?.name || undefined,
family: point?.family || undefined,
}) })
} }
return out return out
} }
function registerMappedChart(mappedId, context, values) { /**
* @param {string} mappedId
* @param {string} context
* @param {Record<string, number|null>} values
* @param {{ title?: string, family?: string }} [meta]
*/
function registerMappedChart(mappedId, context, values, meta = {}) {
const dims = Object.keys(values).map((id) => ({ const dims = Object.keys(values).map((id) => ({
id, id,
name: id, name: id,
algorithm: 'absolute', algorithm: 'absolute',
})) }))
if (!dims.length) dims.push({ id: 'value', name: 'value', algorithm: 'absolute' }) if (!dims.length) dims.push({ id: 'value', name: 'value', algorithm: 'absolute' })
const short = mappedId.split('.').pop() || mappedId
const fromMeta = meta.family || meta.title
let display
if (fromMeta) {
// Titles look like "nginx · CPU" — keep the name side
const base = String(fromMeta).includes('·')
? String(fromMeta).split('·')[0].trim()
: String(fromMeta).trim()
display = resolveContainerLabel(base || fromMeta, null)
} else {
display = isHexId(short, 12) ? `container ${short.slice(0, 12)}` : short
}
const kind = context.includes('mem') ? 'memory' : context.includes('cpu') ? 'CPU' : 'metrics'
const units = context.includes('mem') ? 'MiB' : context.includes('cpu') ? 'percentage' : 'count'
registerChart({ registerChart({
id: mappedId, id: mappedId,
name: mappedId, name: mappedId,
context, context,
title: mappedId, title: `${display} · ${kind}`,
units: context.includes('mem') ? 'MiB' : context.includes('cpu') ? 'percentage' : 'count', units,
family: 'peardock', family: display,
chartType: 'line', chartType: 'line',
priority: 8500, priority: 8500,
plugin: 'peardock', plugin: 'peardock',
@@ -193,11 +216,19 @@ export class PearDockCollector extends EventEmitter {
const conn = await this._ensure(pk) const conn = await this._ensure(pk)
if (!conn) continue if (!conn) continue
try { try {
const metrics = await conn.request(Methods.getAllMetrics, { format: 'json' }) const [metrics, catalog] = await Promise.all([
conn.request(Methods.getAllMetrics, { format: 'json' }),
conn.request(Methods.listCharts, {}).catch(() => ({ charts: {} })),
])
const chartMeta = catalog?.charts || {}
const mapped = extractDockCharts(metrics) const mapped = extractDockCharts(metrics)
peersUp++ peersUp++
for (const row of mapped) { for (const row of mapped) {
registerMappedChart(row.chart, row.context, row.values) const remote = chartMeta[row.sourceChart] || {}
registerMappedChart(row.chart, row.context, row.values, {
title: remote.title || row.title,
family: remote.family || row.family,
})
batch.push({ batch.push({
chart: row.chart, chart: row.chart,
context: row.context, context: row.context,
+2 -2
View File
@@ -17,7 +17,7 @@ const JOURNAL_TIMEOUT_MS = 3000
* @param {string} source * @param {string} source
*/ */
export function assertLogSourceAllowed(role, source) { export function assertLogSourceAllowed(role, source) {
const src = String(source || 'anomaly').toLowerCase() const src = String(source || 'journal').toLowerCase()
if (src === 'anomaly') return { ok: true, source: src } if (src === 'anomaly') return { ok: true, source: src }
if (src === 'audit' || src === 'journal') { if (src === 'audit' || src === 'journal') {
if (!roleAllows(role || Roles.viewer, Roles.admin)) { if (!roleAllows(role || Roles.viewer, Roles.admin)) {
@@ -254,7 +254,7 @@ export function readAuditFileLines(file, maxBytes = 512_000) {
* @param {{ anomalies?: { listRecent: (n: number) => object[] }, spawnJournal?: Function }} [deps] * @param {{ anomalies?: { listRecent: (n: number) => object[] }, spawnJournal?: Function }} [deps]
*/ */
export async function queryLogs(args = {}, deps = {}) { export async function queryLogs(args = {}, deps = {}) {
const gate = assertLogSourceAllowed(args.role || Roles.viewer, args.source || 'anomaly') const gate = assertLogSourceAllowed(args.role || Roles.viewer, args.source || 'journal')
if (!gate.ok) { if (!gate.ok) {
return { return {
ok: false, ok: false,
+190
View File
@@ -0,0 +1,190 @@
/**
* Human-friendly container / cgroup labels for Docker metrics.
* Keeps chart ids stable (shortId) while titles/families show real names.
*/
/**
* @param {string|null|undefined} s
* @param {number} [minLen]
*/
export function isHexId(s, minLen = 12) {
if (typeof s !== 'string') return false
const t = s.trim()
if (t.length < minLen || t.length > 64) return false
return /^[0-9a-f]+$/i.test(t)
}
/**
* Extract a container id from a cgroup directory / scope name.
* @param {string} name
* @returns {string|null} lowercase id
*/
export function extractContainerIdFromCgroupName(name) {
const s = String(name || '')
const m =
s.match(/(?:^|\/)(?:docker|libpod|cri-containerd)-([0-9a-f]{12,64})(?:\.scope)?$/i) ||
s.match(/^([0-9a-f]{64})$/i) ||
s.match(/^docker-([0-9a-f]{12,64})$/i) ||
s.match(/^libpod-([0-9a-f]{12,64})$/i)
return m ? m[1].toLowerCase() : null
}
/**
* Prefer Compose service labels / short Names over project_service_1 / hashes.
* @param {{ Id?: string, Names?: string[], Labels?: Record<string, string>, Image?: string }} container
*/
export function pickContainerDisplayName(container) {
const labels = container?.Labels || {}
const service =
labels['com.docker.compose.service'] ||
labels['com.docker.swarm.service.name'] ||
labels['io.kubernetes.container.name']
const num = labels['com.docker.compose.container-number']
if (service && !isHexId(service, 12)) {
if (num && String(num) !== '1') return `${service} · ${num}`
return String(service).slice(0, 64)
}
const names = (container?.Names || [])
.map((n) => String(n || '').replace(/^\//, '').trim())
.filter(Boolean)
const human = names
.filter((n) => !isHexId(n, 12))
.sort((a, b) => a.length - b.length)
if (human[0]) return beautifyComposeStyleName(human[0], labels).slice(0, 64)
if (names[0] && !isHexId(names[0], 12)) {
return beautifyComposeStyleName(names[0], labels).slice(0, 64)
}
const img = String(container?.Image || '')
.split('@')[0]
.split('/')
.pop()
?.split(':')[0]
if (img && img !== 'sha256' && !isHexId(img, 12)) return img.slice(0, 64)
const id = String(container?.Id || '').toLowerCase()
if (id) return `container ${id.slice(0, 12)}`
return 'container'
}
/**
* @param {string} name
* @param {Record<string, string>} labels
*/
function beautifyComposeStyleName(name, labels = {}) {
const project = labels['com.docker.compose.project']
if (project && name.startsWith(`${project}_`)) {
const rest = name.slice(project.length + 1)
return stripTrailingReplica(rest)
}
return stripTrailingReplica(name)
}
/** myapp_web_1 → web · 1 (or web when replica 1) */
function stripTrailingReplica(name) {
const m = String(name).match(/^(.+)_(\d+)$/)
if (!m || isHexId(m[1], 8)) return name
return m[2] === '1' ? m[1] : `${m[1]} · ${m[2]}`
}
/**
* Look up a display name from id/shortId/cgroup title via a name map.
* @param {string} idOrTitle
* @param {Map<string, string>|null|undefined} nameMap
* @returns {string}
*/
export function resolveContainerLabel(idOrTitle, nameMap) {
const raw = String(idOrTitle || '').trim()
if (!raw) return 'container'
const tryKeys = [raw, raw.toLowerCase()]
const extracted = extractContainerIdFromCgroupName(raw)
if (extracted) {
tryKeys.push(extracted, extracted.slice(0, 12))
}
if (isHexId(raw, 12)) {
tryKeys.push(raw.toLowerCase().slice(0, 12))
}
if (nameMap?.size) {
for (const k of tryKeys) {
const hit = nameMap.get(k)
if (hit && !isHexId(hit, 12)) return hit
if (hit) return hit
}
}
if (extracted) return `container ${extracted.slice(0, 12)}`
if (isHexId(raw, 12)) return `container ${raw.toLowerCase().slice(0, 12)}`
// docker-<hash> without map
const stripped = raw.replace(/\.(service|scope)$/i, '')
if (extractContainerIdFromCgroupName(stripped)) {
const id = extractContainerIdFromCgroupName(stripped)
return `container ${id.slice(0, 12)}`
}
return stripped.slice(0, 64)
}
/**
* Card / catalog title: "nginx · CPU"
* @param {string} metric e.g. CPU, memory, I/O
* @param {string} displayName
*/
export function containerMetricTitle(metric, displayName) {
const name = String(displayName || 'container').trim() || 'container'
return `${name} · ${metric}`
}
/**
* Whether a chart id is a per-container instance chart.
* @param {string} id
*/
export function isContainerInstanceChart(id) {
const s = String(id || '')
return (
/^docker\.(cpu|mem)\./.test(s) ||
/^cgroup\./.test(s) ||
/^peardock\.(cpu|mem)\./.test(s)
)
}
/**
* Metric card subtitle — units + friendly label; never bare docker.cpu.<hash>.
* @param {string} id
* @param {{ title?: string, family?: string, units?: string, context?: string }} meta
*/
export function metricCardSubtitle(id, meta = {}) {
const units = meta.units ? String(meta.units) : ''
if (isContainerInstanceChart(id)) {
// Title already has "name · metric"; subtitle stays light
return units || String(meta.context || '').replace(/^docker\./, '') || 'container'
}
if (id === 'docker.containers' || id === 'peardock.containers') {
return units || 'containers'
}
const bits = []
if (units) bits.push(units)
bits.push(id)
return bits.join(' · ')
}
/**
* Explore / select option label.
* @param {string} id
* @param {{ title?: string, family?: string }} [meta]
*/
export function chartOptionLabel(id, meta = {}) {
if (meta.title && meta.title !== id) return meta.title
if (meta.family && !isHexId(String(meta.family).replace(/^container\s+/, ''), 12)) {
return String(meta.family)
}
if (isContainerInstanceChart(id)) {
const short = id.split('.').pop() || id
return isHexId(short, 12) ? `container ${short.slice(0, 12)}` : short
}
return id
}
+9 -6
View File
@@ -595,13 +595,14 @@ export function makeCpuFreqChart() {
export function makeCgroupCpuChart(id, title) { export function makeCgroupCpuChart(id, title) {
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96) const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
const display = title || safe
return { return {
id: `cgroup.cpu.${safe}`, id: `cgroup.cpu.${safe}`,
name: `cgroup.cpu.${safe}`, name: `cgroup.cpu.${safe}`,
context: 'cgroup.cpu', context: 'cgroup.cpu',
title: `Cgroup CPU ${title || safe}`, title: `${display} · CPU`,
units: 'percentage', units: 'percentage',
family: title || safe, family: display,
chartType: 'area', chartType: 'area',
priority: 6000, priority: 6000,
plugin: 'cgroups', plugin: 'cgroups',
@@ -614,13 +615,14 @@ export function makeCgroupCpuChart(id, title) {
export function makeCgroupMemChart(id, title) { export function makeCgroupMemChart(id, title) {
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96) const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
const display = title || safe
return { return {
id: `cgroup.mem.${safe}`, id: `cgroup.mem.${safe}`,
name: `cgroup.mem.${safe}`, name: `cgroup.mem.${safe}`,
context: 'cgroup.mem', context: 'cgroup.mem',
title: `Cgroup memory ${title || safe}`, title: `${display} · memory`,
units: 'MiB', units: 'MiB',
family: title || safe, family: display,
chartType: 'area', chartType: 'area',
priority: 6100, priority: 6100,
plugin: 'cgroups', plugin: 'cgroups',
@@ -633,13 +635,14 @@ export function makeCgroupMemChart(id, title) {
export function makeCgroupIoChart(id, title) { export function makeCgroupIoChart(id, title) {
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96) const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
const display = title || safe
return { return {
id: `cgroup.io.${safe}`, id: `cgroup.io.${safe}`,
name: `cgroup.io.${safe}`, name: `cgroup.io.${safe}`,
context: 'cgroup.io', context: 'cgroup.io',
title: `Cgroup I/O ${title || safe}`, title: `${display} · I/O`,
units: 'KiB/s', units: 'KiB/s',
family: title || safe, family: display,
chartType: 'area', chartType: 'area',
priority: 6200, priority: 6200,
plugin: 'cgroups', plugin: 'cgroups',
+6 -4
View File
@@ -538,13 +538,14 @@ export function makeNumaNodeMemChart(node) {
export function makeCgroupMemDetailChart(id, title) { export function makeCgroupMemDetailChart(id, title) {
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96) const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
const display = title || safe
return { return {
id: `cgroup.mem_detail.${safe}`, id: `cgroup.mem_detail.${safe}`,
name: `cgroup.mem_detail.${safe}`, name: `cgroup.mem_detail.${safe}`,
context: 'cgroup.mem_detail', context: 'cgroup.mem_detail',
title: `Cgroup memory detail ${title || safe}`, title: `${display} · memory detail`,
units: 'MiB', units: 'MiB',
family: title || safe, family: display,
chartType: 'stacked', chartType: 'stacked',
priority: 6110, priority: 6110,
plugin: 'cgroups', plugin: 'cgroups',
@@ -559,13 +560,14 @@ export function makeCgroupMemDetailChart(id, title) {
export function makeCgroupThrottleChart(id, title) { export function makeCgroupThrottleChart(id, title) {
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96) const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
const display = title || safe
return { return {
id: `cgroup.throttle.${safe}`, id: `cgroup.throttle.${safe}`,
name: `cgroup.throttle.${safe}`, name: `cgroup.throttle.${safe}`,
context: 'cgroup.cpu_throttle', context: 'cgroup.cpu_throttle',
title: `Cgroup CPU throttle ${title || safe}`, title: `${display} · CPU throttle`,
units: 'percentage', units: 'percentage',
family: title || safe, family: display,
chartType: 'area', chartType: 'area',
priority: 6010, priority: 6010,
plugin: 'cgroups', plugin: 'cgroups',
+6 -4
View File
@@ -859,13 +859,14 @@ export const DOCKER_CONTAINERS_CHART = {
export function makeDockerCpuChart(shortId, name) { export function makeDockerCpuChart(shortId, name) {
const id = shortId.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 64) const id = shortId.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 64)
const display = name || `container ${id}`
return { return {
id: `docker.cpu.${id}`, id: `docker.cpu.${id}`,
name: `docker.cpu.${id}`, name: `docker.cpu.${id}`,
context: 'docker.cpu', context: 'docker.cpu',
title: `Container CPU ${name || id}`, title: `${display} · CPU`,
units: 'percentage', units: 'percentage',
family: name || id, family: display,
chartType: 'area', chartType: 'area',
priority: 5100, priority: 5100,
plugin: 'docker', plugin: 'docker',
@@ -875,13 +876,14 @@ export function makeDockerCpuChart(shortId, name) {
export function makeDockerMemChart(shortId, name) { export function makeDockerMemChart(shortId, name) {
const id = shortId.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 64) const id = shortId.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 64)
const display = name || `container ${id}`
return { return {
id: `docker.mem.${id}`, id: `docker.mem.${id}`,
name: `docker.mem.${id}`, name: `docker.mem.${id}`,
context: 'docker.mem', context: 'docker.mem',
title: `Container memory ${name || id}`, title: `${display} · memory`,
units: 'MiB', units: 'MiB',
family: name || id, family: display,
chartType: 'area', chartType: 'area',
priority: 5200, priority: 5200,
plugin: 'docker', plugin: 'docker',
+1 -1
View File
@@ -35,7 +35,7 @@ export function validateMethodArgs(method, args = {}) {
return { ok: true, args } return { ok: true, args }
case 'queryLogs': { case 'queryLogs': {
const source = String(args.source || 'anomaly').toLowerCase() const source = String(args.source || 'journal').toLowerCase()
if (!['anomaly', 'audit', 'journal'].includes(source)) { if (!['anomaly', 'audit', 'journal'].includes(source)) {
return { ok: false, error: 'source must be anomaly|audit|journal' } return { ok: false, error: 'source must be anomaly|audit|journal' }
} }
+100
View File
@@ -0,0 +1,100 @@
import test from 'brittle'
import {
pickContainerDisplayName,
resolveContainerLabel,
extractContainerIdFromCgroupName,
metricCardSubtitle,
chartOptionLabel,
isHexId,
} from '../shared/container-names.js'
import { mapDockerContainerNames } from '../server/services/collectors/docker.js'
import { makeDockerCpuChart, makeDockerMemChart } from '../shared/metrics.js'
test('pickContainerDisplayName prefers compose service', (t) => {
t.is(
pickContainerDisplayName({
Id: 'a'.repeat(64),
Names: ['/myproj_web_1'],
Labels: {
'com.docker.compose.project': 'myproj',
'com.docker.compose.service': 'web',
'com.docker.compose.container-number': '1',
},
}),
'web'
)
t.is(
pickContainerDisplayName({
Id: 'b'.repeat(64),
Names: ['/myproj_web_2'],
Labels: {
'com.docker.compose.service': 'web',
'com.docker.compose.container-number': '2',
},
}),
'web · 2'
)
})
test('pickContainerDisplayName falls back to Names then image', (t) => {
t.is(
pickContainerDisplayName({
Id: 'c'.repeat(64),
Names: ['/friendly-name'],
Labels: {},
}),
'friendly-name'
)
t.is(
pickContainerDisplayName({
Id: 'd'.repeat(64),
Names: [],
Image: 'ghcr.io/org/redis:7',
}),
'redis'
)
})
test('mapDockerContainerNames indexes full and short ids', (t) => {
const id = 'abcdef0123456789' + '0'.repeat(48)
const map = mapDockerContainerNames([
{
Id: id,
Names: ['/nginx'],
Labels: {},
},
])
t.is(map.get(id), 'nginx')
t.is(map.get(id.slice(0, 12)), 'nginx')
})
test('resolveContainerLabel humanizes cgroup docker scopes', (t) => {
const id = 'a1b2c3d4e5f6789012345678abcdef01'
const map = new Map([
[id, 'api'],
[id.slice(0, 12), 'api'],
])
t.is(resolveContainerLabel(`docker-${id}.scope`, map), 'api')
t.is(resolveContainerLabel(id, map), 'api')
t.is(resolveContainerLabel(`docker-${id}`, new Map()), `container ${id.slice(0, 12)}`)
t.ok(extractContainerIdFromCgroupName(`docker-${id}.scope`)?.startsWith('a1b2'))
})
test('docker chart titles use display names', (t) => {
const cpu = makeDockerCpuChart('abc123def456', 'nginx')
t.is(cpu.title, 'nginx · CPU')
t.is(cpu.family, 'nginx')
const mem = makeDockerMemChart('abc123def456', 'nginx')
t.is(mem.title, 'nginx · memory')
})
test('metricCardSubtitle hides hashes for container charts', (t) => {
const id = 'docker.cpu.abc123def456'
t.is(
metricCardSubtitle(id, { title: 'nginx · CPU', family: 'nginx', units: 'percentage' }),
'percentage'
)
t.absent(metricCardSubtitle(id, { units: 'percentage' }).includes('abc123'))
t.ok(isHexId('abc123def456'))
t.is(chartOptionLabel(id, { title: 'nginx · CPU' }), 'nginx · CPU')
})
+3
View File
@@ -31,8 +31,11 @@ test('docker chart helpers', (t) => {
const cpu = makeDockerCpuChart('abc123def456', 'nginx') const cpu = makeDockerCpuChart('abc123def456', 'nginx')
t.is(cpu.id, 'docker.cpu.abc123def456') t.is(cpu.id, 'docker.cpu.abc123def456')
t.is(cpu.context, 'docker.cpu') t.is(cpu.context, 'docker.cpu')
t.is(cpu.title, 'nginx · CPU')
t.is(cpu.family, 'nginx')
const mem = makeDockerMemChart('abc123def456', 'nginx') const mem = makeDockerMemChart('abc123def456', 'nginx')
t.is(mem.id, 'docker.mem.abc123def456') t.is(mem.id, 'docker.mem.abc123def456')
t.is(mem.title, 'nginx · memory')
t.ok(mem.dimensions.find((d) => d.id === 'usage')) t.ok(mem.dimensions.find((d) => d.id === 'usage'))
}) })
+10 -5
View File
@@ -11,6 +11,7 @@ import {
normalizeChartMode, normalizeChartMode,
} from '../shared/chart-types.js' } from '../shared/chart-types.js'
import { drawChart, hoverIndexFromEvent, padLeftFor, pushDim, seriesColor } from './charts.js' import { drawChart, hoverIndexFromEvent, padLeftFor, pushDim, seriesColor } from './charts.js'
import { chartOptionLabel, metricCardSubtitle } from '../shared/container-names.js'
const GROUPS = ['average', 'min', 'max', 'sum'] const GROUPS = ['average', 'min', 'max', 'sum']
@@ -793,7 +794,8 @@ export function createMetricsDashboard(opts) {
} catch { } catch {
weights = undefined weights = undefined
} }
const ranked = rankRelatedCharts(seedId, opts.getCatalog() || {}, state.cards, { const catalog = opts.getCatalog() || {}
const ranked = rankRelatedCharts(seedId, catalog, state.cards, {
limit: 16, limit: 16,
weights, weights,
}) })
@@ -804,7 +806,8 @@ export function createMetricsDashboard(opts) {
panel.innerHTML = '' panel.innerHTML = ''
const head = document.createElement('header') const head = document.createElement('header')
head.className = 'related-head' head.className = 'related-head'
head.innerHTML = `<strong>Related to ${escapeHtml(seedId)}</strong>` const seedLabel = chartOptionLabel(seedId, catalog[seedId] || {})
head.innerHTML = `<strong>Related to ${escapeHtml(seedLabel)}</strong>`
const clear = document.createElement('button') const clear = document.createElement('button')
clear.type = 'button' clear.type = 'button'
clear.className = 'ghost' clear.className = 'ghost'
@@ -821,7 +824,9 @@ export function createMetricsDashboard(opts) {
const btn = document.createElement('button') const btn = document.createElement('button')
btn.type = 'button' btn.type = 'button'
btn.className = 'related-item' btn.className = 'related-item'
btn.innerHTML = `<span>${escapeHtml(row.id)}</span><span class="muted">${escapeHtml(row.reason)} · ${row.score.toFixed(1)}</span>` const label = chartOptionLabel(row.id, catalog[row.id] || {})
btn.innerHTML = `<span>${escapeHtml(label)}</span><span class="muted">${escapeHtml(row.reason)} · ${row.score.toFixed(1)}</span>`
btn.title = row.id
btn.addEventListener('click', () => scrollToChart(row.id)) btn.addEventListener('click', () => scrollToChart(row.id))
list.appendChild(btn) list.appendChild(btn)
} }
@@ -1372,8 +1377,8 @@ export function createMetricsDashboard(opts) {
article.innerHTML = ` article.innerHTML = `
<header class="metric-card-head"> <header class="metric-card-head">
<div> <div>
<h4 class="metric-card-title" title="Click for stats">${escapeHtml(meta.title || id)}</h4> <h4 class="metric-card-title" title="${escapeAttr(id)}">${escapeHtml(meta.title || id)}</h4>
<p class="metric-card-sub muted"><span class="metric-units">${meta.units ? escapeHtml(meta.units) : ''}</span>${meta.units ? ' · ' : ''}${escapeHtml(id)}</p> <p class="metric-card-sub muted">${escapeHtml(metricCardSubtitle(id, meta))}</p>
</div> </div>
<div class="metric-card-actions"> <div class="metric-card-actions">
<button type="button" class="btn btn-ghost metric-pin-btn" title="Pin">${state.pinned.has(id) ? '★' : '☆'}</button> <button type="button" class="btn btn-ghost metric-pin-btn" title="Pin">${state.pinned.has(id) ? '★' : '☆'}</button>
+14 -10
View File
@@ -39,7 +39,10 @@ const SOURCE_LABEL = {
*/ */
export function createLogsView(opts) { export function createLogsView(opts) {
const state = { const state = {
/** Active source shown in the UI (may temporarily fall back when role is insufficient). */
source: 'journal', source: 'journal',
/** User/default preference — Journal is the Logs tab default. */
preferredSource: 'journal',
range: '1h', range: '1h',
loading: false, loading: false,
follow: false, follow: false,
@@ -122,8 +125,16 @@ export function createLogsView(opts) {
return out return out
} }
function resolveSource() {
const preferred = state.preferredSource || 'journal'
const needsAdmin = preferred === 'audit' || preferred === 'journal'
if (needsAdmin && !isAdmin()) return 'anomaly'
return preferred
}
function syncSourceUi() { function syncSourceUi() {
const admin = isAdmin() const admin = isAdmin()
state.source = resolveSource()
opts.els.root?.classList.toggle('logs-source-journal', state.source === 'journal') opts.els.root?.classList.toggle('logs-source-journal', state.source === 'journal')
opts.els.root?.classList.toggle('logs-following', state.follow) opts.els.root?.classList.toggle('logs-following', state.follow)
opts.els.sources?.querySelectorAll('[data-log-source]').forEach((btn) => { opts.els.sources?.querySelectorAll('[data-log-source]').forEach((btn) => {
@@ -136,15 +147,6 @@ export function createLogsView(opts) {
btn.classList.toggle('active', active) btn.classList.toggle('active', active)
btn.setAttribute('aria-selected', active ? 'true' : 'false') btn.setAttribute('aria-selected', active ? 'true' : 'false')
}) })
if ((state.source === 'audit' || state.source === 'journal') && !admin) {
state.source = 'anomaly'
opts.els.sources?.querySelectorAll('[data-log-source]').forEach((btn) => {
const active = btn.getAttribute('data-log-source') === 'anomaly'
btn.classList.toggle('active', active)
btn.setAttribute('aria-selected', active ? 'true' : 'false')
})
opts.els.root?.classList.remove('logs-source-journal')
}
} }
function syncRangeUi() { function syncRangeUi() {
@@ -445,7 +447,9 @@ export function createLogsView(opts) {
if (!btn.dataset.titleDefault) btn.dataset.titleDefault = btn.title || '' if (!btn.dataset.titleDefault) btn.dataset.titleDefault = btn.title || ''
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
if (btn.disabled) return if (btn.disabled) return
state.source = btn.getAttribute('data-log-source') || 'anomaly' const src = btn.getAttribute('data-log-source') || 'journal'
state.preferredSource = src
state.source = src
state.nextCursor = null state.nextCursor = null
syncSourceUi() syncSourceUi()
search().catch(() => {}) search().catch(() => {})