Files
cloudron-box/dashboard/src/components/SystemMetrics.vue
T
2025-09-10 22:28:31 +02:00

246 lines
9.3 KiB
Vue

<script setup>
import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, onUnmounted, useTemplateRef, nextTick } from 'vue';
import { SingleSelect, MultiSelect } from '@cloudron/pankow';
import Section from './Section.vue';
import SystemModel from '../models/SystemModel.js';
import { prettyDecimalSize } from '@cloudron/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: 'short' },
{ hours: 1, intervalSecs: 20, label: t('app.graphs.period.1h'), tickFormat: 'hh:mm A', tooltipFormat: 'short' }, // 180 points
{ hours: 6, intervalSecs: 60, label: t('app.graphs.period.6h'), tickFormat: 'hh:mm A', tooltipFormat: 'short' },
{ hours: 12, intervalSecs: 240, label: t('app.graphs.period.12h'), tickFormat: 'hh:mm A', tooltipFormat: 'short' },
{ hours: 24, intervalSecs: 480, label: t('app.graphs.period.24h'), tickFormat: 'hh:mm A', tooltipFormat: 'short' },
{ hours: 24*7, intervalSecs: 960, label: t('app.graphs.period.7d'), tickFormat: 'DD MMM', tooltipFormat: 'long' },
{ hours: 24*30, intervalSecs: 960, label: t('app.graphs.period.30d'), tickFormat: 'DD MMM', tooltipFormat: 'long' }, // 2700 points
];
const busy = ref(true);
const period = ref(periods[0]);
const cpuGraphItem = useTemplateRef('cpuGraphItem');
const memoryGraphItem = useTemplateRef('memoryGraphItem');
const diskGraphItem = useTemplateRef('diskGraphItem');
const networkGraphItem = useTemplateRef('networkGraphItem');
const networkReadTotal = ref(0);
const networkWriteTotal = ref(0);
const blockReadTotal = ref(0);
const blockWriteTotal = ref(0);
const selectedContainers = ref([]);
const allContainers = ref([]);
// TODO: add redis containers here
const serviceContainers = [
{ label: 'Graphite', id: 'graphite' },
{ label: 'Mail', id: 'mail' },
{ label: 'MySQL', id: 'mysql' },
{ label: 'MongoDB', id: 'mongodb' },
{ label: 'PostgreSQL', id: 'postgresql' },
{ label: 'Turn', id: 'turn' },
];
let systemMemory = {};
let systemCpus = {};
let metricStream = null;
async function liveRefresh() {
const options = {
system: true,
appIds: selectedContainers.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);
for (const [id, metric] of Object.entries(data)) {
const idx = id !== 'system' ? selectedContainers.value.findIndex(c => c.id === id) : selectedContainers.value.length;
if (cpuGraphItem.value) cpuGraphItem.value.pushData(idx, metric.cpu);
if (memoryGraphItem.value) memoryGraphItem.value.pushData(idx*2, metric.memory, metric.swap || []); // apps have no swap
if (diskGraphItem.value) diskGraphItem.value.pushData(idx*2, metric.blockReadRate, metric.blockWriteRate);
if (networkGraphItem.value) 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);
}
}
};
}
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((selectedContainers.value.length+1)*2); // 1 for the 'system'
const datasets = {
cpu: [],
memory: [],
disk: [],
network: [],
};
const appIds = selectedContainers.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' : selectedContainers.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: selectedContainers.value.map(c => c.id),
serviceIds: []
};
const [error, metrics] = await systemModel.getMetrics(options);
if (error) return console.error(error);
const appIds = selectedContainers.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);
}
if (cpuGraphItem.value) cpuGraphItem.value.setDatasets(datasets.cpu);
if (memoryGraphItem.value) memoryGraphItem.value.setDatasets(datasets.memory);
if (diskGraphItem.value) diskGraphItem.value.setDatasets(datasets.disk);
if (networkGraphItem.value) networkGraphItem.value.setDatasets(datasets.network);
if (period.value.hours === 0) return await liveRefresh();
}
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);
allContainers.value = result.map(a => { return { label: a.label || a.fqdn, id: a.id }; });
if (result.length) allContainers.value.push({ separator: true });
allContainers.value.push(...serviceContainers);
selectedContainers.value = [];
busy.value = false;
await nextTick();
await rebuild();
});
onUnmounted(async () => {
if (metricStream) metricStream.close();
});
</script>
<template>
<Section :title="$t('system.graphs.title')">
<template #header-buttons>
<MultiSelect @select="rebuild()" v-model="selectedContainers" :options="allContainers" option-label="label" :search-threshold="20" select-all-label="Select All"/>
<SingleSelect @select="rebuild()" v-model="period" :options="periods" option-label="label"/>
</template>
<div class="graphs" v-if="!busy">
<GraphItem ref="cpuGraphItem"
:title="$t('system.cpuUsage.title')"
:subtext='systemCpus.length ? `${systemCpus.length} Core "${systemCpus[0].model}"` : ""'
:period="period"
yscale="cpu"
:cpu-count="systemCpus.length"
>
</GraphItem>
<GraphItem ref="memoryGraphItem"
:title="$t('system.systemMemory.title')"
:subtext="`RAM: ${prettyDecimalSize(systemMemory.memory)} Swap: ${prettyDecimalSize(systemMemory.swap)}`"
:period="period"
yscale="memory"
:memory="systemMemory.memory + systemMemory.swap"
>
</GraphItem>
<GraphItem ref="diskGraphItem"
title="Disk I/O"
:subtext="$t('app.graphs.diskIOTotal', { read: blockReadTotal, write: blockWriteTotal })"
:period="period"
yscale="disk"
>
</GraphItem>
<GraphItem ref="networkGraphItem"
title="Network I/O"
:subtext="$t('app.graphs.networkIOTotal', { inbound: networkReadTotal, outbound: networkWriteTotal })"
:period="period"
yscale="network"
>
</GraphItem>
</div>
</Section>
</template>