graphs: refactor into GraphItem component

This commit is contained in:
Girish Ramakrishnan
2025-07-03 12:33:02 +02:00
parent 48434453e3
commit f1057bb4a3
2 changed files with 301 additions and 378 deletions

View File

@@ -0,0 +1,236 @@
<script setup>
import moment from 'moment-timezone';
import { onMounted, useTemplateRef, watch } from 'vue';
import Chart from 'chart.js/auto';
import { prettyDecimalSize } from 'pankow/utils';
const LIVE_REFRESH_INTERVAL_MSECS = 500;
const LIVE_REFRESH_HISTORY_MSECS = 5*60*1000; // last 5 mins
const graphNode = useTemplateRef('graphNode');
let graph = null;
let liveRefreshIntervalId = null;
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')
},
yscale: String, // cpu, memory
memory: Object,
cpu: Object
});
function createGraphOptions({ yscale, period }) {
const now = Date.now();
return {
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
title: (tooltipItem) => moment(tooltipItem[0].raw.x).format(period.tooltipFormat),
label: (tooltipItem) => yscale.ticks.callback(tooltipItem.raw.y)
}
}
},
scales: {
x: {
// we used to use 'time' type but it relies on the data to generate ticks. we may not have data for our time periods
type: 'linear',
min: now - (period.hours === 0 ? LIVE_REFRESH_HISTORY_MSECS : period.hours*60*60*1000),
max: now,
ticks: {
autoSkip: true, // skip tick labels as needed
autoSkipPadding: 20, // padding between ticks
maxRotation: 0, // don't rotate the labels
count: 7, // tick labels to show. anything more than 7 will not work for "7 days"
callback: function (value) {
if (period.hours === 0) return `${5-(value-this.min)/60000}min`;
return moment(value).format(period.format);
},
stepSize: period.hours === 0 ? 60*1000 : null // // for realtime graph, generate steps of 1min and appropriate tick text
},
grid: {
drawOnChartArea: false,
},
},
y: yscale,
},
interaction: {
intersect: false,
mode: 'nearest',
axis: 'x'
}
};
}
function transformData(data) {
const x = data[1]*1000; // convert to msecs
// in non-memory case, for relative values like cpu, if null make it 0
const y = props.yscale === 'memory' ? (data[0] / 1024 / 1024 / 1024).toFixed(2) : (data[0] || 0);
return { x, y };
}
function setData(...data) {
for (const [index, items] of data.entries()) {
graph.data.datasets[index].data = items.map(transformData);
}
graph.update('none');
}
function pruneGraphData(dataset, options) {
while (dataset.data.length && (dataset.data[0].x < options.scales.x.min)) { // remove elements beyond our time window
dataset.data.shift();
}
}
function advance() {
graph.options.scales.x.min += LIVE_REFRESH_INTERVAL_MSECS;
graph.options.scales.x.max += LIVE_REFRESH_INTERVAL_MSECS;
graph.update('none');
}
function pushData(...data) {
for (const [index, item] of data.entries()) {
graph.data.datasets[index].data.push(transformData(item));
pruneGraphData(graph.data.datasets[index], graph.options);
}
graph.update('none');
}
function onPeriodChanged() {
if (liveRefreshIntervalId) {
clearInterval(liveRefreshIntervalId);
liveRefreshIntervalId = null;
}
const data = { datasets: [] };
for (const label of props.datasetLabels) {
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
});
}
let yscale = null;
if (props.yscale === 'cpu') {
yscale = {
type: 'linear',
min: 0,
max: 4 * 100,
ticks: {
callback: (value) => `${value.toFixed(2)}%`,
maxTicksLimit: 6 // max tick labels to show
},
beginAtZero: true,
};
} else if (props.yscale === 'memory') {
const giB = 1024 * 1024 * 1024;
const roundedMemory = Math.ceil(props.memory.memory / giB) * giB; // we have to scale up so that the graph can show the data!
const roundedSwap = Math.ceil(props.memory.swap / giB) * giB;
yscale = {
type: 'linear',
min: 0,
max: (roundedMemory + roundedSwap)/ giB,
ticks: {
stepSize: 1,
autoSkip: true, // skip tick labels as needed
autoSkipPadding: 20, // padding between ticks
callback: (value) => `${value} GiB`,
maxTicksLimit: 8 // max tick labels to show
},
beginAtZero: true,
stacked: true,
};
} else if (props.yscale === 'disk') {
yscale = {
type: 'linear',
min: 0,
grace: 100*1000, // add 100kBps. otherwise, the yaxis auto-scales to data and the values appear too dramatic
ticks: {
callback: (value) => `${prettyDecimalSize(value)}ps`,
maxTicksLimit: 6 // max tick labels to show
},
beginAtZero: true,
stacked: false,
};
} else if (props.yscale === 'network') {
yscale = {
type: 'linear',
min: 0,
grace: 50*1000, // add 50kBps. otherwise, the yaxis auto-scales to data and the values appear too dramatic
ticks: {
callback: (value) => `${prettyDecimalSize(value)}ps`,
maxTicksLimit: 6 // max tick labels to show
},
beginAtZero: true,
stacked: false,
};
}
// 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 });
if (!graph) {
graph = new Chart(graphNode.value, { type: 'line', data, options: graphOptions });
} else {
graph.data = data;
graph.options = graphOptions;
graph.update('none');
}
// advances the time window. this is independent of incoming data vis pushData()
if (props.period.hours === 0) liveRefreshIntervalId = setInterval(advance, LIVE_REFRESH_INTERVAL_MSECS);
}
watch(() => props.period, onPeriodChanged);
onMounted(async function () {
await onPeriodChanged();
});
defineExpose({
setData,
pushData,
});
</script>
<template>
<label>{{ title }} <span class="pull-right text-small">{{ subtext }}</span></label>
<div class="graph">
<canvas ref="graphNode"></canvas>
</div>
</template>
<style scoped>
.graphs label {
margin: 16px 0;
}
.graph {
position: relative;
width: 100%;
height: 160px;
}
</style>