Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
364 changes: 362 additions & 2 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-intl": "^10.1.1",
"recharts": "^3.10.1",
"shadcn": "^4.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
Expand Down
41 changes: 41 additions & 0 deletions src/api/metrics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";

import { __test__ } from "./metrics";

const { buildQuery, clampInt } = __test__;

describe("clampInt", () => {
it("passes through an in-range value", () => {
expect(clampInt(24, 1, 168)).toBe(24);
});

it("clamps to the server's bounds", () => {
expect(clampInt(0, 1, 168)).toBe(1);
expect(clampInt(9999, 1, 168)).toBe(168);
});

it("floors fractional values", () => {
expect(clampInt(24.9, 1, 168)).toBe(24);
});

it("drops values the server cannot parse", () => {
expect(clampInt(undefined, 1, 168)).toBeUndefined();
expect(clampInt(Number.NaN, 1, 168)).toBeUndefined();
expect(clampInt(Infinity, 1, 168)).toBeUndefined();
});
});

describe("buildQuery", () => {
it("returns an empty string when no params are given, deferring to the server defaults", () => {
expect(buildQuery({})).toBe("");
});

it("omits a param that clamped away and keeps the other", () => {
expect(buildQuery({ hours: 24, intervalMinutes: Number.NaN })).toBe("?hours=24");
expect(buildQuery({ intervalMinutes: 60 })).toBe("?interval_minutes=60");
});

it("serialises both params with the server's snake_case names", () => {
expect(buildQuery({ hours: 24, intervalMinutes: 60 })).toBe("?hours=24&interval_minutes=60");
});
});
63 changes: 63 additions & 0 deletions src/api/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Observability metrics endpoints.
*
* Both require `metrics:read`, enforced with `allow_admin_bypass=False` and
* `global_only=True`. Platform admins do not bypass it; only `platform_admin`
* and `platform_viewer` hold it by default, so a 403 is a routine outcome.
*/

import type { PercentilesResponse, TimeseriesResponse } from "@/types/metrics";

import { api } from "./client";

// Mirrors the Query() bounds in mcpgateway/routers/observability.py.
const HOURS_MIN = 1;
const HOURS_MAX = 168;
const INTERVAL_MIN = 5;
const INTERVAL_MAX = 1440;

export interface MetricsParams {
/** Time range in hours (1-168). Server defaults to 24. */
hours?: number;
/** Aggregation bucket size in minutes (5-1440). Server defaults to 60. */
intervalMinutes?: number;
signal?: AbortSignal;
}

function clampInt(value: number | undefined, min: number, max: number): number | undefined {
if (value === undefined || !Number.isFinite(value)) return undefined;
return Math.max(min, Math.min(max, Math.floor(value)));
}

function buildQuery(params: MetricsParams): string {
const search = new URLSearchParams();

const hours = clampInt(params.hours, HOURS_MIN, HOURS_MAX);
if (hours !== undefined) search.set("hours", hours.toString());

const interval = clampInt(params.intervalMinutes, INTERVAL_MIN, INTERVAL_MAX);
if (interval !== undefined) search.set("interval_minutes", interval.toString());

const query = search.toString();
return query ? `?${query}` : "";
}

export const metricsApi = {
/** Execution counts bucketed over time. Buckets are sparse. */
getTimeseries: (params: MetricsParams = {}): Promise<TimeseriesResponse> =>
api.get<TimeseriesResponse>(
`/observability/metrics/timeseries${buildQuery(params)}`,
undefined,
params.signal,
),

/** Latency percentiles (p50/p95/p99, ms) bucketed over time. Buckets are sparse. */
getPercentiles: (params: MetricsParams = {}): Promise<PercentilesResponse> =>
api.get<PercentilesResponse>(
`/observability/metrics/percentiles${buildQuery(params)}`,
undefined,
params.signal,
),
};

export const __test__ = { buildQuery, clampInt };
60 changes: 60 additions & 0 deletions src/components/dashboard/Sparkline.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
import { cloneElement, type ReactElement } from "react";
import { renderWithProviders } from "@/test/test-utils";

import { Sparkline } from "./Sparkline";
import { formatCount } from "./systemMetrics";
import type { SparklinePoint } from "./sparklineSeries";

// ResponsiveContainer measures its parent, which is 0x0 under jsdom, so the
// chart would render nothing. Only the sizing wrapper is replaced.
vi.mock("recharts", async () => {
const actual = await vi.importActual<typeof import("recharts")>("recharts");
return {
...actual,
ResponsiveContainer: ({ children }: { children: ReactElement }) =>
cloneElement(children as ReactElement<{ width?: number; height?: number }>, {
width: 200,
height: 37,
}),
};
});

function points(): SparklinePoint[] {
return [0, 3, 1].map((v, i) => ({ t: 1000 + i * 1000, line: v, value: v, count: v }));
}

describe("Sparkline", () => {
it("lifts the tooltip above the rows below it", () => {
// Each row's chart wrapper is position:relative with z-index auto, so
// without this the first row's tooltip paints behind later rows.
const { container } = renderWithProviders(
<Sparkline points={points()} formatValue={formatCount} showCount={false} />,
);

const tooltip = container.querySelector<HTMLElement>(".recharts-tooltip-wrapper");
expect(tooltip).not.toBeNull();
expect(tooltip!.style.zIndex).toBe("50");
});

it("draws one line across every slot it is given", () => {
const { container } = renderWithProviders(
<Sparkline points={points()} formatValue={formatCount} showCount={false} />,
);

const curves = container.querySelectorAll(".recharts-line-curve");
expect(curves).toHaveLength(1);
expect(curves[0].getAttribute("d")).toMatch(/^M0,/);
});

it("hides the chart from assistive tech, since the row value is already text", () => {
// Regression guard: this was dropped when the tooltip landed, exposing four
// unnamed SVGs per card. recharts gives the svg no accessible name.
const { container } = renderWithProviders(
<Sparkline points={points()} formatValue={formatCount} showCount={false} />,
);

expect(container.querySelector("[aria-hidden]")).not.toBeNull();
expect(container.querySelector("svg")!.closest("[aria-hidden]")).not.toBeNull();
});
});
47 changes: 47 additions & 0 deletions src/components/dashboard/Sparkline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* The row's value is text beside the chart, so the line itself carries no
* information a screen reader needs. The tooltip is a pointer-only
* enhancement on top of that, not the only route to the data.
*/

import { Line, LineChart, ResponsiveContainer, Tooltip, YAxis } from "recharts";

import type { SparklinePoint } from "./sparklineSeries";
import { SparklineTooltip } from "./SparklineTooltip";

/** Row height from the design frame. */
export const SPARKLINE_HEIGHT = 37;

interface SparklineProps {
points: SparklinePoint[];
formatValue: (value: number) => string;
showCount: boolean;
}

export function Sparkline({ points, formatValue, showCount }: SparklineProps) {
return (
<div aria-hidden className="min-w-0 flex-1">
<ResponsiveContainer width="100%" height={SPARKLINE_HEIGHT}>
<LineChart data={points} margin={{ top: 2, right: 0, bottom: 2, left: 0 }}>
<YAxis hide domain={[0, "dataMax"]} tickCount={2} />
<Tooltip
content={<SparklineTooltip formatValue={formatValue} showCount={showCount} />}
cursor={{ stroke: "var(--color-muted-foreground)", strokeWidth: 1 }}
isAnimationActive={false}
// Keep tooltip above other elements
wrapperStyle={{ zIndex: 50 }}
/>
<Line
dataKey="line"
type="linear"
stroke="var(--color-sparkline-stroke)"
strokeWidth={1}
strokeOpacity={0.9}
dot={false}
activeDot={{ r: 2, strokeWidth: 0, fill: "var(--color-sparkline-stroke)" }}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
42 changes: 42 additions & 0 deletions src/components/dashboard/SparklineRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* One metric row: a fixed-width label column, so all four lines share a left
* edge, beside the trend line.
*/

import type { ReactNode } from "react";

import { Sparkline, SPARKLINE_HEIGHT } from "./Sparkline";
import type { SparklinePoint } from "./sparklineSeries";
import { StatBlock } from "./SystemStat";

interface SparklineRowProps {
label: ReactNode;
/** Pre-formatted value string (see `systemMetrics.ts` formatters). */
value: ReactNode;
points: SparklinePoint[];
formatValue: (value: number) => string;
showCount: boolean;
loading?: boolean;
}

export function SparklineRow({
label,
value,
points,
formatValue,
showCount,
loading,
}: SparklineRowProps) {
return (
<div className="flex items-end gap-4">
<div className="w-[102px] shrink-0">
<StatBlock label={label} value={value} loading={loading} />
</div>
{loading ? (
<div className="flex-1" style={{ height: SPARKLINE_HEIGHT }} />
) : (
<Sparkline points={points} formatValue={formatValue} showCount={showCount} />
)}
</div>
);
}
69 changes: 69 additions & 0 deletions src/components/dashboard/SparklineTooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { screen } from "@testing-library/react";

import { renderWithProviders } from "@/test/test-utils";

import { SparklineTooltip } from "./SparklineTooltip";
import { formatCount, formatResponseTime } from "./systemMetrics";
import type { SparklinePoint } from "./sparklineSeries";

function point(overrides: Partial<SparklinePoint> = {}): SparklinePoint {
return {
t: Date.parse("2026-09-01T14:00:00Z"),
line: 0.472,
value: 0.472,
count: 3,
...overrides,
};
}

function render(p: SparklinePoint, formatValue = formatResponseTime, showCount = true) {
return renderWithProviders(
<SparklineTooltip
active
payload={[{ payload: p }]}
formatValue={formatValue}
showCount={showCount}
/>,
);
}

describe("SparklineTooltip", () => {
it("renders nothing unless recharts marks it active", () => {
const { container } = renderWithProviders(
<SparklineTooltip payload={[{ payload: point() }]} formatValue={formatCount} showCount />,
);
expect(container).toBeEmptyDOMElement();
});

it("renders nothing without a payload", () => {
const { container } = renderWithProviders(
<SparklineTooltip active formatValue={formatCount} showCount />,
);
expect(container).toBeEmptyDOMElement();
});

it("qualifies a latency reading with the sample it came from", () => {
render(point());
expect(screen.getByText("0.472ms")).toBeInTheDocument();
expect(screen.getByText(/3 requests/)).toBeInTheDocument();
});

it("says no requests for an idle slot instead of reporting the drawn zero", () => {
render(point({ value: null, line: 0, count: 0 }));
expect(screen.getByText("No requests")).toBeInTheDocument();
expect(screen.queryByText("0.000ms")).not.toBeInTheDocument();
});

it("singularizes a lone request", () => {
render(point({ count: 1 }));
expect(screen.getByText(/1 request(?!s)/)).toBeInTheDocument();
});

it("omits the count where the formatter already names the unit", () => {
// Mirrors the executions row, whose formatter renders "22 executions".
render(point({ value: 22, line: 22, count: 22 }), (v) => `${v} executions`, false);
expect(screen.getByText("22 executions")).toBeInTheDocument();
expect(screen.queryByText(/requests/)).not.toBeInTheDocument();
});
});
53 changes: 53 additions & 0 deletions src/components/dashboard/SparklineTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Hover readout for one sparkline slot: the hour, the value, and how many
* requests produced it.
*
* The request count is the point of this: a p95 over three samples is not a
* meaningful percentile, and nothing else on the card lets a reader tell that.
*/

import { useIntl } from "react-intl";

import type { SparklinePoint } from "./sparklineSeries";

interface SparklineTooltipProps {
/** Injected by recharts. */
active?: boolean;
payload?: { payload: SparklinePoint }[];
formatValue: (value: number) => string;
/** Counts are redundant on the executions row, where the value is the count. */
showCount: boolean;
}

export function SparklineTooltip({
active,
payload,
formatValue,
showCount,
}: SparklineTooltipProps) {
const intl = useIntl();
const point = payload?.[0]?.payload;

if (!active || !point) return null;

return (
<div className="flex flex-col gap-0.5 rounded-md bg-popover px-2 py-1 text-xs leading-4 font-light shadow-md ring-1 ring-foreground/10">
<span className="text-muted-foreground">
{intl.formatTime(point.t, { hour: "2-digit", minute: "2-digit" })}
</span>
<span className="tabular-nums text-popover-foreground">
{point.value === null
? intl.formatMessage({ id: "dashboard.home.sparklines.tooltip.noRequests" })
: formatValue(point.value)}
</span>
{showCount && point.value !== null && (
<span className="tabular-nums text-popover-foreground">
{intl.formatMessage(
{ id: "dashboard.home.sparklines.tooltip.requests" },
{ count: point.count },
)}
</span>
)}
</div>
);
}
Loading
Loading