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 @@
-
+