Docker work
This commit is contained in:
@@ -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)) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
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',
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
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
@@ -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
@@ -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(() => {})
|
||||||
|
|||||||
Reference in New Issue
Block a user