Files
cloudron-box/dashboard/src/components/GraphItem.vue
T

292 lines
8.3 KiB
Vue
Raw Normal View History

2025-07-03 12:33:02 +02:00
<script setup>
import moment from 'moment-timezone';
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue';
2025-07-03 12:33:02 +02:00
import Chart from 'chart.js/auto';
import { prettyDecimalSize } from 'pankow/utils';
import annotationPlugin from 'chartjs-plugin-annotation';
Chart.register(annotationPlugin);
2025-07-03 12:33:02 +02:00
const LIVE_REFRESH_INTERVAL_MSECS = 1000; // for realtime graphs, the time (x axis) advances at this pace
2025-07-03 12:33:02 +02:00
const graphNode = useTemplateRef('graphNode');
let graph = null;
let liveRefreshIntervalId = null;
let yscaleUnit = null;
2025-07-03 12:33:02 +02:00
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')
},
2025-07-03 15:59:31 +02:00
datasetColors: {
type: Array,
validator: (val) => Array.isArray(val) && val.every(item => typeof item === 'string')
},
2025-07-03 12:33:02 +02:00
yscale: String, // cpu, memory
memory: Number,
cpuCount: Number,
highMark: Number,
2025-07-03 12:33:02 +02:00
});
function createGraphOptions({ yscale, period, displayLegend, highMark }) {
2025-07-04 21:53:13 +02:00
let startTime, endTime, stepSize, count; // x axis configuration values
2025-07-03 12:33:02 +02:00
const now = Date.now();
2025-07-04 21:53:13 +02:00
if (period.hours === 0) {
startTime = now - 5*60*1000; // for realtime graph, we just show last 5mins
endTime = now;
stepSize = 60*1000; // a tick for a minute
} else {
endTime = Math.ceil(Date.now() / (1000*5*60)) * (1000*5*60); // round up to nearest minute for pretty values. otherwise, we have to display seconds
startTime = endTime - period.hours*60*60*1000;
count = 7; // anything more than 7 will not work for "7 days".
}
2025-07-03 12:33:02 +02:00
return {
maintainAspectRatio: false,
plugins: {
legend: {
2025-07-04 12:51:51 +02:00
display: displayLegend,
position: 'bottom'
2025-07-03 12:33:02 +02:00
},
tooltip: {
callbacks: {
title: (tooltipItem) => moment(tooltipItem[0].raw.x).format(period.tooltipFormat),
2025-07-04 13:18:23 +02:00
label: (tooltipItem) => {
const datasetLabel = tooltipItem.chart.data.datasets[tooltipItem.datasetIndex].label;
return `${datasetLabel}: ${yscale.ticks.callback(tooltipItem.raw.y)}`;
}
2025-07-03 12:33:02 +02:00
}
},
annotation: {
annotations: {
high: {
display: highMark !== null,
type: 'line',
yMin: highMark,
yMax: highMark,
borderColor: 'rgb(75, 192, 192)',
borderWidth: 0.5
}
}
2025-07-03 12:33:02 +02:00
}
},
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',
2025-07-04 21:53:13 +02:00
min: startTime,
max: endTime,
2025-07-03 12:33:02 +02:00
ticks: {
autoSkip: true, // skip tick labels as needed
autoSkipPadding: 20, // padding between ticks
maxRotation: 0, // don't rotate the labels
callback: function (value) {
if (period.hours === 0) return `${5-(value-this.min)/60000}min`;
2025-07-04 21:53:13 +02:00
return moment(value).format(period.tickFormat);
2025-07-03 12:33:02 +02:00
},
2025-07-04 21:53:13 +02:00
count, // ignored when stepSize is set
stepSize // for realtime graph, generate steps of 1min and appropriate tick text
2025-07-03 12:33:02 +02:00
},
grid: {
drawOnChartArea: false,
},
},
y: yscale,
},
interaction: {
intersect: false,
mode: 'nearest',
axis: 'x'
}
};
}
function transformData(data) {
const x = data[1]*1000; // convert to msecs
let y;
if (props.yscale === 'memory') {
y = yscaleUnit === 'GiB' ? data[0] / 1024 / 1024 / 1024 : data[0] / 1024 / 1024;
} else { // in non-memory case, for relative values like cpu, if null make it 0
y = data[0] || 0;
}
2025-07-03 12:33:02 +02:00
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: [] };
2025-07-03 15:59:31 +02:00
for (const [index, label] of props.datasetLabels.entries()) {
2025-07-03 12:33:02 +02:00
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,
2025-07-03 15:59:31 +02:00
fill: true,
color: props.datasetColors[index],
stack: 'stackgroup' // put them all in same stackgroup
2025-07-03 12:33:02 +02:00
});
}
2025-07-03 16:01:14 +02:00
// CPU and Memory graph have known min/max set and auto-scaling gets disabled
// Disk and Network graphs auto-scale the y values.
let yscale = null, highMark = null;
2025-07-03 12:33:02 +02:00
if (props.yscale === 'cpu') {
yscale = {
type: 'linear',
min: 0,
max: props.cpuCount * 100,
2025-07-03 12:33:02 +02:00
ticks: {
callback: (value) => `${value.toFixed(2).replace('.00', '')}%`,
2025-07-03 12:33:02 +02:00
maxTicksLimit: 6 // max tick labels to show
},
beginAtZero: true,
};
if (props.highMark) highMark = props.highMark * 100;
2025-07-03 12:33:02 +02:00
} else if (props.yscale === 'memory') {
const giB = 1024 * 1024 * 1024;
const roundedMemoryGiB = Math.ceil(props.memory / giB);
yscaleUnit = roundedMemoryGiB === 1 ? 'MiB' : 'GiB';
const roundedMemory = yscaleUnit === 'GiB' ? roundedMemoryGiB : roundedMemoryGiB * 1024;
2025-07-03 12:33:02 +02:00
yscale = {
type: 'linear',
min: 0,
max: roundedMemory,
2025-07-03 12:33:02 +02:00
ticks: {
stepSize: yscaleUnit === 'GiB' ? 1 : 256,
2025-07-03 12:33:02 +02:00
autoSkip: true, // skip tick labels as needed
autoSkipPadding: 20, // padding between ticks
2025-07-04 15:24:26 +02:00
callback: (value) => `${value.toFixed(2).replace('.00', '')} ${yscaleUnit}`,
2025-07-03 12:33:02 +02:00
maxTicksLimit: 8 // max tick labels to show
},
beginAtZero: true,
stacked: true,
};
if (props.highMark) highMark = yscaleUnit === 'GiB' ? props.highMark/giB : props.highMark/(1024*1024);
2025-07-03 12:33:02 +02:00
} 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: {
2025-07-04 14:26:09 +02:00
callback: (value) => `${prettyDecimalSize(value)}/s`,
2025-07-03 12:33:02 +02:00
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: {
2025-07-04 14:26:09 +02:00
callback: (value) => `${prettyDecimalSize(value)}/s`,
2025-07-03 12:33:02 +02:00
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, displayLegend: props.datasetLabels.length > 1, highMark });
2025-07-03 12:33:02 +02:00
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();
});
onUnmounted(async function () {
if (liveRefreshIntervalId) clearInterval(liveRefreshIntervalId);
});
2025-07-03 12:33:02 +02:00
defineExpose({
setData,
pushData,
});
</script>
<template>
<div>
<label>{{ title }} <span class="pull-right text-small">{{ subtext }}</span></label>
<div class="graph">
<canvas ref="graphNode"></canvas>
</div>
2025-07-03 12:33:02 +02:00
</div>
</template>
<style scoped>
.graphs label {
margin: 16px 0;
}
.graph {
position: relative;
width: 100%;
height: 160px;
}
</style>