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
3 changes: 2 additions & 1 deletion src/api/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
* and `platform_viewer` hold it by default, so a 403 is a routine outcome.
*/

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

import { api } from "./client";

Expand Down
40 changes: 35 additions & 5 deletions src/components/dashboard/SystemSparklinesCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ describe("SystemSparklinesCard", () => {
it("totals executions across the window and takes the latest latency bucket", () => {
renderWithProviders(
<SystemSparklinesCard
timeseries={{ buckets: [bucket(3), bucket(1)], values: [2, 5] }}
timeseries={{
buckets: [bucket(3), bucket(1)],
values: [2, 5],
success_count: [0, 0],
error_count: [0, 0],
}}
percentiles={{
buckets: [bucket(3), bucket(1)],
p50: [0.9, 0.317],
Expand All @@ -49,16 +54,41 @@ describe("SystemSparklinesCard", () => {
expect(screen.getByText("0.523ms")).toBeInTheDocument();
});

it("explains an empty window rather than showing it as a failure", () => {
it("explains when observability is disabled", () => {
renderWithProviders(
<SystemSparklinesCard
timeseries={{ buckets: [], values: [], success_count: [], error_count: [], enabled: false }}
percentiles={{ buckets: [], p50: [], p95: [], p99: [], enabled: false }}
/>,
);

expect(screen.getByText(/Tracing is off/)).toBeInTheDocument();
expect(screen.queryByText(/No MCP or A2A requests/)).not.toBeInTheDocument();
});

it("explains an enabled window with no traffic", () => {
renderWithProviders(
<SystemSparklinesCard
timeseries={{ buckets: [], values: [], success_count: [], error_count: [], enabled: true }}
percentiles={{ buckets: [], p50: [], p95: [], p99: [], enabled: true }}
/>,
);

expect(screen.getByText(/No MCP or A2A requests/)).toBeInTheDocument();
expect(screen.queryByText(/Tracing is off/)).not.toBeInTheDocument();
});

it("uses the original empty-state message when observability status is unknown", () => {
renderWithProviders(
<SystemSparklinesCard
timeseries={{ buckets: [], values: [] }}
timeseries={{ buckets: [], values: [], success_count: [], error_count: [] }}
percentiles={{ buckets: [], p50: [], p95: [], p99: [] }}
/>,
);

expect(screen.getByText(/No traced requests/)).toBeInTheDocument();
expect(screen.queryByText("Couldn't load metrics.")).not.toBeInTheDocument();
expect(screen.getByText(/set OBSERVABILITY_ENABLED=true/)).toBeInTheDocument();
expect(screen.queryByText(/Tracing is off/)).not.toBeInTheDocument();
expect(screen.queryByText(/No MCP or A2A requests/)).not.toBeInTheDocument();
});

it("surfaces a load failure", () => {
Expand Down
21 changes: 17 additions & 4 deletions src/components/dashboard/SystemSparklinesCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
import { useMemo } from "react";
import { useIntl } from "react-intl";

import type { PercentilesResponse, TimeseriesResponse } from "@/types/metrics";
import type { PercentilesResponse } from "@/generated/types/percentilesResponse";
import type { TimeseriesResponse } from "@/generated/types/timeseriesResponse";
import { WINDOW_HOURS, WINDOW_INTERVAL_MINUTES } from "@/hooks/useMetrics";

import { SparklineRow } from "./SparklineRow";
Expand All @@ -23,9 +24,11 @@ import {
} from "./sparklineSeries";
import { formatCount, formatResponseTime } from "./systemMetrics";

type ObservabilityStatus = { enabled?: boolean };

interface SystemSparklinesCardProps {
timeseries: TimeseriesResponse | null;
percentiles: PercentilesResponse | null;
timeseries: (TimeseriesResponse & ObservabilityStatus) | null;
percentiles: (PercentilesResponse & ObservabilityStatus) | null;
loading?: boolean;
error?: Error | null;
}
Expand Down Expand Up @@ -83,11 +86,21 @@ export function SystemSparklinesCard({

const isEmpty = !loading && !error && rows.every((row) => isEmptySeries(row.points));

// APIs older than IBM/mcp-context-forge#7326 report neither state.
const tracingEnabled = timeseries?.enabled ?? percentiles?.enabled;

// Sits under the title
const statusMessage = error
? intl.formatMessage({ id: "dashboard.home.sparklines.error" })
: isEmpty
? intl.formatMessage({ id: "dashboard.home.sparklines.empty" })
? intl.formatMessage({
id:
tracingEnabled === undefined
? "dashboard.home.sparklines.empty"
: tracingEnabled
? "dashboard.home.sparklines.empty.noTraffic"
: "dashboard.home.sparklines.empty.disabled",
})
: null;

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe("SystemSparklinesCardConnected", () => {
it("renders the card otherwise", () => {
mockUseMetrics.mockReturnValue(
metricsState({
timeseries: { buckets: [], values: [] },
timeseries: { buckets: [], values: [], success_count: [], error_count: [] },
percentiles: { buckets: [], p50: [], p95: [], p99: [] },
}),
);
Expand All @@ -57,7 +57,10 @@ describe("SystemSparklinesCardConnected", () => {

it("only shows the loading state on the first load, not on a refresh", () => {
mockUseMetrics.mockReturnValue(
metricsState({ isLoading: true, timeseries: { buckets: [], values: [] } }),
metricsState({
isLoading: true,
timeseries: { buckets: [], values: [], success_count: [], error_count: [] },
}),
);

const { container } = renderWithProviders(<SystemSparklinesCardConnected />);
Expand Down
17 changes: 14 additions & 3 deletions src/hooks/useMetrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const mockTimeseries = vi.mocked(metricsApi.getTimeseries);
const mockPercentiles = vi.mocked(metricsApi.getPercentiles);

function resolveBoth() {
mockTimeseries.mockResolvedValue({ buckets: [], values: [] });
mockTimeseries.mockResolvedValue({ buckets: [], values: [], success_count: [], error_count: [] });
mockPercentiles.mockResolvedValue({ buckets: [], p50: [], p95: [], p99: [] });
}

Expand Down Expand Up @@ -104,7 +104,13 @@ describe("useMetrics", () => {
mockTimeseries.mockImplementationOnce(
() =>
new Promise((resolve) => {
release = () => resolve({ buckets: ["2026-09-01T12:00:00Z"], values: [9] });
release = () =>
resolve({
buckets: ["2026-09-01T12:00:00Z"],
values: [9],
success_count: [0],
error_count: [0],
});
}),
);

Expand All @@ -118,7 +124,12 @@ describe("useMetrics", () => {
});

await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.timeseries).toEqual({ buckets: [], values: [] });
expect(result.current.timeseries).toEqual({
buckets: [],
values: [],
success_count: [],
error_count: [],
});
});

it("refetches on becoming visible when nothing has loaded yet", async () => {
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/useMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
import { useCallback, useEffect, useRef, useState } from "react";

import { metricsApi } from "@/api/metrics";
import type { PercentilesResponse, TimeseriesResponse } from "@/types/metrics";
import type { PercentilesResponse } from "@/generated/types/percentilesResponse";
import type { TimeseriesResponse } from "@/generated/types/timeseriesResponse";

/** 24h at 60m granularity = 24 buckets. Fixed: the card has no range control. */
export const WINDOW_HOURS = 24;
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/en-US/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@
"dashboard.home.sparklines.p99": "p99 latency",
"dashboard.home.sparklines.error": "Couldn't load metrics.",
"dashboard.home.sparklines.empty": "No traced requests. If you haven't already, set OBSERVABILITY_ENABLED=true and restart ContextForge.",
"dashboard.home.sparklines.empty.disabled": "Tracing is off. Set OBSERVABILITY_ENABLED=true on the ContextForge server and restart.",
"dashboard.home.sparklines.empty.noTraffic": "No MCP or A2A requests in this window.",
"dashboard.home.sparklines.tooltip.noRequests": "No requests",
"dashboard.home.sparklines.tooltip.requests": "{count, plural, one {# request} other {# requests}}",
"dashboard.home.sparklines.tooltip.executions": "{count, plural, one {# execution} other {# executions}}",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/es-ES/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@
"dashboard.home.sparklines.p99": "Latencia p99",
"dashboard.home.sparklines.error": "No se pudieron cargar las métricas.",
"dashboard.home.sparklines.empty": "Sin solicitudes rastreadas. Si aún no lo ha hecho, establezca OBSERVABILITY_ENABLED=true y reinicie ContextForge.",
"dashboard.home.sparklines.empty.disabled": "El seguimiento está desactivado. Establezca OBSERVABILITY_ENABLED=true en el servidor ContextForge y reinícielo.",
"dashboard.home.sparklines.empty.noTraffic": "No hay solicitudes MCP ni A2A en este período.",
"dashboard.home.sparklines.tooltip.noRequests": "Sin solicitudes",
"dashboard.home.sparklines.tooltip.requests": "{count, plural, one {# solicitud} other {# solicitudes}}",
"dashboard.home.sparklines.tooltip.executions": "{count, plural, one {# ejecución} other {# ejecuciones}}",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/pt-BR/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@
"dashboard.home.sparklines.p99": "Latência p99",
"dashboard.home.sparklines.error": "Não foi possível carregar as métricas.",
"dashboard.home.sparklines.empty": "Nenhuma requisição rastreada. Se ainda não tiver feito isso, defina OBSERVABILITY_ENABLED=true e reinicie o ContextForge.",
"dashboard.home.sparklines.empty.disabled": "O rastreamento está desativado. Defina OBSERVABILITY_ENABLED=true no servidor ContextForge e reinicie-o.",
"dashboard.home.sparklines.empty.noTraffic": "Não há requisições MCP ou A2A neste período.",
"dashboard.home.sparklines.tooltip.noRequests": "Sem requisições",
"dashboard.home.sparklines.tooltip.requests": "{count, plural, one {# requisição} other {# requisições}}",
"dashboard.home.sparklines.tooltip.executions": "{count, plural, one {# execução} other {# execuções}}",
Expand Down
27 changes: 0 additions & 27 deletions src/types/metrics.ts

This file was deleted.