diff --git a/src/api/metrics.ts b/src/api/metrics.ts index 82c278b9..9466ae55 100644 --- a/src/api/metrics.ts +++ b/src/api/metrics.ts @@ -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"; diff --git a/src/components/dashboard/SystemSparklinesCard.test.tsx b/src/components/dashboard/SystemSparklinesCard.test.tsx index c2dcfd91..5219bb13 100644 --- a/src/components/dashboard/SystemSparklinesCard.test.tsx +++ b/src/components/dashboard/SystemSparklinesCard.test.tsx @@ -33,7 +33,12 @@ describe("SystemSparklinesCard", () => { it("totals executions across the window and takes the latest latency bucket", () => { renderWithProviders( { 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( + , + ); + + 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( + , + ); + + 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( , ); - 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", () => { diff --git a/src/components/dashboard/SystemSparklinesCard.tsx b/src/components/dashboard/SystemSparklinesCard.tsx index 97144f15..a96ea075 100644 --- a/src/components/dashboard/SystemSparklinesCard.tsx +++ b/src/components/dashboard/SystemSparklinesCard.tsx @@ -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"; @@ -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; } @@ -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 ( diff --git a/src/components/dashboard/SystemSparklinesCardConnected.test.tsx b/src/components/dashboard/SystemSparklinesCardConnected.test.tsx index bce7ecfd..3023dcdb 100644 --- a/src/components/dashboard/SystemSparklinesCardConnected.test.tsx +++ b/src/components/dashboard/SystemSparklinesCardConnected.test.tsx @@ -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: [] }, }), ); @@ -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(); diff --git a/src/hooks/useMetrics.test.ts b/src/hooks/useMetrics.test.ts index c64e1893..e87a8154 100644 --- a/src/hooks/useMetrics.test.ts +++ b/src/hooks/useMetrics.test.ts @@ -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: [] }); } @@ -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], + }); }), ); @@ -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 () => { diff --git a/src/hooks/useMetrics.ts b/src/hooks/useMetrics.ts index 9422b02e..e7aad1a6 100644 --- a/src/hooks/useMetrics.ts +++ b/src/hooks/useMetrics.ts @@ -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; diff --git a/src/i18n/locales/en-US/dashboard.json b/src/i18n/locales/en-US/dashboard.json index cd593385..1e8984a1 100644 --- a/src/i18n/locales/en-US/dashboard.json +++ b/src/i18n/locales/en-US/dashboard.json @@ -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}}", diff --git a/src/i18n/locales/es-ES/dashboard.json b/src/i18n/locales/es-ES/dashboard.json index 451c266e..5aca6c20 100644 --- a/src/i18n/locales/es-ES/dashboard.json +++ b/src/i18n/locales/es-ES/dashboard.json @@ -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}}", diff --git a/src/i18n/locales/pt-BR/dashboard.json b/src/i18n/locales/pt-BR/dashboard.json index 4effdfef..77976eb4 100644 --- a/src/i18n/locales/pt-BR/dashboard.json +++ b/src/i18n/locales/pt-BR/dashboard.json @@ -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}}", diff --git a/src/types/metrics.ts b/src/types/metrics.ts deleted file mode 100644 index 067752f7..00000000 --- a/src/types/metrics.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Observability metrics types. `mcpgateway/routers/observability.py` is the - * source of truth. - * - * `buckets` is sparse — only intervals containing traces are returned, so a 24h - * window with three busy hours yields three entries. Align onto a full grid - * before plotting; see `components/dashboard/sparklineSeries.ts`. - * - * Both endpoints return empty arrays, not an error, when observability is off. - */ - -/** Execution counts bucketed over time. */ -export interface TimeseriesResponse { - /** ISO 8601 bucket start times, ascending. Sparse. */ - buckets: string[]; - /** Execution count per bucket, index-aligned with `buckets`. */ - values: number[]; -} - -/** Latency percentiles (milliseconds) bucketed over time. */ -export interface PercentilesResponse { - /** ISO 8601 bucket start times, ascending. Sparse. */ - buckets: string[]; - p50: number[]; - p95: number[]; - p99: number[]; -}