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: }, ], },