Docker work
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
||||
import { createLogsView } from './ui/logs.js'
|
||||
import { createDataManager } from './ui/data-manager.js'
|
||||
import { formatMib, formatKilobitsPerSec } from './shared/format.js'
|
||||
import { chartOptionLabel } from './shared/container-names.js'
|
||||
|
||||
const $ = (id) => document.getElementById(id)
|
||||
|
||||
@@ -1054,7 +1055,7 @@ async function populateExploreCharts() {
|
||||
for (const id of options) {
|
||||
const opt = document.createElement('option')
|
||||
opt.value = id
|
||||
opt.textContent = id
|
||||
opt.textContent = chartOptionLabel(id, chartCatalog[id] || {})
|
||||
els.exploreChart.appendChild(opt)
|
||||
}
|
||||
if (options.includes(prev)) {
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
makeCgroupMemDetailChart,
|
||||
makeCgroupThrottleChart,
|
||||
} from '../../../shared/metrics.js'
|
||||
import { resolveContainerLabel } from '../../../shared/container-names.js'
|
||||
import { fetchDockerNames } from './docker.js'
|
||||
import logger from '../../utils/logger.js'
|
||||
|
||||
const log = logger.child('cgroups')
|
||||
@@ -164,13 +166,14 @@ function parsePressureSome10(dir, kind) {
|
||||
|
||||
function makeCgroupPressureChart(id, title, kind) {
|
||||
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
|
||||
const display = title || safe
|
||||
return {
|
||||
id: `cgroup.pressure.${kind}.${safe}`,
|
||||
name: `cgroup.pressure.${kind}.${safe}`,
|
||||
context: 'cgroup.pressure',
|
||||
title: `Cgroup ${kind} pressure ${title || safe}`,
|
||||
title: `${display} · ${kind} pressure`,
|
||||
units: 'percentage',
|
||||
family: title || safe,
|
||||
family: display,
|
||||
chartType: 'line',
|
||||
priority: 6120,
|
||||
plugin: 'cgroups',
|
||||
@@ -182,11 +185,15 @@ export class CgroupsCollector extends EventEmitter {
|
||||
constructor(opts = {}) {
|
||||
super()
|
||||
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.running = false
|
||||
/** @type {Map<string, { usage: number, user: number, system: number, rbytes: number, wbytes: number, throttledUsec: number }>} */
|
||||
this.prev = new Map()
|
||||
this.lastTs = 0
|
||||
/** @type {Map<string, string>} */
|
||||
this._names = new Map()
|
||||
this._nameRefreshAt = 0
|
||||
}
|
||||
|
||||
start() {
|
||||
@@ -208,7 +215,25 @@ export class CgroupsCollector extends EventEmitter {
|
||||
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 dtSec = this.lastTs ? (ts - this.lastTs) / 1000 : this.intervalMs / 1000
|
||||
this.lastTs = ts
|
||||
@@ -217,11 +242,12 @@ export class CgroupsCollector extends EventEmitter {
|
||||
const batch = []
|
||||
|
||||
for (const cg of discoverCgroups()) {
|
||||
const cpuDef = makeCgroupCpuChart(cg.id, cg.title)
|
||||
const memDef = makeCgroupMemChart(cg.id, cg.title)
|
||||
const ioDef = makeCgroupIoChart(cg.id, cg.title)
|
||||
const memDetailDef = makeCgroupMemDetailChart(cg.id, cg.title)
|
||||
const throttleDef = makeCgroupThrottleChart(cg.id, cg.title)
|
||||
const title = resolveContainerLabel(cg.title, this._names)
|
||||
const cpuDef = makeCgroupCpuChart(cg.id, title)
|
||||
const memDef = makeCgroupMemChart(cg.id, title)
|
||||
const ioDef = makeCgroupIoChart(cg.id, title)
|
||||
const memDetailDef = makeCgroupMemDetailChart(cg.id, title)
|
||||
const throttleDef = makeCgroupThrottleChart(cg.id, title)
|
||||
registerChart(cpuDef)
|
||||
registerChart(memDef)
|
||||
registerChart(ioDef)
|
||||
@@ -311,7 +337,7 @@ export class CgroupsCollector extends EventEmitter {
|
||||
for (const kind of ['cpu', 'memory', 'io']) {
|
||||
const some10 = parsePressureSome10(cg.path, kind)
|
||||
if (some10 == null) continue
|
||||
const pressureDef = makeCgroupPressureChart(cg.id, cg.title, kind)
|
||||
const pressureDef = makeCgroupPressureChart(cg.id, title, kind)
|
||||
registerChart(pressureDef)
|
||||
batch.push({
|
||||
chart: pressureDef.id,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
makeDockerCpuChart,
|
||||
makeDockerMemChart,
|
||||
} from '../../../shared/metrics.js'
|
||||
import { pickContainerDisplayName, resolveContainerLabel } from '../../../shared/container-names.js'
|
||||
import logger from '../../utils/logger.js'
|
||||
|
||||
const log = logger.child('docker')
|
||||
@@ -154,9 +155,36 @@ export function readCgroupMemory(cgroupPath) {
|
||||
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
|
||||
* @returns {Promise<Map<string, string>>} id → name
|
||||
* @returns {Promise<Map<string, string>>} id → display name
|
||||
*/
|
||||
export function fetchDockerNames(socketPath) {
|
||||
return new Promise((resolve) => {
|
||||
@@ -174,18 +202,7 @@ export function fetchDockerNames(socketPath) {
|
||||
})
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const list = 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)
|
||||
resolve(mapDockerContainerNames(JSON.parse(body)))
|
||||
} catch {
|
||||
resolve(new Map())
|
||||
}
|
||||
@@ -261,8 +278,7 @@ export class DockerCollector extends EventEmitter {
|
||||
]
|
||||
|
||||
for (const c of containers) {
|
||||
const display =
|
||||
this._names.get(c.id) || this._names.get(c.shortId) || c.name || c.shortId
|
||||
const display = resolveContainerLabel(c.id, this._names)
|
||||
const cpuDef = makeDockerCpuChart(c.shortId, display)
|
||||
const memDef = makeDockerMemChart(c.shortId, display)
|
||||
registerChart(cpuDef)
|
||||
|
||||
@@ -16,6 +16,7 @@ import { EventEmitter } from 'events'
|
||||
import { PearDataConnection } from '../../../client/connection.js'
|
||||
import { Methods } from '../../../shared/protocol.js'
|
||||
import { registerChart } from '../../../shared/metrics.js'
|
||||
import { isHexId, resolveContainerLabel } from '../../../shared/container-names.js'
|
||||
import logger from '../../utils/logger.js'
|
||||
|
||||
const log = logger.child('peardock')
|
||||
@@ -68,7 +69,7 @@ export function remapDockChart(chartId) {
|
||||
export function extractDockCharts(metrics) {
|
||||
const root = metrics?.body || metrics
|
||||
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 = []
|
||||
for (const [chartId, point] of Object.entries(charts)) {
|
||||
const mapped = remapDockChart(chartId)
|
||||
@@ -87,25 +88,47 @@ export function extractDockCharts(metrics) {
|
||||
context,
|
||||
values: nums,
|
||||
sourceChart: chartId,
|
||||
title: point?.title || point?.name || undefined,
|
||||
family: point?.family || undefined,
|
||||
})
|
||||
}
|
||||
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) => ({
|
||||
id,
|
||||
name: id,
|
||||
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({
|
||||
id: mappedId,
|
||||
name: mappedId,
|
||||
context,
|
||||
title: mappedId,
|
||||
units: context.includes('mem') ? 'MiB' : context.includes('cpu') ? 'percentage' : 'count',
|
||||
family: 'peardock',
|
||||
title: `${display} · ${kind}`,
|
||||
units,
|
||||
family: display,
|
||||
chartType: 'line',
|
||||
priority: 8500,
|
||||
plugin: 'peardock',
|
||||
@@ -193,11 +216,19 @@ export class PearDockCollector extends EventEmitter {
|
||||
const conn = await this._ensure(pk)
|
||||
if (!conn) continue
|
||||
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)
|
||||
peersUp++
|
||||
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({
|
||||
chart: row.chart,
|
||||
context: row.context,
|
||||
|
||||
@@ -17,7 +17,7 @@ const JOURNAL_TIMEOUT_MS = 3000
|
||||
* @param {string} 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 === 'audit' || src === 'journal') {
|
||||
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]
|
||||
*/
|
||||
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) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -595,13 +595,14 @@ export function makeCpuFreqChart() {
|
||||
|
||||
export function makeCgroupCpuChart(id, title) {
|
||||
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
|
||||
const display = title || safe
|
||||
return {
|
||||
id: `cgroup.cpu.${safe}`,
|
||||
name: `cgroup.cpu.${safe}`,
|
||||
context: 'cgroup.cpu',
|
||||
title: `Cgroup CPU ${title || safe}`,
|
||||
title: `${display} · CPU`,
|
||||
units: 'percentage',
|
||||
family: title || safe,
|
||||
family: display,
|
||||
chartType: 'area',
|
||||
priority: 6000,
|
||||
plugin: 'cgroups',
|
||||
@@ -614,13 +615,14 @@ export function makeCgroupCpuChart(id, title) {
|
||||
|
||||
export function makeCgroupMemChart(id, title) {
|
||||
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
|
||||
const display = title || safe
|
||||
return {
|
||||
id: `cgroup.mem.${safe}`,
|
||||
name: `cgroup.mem.${safe}`,
|
||||
context: 'cgroup.mem',
|
||||
title: `Cgroup memory ${title || safe}`,
|
||||
title: `${display} · memory`,
|
||||
units: 'MiB',
|
||||
family: title || safe,
|
||||
family: display,
|
||||
chartType: 'area',
|
||||
priority: 6100,
|
||||
plugin: 'cgroups',
|
||||
@@ -633,13 +635,14 @@ export function makeCgroupMemChart(id, title) {
|
||||
|
||||
export function makeCgroupIoChart(id, title) {
|
||||
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
|
||||
const display = title || safe
|
||||
return {
|
||||
id: `cgroup.io.${safe}`,
|
||||
name: `cgroup.io.${safe}`,
|
||||
context: 'cgroup.io',
|
||||
title: `Cgroup I/O ${title || safe}`,
|
||||
title: `${display} · I/O`,
|
||||
units: 'KiB/s',
|
||||
family: title || safe,
|
||||
family: display,
|
||||
chartType: 'area',
|
||||
priority: 6200,
|
||||
plugin: 'cgroups',
|
||||
|
||||
@@ -538,13 +538,14 @@ export function makeNumaNodeMemChart(node) {
|
||||
|
||||
export function makeCgroupMemDetailChart(id, title) {
|
||||
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
|
||||
const display = title || safe
|
||||
return {
|
||||
id: `cgroup.mem_detail.${safe}`,
|
||||
name: `cgroup.mem_detail.${safe}`,
|
||||
context: 'cgroup.mem_detail',
|
||||
title: `Cgroup memory detail ${title || safe}`,
|
||||
title: `${display} · memory detail`,
|
||||
units: 'MiB',
|
||||
family: title || safe,
|
||||
family: display,
|
||||
chartType: 'stacked',
|
||||
priority: 6110,
|
||||
plugin: 'cgroups',
|
||||
@@ -559,13 +560,14 @@ export function makeCgroupMemDetailChart(id, title) {
|
||||
|
||||
export function makeCgroupThrottleChart(id, title) {
|
||||
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
|
||||
const display = title || safe
|
||||
return {
|
||||
id: `cgroup.throttle.${safe}`,
|
||||
name: `cgroup.throttle.${safe}`,
|
||||
context: 'cgroup.cpu_throttle',
|
||||
title: `Cgroup CPU throttle ${title || safe}`,
|
||||
title: `${display} · CPU throttle`,
|
||||
units: 'percentage',
|
||||
family: title || safe,
|
||||
family: display,
|
||||
chartType: 'area',
|
||||
priority: 6010,
|
||||
plugin: 'cgroups',
|
||||
|
||||
+6
-4
@@ -859,13 +859,14 @@ export const DOCKER_CONTAINERS_CHART = {
|
||||
|
||||
export function makeDockerCpuChart(shortId, name) {
|
||||
const id = shortId.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 64)
|
||||
const display = name || `container ${id}`
|
||||
return {
|
||||
id: `docker.cpu.${id}`,
|
||||
name: `docker.cpu.${id}`,
|
||||
context: 'docker.cpu',
|
||||
title: `Container CPU ${name || id}`,
|
||||
title: `${display} · CPU`,
|
||||
units: 'percentage',
|
||||
family: name || id,
|
||||
family: display,
|
||||
chartType: 'area',
|
||||
priority: 5100,
|
||||
plugin: 'docker',
|
||||
@@ -875,13 +876,14 @@ export function makeDockerCpuChart(shortId, name) {
|
||||
|
||||
export function makeDockerMemChart(shortId, name) {
|
||||
const id = shortId.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 64)
|
||||
const display = name || `container ${id}`
|
||||
return {
|
||||
id: `docker.mem.${id}`,
|
||||
name: `docker.mem.${id}`,
|
||||
context: 'docker.mem',
|
||||
title: `Container memory ${name || id}`,
|
||||
title: `${display} · memory`,
|
||||
units: 'MiB',
|
||||
family: name || id,
|
||||
family: display,
|
||||
chartType: 'area',
|
||||
priority: 5200,
|
||||
plugin: 'docker',
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ export function validateMethodArgs(method, args = {}) {
|
||||
return { ok: true, args }
|
||||
|
||||
case 'queryLogs': {
|
||||
const source = String(args.source || 'anomaly').toLowerCase()
|
||||
const source = String(args.source || 'journal').toLowerCase()
|
||||
if (!['anomaly', 'audit', 'journal'].includes(source)) {
|
||||
return { ok: false, error: 'source must be anomaly|audit|journal' }
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -31,8 +31,11 @@ test('docker chart helpers', (t) => {
|
||||
const cpu = makeDockerCpuChart('abc123def456', 'nginx')
|
||||
t.is(cpu.id, 'docker.cpu.abc123def456')
|
||||
t.is(cpu.context, 'docker.cpu')
|
||||
t.is(cpu.title, 'nginx · CPU')
|
||||
t.is(cpu.family, 'nginx')
|
||||
const mem = makeDockerMemChart('abc123def456', 'nginx')
|
||||
t.is(mem.id, 'docker.mem.abc123def456')
|
||||
t.is(mem.title, 'nginx · memory')
|
||||
t.ok(mem.dimensions.find((d) => d.id === 'usage'))
|
||||
})
|
||||
|
||||
|
||||
+10
-5
@@ -11,6 +11,7 @@ import {
|
||||
normalizeChartMode,
|
||||
} from '../shared/chart-types.js'
|
||||
import { drawChart, hoverIndexFromEvent, padLeftFor, pushDim, seriesColor } from './charts.js'
|
||||
import { chartOptionLabel, metricCardSubtitle } from '../shared/container-names.js'
|
||||
|
||||
const GROUPS = ['average', 'min', 'max', 'sum']
|
||||
|
||||
@@ -793,7 +794,8 @@ export function createMetricsDashboard(opts) {
|
||||
} catch {
|
||||
weights = undefined
|
||||
}
|
||||
const ranked = rankRelatedCharts(seedId, opts.getCatalog() || {}, state.cards, {
|
||||
const catalog = opts.getCatalog() || {}
|
||||
const ranked = rankRelatedCharts(seedId, catalog, state.cards, {
|
||||
limit: 16,
|
||||
weights,
|
||||
})
|
||||
@@ -804,7 +806,8 @@ export function createMetricsDashboard(opts) {
|
||||
panel.innerHTML = ''
|
||||
const head = document.createElement('header')
|
||||
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')
|
||||
clear.type = 'button'
|
||||
clear.className = 'ghost'
|
||||
@@ -821,7 +824,9 @@ export function createMetricsDashboard(opts) {
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
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))
|
||||
list.appendChild(btn)
|
||||
}
|
||||
@@ -1372,8 +1377,8 @@ export function createMetricsDashboard(opts) {
|
||||
article.innerHTML = `
|
||||
<header class="metric-card-head">
|
||||
<div>
|
||||
<h4 class="metric-card-title" title="Click for stats">${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>
|
||||
<h4 class="metric-card-title" title="${escapeAttr(id)}">${escapeHtml(meta.title || id)}</h4>
|
||||
<p class="metric-card-sub muted">${escapeHtml(metricCardSubtitle(id, meta))}</p>
|
||||
</div>
|
||||
<div class="metric-card-actions">
|
||||
<button type="button" class="btn btn-ghost metric-pin-btn" title="Pin">${state.pinned.has(id) ? '★' : '☆'}</button>
|
||||
|
||||
+14
-10
@@ -39,7 +39,10 @@ const SOURCE_LABEL = {
|
||||
*/
|
||||
export function createLogsView(opts) {
|
||||
const state = {
|
||||
/** Active source shown in the UI (may temporarily fall back when role is insufficient). */
|
||||
source: 'journal',
|
||||
/** User/default preference — Journal is the Logs tab default. */
|
||||
preferredSource: 'journal',
|
||||
range: '1h',
|
||||
loading: false,
|
||||
follow: false,
|
||||
@@ -122,8 +125,16 @@ export function createLogsView(opts) {
|
||||
return out
|
||||
}
|
||||
|
||||
function resolveSource() {
|
||||
const preferred = state.preferredSource || 'journal'
|
||||
const needsAdmin = preferred === 'audit' || preferred === 'journal'
|
||||
if (needsAdmin && !isAdmin()) return 'anomaly'
|
||||
return preferred
|
||||
}
|
||||
|
||||
function syncSourceUi() {
|
||||
const admin = isAdmin()
|
||||
state.source = resolveSource()
|
||||
opts.els.root?.classList.toggle('logs-source-journal', state.source === 'journal')
|
||||
opts.els.root?.classList.toggle('logs-following', state.follow)
|
||||
opts.els.sources?.querySelectorAll('[data-log-source]').forEach((btn) => {
|
||||
@@ -136,15 +147,6 @@ export function createLogsView(opts) {
|
||||
btn.classList.toggle('active', active)
|
||||
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() {
|
||||
@@ -445,7 +447,9 @@ export function createLogsView(opts) {
|
||||
if (!btn.dataset.titleDefault) btn.dataset.titleDefault = btn.title || ''
|
||||
btn.addEventListener('click', () => {
|
||||
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
|
||||
syncSourceUi()
|
||||
search().catch(() => {})
|
||||
|
||||
Reference in New Issue
Block a user