diff --git a/package-lock.json b/package-lock.json
index 882d17093..5e1ed626e 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",
@@ -3911,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"
@@ -3956,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",
@@ -3975,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"
@@ -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",
@@ -4779,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": {
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/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/LatencyHeatmap.jsx b/src/components/Metrics/LatencyHeatmap.jsx
new file mode 100644
index 000000000..af94e0487
--- /dev/null
+++ b/src/components/Metrics/LatencyHeatmap.jsx
@@ -0,0 +1,260 @@
+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';
+import CollectingOverlay from './CollectingOverlay';
+
+// The matrix chart type isn't part of chart.js/auto, so register it once.
+Chart.register(MatrixController, MatrixElement);
+
+// 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' });
+
+// 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`;
+};
+
+// 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);
+ 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(() => {
+ 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 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) => 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 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 = 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 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;
+ 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,
+ };
+ }, [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 gridColor = theme.palette.divider;
+ 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);
+ },
+ // 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,
+ },
+ ],
+ },
+ 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 && }
+
+
+ {/* 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/MetricChart.jsx b/src/components/Metrics/MetricChart.jsx
new file mode 100644
index 000000000..b0af0e2ef
--- /dev/null
+++ b/src/components/Metrics/MetricChart.jsx
@@ -0,0 +1,198 @@
+import React, { useEffect, useMemo, useRef } from 'react';
+import PropTypes from 'prop-types';
+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' });
+
+// A single time-series line chart rendering one or more metric series that
+// share an X axis (the poll timestamps accumulated by useMetricsHistory).
+// 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.
+// 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);
+
+ // 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);
+ };
+
+ // 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 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));
+
+ // 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;
+ const gridColor = theme.palette.divider;
+ const textColor = theme.palette.text.secondary;
+
+ const chart = new Chart(canvasRef.current.getContext('2d'), {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: lines.map((line, i) => ({
+ label: line.label,
+ data: [],
+ unit: line.unit,
+ rate: line.rate,
+ 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: {
+ legend: showLegend
+ ? {
+ display: true,
+ position: 'bottom',
+ labels: { color: textColor, boxWidth: 12, boxHeight: 12, usePointStyle: true, padding: 16 },
+ }
+ : { 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 },
+ // 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,
+ callback: (value) => `${formatValue(value, axisUnit)}${allRate ? '/s' : ''}`,
+ },
+ },
+ },
+ },
+ });
+ chartRef.current = chart;
+ return () => {
+ chart.destroy();
+ chartRef.current = null;
+ };
+ }, [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.
+ useEffect(() => {
+ const chart = chartRef.current;
+ if (!chart) return;
+ chart.data.labels = history.map((point) => formatTick(point.t));
+ datasetsData().forEach((data, i) => {
+ if (!chart.data.datasets[i]) return;
+ chart.data.datasets[i].data = data;
+ });
+ chart.update('none');
+ }, [history, seriesSignature, aggregate]);
+
+ const hasData = datasetsData().some((data) => data.some((v) => v != null));
+
+ return (
+ <>
+
+
+ {!hasData && }
+
+ >
+ );
+};
+
+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,
+ 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
new file mode 100644
index 000000000..ab3884f29
--- /dev/null
+++ b/src/components/Metrics/MetricsDashboard.jsx
@@ -0,0 +1,330 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { Alert, Box, Grid, Stack, Tab, Tabs, Typography } from '@mui/material';
+import MetricChart from './MetricChart';
+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).
+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' },
+];
+
+// 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);
+
+// 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';
+ if (sec < 1) return `${Math.round(sec * 1000)}ms`;
+ return `${sec}s`;
+};
+
+// 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: panels grouped into Requests and Memory & CPU tabs,
+// auto-populated from Qdrant's /metrics endpoint and optionally scoped to a
+// single collection, bound to the metrics a self-hosted instance actually
+// exposes.
+function MetricsDashboard() {
+ const [currentTab, setCurrentTab] = useState('requests');
+ const [scope, setScope] = useState('global');
+ const [collection, setCollection] = useState('');
+ const [collections, setCollections] = useState([]);
+ const [pollInterval, setPollInterval] = useState(POLL_INTERVAL_MS);
+ const { client: qdrantClient } = useClient();
+
+ // 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: pollInterval,
+ 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 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[] }
+ 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 }));
+ }, [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]);
+
+ // --- 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 (
+
+
+
+ Metrics
+
+
+ Live cluster metrics from the Qdrant /metrics endpoint.
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* 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">
+
+
+
+ {/* 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 && (
+ 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) => (
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {currentTab === 'resources' && (
+
+
+ {RESOURCE_TILES.map((tile) => (
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+export default MetricsDashboard;
diff --git a/src/components/Metrics/MetricsScope.jsx b/src/components/Metrics/MetricsScope.jsx
new file mode 100644
index 000000000..7ca9db53a
--- /dev/null
+++ b/src/components/Metrics/MetricsScope.jsx
@@ -0,0 +1,93 @@
+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,
+ // 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': {
+ 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/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/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/StatTile.jsx b/src/components/Metrics/StatTile.jsx
new file mode 100644
index 000000000..8fe195201
--- /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 for a headline metric value.
+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/components/Metrics/colors.js b/src/components/Metrics/colors.js
new file mode 100644
index 000000000..af850b98c
--- /dev/null
+++ b/src/components/Metrics/colors.js
@@ -0,0 +1,15 @@
+// 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.
+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/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 && (
{
+ 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:
+// every tick we timestamp the response and remember the value of each
+// subscribed series. `subscribedKeys` is read through a ref so the interval
+// 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.
+//
+// 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, 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 = 3000,
+ perCollection = false,
+} = {}) => {
+ 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;
+ 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 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 () => {
+ 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' },
+ params: perCollection ? { per_collection: true } : undefined,
+ });
+ if (!active) return;
+
+ const parsed = parsePrometheus(response.data);
+ const index = indexByKey(parsed);
+ let values;
+ if (recordAllRef.current) {
+ values = index;
+ } else {
+ values = {};
+ for (const key of keysRef.current) {
+ if (key in index) values[key] = index[key];
+ }
+ }
+
+ const prev = buffersRef.current[mode] || [];
+ const bounded = boundHistory([...prev, { t: Date.now(), values }], maxPoints);
+ buffersRef.current[mode] = bounded;
+
+ setSnapshot(parsed);
+ setError(null);
+ setHistory(bounded);
+ } 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, perCollection]);
+
+ 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..f9bb16508
--- /dev/null
+++ b/src/lib/metrics-parser.js
@@ -0,0 +1,209 @@
+// 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';
+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
+// 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;
+};
+
+// 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);
+ case 'percent':
+ return `${formatNumber(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) => {
+ // 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.
+// 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..860ada313
--- /dev/null
+++ b/src/lib/tests/metrics-parser.test.js
@@ -0,0 +1,182 @@
+import { describe, it, expect } from 'vitest';
+import {
+ parsePrometheus,
+ buildSeriesKey,
+ listSeries,
+ indexByKey,
+ 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);
+ });
+
+ 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', () => {
+ 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('isCounter', () => {
+ 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/mocks/data.js b/src/mocks/data.js
index 71177fac5..f22a1ca6c 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,
@@ -26,6 +29,239 @@ 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.
+// `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) =>
+ 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 = [
+ // 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',
+ 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,
+ },
+ ];
+ // 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(
+ e.lat[0],
+ e.lat[1],
+ e.lat[2]
+ )}`
+ );
+
+ // Prometheus histogram: cumulative `_bucket{le}` counts (+ `_sum`, `_count`)
+ // 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"
+ 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 = 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 = [
+ { 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 = 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])}`
+ );
+
+ // 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}`,
+ ]),
+ 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)}`,
+ ]),
+ 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),
+
+ // --- 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');
+};
+
// 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..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, 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`).
@@ -31,6 +39,14 @@ export const baseHandlers = [
ok(makeTelemetry({ hasApiKey: Boolean(request.headers.get('api-key')) }))
),
+ // Prometheus metrics (Metrics dashboard). Plain text, not the JSON envelope.
+ // `?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)),
@@ -51,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)),
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: },
],
},