diff --git a/dashboard/src/components/GraphItem.vue b/dashboard/src/components/GraphItem.vue index ad0d7ceb7..e500a4277 100644 --- a/dashboard/src/components/GraphItem.vue +++ b/dashboard/src/components/GraphItem.vue @@ -21,21 +21,13 @@ const props = defineProps({ title: String, subtext: String, period: Object, // { hours, format, tooltpFormat } - datasetLabels: { - type: Array, - validator: (val) => Array.isArray(val) && val.every(item => typeof item === 'string') - }, - datasetColors: { - type: Array, - validator: (val) => Array.isArray(val) && val.every(item => typeof item === 'string') - }, yscale: String, // cpu, memory memory: Number, cpuCount: Number, highMark: Number, }); -function createGraphOptions({ yscale, period, displayLegend, highMark }) { +function createGraphOptions({ yscale, period, highMark }) { let startTime, endTime, stepSize, count; // x axis configuration values const now = Date.now(); @@ -54,8 +46,8 @@ function createGraphOptions({ yscale, period, displayLegend, highMark }) { maintainAspectRatio: false, plugins: { legend: { - display: displayLegend, - position: 'bottom' + display: false, + position: 'bottom' // not used, hidden since color code is shown in tooltip }, tooltip: { callbacks: { @@ -121,10 +113,23 @@ function transformData(data) { return { x, y }; } -function setData(...data) { - for (const [index, items] of data.entries()) { - graph.data.datasets[index].data = items.map(transformData); +function setDatasets(datasets) { + graph.data = { datasets: [] }; + + for (const dataset of datasets) { + graph.data.datasets.push({ + label: dataset.label, + data: dataset.data.map(transformData), + pointRadius: 0, + borderWidth: 1, // https://www.chartjs.org/docs/latest/charts/line.html#line-styling + tension: 0.4, + showLine: true, + fill: true, + color: dataset.color, + stack: dataset.stack || 'stackgroup', // put them all in same stackgroup + }); } + graph.update('none'); } @@ -140,10 +145,10 @@ function advance() { graph.update('none'); } -function pushData(...data) { +function pushData(datasetIndex, ...data) { for (const [index, item] of data.entries()) { - graph.data.datasets[index].data.push(transformData(item)); - pruneGraphData(graph.data.datasets[index], graph.options); + graph.data.datasets[datasetIndex+index].data.push(transformData(item)); + pruneGraphData(graph.data.datasets[datasetIndex+index], graph.options); } graph.update('none'); } @@ -154,22 +159,6 @@ function onPeriodChanged() { liveRefreshIntervalId = null; } - const data = { datasets: [] }; - - for (const [index, label] of props.datasetLabels.entries()) { - data.datasets.push({ - label: label, - data: [], - pointRadius: 0, - borderWidth: 1, // https://www.chartjs.org/docs/latest/charts/line.html#line-styling - tension: 0.4, - showLine: true, - fill: true, - color: props.datasetColors[index], - stack: 'stackgroup' // put them all in same stackgroup - }); - } - // CPU and Memory graph have known min/max set and auto-scaling gets disabled // Disk and Network graphs auto-scale the y values. @@ -236,12 +225,11 @@ function onPeriodChanged() { } // this sets a min 'x' based on current timestamp. so it has to re-created every time the period changes - const graphOptions = createGraphOptions({ yscale, period: props.period, displayLegend: props.datasetLabels.length > 1, highMark }); + const graphOptions = createGraphOptions({ yscale, period: props.period, highMark }); if (!graph) { - graph = new Chart(graphNode.value, { type: 'line', data, options: graphOptions }); + graph = new Chart(graphNode.value, { type: 'line', data: { datasets: [] }, options: graphOptions }); } else { - graph.data = data; graph.options = graphOptions; graph.update('none'); } @@ -261,7 +249,7 @@ onUnmounted(async function () { }); defineExpose({ - setData, + setDatasets, pushData, }); diff --git a/dashboard/src/components/SystemMetrics.vue b/dashboard/src/components/SystemMetrics.vue index b4e2fbf15..dbf2ee94a 100644 --- a/dashboard/src/components/SystemMetrics.vue +++ b/dashboard/src/components/SystemMetrics.vue @@ -4,14 +4,16 @@ import { useI18n } from 'vue-i18n'; const i18n = useI18n(); const t = i18n.t; -import { ref, onMounted, onUnmounted, useTemplateRef, nextTick } from 'vue'; -import { SingleSelect } from 'pankow'; +import { ref, onMounted, onUnmounted, useTemplateRef, nextTick, watch } from 'vue'; +import { SingleSelect, MultiSelect } from 'pankow'; import Section from './Section.vue'; import SystemModel from '../models/SystemModel.js'; import { prettyDecimalSize } from 'pankow/utils'; import GraphItem from './GraphItem.vue'; +import AppsModel from '../models/AppsModel.js'; const systemModel = SystemModel.create(); +const appsModel = AppsModel.create(); const periods = [ { hours: 0, label: t('app.graphs.period.live'), tickFormat: 'hh:mm A', tooltipFormat: 'hh:mm:ss A' }, @@ -36,73 +38,148 @@ const networkWriteTotal = ref(0); const blockReadTotal = ref(0); const blockWriteTotal = ref(0); +const containers = ref([]); +const allContainers = ref([]); + let systemMemory = {}; let systemCpus = {}; let metricStream = null; async function liveRefresh() { - metricStream = await systemModel.getMetricStream(); + const options = { + system: true, + appIds: containers.value.map(c => c.id), + serviceIds: [] + }; + metricStream = await systemModel.getMetricStream(options); metricStream.onerror = (error) => console.log('event stream error:', error); metricStream.onmessage = (message) => { const data = JSON.parse(message.data); - cpuGraphItem.value.pushData(data.cpu); - memoryGraphItem.value.pushData(data.memory, data.swap); - diskGraphItem.value.pushData(data.blockReadRate, data.blockWriteRate); - networkGraphItem.value.pushData(data.networkReadRate, data.networkWriteRate); + for (const [id, metric] of Object.entries(data)) { + const idx = id !== 'system' ? containers.value.findIndex(c => c.id === id) : containers.value.length; - blockReadTotal.value = prettyDecimalSize(data.blockReadTotal); - blockWriteTotal.value = prettyDecimalSize(data.blockWriteTotal); - networkReadTotal.value = prettyDecimalSize(data.networkReadTotal); - networkWriteTotal.value = prettyDecimalSize(data.networkWriteTotal); + cpuGraphItem.value.pushData(idx, metric.cpu); + memoryGraphItem.value.pushData(idx*2, metric.memory, metric.swap || []); // apps have no swap + diskGraphItem.value.pushData(idx*2, metric.blockReadRate, metric.blockWriteRate); + networkGraphItem.value.pushData(idx*2, metric.networkReadRate, metric.networkWriteRate); + + if (id === 'system') { + blockReadTotal.value = prettyDecimalSize(metric.blockReadTotal); + blockWriteTotal.value = prettyDecimalSize(metric.blockWriteTotal); + networkReadTotal.value = prettyDecimalSize(metric.networkReadTotal); + networkWriteTotal.value = prettyDecimalSize(metric.networkWriteTotal); + } + } }; } -async function onPeriodChange() { +function generateConsistentColors(n, saturation = 90, lightness = 90) { + const baseHue = 204; // from #9ad0f5 → hsl(204,82%,78%) + const colors = []; + const step = 360 / n; + + for (let i = 0; i < n; i++) { + const hue = Math.round((baseHue + step * i) % 360); // rotate hue, wrap at 360 + colors.push(`hsl(${hue}, ${saturation}%, ${lightness}%)`); + } + + return colors; +} + +function createDatasets() { + const colors = generateConsistentColors((containers.value.length+1)*2); // 1 for the 'system' + + const datasets = { + cpu: [], + memory: [], + disk: [], + network: [], + }; + const appIds = containers.value.map(c => c.id); + for (const [idx, id] of appIds.concat(['system']).entries()) { // live stream code depends on this concat order! + const prefix = id === 'system' ? 'System' : containers.value[idx].label; + + datasets.cpu.push({ label: `${prefix} CPU`, color: colors[idx*2], stack: `${prefix}-cpu`, data: [] }); + datasets.memory.push({ label: `${prefix} Memory`, color: colors[idx*2], stack: `${prefix}-memswap`, data: [] }); + datasets.memory.push({ label: `${prefix} Swap`, color: colors[idx*2 + 1], stack: `${prefix}-memswap`, data: [] }); + + datasets.disk.push({ label: `${prefix} Read`, color: colors[idx*2], stack: `${prefix}-read`, data: [] }); + datasets.disk.push({ label: `${prefix} Write`, color: colors[idx*2 + 1], stack: `${prefix}-write`, prefix, data: [] }); + + datasets.network.push({ label: `${prefix} RX`, color: colors[idx*2], stack: `${prefix}-rx`, data: [] }); + datasets.network.push({ label: `${prefix} TX`, color: colors[idx*2 + 1], stack: `${prefix}-tx`, data: [] }); + } + + return datasets; +} + +async function rebuild() { if (metricStream) { metricStream.close(); metricStream = null; } + const datasets = createDatasets(); + + if (period.value.hours !== 0) { + const options = { + fromSecs: period.value.hours * 60 * 60, + intervalSecs: period.value.intervalSecs, + system: true, + appIds: containers.value.map(c => c.id), + serviceIds: [] + }; + const [error, metrics] = await systemModel.getMetrics(options); + if (error) return console.error(error); + + const appIds = containers.value.map(c => c.id); + for (const [idx, id] of appIds.concat(['system']).entries()) { + if (!metrics[id]) continue; + datasets.cpu[idx].data = metrics[id].cpu; + datasets.memory[idx*2].data = metrics[id].memory; + datasets.memory[idx*2 + 1].data = metrics[id].swap || []; // apps have no swap + datasets.disk[idx*2].data = metrics[id].blockReadRate; + datasets.disk[idx*2 + 1].data = metrics[id].blockWriteRate; + datasets.network[idx*2].data = metrics[id].networkReadRate; + datasets.network[idx*2 + 1].data = metrics[id].networkWriteRate; + } + + networkReadTotal.value = prettyDecimalSize(metrics.system.networkReadTotal); + networkWriteTotal.value = prettyDecimalSize(metrics.system.networkWriteTotal); + blockReadTotal.value = prettyDecimalSize(metrics.system.blockReadTotal); + blockWriteTotal.value = prettyDecimalSize(metrics.system.blockWriteTotal); + } + + cpuGraphItem.value.setDatasets(datasets.cpu); + memoryGraphItem.value.setDatasets(datasets.memory); + diskGraphItem.value.setDatasets(datasets.disk); + networkGraphItem.value.setDatasets(datasets.network); + if (period.value.hours === 0) return await liveRefresh(); - - const options = { - fromSecs: period.value.hours * 60 * 60, - intervalSecs: period.value.intervalSecs, - system: true, - appIds: [], - serviceIds: [] - }; - const [error, metrics] = await systemModel.getMetrics(options); - if (error) return console.error(error); - - cpuGraphItem.value.setData(metrics.system.cpu); - memoryGraphItem.value.setData(metrics.system.memory, metrics.system.swap); - diskGraphItem.value.setData(metrics.system.blockReadRate, metrics.system.blockWriteRate); - networkGraphItem.value.setData(metrics.system.networkReadRate, metrics.system.networkWriteRate); - - networkReadTotal.value = prettyDecimalSize(metrics.system.networkReadTotal); - networkWriteTotal.value = prettyDecimalSize(metrics.system.networkWriteTotal); - blockReadTotal.value = prettyDecimalSize(metrics.system.blockReadTotal); - blockWriteTotal.value = prettyDecimalSize(metrics.system.blockWriteTotal); } onMounted(async () => { let error, result; + [error, result] = await systemModel.memory(); if (error) return console.error(error); - systemMemory = result; [error, result] = await systemModel.cpus(); if (error) return console.error(error); - systemCpus = result; + [error, result] = await appsModel.list(); + if (error) return console.error(error); + result.forEach(a => a.label = (a.label || a.fqdn)); + allContainers.value = result; + containers.value = []; + busy.value = false; await nextTick(); - await onPeriodChange(); + await rebuild(); }); onUnmounted(async () => { @@ -114,7 +191,8 @@ onUnmounted(async () => {