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

259 lines
7.1 KiB
Vue
Raw Normal View History

<script setup>
import moment from 'moment-timezone';
import { onMounted, onUnmounted, 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;
let yscaleUnit = 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')
},
2025-07-03 15:59:31 +02:00
datasetColors: {
type: Array,
validator: (val) => Array.isArray(val) && val.every(item => typeof item === 'string')
},
yscale: String, // cpu, memory
memory: Object,
2025-07-03 15:59:31 +02:00
cpu: Object,
});
function createGraphOptions({ yscale, period, displayLegend }) {
const now = Date.now();
return {
maintainAspectRatio: false,
plugins: {
legend: {
display: displayLegend,
position: 'bottom'
},
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
let y;
if (props.yscale === 'memory') {
y = yscaleUnit === 'GiB' ? (data[0] / 1024 / 1024 / 1024).toFixed(2) : (data[0] / 1024 / 1024).toFixed(2);
} else { // in non-memory case, for relative values like cpu, if null make it 0
y = 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: [] };
2025-07-03 15:59:31 +02:00
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,
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 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;
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 roundedMemoryGiB = Math.ceil(props.memory / giB);
yscaleUnit = roundedMemoryGiB === 1 ? 'MiB' : 'GiB';
const roundedMemory = yscaleUnit === 'GiB' ? roundedMemoryGiB : roundedMemoryGiB * 1024;
yscale = {
type: 'linear',
min: 0,
max: roundedMemory,
ticks: {
stepSize: yscaleUnit === 'GiB' ? 1 : 256,
autoSkip: true, // skip tick labels as needed
autoSkipPadding: 20, // padding between ticks
callback: (value) => `${value} ${yscaleUnit}`,
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, displayLegend: props.datasetLabels.length > 1 });
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);
});
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>
</div>
</template>
<style scoped>
.graphs label {
margin: 16px 0;
}
.graph {
position: relative;
width: 100%;
height: 160px;
}
</style>