diff --git a/panel/frontend/src/components/ChartLine.vue b/panel/frontend/src/components/ChartLine.vue index b395dfa..47fe11a 100644 --- a/panel/frontend/src/components/ChartLine.vue +++ b/panel/frontend/src/components/ChartLine.vue @@ -2,6 +2,11 @@ // Обёртка chart.js: линейный график в стиле кита. Одна ось, тонкие линии, // hover-тултип по индексу, легенда только при >=2 сериях (одиночную // серию называет заголовок карточки). +// +// Серия может нести `band: { min, max, n?, exp? }` — полосу min–max из дайджеста +// схлопнутых бакетов (история метрик разряжается): линия показывает последний +// замер интервала, полоса — что было внутри (всплески и провалы не теряются). +// В тултипе диапазон пишется рядом с числом замеров `n`/`exp` из `bandHint`. import { Chart } from 'chart.js/auto' import { onBeforeUnmount, onMounted, ref, watch } from 'vue' @@ -10,11 +15,13 @@ const props = defineProps({ labels: { type: Array, default: () => [] }, - // [{ label, data: number[], color, fill? }] + // [{ label, data: number[], color, fill?, band?: { min, max, n?, exp? } }] datasets: { type: Array, default: () => [] }, yMax: { type: Number, default: null }, yUnit: { type: String, default: '' }, height: { type: Number, default: 220 }, + // подпись счётчика замеров в тултипе полосы (i18n приходит со страницы) + bandHint: { type: String, default: '' }, }) // цвет не задан — берём первую позицию палитры: дефолт chart.js — @@ -23,24 +30,63 @@ const canvas = ref(null) let chart = null +// мета плоского списка датасетов (полоса — это две серии на одну легенду) +let meta = [] + +/** Разложить серии с полосами в плоский список датасетов chart.js. */ +function buildDatasets(list) { + const datasets = [] + meta = [] + for (const ds of list) { + const color = colorOf(ds) + if (ds.band) { + // нижняя граница — невидимая линия, верхняя заливается к ней (fill: '-1') + datasets.push({ + label: `${ds.label} min`, + data: ds.band.min, + borderWidth: 0, + borderColor: 'transparent', + pointRadius: 0, + pointHoverRadius: 0, + fill: false, + tension: 0.25, + }) + meta.push({ hideLegend: true, skipTooltip: true }) + datasets.push({ + label: ds.label, + data: ds.band.max, + borderWidth: 0, + pointRadius: 0, + pointHoverRadius: 0, + backgroundColor: color + '1f', + fill: '-1', + tension: 0.25, + }) + meta.push({ hideLegend: true, band: ds.band, label: ds.label }) + } + datasets.push({ + label: ds.label, + data: ds.data, + borderColor: color, + backgroundColor: color + '26', + borderWidth: 2, + // мало точек — линии нет, показываем точку, чтобы график не был пустым + pointRadius: (ctx) => (ctx.dataset.data.length < 3 ? 3 : 0), + pointHoverRadius: 5, + fill: Boolean(ds.fill), + tension: 0.25, + }) + meta.push({ label: ds.label }) + } + return datasets +} onMounted(() => { chart = new Chart(canvas.value, { type: 'line', data: { labels: props.labels, - datasets: props.datasets.map((ds) => ({ - label: ds.label, - data: ds.data, - borderColor: colorOf(ds), - backgroundColor: colorOf(ds) + '26', - borderWidth: 2, - // мало точек — линии нет, показываем точку, чтобы график не был пустым - pointRadius: (ctx) => (ctx.dataset.data.length < 3 ? 3 : 0), - pointHoverRadius: 5, - fill: Boolean(ds.fill), - tension: 0.25, - })), + datasets: buildDatasets(props.datasets), }, options: { responsive: true, @@ -50,7 +96,14 @@ plugins: { legend: { display: props.datasets.length > 1, - labels: { color: INK.muted, usePointStyle: true, boxWidth: 6, boxHeight: 6 }, + labels: { + color: INK.muted, + usePointStyle: true, + boxWidth: 6, + boxHeight: 6, + // полоса — та же серия, в легенде не дублируется + filter: (item) => !meta[item.datasetIndex]?.hideLegend, + }, }, tooltip: { backgroundColor: INK.panel, @@ -59,8 +112,14 @@ titleColor: INK.text, bodyColor: INK.text, padding: 10, + // нижняя граница полосы в тултипе не нужна — диапазон даёт верхняя + filter: (item) => !meta[item.datasetIndex]?.skipTooltip, callbacks: { - label: (ctx) => ` ${ctx.dataset.label}: ${round(ctx.parsed.y)}${props.yUnit}`, + label: (ctx) => { + const m = meta[ctx.datasetIndex] + if (m?.band) return ` ${bandLabel(m, ctx.dataIndex)}` + return ` ${ctx.dataset.label}: ${round(ctx.parsed.y)}${props.yUnit}` + }, }, }, }, @@ -88,14 +147,8 @@ () => { if (!chart) return chart.data.labels = props.labels - props.datasets.forEach((ds, i) => { - if (chart.data.datasets[i]) { - chart.data.datasets[i].label = ds.label - chart.data.datasets[i].data = ds.data - chart.data.datasets[i].borderColor = colorOf(ds) - chart.data.datasets[i].backgroundColor = colorOf(ds) + '26' - } - }) + // серии пересобираем целиком: у полос число датасетов не совпадает с числом серий + chart.data.datasets = buildDatasets(props.datasets) chart.update('none') }, { deep: true }, @@ -106,6 +159,17 @@ chart = null }) +/** Тултип полосы: `CPU: 3.2–98.4% · замеров 114/120`. */ +function bandLabel(m, i) { + const lo = m.band.min?.[i] + const hi = m.band.max?.[i] + if (lo == null || hi == null) return '' + const n = m.band.n?.[i] + const exp = m.band.exp?.[i] + const coverage = props.bandHint && n != null && exp ? ` · ${props.bandHint} ${n}/${exp}` : '' + return `${m.label}: ${round(lo)}–${round(hi)}${props.yUnit}${coverage}` +} + function round(v) { return Math.abs(v) >= 100 ? Math.round(v) : Math.round(v * 10) / 10 } diff --git a/panel/frontend/src/i18n/messages/en.js b/panel/frontend/src/i18n/messages/en.js index f9a63be..b21f573 100644 --- a/panel/frontend/src/i18n/messages/en.js +++ b/panel/frontend/src/i18n/messages/en.js @@ -67,6 +67,7 @@ "server.cpuRamChart": "CPU & RAM, %", "server.diskChart": "Disk usage, %", "server.netChart": "Network speed, MB/s", + "server.samples": "samples", "server.colMount": "Mount point", "server.colSize": "Size", "server.colUsed": "Used", diff --git a/panel/frontend/src/i18n/messages/ru.js b/panel/frontend/src/i18n/messages/ru.js index 6b5681d..e0b20e1 100644 --- a/panel/frontend/src/i18n/messages/ru.js +++ b/panel/frontend/src/i18n/messages/ru.js @@ -66,6 +66,7 @@ "server.cpuRamChart": "CPU и RAM, %", "server.diskChart": "Заполненность дисков, %", "server.netChart": "Скорость сети, MB/s", + "server.samples": "замеров", "server.colMount": "Точка монтирования", "server.colSize": "Размер", "server.colUsed": "Занято", diff --git a/panel/frontend/src/i18n/messages/uk.js b/panel/frontend/src/i18n/messages/uk.js index 0742ca3..c0c5587 100644 --- a/panel/frontend/src/i18n/messages/uk.js +++ b/panel/frontend/src/i18n/messages/uk.js @@ -66,6 +66,7 @@ "server.cpuRamChart": "CPU та RAM, %", "server.diskChart": "Заповненість дисків, %", "server.netChart": "Швидкість мережі, MB/s", + "server.samples": "замірів", "server.colMount": "Точка монтування", "server.colSize": "Розмір", "server.colUsed": "Зайнято", diff --git a/panel/frontend/src/pages/ServerPage.vue b/panel/frontend/src/pages/ServerPage.vue index 0d1ab19..9cbd6d2 100644 --- a/panel/frontend/src/pages/ServerPage.vue +++ b/panel/frontend/src/pages/ServerPage.vue @@ -91,14 +91,59 @@ const labels = computed(() => history.value.map((m) => timeLabel(m.ts))) +/* Полоса min–max из дайджеста схлопнутых бакетов (история разряжается по + возрасту точки): у сырых точек последних суток дайджеста нет — там полосы + нет и не нужно, всё видно точками. n/exp — сколько замеров пришло против + ожидаемого: меньше — в интервале был простой. */ +function band(key) { + const min = [] + const max = [] + const n = [] + const exp = [] + for (const m of history.value) { + const pair = m.agg?.[key] + min.push(pair ? pair[0] : null) + max.push(pair ? pair[1] : null) + n.push(m.agg?.n ?? null) + exp.push(m.agg?.exp ?? null) + } + return { min, max, n, exp } +} + +function diskBand(mount) { + const min = [] + const max = [] + const n = [] + const exp = [] + for (const m of history.value) { + const pair = m.agg?.disks?.[mount] + min.push(pair ? pair[0] : null) + max.push(pair ? pair[1] : null) + n.push(m.agg?.n ?? null) + exp.push(m.agg?.exp ?? null) + } + return { min, max, n, exp } +} + const cpuRamDatasets = computed(() => [ - { label: 'CPU', data: history.value.map((m) => m.cpu), color: CHART_COLORS[1] }, - { label: 'RAM', data: history.value.map((m) => ramPercent(m)), color: CHART_COLORS[0] }, + { label: 'CPU', data: history.value.map((m) => m.cpu), color: CHART_COLORS[1], band: band('cpu') }, + { + label: 'RAM', + data: history.value.map((m) => ramPercent(m)), + color: CHART_COLORS[0], + band: band('ram'), + }, ]) const netDatasets = computed(() => [ - { label: 'In', data: history.value.map((m) => m.net_in_mbs), color: SERIES_IN, fill: true }, - { label: 'Out', data: history.value.map((m) => m.net_out_mbs), color: SERIES_OUT }, + { + label: 'In', + data: history.value.map((m) => m.net_in_mbs), + color: SERIES_IN, + fill: true, + band: band('net'), + }, + { label: 'Out', data: history.value.map((m) => m.net_out_mbs), color: SERIES_OUT, band: band('out') }, ]) const diskMounts = computed(() => { @@ -114,6 +159,7 @@ label: mount, data: history.value.map((m) => m.disks?.find((d) => d.mount === mount)?.percent ?? null), color: CHART_COLORS[i % CHART_COLORS.length], + band: diskBand(mount), })), ) @@ -343,10 +389,22 @@ @@ -371,7 +429,13 @@