From ba7a6a897715a78007b7299434f2604f176bb5d3 Mon Sep 17 00:00:00 2001 From: trean Date: Tue, 11 Aug 2026 22:50:06 +0200 Subject: [PATCH 1/9] wip: metrics dashboard --- src/components/Metrics/AddSeriesField.jsx | 173 +++++++++++ src/components/Metrics/MetricChart.jsx | 153 ++++++++++ src/components/Metrics/MetricsDashboard.jsx | 321 ++++++++++++++++++++ src/components/Metrics/colors.js | 21 ++ src/components/Metrics/presets.js | 34 +++ src/components/Sidebar/Sidebar.jsx | 9 + src/hooks/useMetricsHistory.js | 73 +++++ src/lib/metrics-parser.js | 215 +++++++++++++ src/lib/tests/metrics-parser.test.js | 170 +++++++++++ src/pages/Metrics.jsx | 19 ++ src/routes.jsx | 2 + 11 files changed, 1190 insertions(+) create mode 100644 src/components/Metrics/AddSeriesField.jsx create mode 100644 src/components/Metrics/MetricChart.jsx create mode 100644 src/components/Metrics/MetricsDashboard.jsx create mode 100644 src/components/Metrics/colors.js create mode 100644 src/components/Metrics/presets.js create mode 100644 src/hooks/useMetricsHistory.js create mode 100644 src/lib/metrics-parser.js create mode 100644 src/lib/tests/metrics-parser.test.js create mode 100644 src/pages/Metrics.jsx diff --git a/src/components/Metrics/AddSeriesField.jsx b/src/components/Metrics/AddSeriesField.jsx new file mode 100644 index 000000000..4a473a1fe --- /dev/null +++ b/src/components/Metrics/AddSeriesField.jsx @@ -0,0 +1,173 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { Autocomplete, Box, Chip, IconButton, TextField, Tooltip, Typography } from '@mui/material'; +import { Plus } from 'lucide-react'; +import { seriesLabel, isCounter } from '../../lib/metrics-parser'; + +// Two series can share a chart only if they'd share a meaningful Y axis: the +// same display unit and the same plotting kind (gauge raw vs counter rate). +const optionCompat = (option) => `${option.unit}:${isCounter(option.type) ? 'rate' : 'raw'}`; + +// eslint-disable-next-line react/prop-types +const renderOption = (props, option) => ( + // eslint-disable-next-line react/prop-types + + + + {seriesLabel(option)} + + {option.help && ( + + {option.help} + + )} + + +); + +const autocompleteProps = { + size: 'small', + getOptionLabel: (option) => (typeof option === 'string' ? option : option.key), + isOptionEqualToValue: (option, selected) => option.key === selected.key, + renderOption, +}; + +// Inline adder inside an existing chart: a small search field; picking a metric +// adds it immediately. +function InlineAddField({ options, onAdd, placeholder }) { + const [value, setValue] = useState(null); + const [inputValue, setInputValue] = useState(''); + + const commit = (option) => { + if (!option) return; + onAdd(option); + setValue(null); + setInputValue(''); + }; + + return ( + commit(option)} + inputValue={inputValue} + onInputChange={(_, next) => setInputValue(next)} + options={options} + renderInput={(params) => } + /> + ); +} + +InlineAddField.propTypes = { + options: PropTypes.array.isRequired, + onAdd: PropTypes.func.isRequired, + placeholder: PropTypes.string, +}; + +// The top-of-dashboard bar: stage one or more compatible metrics into the +// field, then create a chart holding all of them by pressing "+" or Enter. +// Once a first metric is staged, the options narrow to those that share its +// unit and gauge/counter kind, so a chart can't end up with a mismatched axis. +function NewChartField({ options, onCreate, placeholder }) { + const [staged, setStaged] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [open, setOpen] = useState(false); + + const stagedKeys = new Set(staged.map((series) => series.key)); + const compat = staged.length ? optionCompat(staged[0]) : null; + const filtered = options.filter( + (option) => !stagedKeys.has(option.key) && (compat === null || optionCompat(option) === compat) + ); + + const create = () => { + if (!staged.length) return; + onCreate(staged); + setStaged([]); + setInputValue(''); + setOpen(false); // creating the chart also dismisses the options list + }; + + // Enter creates the chart when the user isn't mid-typing a filter; while + // typing, Enter falls through to the Autocomplete so it selects the + // highlighted option (staging it) as usual. + const handleKeyDown = (event) => { + if (event.key === 'Enter' && inputValue.trim() === '' && staged.length > 0) { + event.preventDefault(); + event.stopPropagation(); + create(); + } + }; + + return ( + + setOpen(true)} + onClose={() => setOpen(false)} + value={staged} + onChange={(_, next) => setStaged(next)} + inputValue={inputValue} + onInputChange={(_, next) => setInputValue(next)} + options={filtered} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + // key is provided by getTagProps + + )) + } + renderInput={(params) => ( + + )} + /> + + + + + + + + + ); +} + +NewChartField.propTypes = { + options: PropTypes.array.isRequired, + onCreate: PropTypes.func.isRequired, + placeholder: PropTypes.string, +}; + +// `variant="inline"` is the in-chart adder (a small field that adds a metric +// immediately); the default "bar" is the staging field that builds a new chart +// from several metrics at once. +const AddSeriesField = ({ options, onAdd, onCreate, placeholder = 'Add a metric…', variant = 'bar' }) => + variant === 'inline' ? ( + + ) : ( + + ); + +AddSeriesField.propTypes = { + options: PropTypes.array.isRequired, + onAdd: PropTypes.func, + onCreate: PropTypes.func, + placeholder: PropTypes.string, + variant: PropTypes.oneOf(['bar', 'inline']), +}; + +export default AddSeriesField; diff --git a/src/components/Metrics/MetricChart.jsx b/src/components/Metrics/MetricChart.jsx new file mode 100644 index 000000000..80e4a66fb --- /dev/null +++ b/src/components/Metrics/MetricChart.jsx @@ -0,0 +1,153 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { Box, Typography } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import Chart from 'chart.js/auto'; +import { formatValue, detectUnit, isCounter, toRatePerSecond } from '../../lib/metrics-parser'; +import { seriesColor } from './colors'; + +const formatTick = (t) => + new Date(t).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + +// A single time-series line chart rendering one or more metric series that +// share an X axis (the poll timestamps accumulated by useMetricsHistory). +const MetricChart = ({ series, history }) => { + const theme = useTheme(); + const canvasRef = useRef(null); + const chartRef = useRef(null); + + // Signature that identifies the current set of series; a change means the + // chart's datasets must be rebuilt rather than merely re-fed with data. The + // type is part of it so the chart rebuilds once the first snapshot resolves a + // series' type (gauge vs counter changes how it's plotted and labelled). + const seriesSignature = useMemo(() => series.map((s) => `${s.key}:${s.type || ''}`).join('|'), [series]); + // The Y axis carries a single unit; use the first series' unit for it while + // tooltips format each point by its own unit. + const axisUnit = series.length ? detectUnit(series[0].name) : 'number'; + // Counters are shown as a per-second rate; only append the "/s" axis suffix + // when every series on the chart is a rate (a mixed chart is left unsuffixed). + const allRate = series.length > 0 && series.every((s) => isCounter(s.type)); + + // The plotted values for a series: a rate for counters, the raw value for + // gauges. Shared by the render effect and the "has any data yet" check so a + // counter isn't considered ready until it has two points to derive a rate. + const computeData = (s) => { + const points = history.map((point) => ({ t: point.t, v: point.values[s.key] ?? null })); + return isCounter(s.type) ? toRatePerSecond(points) : points.map((point) => point.v); + }; + + // (Re)create the chart when the series set or the theme mode changes. + useEffect(() => { + if (!canvasRef.current) return undefined; + const gridColor = theme.palette.divider; + const textColor = theme.palette.text.secondary; + + const chart = new Chart(canvasRef.current.getContext('2d'), { + type: 'line', + data: { + labels: [], + datasets: series.map((s, i) => ({ + label: isCounter(s.type) ? `${s.label} (rate)` : s.label, + data: [], + unit: detectUnit(s.name), + rate: isCounter(s.type), + borderColor: seriesColor(theme, i).main, + backgroundColor: seriesColor(theme, i).main, + borderWidth: 2, + pointRadius: 0, + pointHoverRadius: 4, + tension: 0.3, + spanGaps: true, + })), + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + interaction: { mode: 'index', intersect: false }, + plugins: { + // The legend is intentionally off — the metric chips below the chart + // carry the same colors and serve as an interactive legend. + legend: { display: false }, + tooltip: { + callbacks: { + label: (ctx) => + `${ctx.dataset.label}: ${formatValue(ctx.parsed.y, ctx.dataset.unit)}${ctx.dataset.rate ? '/s' : ''}`, + }, + }, + }, + scales: { + x: { + grid: { color: gridColor, display: false }, + ticks: { color: textColor, maxRotation: 0, autoSkip: true, maxTicksLimit: 6 }, + }, + y: { + grid: { color: gridColor }, + border: { display: false }, + ticks: { + color: textColor, + maxTicksLimit: 5, + callback: (value) => `${formatValue(value, axisUnit)}${allRate ? '/s' : ''}`, + }, + }, + }, + }, + }); + chartRef.current = chart; + return () => { + chart.destroy(); + chartRef.current = null; + }; + }, [seriesSignature, theme.palette.mode]); + + // Feed the accumulated history into the existing chart on every poll. + useEffect(() => { + const chart = chartRef.current; + if (!chart) return; + chart.data.labels = history.map((point) => formatTick(point.t)); + series.forEach((s, i) => { + if (chart.data.datasets[i]) { + chart.data.datasets[i].data = computeData(s); + } + }); + chart.update('none'); + }, [history, seriesSignature, series]); + + const hasData = series.some((s) => computeData(s).some((v) => v != null)); + + return ( + + + {!hasData && ( + + + Collecting data… + + + )} + + ); +}; + +MetricChart.propTypes = { + series: PropTypes.arrayOf( + PropTypes.shape({ + key: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + type: PropTypes.string, + }) + ).isRequired, + history: PropTypes.array.isRequired, +}; + +export default MetricChart; diff --git a/src/components/Metrics/MetricsDashboard.jsx b/src/components/Metrics/MetricsDashboard.jsx new file mode 100644 index 000000000..3e3e90c57 --- /dev/null +++ b/src/components/Metrics/MetricsDashboard.jsx @@ -0,0 +1,321 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + Divider, + IconButton, + Stack, + Tooltip, + Typography, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import { Plus, Trash2, ChartSpline } from 'lucide-react'; +import MetricChart from './MetricChart'; +import AddSeriesField from './AddSeriesField'; +import { seriesColor } from './colors'; +import { PRESETS } from './presets'; +import { useMetricsHistory } from '../../hooks/useMetricsHistory'; +import { useScrollToId } from '../../hooks/useScrollToId'; +import { + listSeries, + seriesLabel, + buildSeriesKey, + indexTypesByName, + detectUnit, + isCounter, +} from '../../lib/metrics-parser'; + +// todo (ai agents have to ignore this!): do the same for all hard-coded storage keys +const STORAGE_KEY = 'metricsDashboard.charts'; +const POLL_INTERVAL_MS = 5000; +const MAX_POINTS = 120; // ~10 minutes at a 5s interval + +// todo: looks like a good candidate to move to helpers and becoming a function (and maybe rename if +// moved, to express better what type of id it returns +const newId = () => + typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `c-${Date.now()}-${Math.random()}`; + +const loadCharts = () => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +}; + +// A chart's series carry only { key, name, labels }; the human label is derived +// on render so it stays consistent with the parser's formatting. +const makeSeries = (name, labels = {}) => ({ key: buildSeriesKey(name, labels), name, labels }); + +// Series can share a chart only if they'd share a meaningful Y axis: the same +// display unit (bytes / seconds / count) and the same plotting kind (a gauge is +// drawn raw, a counter as a per-second rate). This groups them into a single +// compatibility bucket used to filter the in-chart "add metric" options. +const compatKey = (name, type) => `${detectUnit(name)}:${isCounter(type) ? 'rate' : 'raw'}`; + +// DOM id for a chart card, so a freshly added chart can be scrolled into view. +const chartElementId = (chartId) => `metrics-chart-${chartId}`; + +function MetricsDashboard() { + const theme = useTheme(); + const [charts, setCharts] = useState(loadCharts); + // Id of a just-added chart to scroll to once it mounts (cleared after). + const [scrollToId, setScrollToId] = useState(null); + const clearScrollTo = useCallback(() => setScrollToId(null), []); + useScrollToId(scrollToId, { onScrolled: clearScrollTo }); + + // Persist the dashboard layout so it survives reloads + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(charts)); + } catch { + /* ignore quota / private-mode failures */ + } + }, [charts]); + + // Every distinct series key referenced by any chart — the set we accumulate + // history for. + const subscribedKeys = useMemo(() => { + const keys = new Set(); + charts.forEach((chart) => chart.series.forEach((s) => keys.add(s.key))); + return [...keys]; + }, [charts]); + + const { snapshot, history, loading, error } = useMetricsHistory({ + subscribedKeys, + intervalMs: POLL_INTERVAL_MS, + maxPoints: MAX_POINTS, + }); + + const availableSeries = useMemo(() => listSeries(snapshot), [snapshot]); + const typesByName = useMemo(() => indexTypesByName(snapshot), [snapshot]); + + const addChart = useCallback((title, series) => { + const id = newId(); + setCharts((prev) => [...prev, { id, title, series }]); + setScrollToId(chartElementId(id)); + }, []); + + // Create one chart from a list of staged series (the top-bar builder). The + // title is the metric name, or " +N" when several distinct metrics are + // combined, so a multi-series chart still reads clearly in its header. + const createChart = useCallback( + (seriesList) => { + if (!seriesList?.length) return; + const names = [...new Set(seriesList.map((s) => s.name))]; + const title = names.length === 1 ? names[0] : `${names[0]} +${names.length - 1}`; + addChart( + title, + seriesList.map((s) => makeSeries(s.name, s.labels)) + ); + }, + [addChart] + ); + + const addPreset = useCallback((preset) => { + const created = preset.charts.map((chart) => ({ + id: newId(), + title: chart.title, + series: chart.metrics.map((name) => makeSeries(name)), + })); + setCharts((prev) => [...prev, ...created]); + if (created.length) setScrollToId(chartElementId(created[created.length - 1].id)); + }, []); + + const removeChart = useCallback((chartId) => { + setCharts((prev) => prev.filter((chart) => chart.id !== chartId)); + }, []); + + const addSeriesToChart = useCallback((chartId, series) => { + setCharts((prev) => + prev.map((chart) => { + if (chart.id !== chartId) return chart; + if (chart.series.some((s) => s.key === series.key)) return chart; // no duplicates + return { ...chart, series: [...chart.series, makeSeries(series.name, series.labels)] }; + }) + ); + }, []); + + const removeSeriesFromChart = useCallback((chartId, key) => { + setCharts((prev) => + prev.map((chart) => + chart.id === chartId ? { ...chart, series: chart.series.filter((s) => s.key !== key) } : chart + ) + ); + }, []); + + return ( + + {/* Header */} + + + + Metrics + + + Live cluster metrics, sampled every {POLL_INTERVAL_MS / 1000}s. Build your own charts or start from a + preset. + + + + + {/* Presets */} + + {PRESETS.map((preset) => ( + + ))} + + + {/* Add-chart bar */} + + + + + {error && ( + + {error} + + )} + + {/* Charts */} + {charts.length === 0 ? ( + + ) : ( + + {charts.map((chart) => { + const chartSeries = chart.series.map((s) => ({ + ...s, + label: seriesLabel(s), + type: typesByName[s.name] || '', + })); + // Options for this chart's adder: drop series already on the chart, + // and — once the chart has at least one series — keep only those + // that share its unit and gauge/counter kind. An empty chart offers + // everything. + const existingKeys = new Set(chart.series.map((s) => s.key)); + const chartCompat = chart.series.length + ? compatKey(chart.series[0].name, typesByName[chart.series[0].name]) + : null; + const addOptions = availableSeries.filter( + (option) => + !existingKeys.has(option.key) && + (chartCompat === null || compatKey(option.name, option.type) === chartCompat) + ); + return ( + t.spacing(10) }} + > + + {/* todo: move styles in `sx` if possible */} + + {chart.title} + + + removeChart(chart.id)} aria-label="Remove chart"> + + + + + + + + + + {/* Series chips (left) double as the chart legend — each + filled with its line's color (matched by position). The + "+" adder is pinned to the bottom-right corner. */} + + + {chartSeries.map((s, i) => { + const { main, contrastText } = seriesColor(theme, i); + return ( + removeSeriesFromChart(chart.id, s.key)} + sx={{ + maxWidth: 320, + bgcolor: main, + color: contrastText, + '& .MuiChip-deleteIcon': { + color: contrastText, + opacity: 0.7, + '&:hover': { opacity: 1, color: contrastText }, + }, + }} + /> + ); + })} + + + addSeriesToChart(chart.id, series)} + placeholder="Add a metric…" + /> + + + + + ); + })} + + )} + + ); +} + +function EmptyState({ loading }) { + return ( + + + + No charts yet + + {loading + ? 'Connecting to the metrics endpoint…' + : 'Add a preset above, or search for a metric to create your first chart.'} + + + + ); +} + +EmptyState.propTypes = { + loading: PropTypes.bool, +}; + +export default MetricsDashboard; diff --git a/src/components/Metrics/colors.js b/src/components/Metrics/colors.js new file mode 100644 index 000000000..198037a9e --- /dev/null +++ b/src/components/Metrics/colors.js @@ -0,0 +1,21 @@ +// Series colors for the Metrics charts, drawn from the MUI theme's semantic +// palette so they match the rest of the app and adapt to light/dark mode. A +// series' chart line and its chip share the same color, assigned by position. +// Ordered to keep adjacent series visually distinct (the two blue-ish entries +// come last, so they only appear on charts with five or more series). +// +// The full palette entry is returned (not just `.main`) so consumers can use +// its ready-made `.contrastText` for legible chip labels. +export const seriesPalette = (theme) => [ + theme.palette.primary, + theme.palette.error, + theme.palette.success, + theme.palette.warning, + theme.palette.secondary, + theme.palette.info, +]; + +export const seriesColor = (theme, index) => { + const palette = seriesPalette(theme); + return palette[index % palette.length]; +}; diff --git a/src/components/Metrics/presets.js b/src/components/Metrics/presets.js new file mode 100644 index 000000000..4521272d9 --- /dev/null +++ b/src/components/Metrics/presets.js @@ -0,0 +1,34 @@ +// Preset charts offered as one-click buttons on the Metrics dashboard. +// +// Presets reference label-free gauge metrics, whose series key is simply the +// metric name, so they resolve reliably regardless of the labels a particular +// deployment emits. Metrics that aren't present in the current response just +// render as an empty series until data arrives. +export const PRESETS = [ + { + id: 'memory', + label: 'Memory', + charts: [ + { + title: 'Memory usage', + metrics: [ + 'memory_resident_bytes', + 'memory_allocated_bytes', + 'memory_active_bytes', + 'memory_retained_bytes', + 'memory_metadata_bytes', + ], + }, + ], + }, + { + id: 'collections', + label: 'Collections', + charts: [ + { + title: 'Collections & pending operations', + metrics: ['collections_total', 'collections_vector_total', 'pending_operations'], + }, + ], + }, +]; diff --git a/src/components/Sidebar/Sidebar.jsx b/src/components/Sidebar/Sidebar.jsx index edefbf219..a3690cf04 100644 --- a/src/components/Sidebar/Sidebar.jsx +++ b/src/components/Sidebar/Sidebar.jsx @@ -8,6 +8,7 @@ import { Rocket, SquareTerminal, RectangleEllipsis, + ChartSpline, FileCode, KeyRound, BookMarked, @@ -87,6 +88,14 @@ export default function Sidebar() { disabled={false} /> + } + linkTo="/metrics" + active={isActive('/metrics')} + disabled={false} + /> + {!isRestricted && ( descriptor), or null +// history [{ t, values: { seriesKey: number } }] oldest-first, capped +// loading true until the first response (success or failure) arrives +// error last error message, or null +export const useMetricsHistory = ({ subscribedKeys = [], intervalMs = 5000, maxPoints = 120 } = {}) => { + const [snapshot, setSnapshot] = useState(null); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const keysRef = useRef(subscribedKeys); + keysRef.current = subscribedKeys; + + useEffect(() => { + let active = true; + + const tick = async () => { + try { + // The endpoint returns plain text, so bypass the shared JSON response + // transform and keep the raw body. + const response = await axios.get('/metrics', { + responseType: 'text', + transformResponse: [(data) => data], + headers: { Accept: 'text/plain' }, + }); + if (!active) return; + + const parsed = parsePrometheus(response.data); + const index = indexByKey(parsed); + const values = {}; + for (const key of keysRef.current) { + if (key in index) values[key] = index[key]; + } + + setSnapshot(parsed); + setError(null); + setHistory((prev) => { + const next = [...prev, { t: Date.now(), values }]; + return next.length > maxPoints ? next.slice(next.length - maxPoints) : next; + }); + } catch (err) { + if (!active) return; + setError(err?.response?.data?.status?.error || err?.message || 'Failed to fetch metrics.'); + } finally { + if (active) setLoading(false); + } + }; + + tick(); + const id = setInterval(tick, intervalMs); + return () => { + active = false; + clearInterval(id); + }; + }, [intervalMs, maxPoints]); + + return { snapshot, history, loading, error }; +}; diff --git a/src/lib/metrics-parser.js b/src/lib/metrics-parser.js new file mode 100644 index 000000000..6e964fed3 --- /dev/null +++ b/src/lib/metrics-parser.js @@ -0,0 +1,215 @@ +// Helpers for the Metrics dashboard: parse Qdrant's `/metrics` endpoint +// (Prometheus text exposition format) into structured series, and format the +// numeric values for display. +// +// The `/metrics` endpoint returns plain text, e.g.: +// +// # HELP app_info information about qdrant server +// # TYPE app_info gauge +// app_info{name="qdrant",version="1.15.1"} 1 +// # HELP memory_active_bytes ... +// # TYPE memory_active_bytes gauge +// memory_active_bytes 1234567 +// +// A single metric name can expose many samples that differ only by their +// labels (e.g. `rest_responses_total` per method/endpoint/status), so each +// sample is identified by a canonical "series key" that folds the labels in. + +import prettyBytes from 'pretty-bytes'; + +// Build a stable, canonical key for a sample: the metric name plus its labels +// sorted by name, so the same series always maps to the same string regardless +// of the label order the server happened to emit. +export const buildSeriesKey = (name, labels) => { + const entries = Object.entries(labels || {}); + if (entries.length === 0) return name; + const inner = entries + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}="${v}"`) + .join(','); + return `${name}{${inner}}`; +}; + +// Parse a numeric value, tolerating the Prometheus specials. +const parseValue = (raw) => { + switch (raw) { + case '+Inf': + return Infinity; + case '-Inf': + return -Infinity; + case 'NaN': + return NaN; + default: { + const n = Number(raw); + return Number.isNaN(n) ? null : n; + } + } +}; + +// Parse the `{a="1",b="2"}` label block into an object. Handles escaped quotes +// and backslashes as defined by the exposition format. +const parseLabels = (block) => { + const labels = {}; + if (!block) return labels; + const inner = block.slice(1, -1); // strip { } + const re = /([a-zA-Z_][a-zA-Z0-9_]*)="((?:[^"\\]|\\.)*)"/g; + let m; + while ((m = re.exec(inner)) !== null) { + labels[m[1]] = m[2].replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\\\/g, '\\'); + } + return labels; +}; + +const SAMPLE_RE = /^([a-zA-Z_:][a-zA-Z0-9_:]*)(\{.*\})?\s+(.+)$/; + +// Parse the Prometheus text into a map of metric name -> descriptor: +// { name, help, type, samples: [{ name, labels, value, key }] } +export const parsePrometheus = (text) => { + const metrics = {}; + if (!text || typeof text !== 'string') return metrics; + + const ensure = (name) => { + if (!metrics[name]) metrics[name] = { name, help: '', type: '', samples: [] }; + return metrics[name]; + }; + + for (const rawLine of text.split('\n')) { + const line = rawLine.trim(); + if (!line) continue; + + if (line[0] === '#') { + const meta = line.match(/^#\s+(HELP|TYPE)\s+([a-zA-Z_:][a-zA-Z0-9_:]*)\s+(.*)$/); + if (meta) { + const [, kind, name, rest] = meta; + const metric = ensure(name); + if (kind === 'HELP') metric.help = rest; + else metric.type = rest.trim(); + } + continue; + } + + const m = line.match(SAMPLE_RE); + if (!m) continue; + const [, name, labelBlock, valuePart] = m; + const value = parseValue(valuePart.trim().split(/\s+/)[0]); + if (value === null) continue; + const labels = parseLabels(labelBlock); + ensure(name).samples.push({ name, labels, value, key: buildSeriesKey(name, labels) }); + } + + return metrics; +}; + +// Flatten a parsed metrics map into a flat list of selectable series, each one +// with the metadata the UI needs to render and label it. +export const listSeries = (metrics) => { + const series = []; + for (const metric of Object.values(metrics || {})) { + for (const sample of metric.samples) { + series.push({ + key: sample.key, + name: sample.name, + labels: sample.labels, + help: metric.help, + type: metric.type, + unit: detectUnit(sample.name), + }); + } + } + return series.sort((a, b) => a.key.localeCompare(b.key)); +}; + +// Index a parsed metrics map as seriesKey -> latest value, for quickly pulling +// the current value of every subscribed series on each poll. +export const indexByKey = (metrics) => { + const index = {}; + for (const metric of Object.values(metrics || {})) { + for (const sample of metric.samples) { + index[sample.key] = sample.value; + } + } + return index; +}; + +// Index a parsed metrics map as metricName -> Prometheus type ('gauge', +// 'counter', …). The type is declared per metric name, so it applies to every +// labelled series of that metric. +export const indexTypesByName = (metrics) => { + const types = {}; + for (const metric of Object.values(metrics || {})) { + types[metric.name] = metric.type; + } + return types; +}; + +// Counters are cumulative, so the meaningful quantity to plot is their rate of +// change per second rather than the raw total. Gauges are plotted as-is. +export const isCounter = (type) => type === 'counter'; + +// Convert a series of { t (ms), v } points to a per-second rate, Grafana-style: +// each point is (v - vPrev) / (dtSeconds). The first point and any gap have no +// rate (null). A decrease is treated as a counter reset (process restart) and +// yields a gap rather than a large negative spike. +export const toRatePerSecond = (points) => { + const out = new Array(points.length).fill(null); + for (let i = 1; i < points.length; i++) { + const prev = points[i - 1]; + const curr = points[i]; + if (prev == null || curr == null || prev.v == null || curr.v == null) continue; + const dt = (curr.t - prev.t) / 1000; + if (dt <= 0) continue; + const delta = curr.v - prev.v; + if (delta < 0) continue; // counter reset + out[i] = delta / dt; + } + return out; +}; + +// Guess a display unit from the metric name. Qdrant follows the Prometheus +// convention of encoding the unit as the metric-name suffix. +export const detectUnit = (name) => { + if (/_bytes$/.test(name)) return 'bytes'; + if (/_seconds$/.test(name)) return 'seconds'; + return 'number'; +}; + +// Format a value for axis ticks and tooltips according to its unit. +export const formatValue = (value, unit) => { + if (value === null || value === undefined || Number.isNaN(value)) return '—'; + if (!Number.isFinite(value)) return value > 0 ? '∞' : '-∞'; + switch (unit) { + case 'bytes': + return prettyBytes(value); + case 'seconds': + return formatSeconds(value); + default: + return formatNumber(value); + } +}; + +const formatSeconds = (value) => { + if (value === 0) return '0s'; + if (value < 1e-3) return `${(value * 1e6).toFixed(0)}µs`; + if (value < 1) return `${(value * 1e3).toFixed(1)}ms`; + return `${value.toFixed(2)}s`; +}; + +const formatNumber = (value) => { + if (Number.isInteger(value)) return value.toLocaleString(); + return value.toLocaleString(undefined, { maximumFractionDigits: 3 }); +}; + +// A short, human-friendly label for a series, used in chart legends and chips. +// Prefers the most descriptive label value (endpoint/method/name/version) so +// that many samples of the same metric stay distinguishable. +export const seriesLabel = ({ name, labels }) => { + const values = Object.entries(labels || {}); + if (values.length === 0) return name; + const preferredOrder = ['method', 'endpoint', 'name', 'version', 'id']; + const rank = (label) => { + const i = preferredOrder.indexOf(label); + return i === -1 ? preferredOrder.length : i; + }; + const parts = values.sort(([a], [b]) => rank(a) - rank(b)).map(([, v]) => v); + return `${name} · ${parts.join(' ')}`; +}; diff --git a/src/lib/tests/metrics-parser.test.js b/src/lib/tests/metrics-parser.test.js new file mode 100644 index 000000000..357342258 --- /dev/null +++ b/src/lib/tests/metrics-parser.test.js @@ -0,0 +1,170 @@ +import { describe, it, expect } from 'vitest'; +import { + parsePrometheus, + buildSeriesKey, + listSeries, + indexByKey, + indexTypesByName, + isCounter, + toRatePerSecond, + detectUnit, + formatValue, + seriesLabel, +} from '../metrics-parser'; + +const SAMPLE = `# HELP app_info information about qdrant server +# TYPE app_info gauge +app_info{name="qdrant",version="1.15.1"} 1 +# HELP collections_total number of collections +# TYPE collections_total gauge +collections_total 3 +# HELP memory_active_bytes ... +# TYPE memory_active_bytes gauge +memory_active_bytes 1048576 +# HELP rest_responses_total total number of responses +# TYPE rest_responses_total counter +rest_responses_total{method="GET",endpoint="/collections",status="200"} 42 +rest_responses_total{endpoint="/collections",method="POST",status="200"} 7 +`; + +describe('parsePrometheus', () => { + it('parses help, type and samples', () => { + const metrics = parsePrometheus(SAMPLE); + expect(metrics.collections_total.help).toBe('number of collections'); + expect(metrics.collections_total.type).toBe('gauge'); + expect(metrics.collections_total.samples[0].value).toBe(3); + }); + + it('parses labels for a labelled sample', () => { + const metrics = parsePrometheus(SAMPLE); + const sample = metrics.app_info.samples[0]; + expect(sample.labels).toEqual({ name: 'qdrant', version: '1.15.1' }); + expect(sample.value).toBe(1); + }); + + it('keeps multiple samples of the same metric distinct by key', () => { + const metrics = parsePrometheus(SAMPLE); + expect(metrics.rest_responses_total.samples).toHaveLength(2); + const keys = metrics.rest_responses_total.samples.map((s) => s.key); + expect(new Set(keys).size).toBe(2); + }); + + it('handles empty and non-string input', () => { + expect(parsePrometheus('')).toEqual({}); + expect(parsePrometheus(null)).toEqual({}); + expect(parsePrometheus(undefined)).toEqual({}); + }); + + it('parses the Prometheus special values', () => { + const metrics = parsePrometheus('# TYPE x gauge\nx +Inf\ny -Inf\nz NaN\n'); + expect(metrics.x.samples[0].value).toBe(Infinity); + expect(metrics.y.samples[0].value).toBe(-Infinity); + expect(Number.isNaN(metrics.z.samples[0].value)).toBe(true); + }); +}); + +describe('buildSeriesKey', () => { + it('returns the bare name when there are no labels', () => { + expect(buildSeriesKey('collections_total', {})).toBe('collections_total'); + }); + + it('is order-independent (labels sorted canonically)', () => { + const a = buildSeriesKey('m', { b: '2', a: '1' }); + const b = buildSeriesKey('m', { a: '1', b: '2' }); + expect(a).toBe(b); + expect(a).toBe('m{a="1",b="2"}'); + }); +}); + +describe('listSeries / indexByKey', () => { + it('flattens every sample into a selectable series', () => { + const series = listSeries(parsePrometheus(SAMPLE)); + // app_info, collections_total, memory_active_bytes + 2 rest_responses_total + expect(series).toHaveLength(5); + expect(series.every((s) => 'key' in s && 'unit' in s)).toBe(true); + }); + + it('indexes latest value by series key', () => { + const index = indexByKey(parsePrometheus(SAMPLE)); + expect(index.collections_total).toBe(3); + expect(index.memory_active_bytes).toBe(1048576); + }); +}); + +describe('indexTypesByName / isCounter', () => { + it('maps metric name to its declared Prometheus type', () => { + const types = indexTypesByName(parsePrometheus(SAMPLE)); + expect(types.collections_total).toBe('gauge'); + expect(types.rest_responses_total).toBe('counter'); + }); + + it('isCounter only accepts the counter type', () => { + expect(isCounter('counter')).toBe(true); + expect(isCounter('gauge')).toBe(false); + expect(isCounter('')).toBe(false); + expect(isCounter(undefined)).toBe(false); + }); +}); + +describe('toRatePerSecond', () => { + it('derives a per-second rate from cumulative counter points', () => { + const rate = toRatePerSecond([ + { t: 0, v: 100 }, + { t: 1000, v: 110 }, // +10 over 1s + { t: 3000, v: 130 }, // +20 over 2s + ]); + expect(rate).toEqual([null, 10, 10]); + }); + + it('treats a decrease as a counter reset (gap, not a negative spike)', () => { + const rate = toRatePerSecond([ + { t: 0, v: 500 }, + { t: 1000, v: 20 }, // reset + { t: 2000, v: 45 }, // +25 over 1s + ]); + expect(rate).toEqual([null, null, 25]); + }); + + it('produces gaps around missing points and zero/negative intervals', () => { + const rate = toRatePerSecond([ + { t: 0, v: 10 }, + { t: 1000, v: null }, + { t: 2000, v: 30 }, + ]); + expect(rate).toEqual([null, null, null]); + }); +}); + +describe('detectUnit', () => { + it('detects bytes, seconds and plain numbers', () => { + expect(detectUnit('memory_active_bytes')).toBe('bytes'); + expect(detectUnit('rest_responses_avg_duration_seconds')).toBe('seconds'); + expect(detectUnit('collections_total')).toBe('number'); + }); +}); + +describe('formatValue', () => { + it('formats by unit', () => { + expect(formatValue(1048576, 'bytes')).toBe('1.05 MB'); + expect(formatValue(0.5, 'seconds')).toBe('500.0ms'); + expect(formatValue(1234, 'number')).toBe('1,234'); + }); + + it('handles missing and non-finite values', () => { + expect(formatValue(null, 'number')).toBe('—'); + expect(formatValue(undefined, 'bytes')).toBe('—'); + expect(formatValue(Infinity, 'number')).toBe('∞'); + }); +}); + +describe('seriesLabel', () => { + it('uses the bare name when unlabelled', () => { + expect(seriesLabel({ name: 'collections_total', labels: {} })).toBe('collections_total'); + }); + + it('appends label values for labelled series', () => { + expect( + seriesLabel({ name: 'rest_responses_total', labels: { method: 'GET', endpoint: '/x', status: '200' } }) + ).toBe('rest_responses_total · GET /x 200'); + }); +}); diff --git a/src/pages/Metrics.jsx b/src/pages/Metrics.jsx new file mode 100644 index 000000000..c84a3386a --- /dev/null +++ b/src/pages/Metrics.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Grid } from '@mui/material'; +import { CenteredFrame } from '../components/Common/CenteredFrame'; +import { PAGE_CONTENT_WIDTH } from '../theme/constants'; +import MetricsDashboard from '../components/Metrics/MetricsDashboard'; + +function Metrics() { + return ( + + + + + + + + ); +} + +export default Metrics; diff --git a/src/routes.jsx b/src/routes.jsx index be6ae658b..fcb0e2a9c 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -9,6 +9,7 @@ import Tutorial from './pages/Tutorial'; import Datasets from './pages/Datasets'; import Jwt from './pages/Jwt'; import Settings from './pages/Settings'; +import Metrics from './pages/Metrics'; import Graph from './pages/Graph'; import Welcome from './pages/Welcome'; import Homepage from './pages/Homepage'; @@ -35,6 +36,7 @@ const routes = () => [ { path: '/tutorial', element: }, { path: '/tutorial/:pageSlug', element: }, { path: '/jwt', element: }, + { path: '/metrics', element: }, { path: '/settings', element: }, ], }, From 98bf7267343b6529965f1c53f25ee0392fa466a5 Mon Sep 17 00:00:00 2001 From: trean Date: Fri, 14 Aug 2026 15:53:37 +0200 Subject: [PATCH 2/9] wip: metrics dashboard - fixed preset charts --- package-lock.json | 11 + package.json | 1 + .../Metrics/CustomChartsDashboard.jsx | 334 ++++++++++++++ src/components/Metrics/LatencyHeatmap.jsx | 243 +++++++++++ src/components/Metrics/MetricBarChart.jsx | 93 ++++ src/components/Metrics/MetricChart.jsx | 140 ++++-- src/components/Metrics/MetricsDashboard.jsx | 406 +++++------------- src/components/Metrics/PanelCard.jsx | 32 ++ src/components/Metrics/StatTile.jsx | 26 ++ src/hooks/useMetricsHistory.js | 24 +- src/lib/metrics-parser.js | 7 +- src/lib/tests/metrics-parser.test.js | 21 +- src/mocks/data.js | 154 +++++++ src/mocks/handlers/base.js | 5 +- 14 files changed, 1177 insertions(+), 320 deletions(-) create mode 100644 src/components/Metrics/CustomChartsDashboard.jsx create mode 100644 src/components/Metrics/LatencyHeatmap.jsx create mode 100644 src/components/Metrics/MetricBarChart.jsx create mode 100644 src/components/Metrics/PanelCard.jsx create mode 100644 src/components/Metrics/StatTile.jsx diff --git a/package-lock.json b/package-lock.json index 882d17093..26a0698cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "autocomplete-openapi": "0.1.6", "axios": "^1.9.0", "chart.js": "^4.4.9", + "chartjs-chart-matrix": "^3.0.5", "chroma-js": "^2.4.2", "create-collection-form": "github:qdrant/create-collection-form#master", "force-graph": "^1.43.5", @@ -4160,6 +4161,7 @@ "node_modules/chart.js": { "version": "4.5.0", "license": "MIT", + "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -4167,6 +4169,15 @@ "pnpm": ">=8" } }, + "node_modules/chartjs-chart-matrix": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/chartjs-chart-matrix/-/chartjs-chart-matrix-3.0.5.tgz", + "integrity": "sha512-hVqXBEtLoYJk+iA9NajA/ArG7HlPZl3XXNsRDY3c5sCuqSrM6uTythSJh1jVR7UNVApdY9PStb84xSEjEceKaw==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=3.0.0" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", diff --git a/package.json b/package.json index 0ccb901a8..dbf050324 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "autocomplete-openapi": "0.1.6", "axios": "^1.9.0", "chart.js": "^4.4.9", + "chartjs-chart-matrix": "^3.0.5", "chroma-js": "^2.4.2", "create-collection-form": "github:qdrant/create-collection-form#master", "force-graph": "^1.43.5", diff --git a/src/components/Metrics/CustomChartsDashboard.jsx b/src/components/Metrics/CustomChartsDashboard.jsx new file mode 100644 index 000000000..9aa0297ba --- /dev/null +++ b/src/components/Metrics/CustomChartsDashboard.jsx @@ -0,0 +1,334 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + Divider, + IconButton, + Stack, + Tooltip, + Typography, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import { Plus, Trash2, ChartSpline } from 'lucide-react'; +import MetricChart from './MetricChart'; +import AddSeriesField from './AddSeriesField'; +import { seriesColor } from './colors'; +import { PRESETS } from './presets'; +import { useMetricsHistory } from '../../hooks/useMetricsHistory'; +import { useScrollToId } from '../../hooks/useScrollToId'; +import { + listSeries, + seriesLabel, + buildSeriesKey, + indexTypesByName, + detectUnit, + isCounter, +} from '../../lib/metrics-parser'; + +// todo (ai agents have to ignore this!): do the same for all hard-coded storage keys +const STORAGE_KEY = 'metricsDashboard.charts'; +const POLL_INTERVAL_MS = 5000; +const MAX_POINTS = 120; // ~10 minutes at a 5s interval + +// todo: looks like a good candidate to move to helpers and becoming a function (and maybe rename if +// moved, to express better what type of id it returns +const newId = () => + typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `c-${Date.now()}-${Math.random()}`; + +const loadCharts = () => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +}; + +// A chart's series carry only { key, name, labels }; the human label is derived +// on render so it stays consistent with the parser's formatting. +const makeSeries = (name, labels = {}) => ({ key: buildSeriesKey(name, labels), name, labels }); + +// Series can share a chart only if they'd share a meaningful Y axis: the same +// display unit (bytes / seconds / count) and the same plotting kind (a gauge is +// drawn raw, a counter as a per-second rate). This groups them into a single +// compatibility bucket used to filter the in-chart "add metric" options. +const compatKey = (name, type) => `${detectUnit(name)}:${isCounter(type) ? 'rate' : 'raw'}`; + +// NOTE: This is the earlier user-built "custom charts" dashboard — a metric +// search bar, presets, and per-chart series editing, persisted to localStorage. +// It's kept in the repo but is no longer rendered on the Metrics page, which now +// shows a fixed set of auto-created preset panels (see MetricsDashboard.jsx). + +// DOM id for a chart card, so a freshly added chart can be scrolled into view. +const chartElementId = (chartId) => `metrics-chart-${chartId}`; + +function CustomChartsDashboard({ embedded = false }) { + const theme = useTheme(); + const [charts, setCharts] = useState(loadCharts); + // Id of a just-added chart to scroll to once it mounts (cleared after). + const [scrollToId, setScrollToId] = useState(null); + const clearScrollTo = useCallback(() => setScrollToId(null), []); + useScrollToId(scrollToId, { onScrolled: clearScrollTo }); + + // Persist the dashboard layout so it survives reloads + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(charts)); + } catch { + /* ignore quota / private-mode failures */ + } + }, [charts]); + + // Every distinct series key referenced by any chart — the set we accumulate + // history for. + const subscribedKeys = useMemo(() => { + const keys = new Set(); + charts.forEach((chart) => chart.series.forEach((s) => keys.add(s.key))); + return [...keys]; + }, [charts]); + + const { snapshot, history, loading, error } = useMetricsHistory({ + subscribedKeys, + intervalMs: POLL_INTERVAL_MS, + maxPoints: MAX_POINTS, + }); + + const availableSeries = useMemo(() => listSeries(snapshot), [snapshot]); + const typesByName = useMemo(() => indexTypesByName(snapshot), [snapshot]); + + const addChart = useCallback((title, series) => { + const id = newId(); + setCharts((prev) => [...prev, { id, title, series }]); + setScrollToId(chartElementId(id)); + }, []); + + // Create one chart from a list of staged series (the top-bar builder). The + // title is the metric name, or " +N" when several distinct metrics are + // combined, so a multi-series chart still reads clearly in its header. + const createChart = useCallback( + (seriesList) => { + if (!seriesList?.length) return; + const names = [...new Set(seriesList.map((s) => s.name))]; + const title = names.length === 1 ? names[0] : `${names[0]} +${names.length - 1}`; + addChart( + title, + seriesList.map((s) => makeSeries(s.name, s.labels)) + ); + }, + [addChart] + ); + + const addPreset = useCallback((preset) => { + const created = preset.charts.map((chart) => ({ + id: newId(), + title: chart.title, + series: chart.metrics.map((name) => makeSeries(name)), + })); + setCharts((prev) => [...prev, ...created]); + if (created.length) setScrollToId(chartElementId(created[created.length - 1].id)); + }, []); + + const removeChart = useCallback((chartId) => { + setCharts((prev) => prev.filter((chart) => chart.id !== chartId)); + }, []); + + const addSeriesToChart = useCallback((chartId, series) => { + setCharts((prev) => + prev.map((chart) => { + if (chart.id !== chartId) return chart; + if (chart.series.some((s) => s.key === series.key)) return chart; // no duplicates + return { ...chart, series: [...chart.series, makeSeries(series.name, series.labels)] }; + }) + ); + }, []); + + const removeSeriesFromChart = useCallback((chartId, key) => { + setCharts((prev) => + prev.map((chart) => + chart.id === chartId ? { ...chart, series: chart.series.filter((s) => s.key !== key) } : chart + ) + ); + }, []); + + return ( + + {/* Page header — hidden when embedded under another dashboard. */} + {!embedded && ( + + + + Metrics + + + Live cluster metrics, sampled every {POLL_INTERVAL_MS / 1000}s. Build your own charts or start from a + preset. + + + + )} + + {/* Presets */} + + {PRESETS.map((preset) => ( + + ))} + + + {/* Add-chart bar */} + + + + + {error && ( + + {error} + + )} + + {/* Charts */} + {charts.length === 0 ? ( + + ) : ( + + {charts.map((chart) => { + const chartSeries = chart.series.map((s) => ({ + ...s, + label: seriesLabel(s), + type: typesByName[s.name] || '', + })); + // Options for this chart's adder: drop series already on the chart, + // and — once the chart has at least one series — keep only those + // that share its unit and gauge/counter kind. An empty chart offers + // everything. + const existingKeys = new Set(chart.series.map((s) => s.key)); + const chartCompat = chart.series.length + ? compatKey(chart.series[0].name, typesByName[chart.series[0].name]) + : null; + const addOptions = availableSeries.filter( + (option) => + !existingKeys.has(option.key) && + (chartCompat === null || compatKey(option.name, option.type) === chartCompat) + ); + return ( + t.spacing(10) }} + > + + {/* todo: move styles in `sx` if possible */} + + {chart.title} + + + removeChart(chart.id)} aria-label="Remove chart"> + + + + + + + + + + {/* Series chips (left) double as the chart legend — each + filled with its line's color (matched by position). The + "+" adder is pinned to the bottom-right corner. */} + + + {chartSeries.map((s, i) => { + const { main, contrastText } = seriesColor(theme, i); + return ( + removeSeriesFromChart(chart.id, s.key)} + sx={{ + maxWidth: 320, + bgcolor: main, + color: contrastText, + '& .MuiChip-deleteIcon': { + color: contrastText, + opacity: 0.7, + '&:hover': { opacity: 1, color: contrastText }, + }, + }} + /> + ); + })} + + + addSeriesToChart(chart.id, series)} + placeholder="Add a metric…" + /> + + + + + ); + })} + + )} + + ); +} + +CustomChartsDashboard.propTypes = { + // When true, the component's own page header is hidden so it can be embedded + // beneath another dashboard. + embedded: PropTypes.bool, +}; + +function EmptyState({ loading }) { + return ( + + + + No charts yet + + {loading + ? 'Connecting to the metrics endpoint…' + : 'Add a preset above, or search for a metric to create your first chart.'} + + + + ); +} + +EmptyState.propTypes = { + loading: PropTypes.bool, +}; + +export default CustomChartsDashboard; diff --git a/src/components/Metrics/LatencyHeatmap.jsx b/src/components/Metrics/LatencyHeatmap.jsx new file mode 100644 index 000000000..31da566bb --- /dev/null +++ b/src/components/Metrics/LatencyHeatmap.jsx @@ -0,0 +1,243 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { Box, Typography } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import Chart from 'chart.js/auto'; +import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'; +import { formatValue } from '../../lib/metrics-parser'; + +// The matrix chart type isn't part of chart.js/auto, so register it once. +Chart.register(MatrixController, MatrixElement); + +// Show at most this many time columns so cells stay readable. +const MAX_COLUMNS = 30; + +const formatTick = (t) => + new Date(t).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + +// Parse a CSS color ('#rgb', '#rrggbb' or 'rgb(r,g,b)') into [r, g, b]. +const toRgb = (str) => { + if (typeof str === 'string' && str[0] === '#') { + let hex = str.slice(1); + if (hex.length === 3) + hex = hex + .split('') + .map((c) => c + c) + .join(''); + const n = parseInt(hex.slice(0, 6), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; + } + const m = String(str).match(/\d+(\.\d+)?/g); + return m ? [Number(m[0]), Number(m[1]), Number(m[2])] : [0, 0, 0]; +}; + +const lerp = (a, b, t) => Math.round(a + (b - a) * t); +const blend = (a, b, t) => [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; +const rgbStr = ([r, g, b]) => `rgb(${r}, ${g}, ${b})`; +const mix = (lo, hi, t) => rgbStr(blend(lo, hi, t)); + +// A sequential single-hue ramp built from the theme primary color: a pale tint +// for low values and a deep shade for high ones. The bright end maps to high +// values, so it flips with the theme (bright stands out on dark backgrounds). +const primaryRamp = (theme) => { + const base = toRgb(theme.palette.primary.main); + const light = blend(base, [255, 255, 255], 0.72); // pale primary + const deep = blend(base, [0, 0, 0], 0.35); // deep primary + return theme.palette.mode === 'dark' ? { lo: deep, hi: light } : { lo: light, hi: deep }; +}; + +const fmtRate = (v) => { + if (!v) return '0/s'; + // Small rates keep 3 significant figures (e.g. 0.0000724/s); larger ones use + // the grouped-digit formatter. + const n = v < 1 ? Number(v.toPrecision(3)) : formatValue(v, 'number'); + return `${n}/s`; +}; + +// The Grafana "Latency Distribution" heatmap: Y = response-time buckets, X = +// time, cell color = the per-second rate of requests landing in that latency +// band. Reads Qdrant's `*_responses_duration_seconds` Prometheus histogram — +// `buckets` are the `le` groups (ascending) with the series keys per bucket, and +// history holds their cumulative counts, which we un-cumulate and rate here. +const LatencyHeatmap = ({ buckets, history }) => { + const theme = useTheme(); + const canvasRef = useRef(null); + const chartRef = useRef(null); + + const bucketsSig = buckets.map((b) => b.label).join('|'); + + // Build the matrix cells and the value range once, shared by the chart (for + // color scaling) and the gradient legend (for its min/max labels). + const matrix = useMemo(() => { + // Keep one extra column so the first shown rate has a predecessor to diff. + const hist = history.slice(-(MAX_COLUMNS + 1)); + const rowLabels = buckets.map((b) => b.label); + + // Cumulative count per bucket per time point (summed across series sharing + // the same `le`, e.g. all endpoints/methods/statuses). + const cum = hist.map((point) => + buckets.map((b) => + b.keys.reduce((sum, key) => { + const v = point.values[key]; + return sum + (typeof v === 'number' ? v : 0); + }, 0) + ) + ); + + // Un-cumulate consecutive `le` into per-band counts, then take the rate of + // change versus the previous poll — the requests/s that fell in each band. + const data = []; + const labels = []; + let min = Infinity; + let max = 0; + for (let t = 1; t < hist.length; t++) { + const dt = (hist[t].t - hist[t - 1].t) / 1000; + const label = formatTick(hist[t].t); + labels.push(label); + for (let i = 0; i < buckets.length; i++) { + const bandNow = cum[t][i] - (i > 0 ? cum[t][i - 1] : 0); + const bandPrev = cum[t - 1][i] - (i > 0 ? cum[t - 1][i - 1] : 0); + let rate = dt > 0 ? (bandNow - bandPrev) / dt : 0; + if (!Number.isFinite(rate) || rate < 0) rate = 0; // counter reset / gap + // Every cell is drawn (so the grid shows), but empty ones (rate 0) get a + // transparent fill; only non-zero rates drive the color scale / legend. + data.push({ x: label, y: rowLabels[i], v: rate }); + if (rate > 0) { + if (rate < min) min = rate; + if (rate > max) max = rate; + } + } + } + return { + labels, + rowLabels, + data, + // Color scale / legend span the observed non-zero range (like Grafana), + // not from zero. + min: Number.isFinite(min) ? min : 0, + max, + cols: Math.max(labels.length, 1), + nRows: Math.max(rowLabels.length, 1), + hasData: labels.length > 0 && buckets.length > 0, + }; + }, [history, buckets]); + + const ramp = useMemo(() => primaryRamp(theme), [theme.palette.primary.main, theme.palette.mode]); + + useEffect(() => { + if (!canvasRef.current) return undefined; + const textColor = theme.palette.text.secondary; + const { lo, hi } = ramp; + const { labels, rowLabels, data, min, max, cols, nRows } = matrix; + + const chart = new Chart(canvasRef.current.getContext('2d'), { + type: 'matrix', + data: { + datasets: [ + { + data, + backgroundColor: (ctx) => { + const v = ctx.raw?.v; + if (!v) return 'transparent'; + // Map the non-zero range [min, max] onto the ramp; the legend uses + // the same endpoints, so cells and legend stay consistent. + const norm = max > min ? (v - min) / (max - min) : 1; + return mix(lo, hi, norm); + }, + borderWidth: 0, + width: ({ chart: c }) => (c.chartArea?.width || 0) / cols - 1, + height: ({ chart: c }) => (c.chartArea?.height || 0) / nRows - 1, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + title: (items) => `≤ ${items[0]?.raw?.y ?? ''}`, + label: (ctx) => `${ctx.raw?.x}: ${fmtRate(ctx.raw?.v)}`, + }, + }, + }, + scales: { + x: { + type: 'category', + labels, + offset: true, + grid: { display: false }, + ticks: { color: textColor, maxRotation: 0, autoSkip: true, maxTicksLimit: 6 }, + }, + y: { + type: 'category', + labels: rowLabels, + offset: true, + grid: { display: false }, + ticks: { color: textColor, autoSkip: false }, + }, + }, + }, + }); + chartRef.current = chart; + return () => { + chart.destroy(); + chartRef.current = null; + }; + }, [bucketsSig, matrix, ramp, theme.palette.mode]); + + const height = Math.max(220, buckets.length * 28 + 48); + + return ( + <> + + + {!matrix.hasData && ( + + + Collecting data… + + + )} + + + {/* Color-scale legend: gradient from the low to the high cell color, with + the value range labeled at each end. */} + {matrix.hasData && ( + + + + + {fmtRate(matrix.min)} + + + {fmtRate(matrix.max)} + + + + )} + + ); +}; + +LatencyHeatmap.propTypes = { + // Ascending `le` buckets: { label, keys[] } where keys are the series sharing + // that bucket boundary (summed across endpoints/methods/statuses). + buckets: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + keys: PropTypes.arrayOf(PropTypes.string).isRequired, + }) + ).isRequired, + history: PropTypes.array.isRequired, +}; + +export default LatencyHeatmap; diff --git a/src/components/Metrics/MetricBarChart.jsx b/src/components/Metrics/MetricBarChart.jsx new file mode 100644 index 000000000..d54aae3a5 --- /dev/null +++ b/src/components/Metrics/MetricBarChart.jsx @@ -0,0 +1,93 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { Box, Typography } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import Chart from 'chart.js/auto'; +import { formatValue } from '../../lib/metrics-parser'; +import { seriesColor } from './colors'; + +// Horizontal bar chart of a single value per category (e.g. total requests per +// endpoint). Categories can have long names, hence the horizontal layout. +const MetricBarChart = ({ labels, values, unit = 'number' }) => { + const theme = useTheme(); + const canvasRef = useRef(null); + const chartRef = useRef(null); + const labelsSig = useMemo(() => labels.join('|'), [labels]); + + // (Re)create when the categories or theme change. + useEffect(() => { + if (!canvasRef.current) return undefined; + const gridColor = theme.palette.divider; + const textColor = theme.palette.text.secondary; + const chart = new Chart(canvasRef.current.getContext('2d'), { + type: 'bar', + data: { + labels, + datasets: [ + { + data: values, + backgroundColor: seriesColor(theme, 0).main, + borderRadius: 4, + maxBarThickness: 24, + }, + ], + }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + animation: false, + plugins: { + legend: { display: false }, + tooltip: { callbacks: { label: (ctx) => formatValue(ctx.parsed.x, unit) } }, + }, + scales: { + x: { + grid: { color: gridColor }, + border: { display: false }, + ticks: { color: textColor, maxTicksLimit: 5, callback: (value) => formatValue(value, unit) }, + }, + y: { grid: { display: false }, ticks: { color: textColor, autoSkip: false } }, + }, + }, + }); + chartRef.current = chart; + return () => { + chart.destroy(); + chartRef.current = null; + }; + }, [labelsSig, theme.palette.mode, unit]); + + // Feed new values without rebuilding. + useEffect(() => { + const chart = chartRef.current; + if (!chart) return; + chart.data.labels = labels; + chart.data.datasets[0].data = values; + chart.update('none'); + }, [labels, values]); + + const height = Math.max(160, labels.length * 34); + const hasData = values.some((v) => v != null); + + return ( + + + {!hasData && ( + + + Collecting data… + + + )} + + ); +}; + +MetricBarChart.propTypes = { + labels: PropTypes.arrayOf(PropTypes.string).isRequired, + values: PropTypes.arrayOf(PropTypes.number).isRequired, + unit: PropTypes.string, +}; + +export default MetricBarChart; diff --git a/src/components/Metrics/MetricChart.jsx b/src/components/Metrics/MetricChart.jsx index 80e4a66fb..895f29388 100644 --- a/src/components/Metrics/MetricChart.jsx +++ b/src/components/Metrics/MetricChart.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import PropTypes from 'prop-types'; import { Box, Typography } from '@mui/material'; import { useTheme } from '@mui/material/styles'; @@ -9,18 +9,24 @@ import { seriesColor } from './colors'; const formatTick = (t) => new Date(t).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +const formatStat = (value, unit, rate) => (value == null ? '—' : `${formatValue(value, unit)}${rate ? '/s' : ''}`); + // A single time-series line chart rendering one or more metric series that // share an X axis (the poll timestamps accumulated by useMetricsHistory). -const MetricChart = ({ series, history }) => { +// Pass `showLegend` to render the Grafana-style table legend (Name / Mean / Max) +// below the chart; clicking a row toggles that series. +const MetricChart = ({ series, history = false }) => { const theme = useTheme(); const canvasRef = useRef(null); const chartRef = useRef(null); + const [hidden] = useState(() => new Set()); // Signature that identifies the current set of series; a change means the // chart's datasets must be rebuilt rather than merely re-fed with data. The // type is part of it so the chart rebuilds once the first snapshot resolves a // series' type (gauge vs counter changes how it's plotted and labelled). const seriesSignature = useMemo(() => series.map((s) => `${s.key}:${s.type || ''}`).join('|'), [series]); + const hiddenSignature = [...hidden].join('|'); // The Y axis carries a single unit; use the first series' unit for it while // tooltips format each point by its own unit. const axisUnit = series.length ? detectUnit(series[0].name) : 'number'; @@ -66,8 +72,8 @@ const MetricChart = ({ series, history }) => { animation: false, interaction: { mode: 'index', intersect: false }, plugins: { - // The legend is intentionally off — the metric chips below the chart - // carry the same colors and serve as an interactive legend. + // The custom table legend below (or the dashboard's chips) is the + // legend; chart.js's own legend stays off. legend: { display: false }, tooltip: { callbacks: { @@ -100,7 +106,8 @@ const MetricChart = ({ series, history }) => { }; }, [seriesSignature, theme.palette.mode]); - // Feed the accumulated history into the existing chart on every poll. + // Feed the accumulated history into the existing chart on every poll, and + // apply per-series visibility toggled from the legend. useEffect(() => { const chart = chartRef.current; if (!chart) return; @@ -108,33 +115,36 @@ const MetricChart = ({ series, history }) => { series.forEach((s, i) => { if (chart.data.datasets[i]) { chart.data.datasets[i].data = computeData(s); + chart.data.datasets[i].hidden = hidden.has(s.key); } }); chart.update('none'); - }, [history, seriesSignature, series]); + }, [history, seriesSignature, hiddenSignature]); const hasData = series.some((s) => computeData(s).some((v) => v != null)); return ( - - - {!hasData && ( - - - Collecting data… - - - )} - + <> + + + {!hasData && ( + + + Collecting data… + + + )} + + ); }; @@ -148,6 +158,86 @@ MetricChart.propTypes = { }) ).isRequired, history: PropTypes.array.isRequired, + showLegend: PropTypes.bool, +}; + +// Grafana-style table legend: colored line marker + name, with right-aligned +// Mean/Max stat columns. Scrolls when there are many series; clicking a row +// toggles that series on the chart. +const STAT_WIDTH = 96; +const MARKER_SLOT = 22; + +function ChartLegend({ rows, hidden, onToggle }) { + const theme = useTheme(); + const headColor = theme.palette.primary.main; + const statCell = { + width: STAT_WIDTH, + flexShrink: 0, + pl: 1, + textAlign: 'right', + whiteSpace: 'nowrap', + fontVariantNumeric: 'tabular-nums', + }; + + return ( + + + + Name + Mean + Max + + {rows.map((row) => { + const isHidden = hidden.has(row.key); + return ( + onToggle(row.key)} + sx={{ + display: 'flex', + alignItems: 'center', + px: 0.5, + py: 0.25, + cursor: 'pointer', + borderRadius: 1, + opacity: isHidden ? 0.4 : 1, + '&:hover': { bgcolor: 'action.hover' }, + }} + > + + + + + {row.label} + + {formatStat(row.mean, row.unit, row.rate)} + {formatStat(row.max, row.unit, row.rate)} + + ); + })} + + ); +} + +ChartLegend.propTypes = { + rows: PropTypes.array.isRequired, + hidden: PropTypes.instanceOf(Set).isRequired, + onToggle: PropTypes.func.isRequired, }; export default MetricChart; diff --git a/src/components/Metrics/MetricsDashboard.jsx b/src/components/Metrics/MetricsDashboard.jsx index 3e3e90c57..bfd4ab2bc 100644 --- a/src/components/Metrics/MetricsDashboard.jsx +++ b/src/components/Metrics/MetricsDashboard.jsx @@ -1,321 +1,153 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import PropTypes from 'prop-types'; -import { - Alert, - Box, - Button, - Card, - CardContent, - Chip, - Divider, - IconButton, - Stack, - Tooltip, - Typography, -} from '@mui/material'; -import { useTheme } from '@mui/material/styles'; -import { Plus, Trash2, ChartSpline } from 'lucide-react'; +import React, { useMemo } from 'react'; +import { Alert, Box, Divider, Grid, Stack, Typography } from '@mui/material'; import MetricChart from './MetricChart'; -import AddSeriesField from './AddSeriesField'; -import { seriesColor } from './colors'; -import { PRESETS } from './presets'; +import MetricBarChart from './MetricBarChart'; +import LatencyHeatmap from './LatencyHeatmap'; +import StatTile from './StatTile'; +import PanelCard from './PanelCard'; +import CustomChartsDashboard from './CustomChartsDashboard'; import { useMetricsHistory } from '../../hooks/useMetricsHistory'; -import { useScrollToId } from '../../hooks/useScrollToId'; -import { - listSeries, - seriesLabel, - buildSeriesKey, - indexTypesByName, - detectUnit, - isCounter, -} from '../../lib/metrics-parser'; +import { listSeries, indexByKey, seriesLabel } from '../../lib/metrics-parser'; -// todo (ai agents have to ignore this!): do the same for all hard-coded storage keys -const STORAGE_KEY = 'metricsDashboard.charts'; const POLL_INTERVAL_MS = 5000; const MAX_POINTS = 120; // ~10 minutes at a 5s interval -// todo: looks like a good candidate to move to helpers and becoming a function (and maybe rename if -// moved, to express better what type of id it returns -const newId = () => - typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `c-${Date.now()}-${Math.random()}`; - -const loadCharts = () => { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } +// Big-number tiles (Grafana "stat" panels). +const STAT_TILES = [ + { label: 'Collections', key: 'collections_total' }, + { label: 'Vectors', key: 'collections_vector_total' }, + { label: 'Pending operations', key: 'pending_operations' }, + { label: 'Cluster peers', key: 'cluster_peers_total' }, +]; + +// Format a histogram `le` bucket boundary (seconds) as a short latency label. +const formatLe = (sec) => { + if (!Number.isFinite(sec)) return '+Inf'; + if (sec < 1) return `${Math.round(sec * 1000)}ms`; + return `${sec}s`; }; -// A chart's series carry only { key, name, labels }; the human label is derived -// on render so it stays consistent with the parser's formatting. -const makeSeries = (name, labels = {}) => ({ key: buildSeriesKey(name, labels), name, labels }); - -// Series can share a chart only if they'd share a meaningful Y axis: the same -// display unit (bytes / seconds / count) and the same plotting kind (a gauge is -// drawn raw, a counter as a per-second rate). This groups them into a single -// compatibility bucket used to filter the in-chart "add metric" options. -const compatKey = (name, type) => `${detectUnit(name)}:${isCounter(type) ? 'rate' : 'raw'}`; - -// DOM id for a chart card, so a freshly added chart can be scrolled into view. -const chartElementId = (chartId) => `metrics-chart-${chartId}`; +// Turn parsed series descriptors into the shape MetricChart consumes. +const toChartSeries = (entries) => + entries.map((s) => ({ key: s.key, name: s.name, labels: s.labels, label: seriesLabel(s), type: s.type })); +// The Metrics page: a fixed set of panels, auto-populated from Qdrant's +// /metrics endpoint with no user interaction. Panel types mirror Qdrant's +// Grafana dashboards (github.com/qdrant/prometheus-monitoring), bound to the +// metrics a self-hosted instance actually exposes. function MetricsDashboard() { - const theme = useTheme(); - const [charts, setCharts] = useState(loadCharts); - // Id of a just-added chart to scroll to once it mounts (cleared after). - const [scrollToId, setScrollToId] = useState(null); - const clearScrollTo = useCallback(() => setScrollToId(null), []); - useScrollToId(scrollToId, { onScrolled: clearScrollTo }); - - // Persist the dashboard layout so it survives reloads - useEffect(() => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(charts)); - } catch { - /* ignore quota / private-mode failures */ - } - }, [charts]); - - // Every distinct series key referenced by any chart — the set we accumulate - // history for. - const subscribedKeys = useMemo(() => { - const keys = new Set(); - charts.forEach((chart) => chart.series.forEach((s) => keys.add(s.key))); - return [...keys]; - }, [charts]); - const { snapshot, history, loading, error } = useMetricsHistory({ - subscribedKeys, + recordAll: true, intervalMs: POLL_INTERVAL_MS, maxPoints: MAX_POINTS, }); - const availableSeries = useMemo(() => listSeries(snapshot), [snapshot]); - const typesByName = useMemo(() => indexTypesByName(snapshot), [snapshot]); - - const addChart = useCallback((title, series) => { - const id = newId(); - setCharts((prev) => [...prev, { id, title, series }]); - setScrollToId(chartElementId(id)); - }, []); - - // Create one chart from a list of staged series (the top-bar builder). The - // title is the metric name, or " +N" when several distinct metrics are - // combined, so a multi-series chart still reads clearly in its header. - const createChart = useCallback( - (seriesList) => { - if (!seriesList?.length) return; - const names = [...new Set(seriesList.map((s) => s.name))]; - const title = names.length === 1 ? names[0] : `${names[0]} +${names.length - 1}`; - addChart( - title, - seriesList.map((s) => makeSeries(s.name, s.labels)) - ); - }, - [addChart] - ); - - const addPreset = useCallback((preset) => { - const created = preset.charts.map((chart) => ({ - id: newId(), - title: chart.title, - series: chart.metrics.map((name) => makeSeries(name)), - })); - setCharts((prev) => [...prev, ...created]); - if (created.length) setScrollToId(chartElementId(created[created.length - 1].id)); - }, []); - - const removeChart = useCallback((chartId) => { - setCharts((prev) => prev.filter((chart) => chart.id !== chartId)); - }, []); - - const addSeriesToChart = useCallback((chartId, series) => { - setCharts((prev) => - prev.map((chart) => { - if (chart.id !== chartId) return chart; - if (chart.series.some((s) => s.key === series.key)) return chart; // no duplicates - return { ...chart, series: [...chart.series, makeSeries(series.name, series.labels)] }; - }) - ); - }, []); - - const removeSeriesFromChart = useCallback((chartId, key) => { - setCharts((prev) => - prev.map((chart) => - chart.id === chartId ? { ...chart, series: chart.series.filter((s) => s.key !== key) } : chart - ) - ); - }, []); + const all = useMemo(() => listSeries(snapshot), [snapshot]); + const latest = useMemo(() => indexByKey(snapshot), [snapshot]); + + const restSeries = useMemo(() => all.filter((s) => s.name === 'rest_responses_total'), [all]); + const grpcSeries = useMemo(() => all.filter((s) => s.name === 'grpc_responses_total'), [all]); + const memorySeries = useMemo(() => all.filter((s) => /^memory_.*_bytes$/.test(s.name)), [all]); + const vectorSeries = useMemo(() => all.filter((s) => s.name === 'collections_vector_total'), [all]); + + // Bar chart: total REST requests summed per endpoint (latest snapshot). + const requestsByEndpoint = useMemo(() => { + const sums = {}; + restSeries.forEach((s) => { + const endpoint = s.labels.endpoint || s.name; + sums[endpoint] = (sums[endpoint] || 0) + (latest[s.key] || 0); + }); + const entries = Object.entries(sums).sort((a, b) => b[1] - a[1]); + return { labels: entries.map((e) => e[0]), values: entries.map((e) => e[1]) }; + }, [restSeries, latest]); + + // Latency-distribution heatmap: group the response-duration histogram's + // `_bucket` series by their `le` boundary (across every endpoint/method/ + // status, matching Grafana's `sum by (le)`), ordered ascending. + const latencyBuckets = useMemo(() => { + const groups = new Map(); // le string -> { sec, keys[] } + all + .filter((s) => /_responses_duration_seconds_bucket$/.test(s.name) && s.labels.le !== undefined) + .forEach((s) => { + const le = s.labels.le; + if (!groups.has(le)) groups.set(le, { sec: le === '+Inf' ? Infinity : Number(le), keys: [] }); + groups.get(le).keys.push(s.key); + }); + return [...groups.entries()] + .sort((a, b) => a[1].sec - b[1].sec) + .map(([, g]) => ({ label: formatLe(g.sec), keys: g.keys })); + }, [all]); return ( - {/* Header */} - - - - Metrics - - - Live cluster metrics, sampled every {POLL_INTERVAL_MS / 1000}s. Build your own charts or start from a - preset. - - + + + Metrics + + + Live cluster metrics from the Qdrant /metrics endpoint, sampled every {POLL_INTERVAL_MS / 1000}s. + - {/* Presets */} - - {PRESETS.map((preset) => ( - - ))} - - - {/* Add-chart bar */} - - - - {error && ( {error} )} - {/* Charts */} - {charts.length === 0 ? ( - - ) : ( - - {charts.map((chart) => { - const chartSeries = chart.series.map((s) => ({ - ...s, - label: seriesLabel(s), - type: typesByName[s.name] || '', - })); - // Options for this chart's adder: drop series already on the chart, - // and — once the chart has at least one series — keep only those - // that share its unit and gauge/counter kind. An empty chart offers - // everything. - const existingKeys = new Set(chart.series.map((s) => s.key)); - const chartCompat = chart.series.length - ? compatKey(chart.series[0].name, typesByName[chart.series[0].name]) - : null; - const addOptions = availableSeries.filter( - (option) => - !existingKeys.has(option.key) && - (chartCompat === null || compatKey(option.name, option.type) === chartCompat) - ); - return ( - t.spacing(10) }} - > - - {/* todo: move styles in `sx` if possible */} - - {chart.title} - - - removeChart(chart.id)} aria-label="Remove chart"> - - - - - - - - + {/* Stat tiles */} + + {STAT_TILES.map((tile) => ( + + + + ))} + - {/* Series chips (left) double as the chart legend — each - filled with its line's color (matched by position). The - "+" adder is pinned to the bottom-right corner. */} - - - {chartSeries.map((s, i) => { - const { main, contrastText } = seriesColor(theme, i); - return ( - removeSeriesFromChart(chart.id, s.key)} - sx={{ - maxWidth: 320, - bgcolor: main, - color: contrastText, - '& .MuiChip-deleteIcon': { - color: contrastText, - opacity: 0.7, - '&:hover': { opacity: 1, color: contrastText }, - }, - }} - /> - ); - })} - - - addSeriesToChart(chart.id, series)} - placeholder="Add a metric…" - /> - - - - - ); - })} - - )} - - ); -} + {/* Charts */} + + + + + + + + + + + + + + + + + + + + + + + + + -function EmptyState({ loading }) { - return ( - - - - No charts yet - - {loading - ? 'Connecting to the metrics endpoint…' - : 'Add a preset above, or search for a metric to create your first chart.'} + {/* TEMPORARY: the earlier custom-chart builder, reconnected at the end of + the page. Embedded (its own page header suppressed). */} + + + + Custom charts + + + Build an ad-hoc chart from any exposed metric. - + + ); } -EmptyState.propTypes = { - loading: PropTypes.bool, -}; - export default MetricsDashboard; diff --git a/src/components/Metrics/PanelCard.jsx b/src/components/Metrics/PanelCard.jsx new file mode 100644 index 000000000..90e79e014 --- /dev/null +++ b/src/components/Metrics/PanelCard.jsx @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Box, Card, CardContent, Typography } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; + +// Consistent titled card used by the preset dashboard panels. +function PanelCard({ title, subtitle, children }) { + const theme = useTheme(); + return ( + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + {children} + + ); +} + +PanelCard.propTypes = { + title: PropTypes.string.isRequired, + subtitle: PropTypes.string, + children: PropTypes.node, +}; + +export default PanelCard; diff --git a/src/components/Metrics/StatTile.jsx b/src/components/Metrics/StatTile.jsx new file mode 100644 index 000000000..fdc1bb700 --- /dev/null +++ b/src/components/Metrics/StatTile.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Card, Typography } from '@mui/material'; +import { formatValue } from '../../lib/metrics-parser'; + +// A single big-number tile, mirroring Grafana's "stat" panels. +function StatTile({ label, value, unit = 'number' }) { + return ( + + + {label} + + + {value === null || value === undefined ? '—' : formatValue(value, unit)} + + + ); +} + +StatTile.propTypes = { + label: PropTypes.string.isRequired, + value: PropTypes.number, + unit: PropTypes.string, +}; + +export default StatTile; diff --git a/src/hooks/useMetricsHistory.js b/src/hooks/useMetricsHistory.js index b9ca23294..c0d860d17 100644 --- a/src/hooks/useMetricsHistory.js +++ b/src/hooks/useMetricsHistory.js @@ -12,12 +12,21 @@ import { parsePrometheus, indexByKey } from '../lib/metrics-parser'; // always samples the latest set of series without being torn down and // recreated whenever the dashboard changes. // +// Pass `recordAll: true` to accumulate history for every series in each +// response (used by the preset dashboard, which charts whatever the server +// exposes); otherwise only `subscribedKeys` are recorded. +// // Returns: // snapshot latest parsed metrics map (name -> descriptor), or null // history [{ t, values: { seriesKey: number } }] oldest-first, capped // loading true until the first response (success or failure) arrives // error last error message, or null -export const useMetricsHistory = ({ subscribedKeys = [], intervalMs = 5000, maxPoints = 120 } = {}) => { +export const useMetricsHistory = ({ + subscribedKeys = [], + recordAll = false, + intervalMs = 5000, + maxPoints = 120, +} = {}) => { const [snapshot, setSnapshot] = useState(null); const [history, setHistory] = useState([]); const [loading, setLoading] = useState(true); @@ -25,6 +34,8 @@ export const useMetricsHistory = ({ subscribedKeys = [], intervalMs = 5000, maxP const keysRef = useRef(subscribedKeys); keysRef.current = subscribedKeys; + const recordAllRef = useRef(recordAll); + recordAllRef.current = recordAll; useEffect(() => { let active = true; @@ -42,9 +53,14 @@ export const useMetricsHistory = ({ subscribedKeys = [], intervalMs = 5000, maxP const parsed = parsePrometheus(response.data); const index = indexByKey(parsed); - const values = {}; - for (const key of keysRef.current) { - if (key in index) values[key] = index[key]; + let values; + if (recordAllRef.current) { + values = index; + } else { + values = {}; + for (const key of keysRef.current) { + if (key in index) values[key] = index[key]; + } } setSnapshot(parsed); diff --git a/src/lib/metrics-parser.js b/src/lib/metrics-parser.js index 6e964fed3..575ea35d5 100644 --- a/src/lib/metrics-parser.js +++ b/src/lib/metrics-parser.js @@ -16,6 +16,7 @@ // sample is identified by a canonical "series key" that folds the labels in. import prettyBytes from 'pretty-bytes'; +import { formatGroupedDigits } from './common-helpers'; // Build a stable, canonical key for a sample: the metric name plus its labels // sorted by name, so the same series always maps to the same string regardless @@ -195,8 +196,10 @@ const formatSeconds = (value) => { }; const formatNumber = (value) => { - if (Number.isInteger(value)) return value.toLocaleString(); - return value.toLocaleString(undefined, { maximumFractionDigits: 3 }); + // Group digits with the app's locale-independent formatter; round + // non-integers to 3 decimals to avoid long floats on the axis/tooltip. + const rounded = Number.isInteger(value) ? value : Math.round(value * 1000) / 1000; + return formatGroupedDigits(rounded); }; // A short, human-friendly label for a series, used in chart legends and chips. diff --git a/src/lib/tests/metrics-parser.test.js b/src/lib/tests/metrics-parser.test.js index 357342258..ec0d06e6d 100644 --- a/src/lib/tests/metrics-parser.test.js +++ b/src/lib/tests/metrics-parser.test.js @@ -61,6 +61,25 @@ describe('parsePrometheus', () => { expect(metrics.y.samples[0].value).toBe(-Infinity); expect(Number.isNaN(metrics.z.samples[0].value)).toBe(true); }); + + it('parses histogram buckets with their le label (real Qdrant format)', () => { + const HIST = `# HELP rest_responses_duration_seconds response duration histogram +# TYPE rest_responses_duration_seconds histogram +rest_responses_duration_seconds_bucket{method="PUT",endpoint="/x",status="200",le="0.005"} 0 +rest_responses_duration_seconds_bucket{method="PUT",endpoint="/x",status="200",le="0.01"} 1 +rest_responses_duration_seconds_bucket{method="PUT",endpoint="/x",status="200",le="+Inf"} 1 +rest_responses_duration_seconds_sum{method="PUT",endpoint="/x",status="200"} 0.005271 +rest_responses_duration_seconds_count{method="PUT",endpoint="/x",status="200"} 1 +`; + const metrics = parsePrometheus(HIST); + const buckets = metrics.rest_responses_duration_seconds_bucket.samples; + expect(buckets).toHaveLength(3); + // le labels are preserved (including +Inf) and values parsed + expect(buckets.map((s) => s.labels.le)).toEqual(['0.005', '0.01', '+Inf']); + expect(buckets.map((s) => s.value)).toEqual([0, 1, 1]); + // sum/count land under their own suffixed metric names + expect(metrics.rest_responses_duration_seconds_count.samples[0].value).toBe(1); + }); }); describe('buildSeriesKey', () => { @@ -147,7 +166,7 @@ describe('formatValue', () => { it('formats by unit', () => { expect(formatValue(1048576, 'bytes')).toBe('1.05 MB'); expect(formatValue(0.5, 'seconds')).toBe('500.0ms'); - expect(formatValue(1234, 'number')).toBe('1,234'); + expect(formatValue(1234, 'number')).toBe('1 234'); }); it('handles missing and non-finite values', () => { diff --git a/src/mocks/data.js b/src/mocks/data.js index 71177fac5..1484d990e 100644 --- a/src/mocks/data.js +++ b/src/mocks/data.js @@ -26,6 +26,160 @@ export const makeTelemetry = ({ hasApiKey = false, clusterEnabled = false, resha cluster: { enabled: clusterEnabled, resharding_enabled: reshardingEnabled }, }); +// GET /metrics — Prometheus text exposition format. Values wobble over time so +// the live Metrics dashboard shows movement in mock mode: gauges oscillate +// around a baseline and counters grow monotonically with elapsed time. +export const makeMetrics = ({ clusterEnabled = false, version = '1.15.1' } = {}) => { + const now = Date.now(); + const t = now / 1000; + const wobble = (base, amp, periodSec, phase = 0) => + Math.round(base + amp * Math.sin((t / periodSec) * 2 * Math.PI + phase)); + const since = Math.floor(now / 1000); // steadily increasing seconds, for counters + const MB = 1024 * 1024; + + const block = (name, help, type, samples) => + [`# HELP ${name} ${help}`, `# TYPE ${name} ${type}`, ...samples].join('\n'); + + // Monotonic counter value: a baseline plus steady growth, so the dashboard's + // rate() view shows a roughly constant per-second request rate. + const counter = (base, rate) => base + Math.floor(since * rate); + const latSeconds = (baseUs, ampUs, periodSec) => (wobble(baseUs, ampUs, periodSec) / 1e6).toFixed(6); + + // REST endpoints: request counters (mostly 2xx, a few errors), avg latency, + // and a full duration histogram. `center` is the latency-bucket index the + // endpoint's requests cluster around, `sigma` the spread — together they + // shape the histogram so the latency-distribution heatmap has realistic bands. + const restEndpoints = [ + { method: 'GET', endpoint: '/collections', rate: 3, base: 1200, lat: [1200, 300, 25], center: 0, sigma: 1 }, + { + method: 'POST', + endpoint: '/collections/{name}/points/search', + rate: 11, + base: 5400, + lat: [9000, 2500, 35], + center: 4, + sigma: 1.4, + }, + { + method: 'PUT', + endpoint: '/collections/{name}/points', + rate: 2, + base: 800, + lat: [4200, 1200, 30], + center: 2, + sigma: 1.2, + }, + { + method: 'POST', + endpoint: '/collections/{name}/points/scroll', + rate: 1.5, + base: 640, + lat: [3100, 900, 28], + center: 3, + sigma: 1.2, + }, + { + method: 'DELETE', + endpoint: '/collections/{name}/points', + rate: 0.4, + base: 120, + lat: [2500, 700, 22], + center: 1, + sigma: 1, + }, + ]; + const restTotals = restEndpoints.map( + (e) => `rest_responses_total{method="${e.method}",endpoint="${e.endpoint}",status="2xx"} ${counter(e.base, e.rate)}` + ); + // A handful of non-2xx responses so "requests by status" has variety. + restTotals.push( + `rest_responses_total{method="POST",endpoint="/collections/{name}/points/search",status="4xx"} ${counter(90, 0.3)}`, + `rest_responses_total{method="PUT",endpoint="/collections/{name}/points",status="4xx"} ${counter(40, 0.1)}`, + `rest_responses_total{method="POST",endpoint="/collections/{name}/points/search",status="5xx"} ${counter(6, 0.02)}` + ); + const restLatency = restEndpoints.map( + (e) => + `rest_responses_avg_duration_seconds{method="${e.method}",endpoint="${e.endpoint}"} ${latSeconds( + e.lat[0], + e.lat[1], + e.lat[2] + )}` + ); + + // Prometheus histogram: cumulative `_bucket{le}` counts (+ `_sum`, `_count`) + // per endpoint. Counts grow with elapsed time and are spread across buckets by + // a Gaussian kernel around each endpoint's `center`, so `rate(bucket)` yields + // a realistic latency-distribution heatmap. + const LE = [0.001, 0.005, 0.01, 0.02, 0.05, 0.1, 0.5, 1, 5, 10, 50]; + const bandCdf = (center, sigma) => { + const n = LE.length + 1; // finite buckets + "+Inf" + const weights = Array.from({ length: n }, (_, i) => Math.exp(-((i - center) ** 2) / (2 * sigma * sigma))); + const total = weights.reduce((a, b) => a + b, 0); + let acc = 0; + return weights.map((w) => (acc += w / total)); + }; + const restHistogram = restEndpoints.flatMap((e) => { + const count = counter(e.base, e.rate); + const cdf = bandCdf(e.center, e.sigma); + const labels = `method="${e.method}",endpoint="${e.endpoint}",status="2xx"`; + const lines = LE.map( + (le, i) => `rest_responses_duration_seconds_bucket{${labels},le="${le}"} ${Math.round(count * cdf[i])}` + ); + lines.push(`rest_responses_duration_seconds_bucket{${labels},le="+Inf"} ${count}`); + lines.push(`rest_responses_duration_seconds_sum{${labels}} ${((count * e.lat[0]) / 1e6).toFixed(6)}`); + lines.push(`rest_responses_duration_seconds_count{${labels}} ${count}`); + return lines; + }); + + // gRPC endpoints: request counters and avg latency. + const grpcEndpoints = [ + { endpoint: '/qdrant.Points/Search', rate: 9, base: 4200, lat: [7000, 2000, 32] }, + { endpoint: '/qdrant.Points/Upsert', rate: 2, base: 900, lat: [3800, 1000, 27] }, + { endpoint: '/qdrant.Collections/Get', rate: 0.5, base: 300, lat: [900, 250, 24] }, + ]; + const grpcTotals = grpcEndpoints.map( + (e) => `grpc_responses_total{endpoint="${e.endpoint}"} ${counter(e.base, e.rate)}` + ); + const grpcLatency = grpcEndpoints.map( + (e) => `grpc_responses_avg_duration_seconds{endpoint="${e.endpoint}"} ${latSeconds(e.lat[0], e.lat[1], e.lat[2])}` + ); + + return [ + block('app_info', 'information about qdrant server', 'gauge', [`app_info{name="qdrant",version="${version}"} 1`]), + block('cluster_enabled', 'is cluster support enabled', 'gauge', [`cluster_enabled ${clusterEnabled ? 1 : 0}`]), + block('cluster_peers_total', 'total number of cluster peers', 'gauge', [ + `cluster_peers_total ${clusterEnabled ? 3 : 1}`, + ]), + block('collections_total', 'number of collections', 'gauge', ['collections_total 1']), + block('collections_vector_total', 'total number of vectors in all collections', 'gauge', [ + `collections_vector_total ${wobble(125000, 400, 45)}`, + ]), + block('pending_operations', 'total number of pending operations', 'gauge', [ + `pending_operations ${Math.max(0, wobble(2, 3, 20))}`, + ]), + block('memory_active_bytes', 'total number of bytes in active pages', 'gauge', [ + `memory_active_bytes ${wobble(760 * MB, 30 * MB, 40)}`, + ]), + block('memory_allocated_bytes', 'total number of bytes allocated by the application', 'gauge', [ + `memory_allocated_bytes ${wobble(910 * MB, 25 * MB, 55, 1)}`, + ]), + block('memory_metadata_bytes', 'total number of bytes dedicated to metadata', 'gauge', [ + `memory_metadata_bytes ${wobble(48 * MB, 2 * MB, 60)}`, + ]), + block('memory_resident_bytes', 'total number of bytes in physically resident data pages', 'gauge', [ + `memory_resident_bytes ${wobble(830 * MB, 28 * MB, 50, 0.5)}`, + ]), + block('memory_retained_bytes', 'total number of bytes in virtual memory mappings', 'gauge', [ + `memory_retained_bytes ${wobble(210 * MB, 12 * MB, 70)}`, + ]), + block('rest_responses_total', 'total number of responses through REST API', 'counter', restTotals), + block('rest_responses_avg_duration_seconds', 'average response duration in REST API', 'gauge', restLatency), + block('rest_responses_duration_seconds', 'response duration histogram', 'histogram', restHistogram), + block('grpc_responses_total', 'total number of responses through gRPC API', 'counter', grpcTotals), + block('grpc_responses_avg_duration_seconds', 'average response duration in gRPC API', 'gauge', grpcLatency), + ].join('\n'); +}; + // Result of GET /collections/{name}. Override shardNumber / replicationFactor // to match a scenario's cluster topology. export const makeCollectionInfo = ({ shardNumber = 1, replicationFactor = 1 } = {}) => ({ diff --git a/src/mocks/handlers/base.js b/src/mocks/handlers/base.js index 53dbab4df..9b25b8947 100644 --- a/src/mocks/handlers/base.js +++ b/src/mocks/handlers/base.js @@ -4,7 +4,7 @@ // Don't use these mocks for testing! They're a developer workflow aid. import { http, HttpResponse } from 'msw'; import { BASE_URL, ok, acknowledged } from '../lib'; -import { COLLECTION, POINTS, makeTelemetry, makeCollectionInfo, singleNodeClusterInfo } from '../data'; +import { COLLECTION, POINTS, makeTelemetry, makeCollectionInfo, makeMetrics, singleNodeClusterInfo } from '../data'; // Quotas shown on the Settings page. Mutable so that saving in the UI sticks // for the session. Single node, so usage is reported via `usage` (no `peers`). @@ -31,6 +31,9 @@ export const baseHandlers = [ ok(makeTelemetry({ hasApiKey: Boolean(request.headers.get('api-key')) })) ), + // Prometheus metrics (Metrics dashboard). Plain text, not the JSON envelope. + http.get(`${BASE_URL}/metrics`, () => new HttpResponse(makeMetrics(), { headers: { 'Content-Type': 'text/plain' } })), + http.get(`${BASE_URL}/issues`, () => ok({ issues: [] })), http.delete(`${BASE_URL}/issues`, () => ok(true)), From e56141f21b618788604aaf069f8cd9e85f2f024f Mon Sep 17 00:00:00 2001 From: trean Date: Mon, 17 Aug 2026 14:01:14 +0200 Subject: [PATCH 3/9] tabs, request charts, global or per-collection mod --- src/components/Metrics/LatencyHeatmap.jsx | 74 +++-- src/components/Metrics/MetricChart.jsx | 146 +++------- src/components/Metrics/MetricsDashboard.jsx | 293 ++++++++++++++------ src/components/Metrics/MetricsScope.jsx | 89 ++++++ src/hooks/useMetricsHistory.js | 55 +++- src/lib/metrics-parser.js | 3 + src/mocks/data.js | 86 ++++-- src/mocks/handlers/base.js | 25 +- 8 files changed, 516 insertions(+), 255 deletions(-) create mode 100644 src/components/Metrics/MetricsScope.jsx diff --git a/src/components/Metrics/LatencyHeatmap.jsx b/src/components/Metrics/LatencyHeatmap.jsx index 31da566bb..8802a1a35 100644 --- a/src/components/Metrics/LatencyHeatmap.jsx +++ b/src/components/Metrics/LatencyHeatmap.jsx @@ -9,8 +9,9 @@ import { formatValue } from '../../lib/metrics-parser'; // The matrix chart type isn't part of chart.js/auto, so register it once. Chart.register(MatrixController, MatrixElement); -// Show at most this many time columns so cells stay readable. -const MAX_COLUMNS = 30; +// The timeline grows for the whole session; aggregate it into at most this many +// columns so cells stay readable however long the page stays open. +const TARGET_COLUMNS = 60; const formatTick = (t) => new Date(t).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); @@ -69,38 +70,54 @@ const LatencyHeatmap = ({ buckets, history }) => { // Build the matrix cells and the value range once, shared by the chart (for // color scaling) and the gradient legend (for its min/max labels). const matrix = useMemo(() => { - // Keep one extra column so the first shown rate has a predecessor to diff. - const hist = history.slice(-(MAX_COLUMNS + 1)); + const n = history.length; const rowLabels = buckets.map((b) => b.label); + const empty = { + labels: [], + rowLabels, + data: [], + min: 0, + max: 0, + cols: 1, + nRows: Math.max(rowLabels.length, 1), + hasData: false, + }; + if (n < 2 || buckets.length === 0) return empty; - // Cumulative count per bucket per time point (summed across series sharing - // the same `le`, e.g. all endpoints/methods/statuses). - const cum = hist.map((point) => + // Cumulative count per bucket at one history point (summed across the series + // that share each `le`, e.g. all endpoints/methods/statuses). + const cumAt = (point) => buckets.map((b) => - b.keys.reduce((sum, key) => { - const v = point.values[key]; - return sum + (typeof v === 'number' ? v : 0); - }, 0) - ) - ); - - // Un-cumulate consecutive `le` into per-band counts, then take the rate of - // change versus the previous poll — the requests/s that fell in each band. + b.keys.reduce((sum, key) => sum + (typeof point.values[key] === 'number' ? point.values[key] : 0), 0) + ); + + // Aggregate the whole (growing) timeline into at most TARGET_COLUMNS columns: + // pick evenly spaced boundary points and rate each column over the span + // between them. As history grows a column just covers more polls, so cells + // never shrink to slivers. + const columns = Math.min(TARGET_COLUMNS, n - 1); + const boundaries = []; + for (let j = 0; j <= columns; j++) boundaries.push(Math.round((j * (n - 1)) / columns)); + const cums = boundaries.map((idx) => cumAt(history[idx])); + + // Un-cumulate consecutive `le` into per-band counts, then rate the column's + // span — the average requests/s that fell in each band over that window. const data = []; const labels = []; let min = Infinity; let max = 0; - for (let t = 1; t < hist.length; t++) { - const dt = (hist[t].t - hist[t - 1].t) / 1000; - const label = formatTick(hist[t].t); + for (let j = 0; j < columns; j++) { + const dt = (history[boundaries[j + 1]].t - history[boundaries[j]].t) / 1000; + const label = formatTick(history[boundaries[j + 1]].t); labels.push(label); for (let i = 0; i < buckets.length; i++) { - const bandNow = cum[t][i] - (i > 0 ? cum[t][i - 1] : 0); - const bandPrev = cum[t - 1][i] - (i > 0 ? cum[t - 1][i - 1] : 0); + const bandNow = cums[j + 1][i] - (i > 0 ? cums[j + 1][i - 1] : 0); + const bandPrev = cums[j][i] - (i > 0 ? cums[j][i - 1] : 0); let rate = dt > 0 ? (bandNow - bandPrev) / dt : 0; if (!Number.isFinite(rate) || rate < 0) rate = 0; // counter reset / gap // Every cell is drawn (so the grid shows), but empty ones (rate 0) get a - // transparent fill; only non-zero rates drive the color scale / legend. + // transparent fill and a border; only non-zero rates drive the color + // scale / legend. data.push({ x: label, y: rowLabels[i], v: rate }); if (rate > 0) { if (rate < min) min = rate; @@ -118,7 +135,7 @@ const LatencyHeatmap = ({ buckets, history }) => { max, cols: Math.max(labels.length, 1), nRows: Math.max(rowLabels.length, 1), - hasData: labels.length > 0 && buckets.length > 0, + hasData: labels.length > 0, }; }, [history, buckets]); @@ -127,6 +144,7 @@ const LatencyHeatmap = ({ buckets, history }) => { useEffect(() => { if (!canvasRef.current) return undefined; const textColor = theme.palette.text.secondary; + const gridColor = theme.palette.divider; const { lo, hi } = ramp; const { labels, rowLabels, data, min, max, cols, nRows } = matrix; @@ -144,9 +162,13 @@ const LatencyHeatmap = ({ buckets, history }) => { const norm = max > min ? (v - min) / (max - min) : 1; return mix(lo, hi, norm); }, - borderWidth: 0, - width: ({ chart: c }) => (c.chartArea?.width || 0) / cols - 1, - height: ({ chart: c }) => (c.chartArea?.height || 0) / nRows - 1, + // Empty cells are outlined so the grid stays readable. The border is + // drawn inside the cell, so neighbours share a seam without gaps. + borderWidth: 1, + borderColor: (ctx) => (ctx.raw?.v ? 'transparent' : gridColor), + // Cells tile the plot area exactly — no spacing between them. + width: ({ chart: c }) => (c.chartArea?.width || 0) / cols, + height: ({ chart: c }) => (c.chartArea?.height || 0) / nRows, }, ], }, diff --git a/src/components/Metrics/MetricChart.jsx b/src/components/Metrics/MetricChart.jsx index 895f29388..fd587ff73 100644 --- a/src/components/Metrics/MetricChart.jsx +++ b/src/components/Metrics/MetricChart.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import PropTypes from 'prop-types'; import { Box, Typography } from '@mui/material'; import { useTheme } from '@mui/material/styles'; @@ -9,24 +9,21 @@ import { seriesColor } from './colors'; const formatTick = (t) => new Date(t).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); -const formatStat = (value, unit, rate) => (value == null ? '—' : `${formatValue(value, unit)}${rate ? '/s' : ''}`); - // A single time-series line chart rendering one or more metric series that // share an X axis (the poll timestamps accumulated by useMetricsHistory). -// Pass `showLegend` to render the Grafana-style table legend (Name / Mean / Max) -// below the chart; clicking a row toggles that series. -const MetricChart = ({ series, history = false }) => { +// Pass `aggregate` to collapse every series into one line: the counters are +// summed per timestamp and rated once — i.e. `rate(sum(...))`, the same as +// dropping the per-request labels and treating them as one series. +const MetricChart = ({ series, history = [], aggregate = false, aggregateLabel = 'Total' }) => { const theme = useTheme(); const canvasRef = useRef(null); const chartRef = useRef(null); - const [hidden] = useState(() => new Set()); // Signature that identifies the current set of series; a change means the // chart's datasets must be rebuilt rather than merely re-fed with data. The // type is part of it so the chart rebuilds once the first snapshot resolves a // series' type (gauge vs counter changes how it's plotted and labelled). const seriesSignature = useMemo(() => series.map((s) => `${s.key}:${s.type || ''}`).join('|'), [series]); - const hiddenSignature = [...hidden].join('|'); // The Y axis carries a single unit; use the first series' unit for it while // tooltips format each point by its own unit. const axisUnit = series.length ? detectUnit(series[0].name) : 'number'; @@ -42,6 +39,33 @@ const MetricChart = ({ series, history = false }) => { return isCounter(s.type) ? toRatePerSecond(points) : points.map((point) => point.v); }; + // Aggregate: sum the raw counter values across all series at each timestamp + // into one synthetic counter, then take a single rate. This is exactly + // "erase the per-request labels and treat them as one series" — `rate(sum(x))`. + // Within a scope the series set is stable, so it equals `sum(rate(x))` but is + // simpler; toRatePerSecond still drops the first point and any counter reset. + const computeTotal = () => { + const summed = history.map((point) => ({ + t: point.t, + v: series.reduce((sum, s) => { + const value = point.values[s.key]; + return typeof value === 'number' ? sum + value : sum; + }, 0), + })); + return toRatePerSecond(summed); + }; + + const datasetsData = () => (aggregate ? [computeTotal()] : series.map(computeData)); + + // One dataset per series, or a single summed one in aggregate mode. + const lines = aggregate + ? [{ label: allRate ? `${aggregateLabel} (rate)` : aggregateLabel, unit: axisUnit, rate: allRate }] + : series.map((s) => ({ + label: isCounter(s.type) ? `${s.label} (rate)` : s.label, + unit: detectUnit(s.name), + rate: isCounter(s.type), + })); + // (Re)create the chart when the series set or the theme mode changes. useEffect(() => { if (!canvasRef.current) return undefined; @@ -52,11 +76,11 @@ const MetricChart = ({ series, history = false }) => { type: 'line', data: { labels: [], - datasets: series.map((s, i) => ({ - label: isCounter(s.type) ? `${s.label} (rate)` : s.label, + datasets: lines.map((line, i) => ({ + label: line.label, data: [], - unit: detectUnit(s.name), - rate: isCounter(s.type), + unit: line.unit, + rate: line.rate, borderColor: seriesColor(theme, i).main, backgroundColor: seriesColor(theme, i).main, borderWidth: 2, @@ -104,7 +128,7 @@ const MetricChart = ({ series, history = false }) => { chart.destroy(); chartRef.current = null; }; - }, [seriesSignature, theme.palette.mode]); + }, [seriesSignature, theme.palette.mode, aggregate, aggregateLabel]); // Feed the accumulated history into the existing chart on every poll, and // apply per-series visibility toggled from the legend. @@ -112,16 +136,14 @@ const MetricChart = ({ series, history = false }) => { const chart = chartRef.current; if (!chart) return; chart.data.labels = history.map((point) => formatTick(point.t)); - series.forEach((s, i) => { - if (chart.data.datasets[i]) { - chart.data.datasets[i].data = computeData(s); - chart.data.datasets[i].hidden = hidden.has(s.key); - } + datasetsData().forEach((data, i) => { + if (!chart.data.datasets[i]) return; + chart.data.datasets[i].data = data; }); chart.update('none'); - }, [history, seriesSignature, hiddenSignature]); + }, [history, seriesSignature, aggregate]); - const hasData = series.some((s) => computeData(s).some((v) => v != null)); + const hasData = datasetsData().some((data) => data.some((v) => v != null)); return ( <> @@ -157,87 +179,9 @@ MetricChart.propTypes = { type: PropTypes.string, }) ).isRequired, - history: PropTypes.array.isRequired, - showLegend: PropTypes.bool, -}; - -// Grafana-style table legend: colored line marker + name, with right-aligned -// Mean/Max stat columns. Scrolls when there are many series; clicking a row -// toggles that series on the chart. -const STAT_WIDTH = 96; -const MARKER_SLOT = 22; - -function ChartLegend({ rows, hidden, onToggle }) { - const theme = useTheme(); - const headColor = theme.palette.primary.main; - const statCell = { - width: STAT_WIDTH, - flexShrink: 0, - pl: 1, - textAlign: 'right', - whiteSpace: 'nowrap', - fontVariantNumeric: 'tabular-nums', - }; - - return ( - - - - Name - Mean - Max - - {rows.map((row) => { - const isHidden = hidden.has(row.key); - return ( - onToggle(row.key)} - sx={{ - display: 'flex', - alignItems: 'center', - px: 0.5, - py: 0.25, - cursor: 'pointer', - borderRadius: 1, - opacity: isHidden ? 0.4 : 1, - '&:hover': { bgcolor: 'action.hover' }, - }} - > - - - - - {row.label} - - {formatStat(row.mean, row.unit, row.rate)} - {formatStat(row.max, row.unit, row.rate)} - - ); - })} - - ); -} - -ChartLegend.propTypes = { - rows: PropTypes.array.isRequired, - hidden: PropTypes.instanceOf(Set).isRequired, - onToggle: PropTypes.func.isRequired, + history: PropTypes.array, + aggregate: PropTypes.bool, + aggregateLabel: PropTypes.string, }; export default MetricChart; diff --git a/src/components/Metrics/MetricsDashboard.jsx b/src/components/Metrics/MetricsDashboard.jsx index bfd4ab2bc..fd843c410 100644 --- a/src/components/Metrics/MetricsDashboard.jsx +++ b/src/components/Metrics/MetricsDashboard.jsx @@ -1,25 +1,51 @@ -import React, { useMemo } from 'react'; -import { Alert, Box, Divider, Grid, Stack, Typography } from '@mui/material'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Alert, Box, Grid, Stack, Tab, Tabs, Typography } from '@mui/material'; import MetricChart from './MetricChart'; -import MetricBarChart from './MetricBarChart'; import LatencyHeatmap from './LatencyHeatmap'; import StatTile from './StatTile'; import PanelCard from './PanelCard'; -import CustomChartsDashboard from './CustomChartsDashboard'; +import MetricsScope from './MetricsScope'; +import { useClient } from '../../context/client-context'; import { useMetricsHistory } from '../../hooks/useMetricsHistory'; import { listSeries, indexByKey, seriesLabel } from '../../lib/metrics-parser'; const POLL_INTERVAL_MS = 5000; -const MAX_POINTS = 120; // ~10 minutes at a 5s interval - -// Big-number tiles (Grafana "stat" panels). -const STAT_TILES = [ - { label: 'Collections', key: 'collections_total' }, - { label: 'Vectors', key: 'collections_vector_total' }, - { label: 'Pending operations', key: 'pending_operations' }, - { label: 'Cluster peers', key: 'cluster_peers_total' }, +// The timeline grows for the whole session; this is only a memory safety limit +// (beyond it the oldest history is decimated — see useMetricsHistory). +const MAX_STORED_POINTS = 3000; + +// Big-number tiles for the Requests tab, keyed by `requestStats` fields. +const REQUEST_TILES = [ + { label: 'Requests/s', stat: 'rate' }, + { label: 'Avg latency', stat: 'avgLatency', unit: 'seconds' }, + { label: 'Error rate', stat: 'errorRate', unit: 'percent' }, + { label: 'Total requests', stat: 'total' }, ]; +const sumKeys = (values, keys) => keys.reduce((acc, k) => acc + (typeof values?.[k] === 'number' ? values[k] : 0), 0); + +const hasCollectionLabel = (entries) => entries.some((s) => s.labels.collection !== undefined); + +// CORS preflights: the browser sends one before most requests the UI makes, so +// counting them doubles the traffic and drags the latency stats down. +const isPreflight = (s) => s.labels.method === 'OPTIONS'; + +// Narrow a family of series to one collection. Qdrant only labels the request +// counters per collection, so a family without the label (the duration +// histogram, for one) stays instance-wide and is returned untouched. +const scopeToCollection = (entries, collection) => + collection && hasCollectionLabel(entries) ? entries.filter((s) => s.labels.collection === collection) : entries; + +// Growth of a set of counters between two history points, or undefined when +// there is no usable interval (too few samples, a gap, or a counter reset). +const counterDelta = (history, keys, from, to) => { + if (!keys.length || !history[from] || !history[to]) return undefined; + const dt = (history[to].t - history[from].t) / 1000; + const dv = sumKeys(history[to].values, keys) - sumKeys(history[from].values, keys); + if (dt <= 0 || dv < 0) return undefined; + return { dv, dt }; +}; + // Format a histogram `le` bucket boundary (seconds) as a short latency label. const formatLe = (sec) => { if (!Number.isFinite(sec)) return '+Inf'; @@ -36,47 +62,124 @@ const toChartSeries = (entries) => // Grafana dashboards (github.com/qdrant/prometheus-monitoring), bound to the // metrics a self-hosted instance actually exposes. function MetricsDashboard() { - const { snapshot, history, loading, error } = useMetricsHistory({ + const [currentTab, setCurrentTab] = useState('requests'); + const [scope, setScope] = useState('global'); + const [collection, setCollection] = useState(''); + const [collections, setCollections] = useState([]); + const { client: qdrantClient } = useClient(); + + const perCollection = scope === 'collection'; + // Only filter once a collection is picked, so the panels never silently show + // instance-wide numbers under a collection heading. + const activeCollection = perCollection ? collection : ''; + + const { snapshot, history, error } = useMetricsHistory({ recordAll: true, intervalMs: POLL_INTERVAL_MS, - maxPoints: MAX_POINTS, + maxPoints: MAX_STORED_POINTS, + perCollection, }); + // The collection choice is mandatory in per-collection mode, so preselect the + // first one and keep the selection valid as collections come and go. + useEffect(() => { + if (!perCollection) return undefined; + let active = true; + qdrantClient + .getCollections() + .then(({ collections: found }) => { + if (!active) return; + const names = found.map((c) => c.name).sort((a, b) => a.localeCompare(b)); + setCollections(names); + setCollection((current) => (names.includes(current) ? current : names[0] || '')); + }) + .catch(() => active && setCollections([])); + return () => { + active = false; + }; + }, [perCollection, qdrantClient]); + const all = useMemo(() => listSeries(snapshot), [snapshot]); const latest = useMemo(() => indexByKey(snapshot), [snapshot]); - const restSeries = useMemo(() => all.filter((s) => s.name === 'rest_responses_total'), [all]); - const grpcSeries = useMemo(() => all.filter((s) => s.name === 'grpc_responses_total'), [all]); - const memorySeries = useMemo(() => all.filter((s) => /^memory_.*_bytes$/.test(s.name)), [all]); - const vectorSeries = useMemo(() => all.filter((s) => s.name === 'collections_vector_total'), [all]); - - // Bar chart: total REST requests summed per endpoint (latest snapshot). - const requestsByEndpoint = useMemo(() => { - const sums = {}; - restSeries.forEach((s) => { - const endpoint = s.labels.endpoint || s.name; - sums[endpoint] = (sums[endpoint] || 0) + (latest[s.key] || 0); - }); - const entries = Object.entries(sums).sort((a, b) => b[1] - a[1]); - return { labels: entries.map((e) => e[0]), values: entries.map((e) => e[1]) }; - }, [restSeries, latest]); + const restAll = useMemo(() => all.filter((s) => s.name === 'rest_responses_total' && !isPreflight(s)), [all]); + const restSeries = useMemo(() => scopeToCollection(restAll, activeCollection), [restAll, activeCollection]); + + // Qdrant ignores `per_collection` on older versions, and when the feature is + // disabled in its config: the counters stay instance-wide. Say so rather than + // passing those numbers off as one collection's. + const perCollectionUnsupported = Boolean(activeCollection) && restAll.length > 0 && !hasCollectionLabel(restAll); // Latency-distribution heatmap: group the response-duration histogram's // `_bucket` series by their `le` boundary (across every endpoint/method/ // status, matching Grafana's `sum by (le)`), ordered ascending. + const bucketSeries = useMemo( + () => + all.filter( + (s) => /_responses_duration_seconds_bucket$/.test(s.name) && s.labels.le !== undefined && !isPreflight(s) + ), + [all] + ); + const latencyBuckets = useMemo(() => { const groups = new Map(); // le string -> { sec, keys[] } - all - .filter((s) => /_responses_duration_seconds_bucket$/.test(s.name) && s.labels.le !== undefined) - .forEach((s) => { - const le = s.labels.le; - if (!groups.has(le)) groups.set(le, { sec: le === '+Inf' ? Infinity : Number(le), keys: [] }); - groups.get(le).keys.push(s.key); - }); + scopeToCollection(bucketSeries, activeCollection).forEach((s) => { + const le = s.labels.le; + if (!groups.has(le)) groups.set(le, { sec: le === '+Inf' ? Infinity : Number(le), keys: [] }); + groups.get(le).keys.push(s.key); + }); return [...groups.entries()] .sort((a, b) => a[1].sec - b[1].sec) .map(([, g]) => ({ label: formatLe(g.sec), keys: g.keys })); - }, [all]); + }, [bucketSeries, activeCollection]); + + // Qdrant may report the duration histogram instance-wide even in + // per-collection mode; say so rather than implying the panel is filtered. + const latencyIsGlobal = Boolean(activeCollection) && !hasCollectionLabel(bucketSeries); + + // Each scope keeps its own history buffer (see useMetricsHistory), so `history` + // already holds only this scope's points. This still drops any point taken + // before the selected series existed — e.g. a collection created mid-session — + // so a counter delta is never measured against a missing value and read as an + // enormous spike. + const requestsHistory = useMemo(() => { + const keys = [...restSeries.map((s) => s.key), ...latencyBuckets.flatMap((b) => b.keys)]; + if (!keys.length) return history; + return history.filter((point) => keys.some((key) => point.values[key] != null)); + }, [history, restSeries, latencyBuckets]); + + // Stats for the Requests tab: throughput right now, average latency across + // the retained window (total time spent / requests served, from the duration + // histogram) and the lifetime totals behind the error share. + const requestStats = useMemo(() => { + const restKeys = restSeries.map((s) => s.key); + const errorKeys = restSeries.filter((s) => /^[45]/.test(s.labels.status || '')).map((s) => s.key); + const keysEndingIn = (suffix) => + scopeToCollection( + all.filter((s) => s.name.endsWith(`_responses_duration_seconds_${suffix}`) && !isPreflight(s)), + activeCollection + ).map((s) => s.key); + const spentKeys = keysEndingIn('sum'); + const servedKeys = keysEndingIn('count'); + const avgOf = (spent, served) => (served > 0 ? spent / served : undefined); + + const last = requestsHistory.length - 1; + const throughput = counterDelta(requestsHistory, restKeys, last - 1, last); + const spent = counterDelta(requestsHistory, spentKeys, 0, last); + const served = counterDelta(requestsHistory, servedKeys, 0, last); + const total = sumKeys(latest, restKeys); + + return { + rate: throughput ? throughput.dv / throughput.dt : undefined, + // Average over the window when there was traffic in it, otherwise the + // instance's lifetime average so an idle instance still shows a value. + avgLatency: + (spent && served && avgOf(spent.dv, served.dv)) ?? + avgOf(sumKeys(latest, spentKeys), sumKeys(latest, servedKeys)), + errorRate: total > 0 ? (sumKeys(latest, errorKeys) / total) * 100 : undefined, + total: restKeys.length ? total : undefined, + }; + }, [restSeries, all, latest, requestsHistory, activeCollection]); return ( @@ -95,57 +198,75 @@ function MetricsDashboard() { )} - {/* Stat tiles */} - - {STAT_TILES.map((tile) => ( - - - - ))} - - - {/* Charts */} - - - - - - - - - - - - - - - - - - - - - - - - + {/* Charts, grouped by tab, for the instance or a single collection. Below + lg the scope control drops to its own row so toggling "Per collection" + (wider than "Global") never reflows the tabs. */} + + setCurrentTab(tab)} aria-label="Metrics tabs"> + + + + + + + - {/* TEMPORARY: the earlier custom-chart builder, reconnected at the end of - the page. Embedded (its own page header suppressed). */} - - - - Custom charts - - - Build an ad-hoc chart from any exposed metric. - - - + {currentTab === 'requests' && perCollection && !collection && ( + Select a collection to see its request metrics. + )} + + {currentTab === 'requests' && !(perCollection && !collection) && ( + + {perCollectionUnsupported && ( + + This Qdrant reports request metrics instance-wide, so the panels below are not filtered by collection. + + )} + + + {REQUEST_TILES.map((tile) => ( + + + + ))} + + + + + + + + + + + )} ); } diff --git a/src/components/Metrics/MetricsScope.jsx b/src/components/Metrics/MetricsScope.jsx new file mode 100644 index 000000000..3de9023b0 --- /dev/null +++ b/src/components/Metrics/MetricsScope.jsx @@ -0,0 +1,89 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { alpha } from '@mui/material/styles'; +import { Box, FormControl, MenuItem, Select, Stack, ToggleButton, ToggleButtonGroup } from '@mui/material'; + +// Picks what the request panels are about: the whole instance, or one +// collection. Qdrant only breaks the request metrics down per collection when +// `/metrics` is asked for it, so the choice drives the poll as well as the +// filtering — see MetricsDashboard. +// +// Styled to sit quietly next to the tabs: a rounded segmented toggle using the +// primary accent for the active side (matching the app's buttons), plus a +// compact collection picker that appears only in per-collection mode. +function MetricsScope({ scope, onScopeChange, collection, collections, onCollectionChange }) { + return ( + + value && onScopeChange(value)} + aria-label="Metrics scope" + sx={{ + '& .MuiToggleButton-root': { + textTransform: 'capitalize', + fontWeight: 500, + fontSize: '0.8125rem', + lineHeight: 1.4, + px: 1.5, + py: 0.5, + color: 'text.secondary', + borderColor: 'divider', + '&.Mui-selected': { + color: 'primary.main', + backgroundColor: (t) => alpha(t.palette.primary.main, 0.1), + borderColor: (t) => alpha(t.palette.primary.main, 0.5), + '&:hover': { backgroundColor: (t) => alpha(t.palette.primary.main, 0.16) }, + }, + }, + }} + > + Global + Per collection + + + {scope === 'collection' && ( + + + + )} + + ); +} + +MetricsScope.propTypes = { + scope: PropTypes.oneOf(['global', 'collection']).isRequired, + onScopeChange: PropTypes.func.isRequired, + collection: PropTypes.string.isRequired, + collections: PropTypes.arrayOf(PropTypes.string).isRequired, + onCollectionChange: PropTypes.func.isRequired, +}; + +export default MetricsScope; diff --git a/src/hooks/useMetricsHistory.js b/src/hooks/useMetricsHistory.js index c0d860d17..34148c527 100644 --- a/src/hooks/useMetricsHistory.js +++ b/src/hooks/useMetricsHistory.js @@ -2,8 +2,22 @@ import { useEffect, useRef, useState } from 'react'; import { axiosInstance as axios } from '../common/axios'; import { parsePrometheus, indexByKey } from '../lib/metrics-parser'; -// Poll Qdrant's `/metrics` endpoint on an interval and accumulate a bounded, -// in-browser time series for the currently subscribed metrics. +// The timeline grows for the whole session (charts show everything since the +// page opened). To keep memory bounded on very long, days-long sessions, once a +// buffer exceeds `limit` points we halve the resolution of its OLDEST half — +// preserving the full time span and recent full-resolution detail while shedding +// points. Rates are computed from deltas, so coarser old spacing stays correct. +const boundHistory = (points, limit) => { + if (points.length <= limit) return points; + const half = points.length >> 1; + const older = points.slice(0, half).filter((_, i) => i % 2 === 0); + return [...older, ...points.slice(half)]; +}; + +// Poll Qdrant's `/metrics` endpoint on an interval and accumulate an in-browser +// time series for the currently subscribed metrics. The history grows for the +// whole session (charts show everything since the page opened); `maxPoints` is +// only a memory safety limit past which the oldest points are decimated. // // Qdrant exposes point-in-time Prometheus metrics rather than a time-series // database, so — like a lightweight Grafana — we build the history client-side: @@ -16,16 +30,26 @@ import { parsePrometheus, indexByKey } from '../lib/metrics-parser'; // response (used by the preset dashboard, which charts whatever the server // exposes); otherwise only `subscribedKeys` are recorded. // +// Pass `perCollection: true` to request `/metrics?per_collection=true`, where +// Qdrant labels the response metrics with the collection they belong to instead +// of reporting them cluster-wide. +// +// The two poll modes ("global" and per-collection) return different series, so +// each keeps its OWN history buffer. Switching between them resumes the target +// mode's buffer where it left off — nothing is lost, the timelines never +// interleave, and one mode's samples can't evict the other's from a shared cap. +// // Returns: // snapshot latest parsed metrics map (name -> descriptor), or null -// history [{ t, values: { seriesKey: number } }] oldest-first, capped +// history [{ t, values: { seriesKey: number } }] oldest-first, growing // loading true until the first response (success or failure) arrives // error last error message, or null export const useMetricsHistory = ({ subscribedKeys = [], recordAll = false, intervalMs = 5000, - maxPoints = 120, + maxPoints = 3000, + perCollection = false, } = {}) => { const [snapshot, setSnapshot] = useState(null); const [history, setHistory] = useState([]); @@ -37,8 +61,19 @@ export const useMetricsHistory = ({ const recordAllRef = useRef(recordAll); recordAllRef.current = recordAll; + // One history buffer per poll mode, preserved across the component's life. + const buffersRef = useRef({}); + useEffect(() => { let active = true; + const mode = perCollection ? 'collection' : 'global'; + + // Resume this mode's buffer (empty on first visit); the other mode's buffer + // is left untouched. The snapshot is dropped so nothing reads the previous + // mode's series until the first response of the new one lands. + setSnapshot(null); + setLoading(true); + setHistory(buffersRef.current[mode] || []); const tick = async () => { try { @@ -48,6 +83,7 @@ export const useMetricsHistory = ({ responseType: 'text', transformResponse: [(data) => data], headers: { Accept: 'text/plain' }, + params: perCollection ? { per_collection: true } : undefined, }); if (!active) return; @@ -63,12 +99,13 @@ export const useMetricsHistory = ({ } } + const prev = buffersRef.current[mode] || []; + const bounded = boundHistory([...prev, { t: Date.now(), values }], maxPoints); + buffersRef.current[mode] = bounded; + setSnapshot(parsed); setError(null); - setHistory((prev) => { - const next = [...prev, { t: Date.now(), values }]; - return next.length > maxPoints ? next.slice(next.length - maxPoints) : next; - }); + setHistory(bounded); } catch (err) { if (!active) return; setError(err?.response?.data?.status?.error || err?.message || 'Failed to fetch metrics.'); @@ -83,7 +120,7 @@ export const useMetricsHistory = ({ active = false; clearInterval(id); }; - }, [intervalMs, maxPoints]); + }, [intervalMs, maxPoints, perCollection]); return { snapshot, history, loading, error }; }; diff --git a/src/lib/metrics-parser.js b/src/lib/metrics-parser.js index 575ea35d5..ef39a0ddd 100644 --- a/src/lib/metrics-parser.js +++ b/src/lib/metrics-parser.js @@ -28,6 +28,7 @@ export const buildSeriesKey = (name, labels) => { .sort(([a], [b]) => a.localeCompare(b)) .map(([k, v]) => `${k}="${v}"`) .join(','); + // console.log(`${name}{${inner}}`) return `${name}{${inner}}`; }; @@ -183,6 +184,8 @@ export const formatValue = (value, unit) => { return prettyBytes(value); case 'seconds': return formatSeconds(value); + case 'percent': + return `${formatNumber(value)}%`; default: return formatNumber(value); } diff --git a/src/mocks/data.js b/src/mocks/data.js index 1484d990e..0dedb7818 100644 --- a/src/mocks/data.js +++ b/src/mocks/data.js @@ -1,6 +1,9 @@ // Shared mock data and builders used across scenarios. Tweak the numbers here // and every scenario that reuses them stays consistent. export const COLLECTION = 'demo_collection'; +// Every collection the mock instance knows about. More than one so features that +// pick a collection (e.g. the Metrics per-collection scope) can be exercised. +export const COLLECTIONS = [COLLECTION, 'products_index', 'support_docs']; export const VECTOR_SIZE = 4; // A few points with a simple payload. Enough for the Points tab, faceting, @@ -29,7 +32,10 @@ export const makeTelemetry = ({ hasApiKey = false, clusterEnabled = false, resha // GET /metrics — Prometheus text exposition format. Values wobble over time so // the live Metrics dashboard shows movement in mock mode: gauges oscillate // around a baseline and counters grow monotonically with elapsed time. -export const makeMetrics = ({ clusterEnabled = false, version = '1.15.1' } = {}) => { +// `perCollection` mirrors `/metrics?per_collection=true`: Qdrant then labels the +// request counters with the collection they belong to and stops reporting the +// unlabelled global ones. +export const makeMetrics = ({ clusterEnabled = false, version = '1.15.1', perCollection = false } = {}) => { const now = Date.now(); const t = now / 1000; const wobble = (base, amp, periodSec, phase = 0) => @@ -50,6 +56,16 @@ export const makeMetrics = ({ clusterEnabled = false, version = '1.15.1' } = {}) // endpoint's requests cluster around, `sigma` the spread — together they // shape the histogram so the latency-distribution heatmap has realistic bands. const restEndpoints = [ + // CORS preflights: real Qdrant emits these; the dashboard filters them out. + { + method: 'OPTIONS', + endpoint: '/collections/{name}/points', + rate: 8, + base: 3200, + lat: [13, 4, 20], + center: 0, + sigma: 0.6, + }, { method: 'GET', endpoint: '/collections', rate: 3, base: 1200, lat: [1200, 300, 25], center: 0, sigma: 1 }, { method: 'POST', @@ -88,15 +104,24 @@ export const makeMetrics = ({ clusterEnabled = false, version = '1.15.1' } = {}) sigma: 1, }, ]; - const restTotals = restEndpoints.map( - (e) => `rest_responses_total{method="${e.method}",endpoint="${e.endpoint}",status="2xx"} ${counter(e.base, e.rate)}` - ); - // A handful of non-2xx responses so "requests by status" has variety. - restTotals.push( - `rest_responses_total{method="POST",endpoint="/collections/{name}/points/search",status="4xx"} ${counter(90, 0.3)}`, - `rest_responses_total{method="PUT",endpoint="/collections/{name}/points",status="4xx"} ${counter(40, 0.1)}`, - `rest_responses_total{method="POST",endpoint="/collections/{name}/points/search",status="5xx"} ${counter(6, 0.02)}` - ); + // One stream of request counters per collection when asked for per-collection + // metrics, otherwise a single unlabelled global stream. Each collection takes a + // different share of the traffic, so switching collection visibly changes the + // charts. + const streams = perCollection + ? COLLECTIONS.map((name, i) => ({ label: `,collection="${name}"`, scale: [1, 0.45, 0.15][i] ?? 0.1 })) + : [{ label: '', scale: 1 }]; + + // A handful of non-2xx responses so error rate / status breakdowns have variety. + const restErrors = [ + { method: 'POST', endpoint: '/collections/{name}/points/search', status: '4xx', base: 90, rate: 0.3 }, + { method: 'PUT', endpoint: '/collections/{name}/points', status: '4xx', base: 40, rate: 0.1 }, + { method: 'POST', endpoint: '/collections/{name}/points/search', status: '5xx', base: 6, rate: 0.02 }, + ]; + const restTotal = ({ method, endpoint, status = '2xx', base, rate }, stream) => + `rest_responses_total{method="${method}",endpoint="${endpoint}",status="${status}"${stream.label}} ` + + `${counter(Math.round(base * stream.scale), rate * stream.scale)}`; + const restTotals = streams.flatMap((stream) => [...restEndpoints, ...restErrors].map((e) => restTotal(e, stream))); const restLatency = restEndpoints.map( (e) => `rest_responses_avg_duration_seconds{method="${e.method}",endpoint="${e.endpoint}"} ${latSeconds( @@ -107,9 +132,10 @@ export const makeMetrics = ({ clusterEnabled = false, version = '1.15.1' } = {}) ); // Prometheus histogram: cumulative `_bucket{le}` counts (+ `_sum`, `_count`) - // per endpoint. Counts grow with elapsed time and are spread across buckets by - // a Gaussian kernel around each endpoint's `center`, so `rate(bucket)` yields - // a realistic latency-distribution heatmap. + // per endpoint, per stream (Qdrant labels the histogram per collection too). + // Counts grow with elapsed time and are spread across buckets by a Gaussian + // kernel around each endpoint's `center`, so `rate(bucket)` yields a realistic + // latency-distribution heatmap. const LE = [0.001, 0.005, 0.01, 0.02, 0.05, 0.1, 0.5, 1, 5, 10, 50]; const bandCdf = (center, sigma) => { const n = LE.length + 1; // finite buckets + "+Inf" @@ -118,18 +144,20 @@ export const makeMetrics = ({ clusterEnabled = false, version = '1.15.1' } = {}) let acc = 0; return weights.map((w) => (acc += w / total)); }; - const restHistogram = restEndpoints.flatMap((e) => { - const count = counter(e.base, e.rate); - const cdf = bandCdf(e.center, e.sigma); - const labels = `method="${e.method}",endpoint="${e.endpoint}",status="2xx"`; - const lines = LE.map( - (le, i) => `rest_responses_duration_seconds_bucket{${labels},le="${le}"} ${Math.round(count * cdf[i])}` - ); - lines.push(`rest_responses_duration_seconds_bucket{${labels},le="+Inf"} ${count}`); - lines.push(`rest_responses_duration_seconds_sum{${labels}} ${((count * e.lat[0]) / 1e6).toFixed(6)}`); - lines.push(`rest_responses_duration_seconds_count{${labels}} ${count}`); - return lines; - }); + const restHistogram = streams.flatMap((stream) => + restEndpoints.flatMap((e) => { + const count = counter(Math.round(e.base * stream.scale), e.rate * stream.scale); + const cdf = bandCdf(e.center, e.sigma); + const labels = `method="${e.method}",endpoint="${e.endpoint}",status="2xx"${stream.label}`; + const lines = LE.map( + (le, i) => `rest_responses_duration_seconds_bucket{${labels},le="${le}"} ${Math.round(count * cdf[i])}` + ); + lines.push(`rest_responses_duration_seconds_bucket{${labels},le="+Inf"} ${count}`); + lines.push(`rest_responses_duration_seconds_sum{${labels}} ${((count * e.lat[0]) / 1e6).toFixed(6)}`); + lines.push(`rest_responses_duration_seconds_count{${labels}} ${count}`); + return lines; + }) + ); // gRPC endpoints: request counters and avg latency. const grpcEndpoints = [ @@ -137,8 +165,12 @@ export const makeMetrics = ({ clusterEnabled = false, version = '1.15.1' } = {}) { endpoint: '/qdrant.Points/Upsert', rate: 2, base: 900, lat: [3800, 1000, 27] }, { endpoint: '/qdrant.Collections/Get', rate: 0.5, base: 300, lat: [900, 250, 24] }, ]; - const grpcTotals = grpcEndpoints.map( - (e) => `grpc_responses_total{endpoint="${e.endpoint}"} ${counter(e.base, e.rate)}` + const grpcTotals = streams.flatMap((stream) => + grpcEndpoints.map( + (e) => + `grpc_responses_total{endpoint="${e.endpoint}"${stream.label}} ` + + `${counter(Math.round(e.base * stream.scale), e.rate * stream.scale)}` + ) ); const grpcLatency = grpcEndpoints.map( (e) => `grpc_responses_avg_duration_seconds{endpoint="${e.endpoint}"} ${latSeconds(e.lat[0], e.lat[1], e.lat[2])}` diff --git a/src/mocks/handlers/base.js b/src/mocks/handlers/base.js index 9b25b8947..e868cc585 100644 --- a/src/mocks/handlers/base.js +++ b/src/mocks/handlers/base.js @@ -4,7 +4,15 @@ // Don't use these mocks for testing! They're a developer workflow aid. import { http, HttpResponse } from 'msw'; import { BASE_URL, ok, acknowledged } from '../lib'; -import { COLLECTION, POINTS, makeTelemetry, makeCollectionInfo, makeMetrics, singleNodeClusterInfo } from '../data'; +import { + COLLECTION, + COLLECTIONS, + POINTS, + makeTelemetry, + makeCollectionInfo, + makeMetrics, + singleNodeClusterInfo, +} from '../data'; // Quotas shown on the Settings page. Mutable so that saving in the UI sticks // for the session. Single node, so usage is reported via `usage` (no `peers`). @@ -32,7 +40,12 @@ export const baseHandlers = [ ), // Prometheus metrics (Metrics dashboard). Plain text, not the JSON envelope. - http.get(`${BASE_URL}/metrics`, () => new HttpResponse(makeMetrics(), { headers: { 'Content-Type': 'text/plain' } })), + // `?per_collection=true` swaps the global request counters for per-collection + // ones, as the real endpoint does. + http.get(`${BASE_URL}/metrics`, ({ request }) => { + const perCollection = new URL(request.url).searchParams.get('per_collection') === 'true'; + return new HttpResponse(makeMetrics({ perCollection }), { headers: { 'Content-Type': 'text/plain' } }); + }), http.get(`${BASE_URL}/issues`, () => ok({ issues: [] })), http.delete(`${BASE_URL}/issues`, () => ok(true)), @@ -54,13 +67,13 @@ export const baseHandlers = [ http.get(`${BASE_URL}/cluster`, () => ok({ status: 'disabled' })), // --- collections --- - http.get(`${BASE_URL}/collections`, () => ok({ collections: [{ name: COLLECTION }] })), + http.get(`${BASE_URL}/collections`, () => ok({ collections: COLLECTIONS.map((name) => ({ name })) })), http.get(`${BASE_URL}/aliases`, () => ok({ aliases: [] })), http.get(`${BASE_URL}/collections/:collection`, () => ok(makeCollectionInfo())), - // Only the single mock collection exists; report everything else as absent - // so the create form doesn't wrongly think new names already exist. + // Only the mock collections exist; report everything else as absent so the + // create form doesn't wrongly think new names already exist. http.get(`${BASE_URL}/collections/:collection/exists`, ({ params }) => - ok({ exists: params.collection === COLLECTION }) + ok({ exists: COLLECTIONS.includes(params.collection) }) ), http.get(`${BASE_URL}/collections/:collection/aliases`, () => ok({ aliases: [] })), http.get(`${BASE_URL}/collections/:collection/cluster`, () => ok(singleNodeClusterInfo)), From 28a76b3e650abcf82ecc1eeb5da4fb39fa706637 Mon Sep 17 00:00:00 2001 From: trean Date: Mon, 17 Aug 2026 17:01:29 +0200 Subject: [PATCH 4/9] removing unused code, finishing memory and cpu tab, improving styles --- src/components/Metrics/AddSeriesField.jsx | 173 --------- src/components/Metrics/CollectingOverlay.jsx | 35 ++ .../Metrics/CustomChartsDashboard.jsx | 334 ------------------ src/components/Metrics/LatencyHeatmap.jsx | 9 +- src/components/Metrics/MetricBarChart.jsx | 93 ----- src/components/Metrics/MetricChart.jsx | 48 +-- src/components/Metrics/MetricsDashboard.jsx | 81 ++++- src/components/Metrics/MetricsScope.jsx | 8 +- src/components/Metrics/PollIntervalSelect.jsx | 60 ++++ src/components/Metrics/presets.js | 34 -- src/lib/metrics-parser.js | 11 - src/lib/tests/metrics-parser.test.js | 9 +- src/mocks/data.js | 50 +++ 13 files changed, 249 insertions(+), 696 deletions(-) delete mode 100644 src/components/Metrics/AddSeriesField.jsx create mode 100644 src/components/Metrics/CollectingOverlay.jsx delete mode 100644 src/components/Metrics/CustomChartsDashboard.jsx delete mode 100644 src/components/Metrics/MetricBarChart.jsx create mode 100644 src/components/Metrics/PollIntervalSelect.jsx delete mode 100644 src/components/Metrics/presets.js diff --git a/src/components/Metrics/AddSeriesField.jsx b/src/components/Metrics/AddSeriesField.jsx deleted file mode 100644 index 4a473a1fe..000000000 --- a/src/components/Metrics/AddSeriesField.jsx +++ /dev/null @@ -1,173 +0,0 @@ -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; -import { Autocomplete, Box, Chip, IconButton, TextField, Tooltip, Typography } from '@mui/material'; -import { Plus } from 'lucide-react'; -import { seriesLabel, isCounter } from '../../lib/metrics-parser'; - -// Two series can share a chart only if they'd share a meaningful Y axis: the -// same display unit and the same plotting kind (gauge raw vs counter rate). -const optionCompat = (option) => `${option.unit}:${isCounter(option.type) ? 'rate' : 'raw'}`; - -// eslint-disable-next-line react/prop-types -const renderOption = (props, option) => ( - // eslint-disable-next-line react/prop-types - - - - {seriesLabel(option)} - - {option.help && ( - - {option.help} - - )} - - -); - -const autocompleteProps = { - size: 'small', - getOptionLabel: (option) => (typeof option === 'string' ? option : option.key), - isOptionEqualToValue: (option, selected) => option.key === selected.key, - renderOption, -}; - -// Inline adder inside an existing chart: a small search field; picking a metric -// adds it immediately. -function InlineAddField({ options, onAdd, placeholder }) { - const [value, setValue] = useState(null); - const [inputValue, setInputValue] = useState(''); - - const commit = (option) => { - if (!option) return; - onAdd(option); - setValue(null); - setInputValue(''); - }; - - return ( - commit(option)} - inputValue={inputValue} - onInputChange={(_, next) => setInputValue(next)} - options={options} - renderInput={(params) => } - /> - ); -} - -InlineAddField.propTypes = { - options: PropTypes.array.isRequired, - onAdd: PropTypes.func.isRequired, - placeholder: PropTypes.string, -}; - -// The top-of-dashboard bar: stage one or more compatible metrics into the -// field, then create a chart holding all of them by pressing "+" or Enter. -// Once a first metric is staged, the options narrow to those that share its -// unit and gauge/counter kind, so a chart can't end up with a mismatched axis. -function NewChartField({ options, onCreate, placeholder }) { - const [staged, setStaged] = useState([]); - const [inputValue, setInputValue] = useState(''); - const [open, setOpen] = useState(false); - - const stagedKeys = new Set(staged.map((series) => series.key)); - const compat = staged.length ? optionCompat(staged[0]) : null; - const filtered = options.filter( - (option) => !stagedKeys.has(option.key) && (compat === null || optionCompat(option) === compat) - ); - - const create = () => { - if (!staged.length) return; - onCreate(staged); - setStaged([]); - setInputValue(''); - setOpen(false); // creating the chart also dismisses the options list - }; - - // Enter creates the chart when the user isn't mid-typing a filter; while - // typing, Enter falls through to the Autocomplete so it selects the - // highlighted option (staging it) as usual. - const handleKeyDown = (event) => { - if (event.key === 'Enter' && inputValue.trim() === '' && staged.length > 0) { - event.preventDefault(); - event.stopPropagation(); - create(); - } - }; - - return ( - - setOpen(true)} - onClose={() => setOpen(false)} - value={staged} - onChange={(_, next) => setStaged(next)} - inputValue={inputValue} - onInputChange={(_, next) => setInputValue(next)} - options={filtered} - renderTags={(value, getTagProps) => - value.map((option, index) => ( - // key is provided by getTagProps - - )) - } - renderInput={(params) => ( - - )} - /> - - - - - - - - - ); -} - -NewChartField.propTypes = { - options: PropTypes.array.isRequired, - onCreate: PropTypes.func.isRequired, - placeholder: PropTypes.string, -}; - -// `variant="inline"` is the in-chart adder (a small field that adds a metric -// immediately); the default "bar" is the staging field that builds a new chart -// from several metrics at once. -const AddSeriesField = ({ options, onAdd, onCreate, placeholder = 'Add a metric…', variant = 'bar' }) => - variant === 'inline' ? ( - - ) : ( - - ); - -AddSeriesField.propTypes = { - options: PropTypes.array.isRequired, - onAdd: PropTypes.func, - onCreate: PropTypes.func, - placeholder: PropTypes.string, - variant: PropTypes.oneOf(['bar', 'inline']), -}; - -export default AddSeriesField; diff --git a/src/components/Metrics/CollectingOverlay.jsx b/src/components/Metrics/CollectingOverlay.jsx new file mode 100644 index 000000000..5c136b783 --- /dev/null +++ b/src/components/Metrics/CollectingOverlay.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Box, CircularProgress, Typography } from '@mui/material'; + +// Centered "collecting data" overlay shown over a chart until it has enough +// samples to draw. The spinner signals that polling is actively in progress +// rather than the chart being broken or empty. Absolutely positioned to sit on +// top of the (empty) canvas without shifting layout. +function CollectingOverlay({ label = 'Collecting data…' }) { + return ( + + + + {label} + + + ); +} + +CollectingOverlay.propTypes = { + label: PropTypes.string, +}; + +export default CollectingOverlay; diff --git a/src/components/Metrics/CustomChartsDashboard.jsx b/src/components/Metrics/CustomChartsDashboard.jsx deleted file mode 100644 index 9aa0297ba..000000000 --- a/src/components/Metrics/CustomChartsDashboard.jsx +++ /dev/null @@ -1,334 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import PropTypes from 'prop-types'; -import { - Alert, - Box, - Button, - Card, - CardContent, - Chip, - Divider, - IconButton, - Stack, - Tooltip, - Typography, -} from '@mui/material'; -import { useTheme } from '@mui/material/styles'; -import { Plus, Trash2, ChartSpline } from 'lucide-react'; -import MetricChart from './MetricChart'; -import AddSeriesField from './AddSeriesField'; -import { seriesColor } from './colors'; -import { PRESETS } from './presets'; -import { useMetricsHistory } from '../../hooks/useMetricsHistory'; -import { useScrollToId } from '../../hooks/useScrollToId'; -import { - listSeries, - seriesLabel, - buildSeriesKey, - indexTypesByName, - detectUnit, - isCounter, -} from '../../lib/metrics-parser'; - -// todo (ai agents have to ignore this!): do the same for all hard-coded storage keys -const STORAGE_KEY = 'metricsDashboard.charts'; -const POLL_INTERVAL_MS = 5000; -const MAX_POINTS = 120; // ~10 minutes at a 5s interval - -// todo: looks like a good candidate to move to helpers and becoming a function (and maybe rename if -// moved, to express better what type of id it returns -const newId = () => - typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `c-${Date.now()}-${Math.random()}`; - -const loadCharts = () => { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -}; - -// A chart's series carry only { key, name, labels }; the human label is derived -// on render so it stays consistent with the parser's formatting. -const makeSeries = (name, labels = {}) => ({ key: buildSeriesKey(name, labels), name, labels }); - -// Series can share a chart only if they'd share a meaningful Y axis: the same -// display unit (bytes / seconds / count) and the same plotting kind (a gauge is -// drawn raw, a counter as a per-second rate). This groups them into a single -// compatibility bucket used to filter the in-chart "add metric" options. -const compatKey = (name, type) => `${detectUnit(name)}:${isCounter(type) ? 'rate' : 'raw'}`; - -// NOTE: This is the earlier user-built "custom charts" dashboard — a metric -// search bar, presets, and per-chart series editing, persisted to localStorage. -// It's kept in the repo but is no longer rendered on the Metrics page, which now -// shows a fixed set of auto-created preset panels (see MetricsDashboard.jsx). - -// DOM id for a chart card, so a freshly added chart can be scrolled into view. -const chartElementId = (chartId) => `metrics-chart-${chartId}`; - -function CustomChartsDashboard({ embedded = false }) { - const theme = useTheme(); - const [charts, setCharts] = useState(loadCharts); - // Id of a just-added chart to scroll to once it mounts (cleared after). - const [scrollToId, setScrollToId] = useState(null); - const clearScrollTo = useCallback(() => setScrollToId(null), []); - useScrollToId(scrollToId, { onScrolled: clearScrollTo }); - - // Persist the dashboard layout so it survives reloads - useEffect(() => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(charts)); - } catch { - /* ignore quota / private-mode failures */ - } - }, [charts]); - - // Every distinct series key referenced by any chart — the set we accumulate - // history for. - const subscribedKeys = useMemo(() => { - const keys = new Set(); - charts.forEach((chart) => chart.series.forEach((s) => keys.add(s.key))); - return [...keys]; - }, [charts]); - - const { snapshot, history, loading, error } = useMetricsHistory({ - subscribedKeys, - intervalMs: POLL_INTERVAL_MS, - maxPoints: MAX_POINTS, - }); - - const availableSeries = useMemo(() => listSeries(snapshot), [snapshot]); - const typesByName = useMemo(() => indexTypesByName(snapshot), [snapshot]); - - const addChart = useCallback((title, series) => { - const id = newId(); - setCharts((prev) => [...prev, { id, title, series }]); - setScrollToId(chartElementId(id)); - }, []); - - // Create one chart from a list of staged series (the top-bar builder). The - // title is the metric name, or " +N" when several distinct metrics are - // combined, so a multi-series chart still reads clearly in its header. - const createChart = useCallback( - (seriesList) => { - if (!seriesList?.length) return; - const names = [...new Set(seriesList.map((s) => s.name))]; - const title = names.length === 1 ? names[0] : `${names[0]} +${names.length - 1}`; - addChart( - title, - seriesList.map((s) => makeSeries(s.name, s.labels)) - ); - }, - [addChart] - ); - - const addPreset = useCallback((preset) => { - const created = preset.charts.map((chart) => ({ - id: newId(), - title: chart.title, - series: chart.metrics.map((name) => makeSeries(name)), - })); - setCharts((prev) => [...prev, ...created]); - if (created.length) setScrollToId(chartElementId(created[created.length - 1].id)); - }, []); - - const removeChart = useCallback((chartId) => { - setCharts((prev) => prev.filter((chart) => chart.id !== chartId)); - }, []); - - const addSeriesToChart = useCallback((chartId, series) => { - setCharts((prev) => - prev.map((chart) => { - if (chart.id !== chartId) return chart; - if (chart.series.some((s) => s.key === series.key)) return chart; // no duplicates - return { ...chart, series: [...chart.series, makeSeries(series.name, series.labels)] }; - }) - ); - }, []); - - const removeSeriesFromChart = useCallback((chartId, key) => { - setCharts((prev) => - prev.map((chart) => - chart.id === chartId ? { ...chart, series: chart.series.filter((s) => s.key !== key) } : chart - ) - ); - }, []); - - return ( - - {/* Page header — hidden when embedded under another dashboard. */} - {!embedded && ( - - - - Metrics - - - Live cluster metrics, sampled every {POLL_INTERVAL_MS / 1000}s. Build your own charts or start from a - preset. - - - - )} - - {/* Presets */} - - {PRESETS.map((preset) => ( - - ))} - - - {/* Add-chart bar */} - - - - - {error && ( - - {error} - - )} - - {/* Charts */} - {charts.length === 0 ? ( - - ) : ( - - {charts.map((chart) => { - const chartSeries = chart.series.map((s) => ({ - ...s, - label: seriesLabel(s), - type: typesByName[s.name] || '', - })); - // Options for this chart's adder: drop series already on the chart, - // and — once the chart has at least one series — keep only those - // that share its unit and gauge/counter kind. An empty chart offers - // everything. - const existingKeys = new Set(chart.series.map((s) => s.key)); - const chartCompat = chart.series.length - ? compatKey(chart.series[0].name, typesByName[chart.series[0].name]) - : null; - const addOptions = availableSeries.filter( - (option) => - !existingKeys.has(option.key) && - (chartCompat === null || compatKey(option.name, option.type) === chartCompat) - ); - return ( - t.spacing(10) }} - > - - {/* todo: move styles in `sx` if possible */} - - {chart.title} - - - removeChart(chart.id)} aria-label="Remove chart"> - - - - - - - - - - {/* Series chips (left) double as the chart legend — each - filled with its line's color (matched by position). The - "+" adder is pinned to the bottom-right corner. */} - - - {chartSeries.map((s, i) => { - const { main, contrastText } = seriesColor(theme, i); - return ( - removeSeriesFromChart(chart.id, s.key)} - sx={{ - maxWidth: 320, - bgcolor: main, - color: contrastText, - '& .MuiChip-deleteIcon': { - color: contrastText, - opacity: 0.7, - '&:hover': { opacity: 1, color: contrastText }, - }, - }} - /> - ); - })} - - - addSeriesToChart(chart.id, series)} - placeholder="Add a metric…" - /> - - - - - ); - })} - - )} - - ); -} - -CustomChartsDashboard.propTypes = { - // When true, the component's own page header is hidden so it can be embedded - // beneath another dashboard. - embedded: PropTypes.bool, -}; - -function EmptyState({ loading }) { - return ( - - - - No charts yet - - {loading - ? 'Connecting to the metrics endpoint…' - : 'Add a preset above, or search for a metric to create your first chart.'} - - - - ); -} - -EmptyState.propTypes = { - loading: PropTypes.bool, -}; - -export default CustomChartsDashboard; diff --git a/src/components/Metrics/LatencyHeatmap.jsx b/src/components/Metrics/LatencyHeatmap.jsx index 8802a1a35..dc0dc1827 100644 --- a/src/components/Metrics/LatencyHeatmap.jsx +++ b/src/components/Metrics/LatencyHeatmap.jsx @@ -5,6 +5,7 @@ import { useTheme } from '@mui/material/styles'; import Chart from 'chart.js/auto'; import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'; import { formatValue } from '../../lib/metrics-parser'; +import CollectingOverlay from './CollectingOverlay'; // The matrix chart type isn't part of chart.js/auto, so register it once. Chart.register(MatrixController, MatrixElement); @@ -216,13 +217,7 @@ const LatencyHeatmap = ({ buckets, history }) => { <> - {!matrix.hasData && ( - - - Collecting data… - - - )} + {!matrix.hasData && } {/* Color-scale legend: gradient from the low to the high cell color, with diff --git a/src/components/Metrics/MetricBarChart.jsx b/src/components/Metrics/MetricBarChart.jsx deleted file mode 100644 index d54aae3a5..000000000 --- a/src/components/Metrics/MetricBarChart.jsx +++ /dev/null @@ -1,93 +0,0 @@ -import React, { useEffect, useMemo, useRef } from 'react'; -import PropTypes from 'prop-types'; -import { Box, Typography } from '@mui/material'; -import { useTheme } from '@mui/material/styles'; -import Chart from 'chart.js/auto'; -import { formatValue } from '../../lib/metrics-parser'; -import { seriesColor } from './colors'; - -// Horizontal bar chart of a single value per category (e.g. total requests per -// endpoint). Categories can have long names, hence the horizontal layout. -const MetricBarChart = ({ labels, values, unit = 'number' }) => { - const theme = useTheme(); - const canvasRef = useRef(null); - const chartRef = useRef(null); - const labelsSig = useMemo(() => labels.join('|'), [labels]); - - // (Re)create when the categories or theme change. - useEffect(() => { - if (!canvasRef.current) return undefined; - const gridColor = theme.palette.divider; - const textColor = theme.palette.text.secondary; - const chart = new Chart(canvasRef.current.getContext('2d'), { - type: 'bar', - data: { - labels, - datasets: [ - { - data: values, - backgroundColor: seriesColor(theme, 0).main, - borderRadius: 4, - maxBarThickness: 24, - }, - ], - }, - options: { - indexAxis: 'y', - responsive: true, - maintainAspectRatio: false, - animation: false, - plugins: { - legend: { display: false }, - tooltip: { callbacks: { label: (ctx) => formatValue(ctx.parsed.x, unit) } }, - }, - scales: { - x: { - grid: { color: gridColor }, - border: { display: false }, - ticks: { color: textColor, maxTicksLimit: 5, callback: (value) => formatValue(value, unit) }, - }, - y: { grid: { display: false }, ticks: { color: textColor, autoSkip: false } }, - }, - }, - }); - chartRef.current = chart; - return () => { - chart.destroy(); - chartRef.current = null; - }; - }, [labelsSig, theme.palette.mode, unit]); - - // Feed new values without rebuilding. - useEffect(() => { - const chart = chartRef.current; - if (!chart) return; - chart.data.labels = labels; - chart.data.datasets[0].data = values; - chart.update('none'); - }, [labels, values]); - - const height = Math.max(160, labels.length * 34); - const hasData = values.some((v) => v != null); - - return ( - - - {!hasData && ( - - - Collecting data… - - - )} - - ); -}; - -MetricBarChart.propTypes = { - labels: PropTypes.arrayOf(PropTypes.string).isRequired, - values: PropTypes.arrayOf(PropTypes.number).isRequired, - unit: PropTypes.string, -}; - -export default MetricBarChart; diff --git a/src/components/Metrics/MetricChart.jsx b/src/components/Metrics/MetricChart.jsx index fd587ff73..7a8bcf6d5 100644 --- a/src/components/Metrics/MetricChart.jsx +++ b/src/components/Metrics/MetricChart.jsx @@ -1,10 +1,11 @@ import React, { useEffect, useMemo, useRef } from 'react'; import PropTypes from 'prop-types'; -import { Box, Typography } from '@mui/material'; +import { Box } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import Chart from 'chart.js/auto'; import { formatValue, detectUnit, isCounter, toRatePerSecond } from '../../lib/metrics-parser'; import { seriesColor } from './colors'; +import CollectingOverlay from './CollectingOverlay'; const formatTick = (t) => new Date(t).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); @@ -14,7 +15,16 @@ const formatTick = (t) => // Pass `aggregate` to collapse every series into one line: the counters are // summed per timestamp and rated once — i.e. `rate(sum(...))`, the same as // dropping the per-request labels and treating them as one series. -const MetricChart = ({ series, history = [], aggregate = false, aggregateLabel = 'Total' }) => { +// Pass `showLegend` to render chart.js's built-in legend below the chart — +// useful when several distinct series share it (e.g. memory, disk read/write). +const MetricChart = ({ + series, + history = [], + aggregate = false, + aggregateLabel = 'Total', + showLegend = false, + beginAtZero = false, +}) => { const theme = useTheme(); const canvasRef = useRef(null); const chartRef = useRef(null); @@ -96,9 +106,13 @@ const MetricChart = ({ series, history = [], aggregate = false, aggregateLabel = animation: false, interaction: { mode: 'index', intersect: false }, plugins: { - // The custom table legend below (or the dashboard's chips) is the - // legend; chart.js's own legend stays off. - legend: { display: false }, + legend: showLegend + ? { + display: true, + position: 'bottom', + labels: { color: textColor, boxWidth: 12, boxHeight: 12, usePointStyle: true, padding: 16 }, + } + : { display: false }, tooltip: { callbacks: { label: (ctx) => @@ -114,6 +128,9 @@ const MetricChart = ({ series, history = [], aggregate = false, aggregateLabel = y: { grid: { color: gridColor }, border: { display: false }, + // Rates and other non-negative metrics anchor the axis at zero so the + // line never dips below it (e.g. CPU usage can't be negative). + min: beginAtZero ? 0 : undefined, ticks: { color: textColor, maxTicksLimit: 5, @@ -128,7 +145,7 @@ const MetricChart = ({ series, history = [], aggregate = false, aggregateLabel = chart.destroy(); chartRef.current = null; }; - }, [seriesSignature, theme.palette.mode, aggregate, aggregateLabel]); + }, [seriesSignature, theme.palette.mode, aggregate, aggregateLabel, showLegend, beginAtZero]); // Feed the accumulated history into the existing chart on every poll, and // apply per-series visibility toggled from the legend. @@ -149,22 +166,7 @@ const MetricChart = ({ series, history = [], aggregate = false, aggregateLabel = <> - {!hasData && ( - - - Collecting data… - - - )} + {!hasData && } ); @@ -182,6 +184,8 @@ MetricChart.propTypes = { history: PropTypes.array, aggregate: PropTypes.bool, aggregateLabel: PropTypes.string, + showLegend: PropTypes.bool, + beginAtZero: PropTypes.bool, }; export default MetricChart; diff --git a/src/components/Metrics/MetricsDashboard.jsx b/src/components/Metrics/MetricsDashboard.jsx index fd843c410..9588876d1 100644 --- a/src/components/Metrics/MetricsDashboard.jsx +++ b/src/components/Metrics/MetricsDashboard.jsx @@ -5,10 +5,12 @@ import LatencyHeatmap from './LatencyHeatmap'; import StatTile from './StatTile'; import PanelCard from './PanelCard'; import MetricsScope from './MetricsScope'; +import PollIntervalSelect from './PollIntervalSelect'; import { useClient } from '../../context/client-context'; import { useMetricsHistory } from '../../hooks/useMetricsHistory'; import { listSeries, indexByKey, seriesLabel } from '../../lib/metrics-parser'; +// Default polling cadence; the user can change it live (see PollIntervalSelect). const POLL_INTERVAL_MS = 5000; // The timeline grows for the whole session; this is only a memory safety limit // (beyond it the oldest history is decimated — see useMetricsHistory). @@ -22,6 +24,14 @@ const REQUEST_TILES = [ { label: 'Total requests', stat: 'total' }, ]; +// Big-number tiles for the Memory & CPU tab, keyed by `resourceStats` fields. +const RESOURCE_TILES = [ + { label: 'Resident memory', stat: 'residentMemory', unit: 'bytes' }, + { label: 'CPU cores used', stat: 'cpu' }, + { label: 'Open file descriptors', stat: 'openFds' }, + { label: 'Threads', stat: 'threads' }, +]; + const sumKeys = (values, keys) => keys.reduce((acc, k) => acc + (typeof values?.[k] === 'number' ? values[k] : 0), 0); const hasCollectionLabel = (entries) => entries.some((s) => s.labels.collection !== undefined); @@ -66,16 +76,20 @@ function MetricsDashboard() { const [scope, setScope] = useState('global'); const [collection, setCollection] = useState(''); const [collections, setCollections] = useState([]); + const [pollInterval, setPollInterval] = useState(POLL_INTERVAL_MS); const { client: qdrantClient } = useClient(); - const perCollection = scope === 'collection'; + // The Memory & CPU tab is instance-wide, so it must never poll in per-collection + // mode — even if the user left the scope on "Per collection" on another tab. The + // scope selection itself is preserved and takes effect again on the request tabs. + const perCollection = scope === 'collection' && currentTab !== 'resources'; // Only filter once a collection is picked, so the panels never silently show // instance-wide numbers under a collection heading. const activeCollection = perCollection ? collection : ''; const { snapshot, history, error } = useMetricsHistory({ recordAll: true, - intervalMs: POLL_INTERVAL_MS, + intervalMs: pollInterval, maxPoints: MAX_STORED_POINTS, perCollection, }); @@ -181,6 +195,23 @@ function MetricsDashboard() { }; }, [restSeries, all, latest, requestsHistory, activeCollection]); + // --- Memory & CPU tab --- + // All instance-wide, so this tab ignores the collection scope. Memory is the + // active-pages gauge; CPU is `cpu_cores_used`, a gauge of fractional cores in + // use (newer Qdrant), plotted raw. + const memorySeries = useMemo(() => all.filter((s) => s.name === 'memory_active_bytes'), [all]); + const cpuSeries = useMemo(() => all.filter((s) => s.name === 'cpu_cores_used'), [all]); + + const resourceStats = useMemo( + () => ({ + residentMemory: latest.memory_resident_bytes, + cpu: latest.cpu_cores_used, + openFds: latest.process_open_fds, + threads: latest.process_threads, + }), + [latest] + ); + return ( @@ -188,7 +219,7 @@ function MetricsDashboard() { Metrics - Live cluster metrics from the Qdrant /metrics endpoint, sampled every {POLL_INTERVAL_MS / 1000}s. + Live cluster metrics from the Qdrant /metrics endpoint. @@ -212,15 +243,21 @@ function MetricsDashboard() { - - - + {/* Poll-interval selector is always shown; the scope control applies only + to per-collection tabs (Memory & CPU is instance-wide), so it's hidden + there. */} + + + {currentTab !== 'resources' && ( + + )} + {currentTab === 'requests' && perCollection && !collection && ( @@ -267,6 +304,26 @@ function MetricsDashboard() { )} + + {currentTab === 'resources' && ( + + + {RESOURCE_TILES.map((tile) => ( + + + + ))} + + + + + + + + + + + )} ); } diff --git a/src/components/Metrics/MetricsScope.jsx b/src/components/Metrics/MetricsScope.jsx index 3de9023b0..7ca9db53a 100644 --- a/src/components/Metrics/MetricsScope.jsx +++ b/src/components/Metrics/MetricsScope.jsx @@ -27,7 +27,9 @@ function MetricsScope({ scope, onScopeChange, collection, collections, onCollect fontSize: '0.8125rem', lineHeight: 1.4, px: 1.5, - py: 0.5, + // Match the poll-interval / collection field height (~33.7px) so the + // controls line up on one row. + py: '6.75px', color: 'text.secondary', borderColor: 'divider', '&.Mui-selected': { @@ -49,7 +51,9 @@ function MetricsScope({ scope, onScopeChange, collection, collections, onCollect error={!collection} sx={{ minWidth: 180, - // Match the toggle's height/type so the two controls line up. + // Match the toggle's height/type and radius (4px) so the controls + // line up; overrides the app's larger default select radius. + '& .MuiOutlinedInput-root': { borderRadius: 1 }, '& .MuiSelect-select': { py: '7.5px', minHeight: 'auto', fontSize: '0.8125rem', lineHeight: 1.4 }, }} > diff --git a/src/components/Metrics/PollIntervalSelect.jsx b/src/components/Metrics/PollIntervalSelect.jsx new file mode 100644 index 000000000..1be56f9a6 --- /dev/null +++ b/src/components/Metrics/PollIntervalSelect.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Box, FormControl, MenuItem, Select } from '@mui/material'; + +// How often the dashboard polls /metrics. Kept short so the whole timeline stays +// responsive; the fastest sensible cadence is 1s (Qdrant serves point-in-time +// metrics, so polling faster gains nothing). +export const POLL_INTERVAL_OPTIONS = [ + { label: '1s', value: 1000 }, + { label: '5s', value: 5000 }, + { label: '10s', value: 10000 }, + { label: '30s', value: 30000 }, + { label: '1m', value: 60000 }, +]; + +export const DEFAULT_POLL_INTERVAL_MS = 5000; + +// Picks the polling cadence. Styled to match the scope control it sits beside: +// same compact height and type scale. +function PollIntervalSelect({ value, onChange }) { + return ( + + + + ); +} + +PollIntervalSelect.propTypes = { + value: PropTypes.number.isRequired, + onChange: PropTypes.func.isRequired, +}; + +export default PollIntervalSelect; diff --git a/src/components/Metrics/presets.js b/src/components/Metrics/presets.js deleted file mode 100644 index 4521272d9..000000000 --- a/src/components/Metrics/presets.js +++ /dev/null @@ -1,34 +0,0 @@ -// Preset charts offered as one-click buttons on the Metrics dashboard. -// -// Presets reference label-free gauge metrics, whose series key is simply the -// metric name, so they resolve reliably regardless of the labels a particular -// deployment emits. Metrics that aren't present in the current response just -// render as an empty series until data arrives. -export const PRESETS = [ - { - id: 'memory', - label: 'Memory', - charts: [ - { - title: 'Memory usage', - metrics: [ - 'memory_resident_bytes', - 'memory_allocated_bytes', - 'memory_active_bytes', - 'memory_retained_bytes', - 'memory_metadata_bytes', - ], - }, - ], - }, - { - id: 'collections', - label: 'Collections', - charts: [ - { - title: 'Collections & pending operations', - metrics: ['collections_total', 'collections_vector_total', 'pending_operations'], - }, - ], - }, -]; diff --git a/src/lib/metrics-parser.js b/src/lib/metrics-parser.js index ef39a0ddd..a6f4cf55c 100644 --- a/src/lib/metrics-parser.js +++ b/src/lib/metrics-parser.js @@ -133,17 +133,6 @@ export const indexByKey = (metrics) => { return index; }; -// Index a parsed metrics map as metricName -> Prometheus type ('gauge', -// 'counter', …). The type is declared per metric name, so it applies to every -// labelled series of that metric. -export const indexTypesByName = (metrics) => { - const types = {}; - for (const metric of Object.values(metrics || {})) { - types[metric.name] = metric.type; - } - return types; -}; - // Counters are cumulative, so the meaningful quantity to plot is their rate of // change per second rather than the raw total. Gauges are plotted as-is. export const isCounter = (type) => type === 'counter'; diff --git a/src/lib/tests/metrics-parser.test.js b/src/lib/tests/metrics-parser.test.js index ec0d06e6d..860ada313 100644 --- a/src/lib/tests/metrics-parser.test.js +++ b/src/lib/tests/metrics-parser.test.js @@ -4,7 +4,6 @@ import { buildSeriesKey, listSeries, indexByKey, - indexTypesByName, isCounter, toRatePerSecond, detectUnit, @@ -110,13 +109,7 @@ describe('listSeries / indexByKey', () => { }); }); -describe('indexTypesByName / isCounter', () => { - it('maps metric name to its declared Prometheus type', () => { - const types = indexTypesByName(parsePrometheus(SAMPLE)); - expect(types.collections_total).toBe('gauge'); - expect(types.rest_responses_total).toBe('counter'); - }); - +describe('isCounter', () => { it('isCounter only accepts the counter type', () => { expect(isCounter('counter')).toBe(true); expect(isCounter('gauge')).toBe(false); diff --git a/src/mocks/data.js b/src/mocks/data.js index 0dedb7818..6d459cf8a 100644 --- a/src/mocks/data.js +++ b/src/mocks/data.js @@ -176,8 +176,32 @@ export const makeMetrics = ({ clusterEnabled = false, version = '1.15.1', perCol (e) => `grpc_responses_avg_duration_seconds{endpoint="${e.endpoint}"} ${latSeconds(e.lat[0], e.lat[1], e.lat[2])}` ); + // Per-collection hardware counters (CPU + disk I/O) that a real instance emits. + // The dashboard doesn't chart these, but the mock keeps them so it mirrors the + // real /metrics payload. Each always carries an `id` (collection) label. + const hwScale = [1, 0.5, 0.2]; + const hwMetric = (name, help, base, rate) => + block( + name, + help, + 'counter', + COLLECTIONS.map((col, i) => { + const scale = hwScale[i] ?? 0.1; + return `${name}{id="${col}"} ${counter(Math.round(base * scale), rate * scale)}`; + }) + ); + return [ block('app_info', 'information about qdrant server', 'gauge', [`app_info{name="qdrant",version="${version}"} 1`]), + // Average fractional CPU cores used by the process over ~the last 2s (a gauge, + // emitted alongside app_info by newer Qdrant — see qdrant/qdrant#10243). A real + // instance omits it when unavailable; the mock always reports it. Non-negative. + block( + 'cpu_cores_used', + 'average number of CPU cores used by this process over roughly the last two seconds', + 'gauge', + [`cpu_cores_used ${(1.4 + 0.5 * Math.sin((t / 45) * 2 * Math.PI)).toFixed(3)}`] + ), block('cluster_enabled', 'is cluster support enabled', 'gauge', [`cluster_enabled ${clusterEnabled ? 1 : 0}`]), block('cluster_peers_total', 'total number of cluster peers', 'gauge', [ `cluster_peers_total ${clusterEnabled ? 3 : 1}`, @@ -209,6 +233,32 @@ export const makeMetrics = ({ clusterEnabled = false, version = '1.15.1', perCol block('rest_responses_duration_seconds', 'response duration histogram', 'histogram', restHistogram), block('grpc_responses_total', 'total number of responses through gRPC API', 'counter', grpcTotals), block('grpc_responses_avg_duration_seconds', 'average response duration in gRPC API', 'gauge', grpcLatency), + + // --- process / OS (instance-wide) --- + block('process_threads', 'count of active threads', 'gauge', [`process_threads ${wobble(24, 3, 30)}`]), + block('process_open_fds', 'count of currently open file descriptors', 'gauge', [ + `process_open_fds ${wobble(41, 6, 25)}`, + ]), + block('process_max_fds', 'limit for open file descriptors', 'gauge', ['process_max_fds 1048576']), + + // --- per-collection hardware (CPU + disk I/O), not charted by the UI --- + hwMetric('collection_hardware_metric_cpu', 'CPU measurements of a collection', 5000, 40), + hwMetric('collection_hardware_metric_payload_io_read', 'total IO payload read of a collection', 8000, 120), + hwMetric('collection_hardware_metric_payload_io_write', 'total IO payload write of a collection', 3000, 45), + hwMetric( + 'collection_hardware_metric_payload_index_io_read', + 'total IO payload index read of a collection', + 2000, + 30 + ), + hwMetric( + 'collection_hardware_metric_payload_index_io_write', + 'total IO payload index write of a collection', + 1000, + 15 + ), + hwMetric('collection_hardware_metric_vector_io_read', 'total IO vector read of a collection', 12000, 200), + hwMetric('collection_hardware_metric_vector_io_write', 'total IO vector write of a collection', 4000, 60), ].join('\n'); }; From 6983c08fd8dfe13352f4de3802cfc12af187d3bf Mon Sep 17 00:00:00 2001 From: trean Date: Mon, 17 Aug 2026 18:23:55 +0200 Subject: [PATCH 5/9] remove collections tab from metrics page --- src/components/Metrics/MetricsDashboard.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Metrics/MetricsDashboard.jsx b/src/components/Metrics/MetricsDashboard.jsx index 9588876d1..9569deae2 100644 --- a/src/components/Metrics/MetricsDashboard.jsx +++ b/src/components/Metrics/MetricsDashboard.jsx @@ -240,7 +240,6 @@ function MetricsDashboard() { > setCurrentTab(tab)} aria-label="Metrics tabs"> - {/* Poll-interval selector is always shown; the scope control applies only From 527dd461eb20fcb5ebcae3f97742a01298a02213 Mon Sep 17 00:00:00 2001 From: trean Date: Mon, 7 Sep 2026 16:24:41 +0200 Subject: [PATCH 6/9] asked agent to actualize comments --- src/components/Metrics/MetricsDashboard.jsx | 9 +++++---- src/components/Metrics/colors.js | 8 +------- src/lib/metrics-parser.js | 1 - 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/components/Metrics/MetricsDashboard.jsx b/src/components/Metrics/MetricsDashboard.jsx index 9569deae2..45c194bba 100644 --- a/src/components/Metrics/MetricsDashboard.jsx +++ b/src/components/Metrics/MetricsDashboard.jsx @@ -67,10 +67,11 @@ const formatLe = (sec) => { const toChartSeries = (entries) => entries.map((s) => ({ key: s.key, name: s.name, labels: s.labels, label: seriesLabel(s), type: s.type })); -// The Metrics page: a fixed set of panels, auto-populated from Qdrant's -// /metrics endpoint with no user interaction. Panel types mirror Qdrant's -// Grafana dashboards (github.com/qdrant/prometheus-monitoring), bound to the -// metrics a self-hosted instance actually exposes. +// The Metrics page: panels grouped into Requests and Memory & CPU tabs, +// auto-populated from Qdrant's /metrics endpoint and optionally scoped to a +// single collection. Panel types mirror Qdrant's Grafana dashboards +// (github.com/qdrant/prometheus-monitoring), bound to the metrics a self-hosted +// instance actually exposes. function MetricsDashboard() { const [currentTab, setCurrentTab] = useState('requests'); const [scope, setScope] = useState('global'); diff --git a/src/components/Metrics/colors.js b/src/components/Metrics/colors.js index 198037a9e..af850b98c 100644 --- a/src/components/Metrics/colors.js +++ b/src/components/Metrics/colors.js @@ -1,11 +1,5 @@ // Series colors for the Metrics charts, drawn from the MUI theme's semantic -// palette so they match the rest of the app and adapt to light/dark mode. A -// series' chart line and its chip share the same color, assigned by position. -// Ordered to keep adjacent series visually distinct (the two blue-ish entries -// come last, so they only appear on charts with five or more series). -// -// The full palette entry is returned (not just `.main`) so consumers can use -// its ready-made `.contrastText` for legible chip labels. +// palette so they match the rest of the app and adapt to light/dark mode. export const seriesPalette = (theme) => [ theme.palette.primary, theme.palette.error, diff --git a/src/lib/metrics-parser.js b/src/lib/metrics-parser.js index a6f4cf55c..f9bb16508 100644 --- a/src/lib/metrics-parser.js +++ b/src/lib/metrics-parser.js @@ -28,7 +28,6 @@ export const buildSeriesKey = (name, labels) => { .sort(([a], [b]) => a.localeCompare(b)) .map(([k, v]) => `${k}="${v}"`) .join(','); - // console.log(`${name}{${inner}}`) return `${name}{${inner}}`; }; From 3294ce3d576687373b3cb0831ad8549fa671b612 Mon Sep 17 00:00:00 2001 From: trean Date: Mon, 7 Sep 2026 16:24:58 +0200 Subject: [PATCH 7/9] audit fix --- package-lock.json | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 26a0698cd..5e1ed626e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3912,9 +3912,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.20", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", - "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -3957,9 +3957,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.8", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", - "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "funding": [ { "type": "opencollective", @@ -3976,11 +3976,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.12", - "caniuse-lite": "^1.0.30001809", - "electron-to-chromium": "^1.5.402", - "node-releases": "^2.0.53", - "update-browserslist-db": "^1.3.0" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -4790,9 +4790,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.420", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", - "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", "license": "ISC" }, "node_modules/emoji-regex": { From d5aa347cb9867640f08fd011aad0dd3d33d667de Mon Sep 17 00:00:00 2001 From: trean Date: Mon, 7 Sep 2026 16:41:21 +0200 Subject: [PATCH 8/9] fixing comments --- src/components/Metrics/LatencyHeatmap.jsx | 10 +++++----- src/components/Metrics/MetricsDashboard.jsx | 5 ++--- src/components/Metrics/StatTile.jsx | 2 +- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/components/Metrics/LatencyHeatmap.jsx b/src/components/Metrics/LatencyHeatmap.jsx index dc0dc1827..af94e0487 100644 --- a/src/components/Metrics/LatencyHeatmap.jsx +++ b/src/components/Metrics/LatencyHeatmap.jsx @@ -56,11 +56,11 @@ const fmtRate = (v) => { return `${n}/s`; }; -// The Grafana "Latency Distribution" heatmap: Y = response-time buckets, X = -// time, cell color = the per-second rate of requests landing in that latency -// band. Reads Qdrant's `*_responses_duration_seconds` Prometheus histogram — -// `buckets` are the `le` groups (ascending) with the series keys per bucket, and -// history holds their cumulative counts, which we un-cumulate and rate here. +// A latency-distribution heatmap: Y = response-time buckets, X = time, cell +// color = the per-second rate of requests landing in that latency band. Reads +// Qdrant's `*_responses_duration_seconds` Prometheus histogram — `buckets` are +// the `le` groups (ascending) with the series keys per bucket, and history holds +// their cumulative counts, which we un-cumulate and rate here. const LatencyHeatmap = ({ buckets, history }) => { const theme = useTheme(); const canvasRef = useRef(null); diff --git a/src/components/Metrics/MetricsDashboard.jsx b/src/components/Metrics/MetricsDashboard.jsx index 45c194bba..ab3884f29 100644 --- a/src/components/Metrics/MetricsDashboard.jsx +++ b/src/components/Metrics/MetricsDashboard.jsx @@ -69,9 +69,8 @@ const toChartSeries = (entries) => // The Metrics page: panels grouped into Requests and Memory & CPU tabs, // auto-populated from Qdrant's /metrics endpoint and optionally scoped to a -// single collection. Panel types mirror Qdrant's Grafana dashboards -// (github.com/qdrant/prometheus-monitoring), bound to the metrics a self-hosted -// instance actually exposes. +// single collection, bound to the metrics a self-hosted instance actually +// exposes. function MetricsDashboard() { const [currentTab, setCurrentTab] = useState('requests'); const [scope, setScope] = useState('global'); diff --git a/src/components/Metrics/StatTile.jsx b/src/components/Metrics/StatTile.jsx index fdc1bb700..8fe195201 100644 --- a/src/components/Metrics/StatTile.jsx +++ b/src/components/Metrics/StatTile.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { Card, Typography } from '@mui/material'; import { formatValue } from '../../lib/metrics-parser'; -// A single big-number tile, mirroring Grafana's "stat" panels. +// A single big-number tile for a headline metric value. function StatTile({ label, value, unit = 'number' }) { return ( From 368f87ac2a0f2600c142ffa2e3d167535424b70a Mon Sep 17 00:00:00 2001 From: trean Date: Mon, 7 Sep 2026 17:14:49 +0200 Subject: [PATCH 9/9] fixes --- src/components/Metrics/MetricChart.jsx | 33 ++++++++++++++++---------- src/hooks/useMetricsHistory.js | 6 +++-- src/mocks/data.js | 2 +- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/components/Metrics/MetricChart.jsx b/src/components/Metrics/MetricChart.jsx index 7a8bcf6d5..b0af0e2ef 100644 --- a/src/components/Metrics/MetricChart.jsx +++ b/src/components/Metrics/MetricChart.jsx @@ -49,20 +49,27 @@ const MetricChart = ({ return isCounter(s.type) ? toRatePerSecond(points) : points.map((point) => point.v); }; - // Aggregate: sum the raw counter values across all series at each timestamp - // into one synthetic counter, then take a single rate. This is exactly - // "erase the per-request labels and treat them as one series" — `rate(sum(x))`. - // Within a scope the series set is stable, so it equals `sum(rate(x))` but is - // simpler; toRatePerSecond still drops the first point and any counter reset. + // Aggregate: rate each series independently, then sum the rates at each + // timestamp — `sum(rate(x))`. Summing the raw counters first (`rate(sum(x))`) + // would be simpler but assumes the series set never changes: a label that only + // starts being reported mid-session would jump from an implicit 0 to its full + // count in one interval and spike the total. Rating per series first lets a + // newly-appearing (or reset) series contribute a gap on that step instead of a + // spike; a step is null only when no series has a value yet (the first point). const computeTotal = () => { - const summed = history.map((point) => ({ - t: point.t, - v: series.reduce((sum, s) => { - const value = point.values[s.key]; - return typeof value === 'number' ? sum + value : sum; - }, 0), - })); - return toRatePerSecond(summed); + const perSeries = series.map(computeData); + return history.map((_, i) => { + let sum = 0; + let any = false; + for (const data of perSeries) { + const v = data[i]; + if (typeof v === 'number') { + sum += v; + any = true; + } + } + return any ? sum : null; + }); }; const datasetsData = () => (aggregate ? [computeTotal()] : series.map(computeData)); diff --git a/src/hooks/useMetricsHistory.js b/src/hooks/useMetricsHistory.js index 34148c527..0b3ff0fe8 100644 --- a/src/hooks/useMetricsHistory.js +++ b/src/hooks/useMetricsHistory.js @@ -69,10 +69,12 @@ export const useMetricsHistory = ({ const mode = perCollection ? 'collection' : 'global'; // Resume this mode's buffer (empty on first visit); the other mode's buffer - // is left untouched. The snapshot is dropped so nothing reads the previous - // mode's series until the first response of the new one lands. + // is left untouched. The snapshot is dropped and any error from the previous + // mode cleared so nothing reads the previous mode's series — or shows its + // stale error banner — until the first response of the new one lands. setSnapshot(null); setLoading(true); + setError(null); setHistory(buffersRef.current[mode] || []); const tick = async () => { diff --git a/src/mocks/data.js b/src/mocks/data.js index 6d459cf8a..f22a1ca6c 100644 --- a/src/mocks/data.js +++ b/src/mocks/data.js @@ -206,7 +206,7 @@ export const makeMetrics = ({ clusterEnabled = false, version = '1.15.1', perCol block('cluster_peers_total', 'total number of cluster peers', 'gauge', [ `cluster_peers_total ${clusterEnabled ? 3 : 1}`, ]), - block('collections_total', 'number of collections', 'gauge', ['collections_total 1']), + block('collections_total', 'number of collections', 'gauge', [`collections_total ${COLLECTIONS.length}`]), block('collections_vector_total', 'total number of vectors in all collections', 'gauge', [ `collections_vector_total ${wobble(125000, 400, 45)}`, ]),