diff --git a/frontend/bun.lock b/frontend/bun.lock index 44ddea665..a82484e91 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -80,6 +80,7 @@ "react-markdown": "^10.1.0", "react-simple-code-editor": "^0.14.1", "react-syntax-highlighter": "^15.6.6", + "recharts": "2.15.4", "remark-emoji": "^5.0.2", "remark-gfm": "^4.0.1", "remark-prism": "^1.3.6", @@ -1884,6 +1885,8 @@ "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="], "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], @@ -2080,6 +2083,8 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="], + "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], @@ -2998,7 +3003,7 @@ "react-icons": ["react-icons@4.12.0", "", { "peerDependencies": { "react": "*" } }, "sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw=="], - "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], @@ -3024,6 +3029,8 @@ "react-simple-code-editor": ["react-simple-code-editor@0.14.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow=="], + "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="], + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], "react-syntax-highlighter": ["react-syntax-highlighter@15.6.6", "", { "dependencies": { "@babel/runtime": "^7.3.1", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^3.6.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw=="], @@ -3038,6 +3045,10 @@ "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], + + "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], "reduce-configs": ["reduce-configs@1.1.1", "", {}, "sha512-EYtsVGAQarE8daT54cnaY1PIknF2VB78ug6Zre2rs36EsJfC40EG6hmTU2A2P1ZuXnKAt2KI0fzOGHcX7wzdPw=="], @@ -3504,6 +3515,8 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], + "vite": ["vite@7.2.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ=="], "vite-plugin-env-compatible": ["vite-plugin-env-compatible@2.0.1", "", { "dependencies": { "dotenv": "8.2.0", "dotenv-expand": "5.1.0" } }, "sha512-DRrOZTg/W44ojVQQfGSMPEgYQGzp5TeIpt9cpaK35hTOC/b2D7Ffl8/RIgK8vQ0mlnDIUgETcA173bnMEkyzdw=="], @@ -4058,6 +4071,8 @@ "prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -4072,6 +4087,8 @@ "react-highlight-words/memoize-one": ["memoize-one@4.1.0", "", {}, "sha512-2GApq0yI/b22J2j9rhbrAlsHb0Qcz+7yWxeLG8h+95sl1XPUgeLimQSOdur4Vw7cUhrBHwaUZxWFZueojqNRzA=="], + "react-redux/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-remove-scroll/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "react-remove-scroll-bar/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -4240,8 +4257,6 @@ "@redpanda-data/ui/react-markdown/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], - "@redpanda-data/ui/react-markdown/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], - "@redpanda-data/ui/react-markdown/remark-parse": ["remark-parse@10.0.2", "", { "dependencies": { "@types/mdast": "^3.0.0", "mdast-util-from-markdown": "^1.0.0", "unified": "^10.0.0" } }, "sha512-3ydxgHa/ZQzG8LvC7jTXccARYDcRld3VfcgIIFs7bI6vbRSxJJmzgLEIIoYKyrfhaY+ujuWaf/PJiMZXoiCXgw=="], "@redpanda-data/ui/react-markdown/remark-rehype": ["remark-rehype@10.1.0", "", { "dependencies": { "@types/hast": "^2.0.0", "@types/mdast": "^3.0.0", "mdast-util-to-hast": "^12.1.0", "unified": "^10.0.0" } }, "sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw=="], diff --git a/frontend/package.json b/frontend/package.json index 4dba7c890..46f05d1f6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -120,6 +120,7 @@ "react-markdown": "^10.1.0", "react-simple-code-editor": "^0.14.1", "react-syntax-highlighter": "^15.6.6", + "recharts": "2.15.4", "remark-emoji": "^5.0.2", "remark-gfm": "^4.0.1", "remark-prism": "^1.3.6", diff --git a/frontend/src/components/constants.ts b/frontend/src/components/constants.ts index 172027f3e..b61384fc1 100644 --- a/frontend/src/components/constants.ts +++ b/frontend/src/components/constants.ts @@ -20,6 +20,8 @@ export const FEATURE_FLAGS = { enableTranscriptsInConsole: false, enableApiKeyConfigurationAgent: false, shadowlinkCloudUi: false, + enableDataplaneObservabilityServerless: false, + enableDataplaneObservability: false, }; // Cloud-managed tag keys for service account integration diff --git a/frontend/src/components/pages/observability/metric-chart.tsx b/frontend/src/components/pages/observability/metric-chart.tsx new file mode 100644 index 000000000..982c3e6f7 --- /dev/null +++ b/frontend/src/components/pages/observability/metric-chart.tsx @@ -0,0 +1,192 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { timestampFromMs } from '@bufbuild/protobuf/wkt'; +import type { FC } from 'react'; +import { useMemo } from 'react'; +import { useExecuteRangeQuery } from 'react-query/api/observability'; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'; + +import { CHART_COLORS, transformTimeSeriesData } from './utils/chart-data'; +import { formatWithUnit } from '../../../utils/unit'; +import { Alert, AlertDescription } from '../../redpanda-ui/components/alert'; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from '../../redpanda-ui/components/chart'; +import { Skeleton } from '../../redpanda-ui/components/skeleton'; +import { Heading } from '../../redpanda-ui/components/typography'; + +type MetricChartProps = { + queryName: string; + timeRange: { + start: Date; + end: Date; + }; +}; + +export const MetricChart: FC = ({ queryName, timeRange }) => { + const { data, isLoading, isError } = useExecuteRangeQuery({ + queryName, + params: { + start: timestampFromMs(timeRange.start.getTime()), + end: timestampFromMs(timeRange.end.getTime()), + filters: {}, + }, + }); + + // Transform the time series data into chart format + const chartData = useMemo(() => transformTimeSeriesData(data?.results || []), [data]); + + // Extract series names for creating lines + const seriesNames = useMemo(() => { + if (!data?.results) { + return []; + } + return data.results + .map((series) => series.name || 'value') + .filter((name, index, self) => self.indexOf(name) === index); + }, [data]); + + // Chart configuration + const chartConfig = useMemo(() => { + const config: Record = {}; + + for (let i = 0; i < seriesNames.length; i++) { + config[seriesNames[i]] = { + label: seriesNames[i], + color: CHART_COLORS[i % CHART_COLORS.length], + }; + } + + return config; + }, [seriesNames]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError || !data) { + return ( +
+ + Failed to load data for this metric + +
+ ); + } + + if (chartData.length === 0) { + return ( +
+ {data.metadata?.description ? ( + + {data.metadata.description} + + ) : null} + + No data available for this time range + +
+ ); + } + + return ( +
+ {data.metadata?.description ? ( + + {data.metadata.description} + + ) : null} + + + + + { + const date = new Date(value); + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + timeZone: 'UTC', + }); + }} + tickLine={false} + tickMargin={10} + /> + formatWithUnit(value, data.metadata?.unit)} + tickLine={false} + width={80} + /> + { + const indicatorColor = item.payload.fill || item.color; + const formattedValue = typeof value === 'number' ? formatWithUnit(value, data.metadata?.unit) : value; + return ( +
+
+ {name} + {formattedValue} +
+ ); + }} + hideLabel={false} + labelFormatter={(_value, payload) => { + const timestamp = payload?.[0]?.payload?.timestamp; + if (!timestamp || typeof timestamp !== 'number') { + return ''; + } + const date = new Date(timestamp); + if (!date.getTime()) { + return ''; + } + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZone: 'UTC', + timeZoneName: 'short', + }); + }} + /> + } + /> + {seriesNames.map((seriesName) => ( + + ))} + } /> + + +
+ ); +}; diff --git a/frontend/src/components/pages/observability/observability-page.test.tsx b/frontend/src/components/pages/observability/observability-page.test.tsx new file mode 100644 index 000000000..1a38a7b4b --- /dev/null +++ b/frontend/src/components/pages/observability/observability-page.test.tsx @@ -0,0 +1,179 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { create } from '@bufbuild/protobuf'; +import { timestampFromMs } from '@bufbuild/protobuf/wkt'; +import { createRouterTransport } from '@connectrpc/connect'; +import { + DataPointSchema, + ExecuteRangeQueryResponseSchema, + ListQueriesResponseSchema, + QueryMetadataSchema, + TimeSeriesSchema, +} from 'protogen/redpanda/api/dataplane/v1alpha3/observability_pb'; +import { + executeRangeQuery, + listQueries, +} from 'protogen/redpanda/api/dataplane/v1alpha3/observability-ObservabilityService_connectquery'; +import { renderWithFileRoutes, screen, waitFor } from 'test-utils'; + +vi.mock('config', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + config: { + jwt: 'test-jwt-token', + controlplaneUrl: 'http://localhost:9090', + }, + isFeatureFlagEnabled: vi.fn(() => false), + addBearerTokenInterceptor: vi.fn((next) => async (request: unknown) => await next(request)), + }; +}); + +vi.mock('state/ui-state', () => ({ + uiState: { + pageTitle: '', + pageBreadcrumbs: [], + }, +})); + +vi.mock('state/app-global', () => ({ + appGlobal: { + onRefresh: null, + }, +})); + +import ObservabilityPage from './observability-page'; + +describe('ObservabilityPage', () => { + test('should render and display content when data loads', async () => { + const listQueriesResponse = create(ListQueriesResponseSchema, { + queries: [], + }); + + const listQueriesMock = vi.fn().mockReturnValue(listQueriesResponse); + + const transport = createRouterTransport(({ rpc }) => { + rpc(listQueries, listQueriesMock); + }); + + renderWithFileRoutes(, { transport }); + + await waitFor(() => { + expect(screen.getByText('No metrics queries available at this time.')).toBeInTheDocument(); + }); + + expect(listQueriesMock).toHaveBeenCalledTimes(1); + }); + + test('should display metrics queries when data is loaded', async () => { + const query1 = create(QueryMetadataSchema, { + name: 'cpu_usage', + description: 'CPU usage percentage', + unit: 'percent', + filters: [], + tags: {}, + }); + + const query2 = create(QueryMetadataSchema, { + name: 'memory_usage', + description: 'Memory usage in bytes', + unit: 'bytes', + filters: [], + tags: {}, + }); + + const listQueriesResponse = create(ListQueriesResponseSchema, { + queries: [query1, query2], + }); + + // Create mock time series data + const now = Date.now(); + const oneHourAgo = now - 60 * 60 * 1000; + + const mockTimeSeries = create(TimeSeriesSchema, { + name: 'default', + values: [ + create(DataPointSchema, { + timestamp: timestampFromMs(oneHourAgo), + value: 50.5, + }), + create(DataPointSchema, { + timestamp: timestampFromMs(now), + value: 75.2, + }), + ], + }); + + const listQueriesMock = vi.fn().mockReturnValue(listQueriesResponse); + const executeRangeQueryMock = vi.fn().mockImplementation((request) => { + let metadata: typeof query1 | typeof query2 | undefined; + if (request.queryName === 'cpu_usage') { + metadata = query1; + } else if (request.queryName === 'memory_usage') { + metadata = query2; + } + + return create(ExecuteRangeQueryResponseSchema, { + metadata, + results: [mockTimeSeries], + }); + }); + + const transport = createRouterTransport(({ rpc }) => { + rpc(listQueries, listQueriesMock); + rpc(executeRangeQuery, executeRangeQueryMock); + }); + + renderWithFileRoutes(, { transport }); + + // Wait for titles to appear + await waitFor(() => { + expect(screen.getByText('CPU usage percentage')).toBeInTheDocument(); + expect(screen.getByText('Memory usage in bytes')).toBeInTheDocument(); + }); + }); + + test('should display no metrics message when queries array is empty', async () => { + const listQueriesResponse = create(ListQueriesResponseSchema, { + queries: [], + }); + + const listQueriesMock = vi.fn().mockReturnValue(listQueriesResponse); + + const transport = createRouterTransport(({ rpc }) => { + rpc(listQueries, listQueriesMock); + }); + + renderWithFileRoutes(, { transport }); + + await waitFor(() => { + expect(screen.getByText('No metrics queries available at this time.')).toBeInTheDocument(); + }); + }); + + test('should display error message when query fails', async () => { + const listQueriesMock = vi.fn().mockImplementation(() => { + throw new Error('Failed to fetch metrics'); + }); + + const transport = createRouterTransport(({ rpc }) => { + rpc(listQueries, listQueriesMock); + }); + + renderWithFileRoutes(, { transport }); + + await waitFor(() => { + expect(screen.getByText('Error loading metrics')).toBeInTheDocument(); + expect(screen.getByText('Failed to load observability metrics. Please try again later.')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/pages/observability/observability-page.tsx b/frontend/src/components/pages/observability/observability-page.tsx new file mode 100644 index 000000000..0473c0663 --- /dev/null +++ b/frontend/src/components/pages/observability/observability-page.tsx @@ -0,0 +1,98 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import type { FC } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useListQueries } from 'react-query/api/observability'; +import { appGlobal } from 'state/app-global'; +import { uiState } from 'state/ui-state'; + +import { MetricChart } from './metric-chart'; +import { ObservabilityToolbar } from './observability-toolbar'; +import { calculateTimeRange, type TimeRange } from '../../../utils/time-range'; +import { Alert, AlertDescription, AlertTitle } from '../../redpanda-ui/components/alert'; +import { Skeleton } from '../../redpanda-ui/components/skeleton'; + +const ObservabilityPage: FC = () => { + const [selectedTimeRange, setSelectedTimeRange] = useState('1h'); + const [refreshKey, setRefreshKey] = useState(0); + + const { + data: queries, + isLoading: isLoadingQueries, + isError, + refetch, + } = useListQueries({ + filter: { + tags: { + // We show in the cluster observability page only metric charts marked with (all) the following tags. + component: 'cluster', + category: 'overview', + }, + }, + }); + + const refreshData = useCallback(() => { + setRefreshKey((prev) => prev + 1); + refetch(); + }, [refetch]); + + useEffect(() => { + uiState.pageBreadcrumbs = [{ title: 'Metrics', linkTo: '/observability' }]; + appGlobal.onRefresh = () => refreshData(); + }, [refreshData]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey triggers recalculation on refresh + const timeRange = useMemo(() => calculateTimeRange(selectedTimeRange), [selectedTimeRange, refreshKey]); + + if (isLoadingQueries) { + return ( +
+ + + +
+ ); + } + + if (isError) { + return ( + + Error loading metrics + Failed to load observability metrics. Please try again later. + + ); + } + + return ( +
+ + + {queries?.queries && queries.queries.length > 0 ? ( +
+ {queries.queries.map((query) => ( + + ))} +
+ ) : ( + + No metrics queries available at this time. + + )} +
+ ); +}; + +export default ObservabilityPage; diff --git a/frontend/src/components/pages/observability/observability-toolbar.tsx b/frontend/src/components/pages/observability/observability-toolbar.tsx new file mode 100644 index 000000000..af37661aa --- /dev/null +++ b/frontend/src/components/pages/observability/observability-toolbar.tsx @@ -0,0 +1,90 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import type { FC } from 'react'; +import { useMemo } from 'react'; + +import type { TimeRange } from '../../../utils/time-range'; +import { calculateTimeRange, getTimeRanges } from '../../../utils/time-range'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../redpanda-ui/components/select'; + +const TIME_RANGES = getTimeRanges(12 * 60 * 60 * 1000); // Up to 12 hours + +function formatTimeRangeDate(date: Date): string { + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZone: 'UTC', + }); +} + +type ObservabilityToolbarProps = { + selectedTimeRange: TimeRange; + onTimeRangeChange: (timeRange: TimeRange) => void; + refreshKey: number; +}; + +export const ObservabilityToolbar: FC = ({ + selectedTimeRange, + onTimeRangeChange, + refreshKey, +}) => { + // biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey triggers recalculation on refresh + const timeRange = useMemo(() => calculateTimeRange(selectedTimeRange), [selectedTimeRange, refreshKey]); + + const timeRangeDisplay = useMemo( + () => ({ + start: formatTimeRangeDate(timeRange.start), + end: formatTimeRangeDate(timeRange.end), + }), + [timeRange] + ); + + return ( +
+
+
+
TIME RANGE
+ +
+
+
+
FROM
+
{timeRangeDisplay.start}
+
+
+
+
TO
+
{timeRangeDisplay.end}
+
+
+
+
TIMEZONE
+
UTC
+
+
+
+ ); +}; diff --git a/frontend/src/components/pages/observability/utils/chart-data.ts b/frontend/src/components/pages/observability/utils/chart-data.ts new file mode 100644 index 000000000..21c5af249 --- /dev/null +++ b/frontend/src/components/pages/observability/utils/chart-data.ts @@ -0,0 +1,64 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import type { ExecuteRangeQueryResponse } from 'protogen/redpanda/api/dataplane/v1alpha3/observability_pb'; + +export const CHART_COLORS = [ + 'var(--color-chart-1)', + 'var(--color-chart-2)', + 'var(--color-chart-3)', + 'var(--color-chart-4)', + 'var(--color-chart-5)', +] as const; + +// Helper to add data point to timestamp map +function addDataPoint( + timestampMap: Map, + seriesName: string, + point: { timestamp?: { seconds: bigint }; value?: number } +): void { + if (!point.timestamp || point.value === undefined) { + return; + } + + // Convert seconds to milliseconds for JavaScript Date + const ts = Number(point.timestamp.seconds) * 1000; + + if (!timestampMap.has(ts)) { + timestampMap.set(ts, { timestamp: ts }); + } + + const entry = timestampMap.get(ts); + if (entry) { + entry[seriesName] = point.value; + } +} + +// Helper function to transform time series data into chart format +export function transformTimeSeriesData(results: ExecuteRangeQueryResponse['results']): Array<{ + timestamp: number; + [key: string]: number; +}> { + if (!results || results.length === 0) { + return []; + } + + const timestampMap = new Map(); + + for (const series of results) { + const seriesName = series.name || 'value'; + for (const point of series.values) { + addDataPoint(timestampMap, seriesName, point); + } + } + + return Array.from(timestampMap.values()).sort((a, b) => a.timestamp - b.timestamp); +} diff --git a/frontend/src/components/pages/transcripts/components/transcript-filter-bar.tsx b/frontend/src/components/pages/transcripts/components/transcript-filter-bar.tsx index 3231b6a66..6fbb907ca 100644 --- a/frontend/src/components/pages/transcripts/components/transcript-filter-bar.tsx +++ b/frontend/src/components/pages/transcripts/components/transcript-filter-bar.tsx @@ -28,6 +28,7 @@ import type { LucideIcon } from 'lucide-react'; import { AlertCircle, ArrowLeft, Bot, Plus, RefreshCw, Sparkles, Wrench, X, Zap } from 'lucide-react'; import type { FC } from 'react'; import { useState } from 'react'; +import { getTimeRanges } from 'utils/time-range'; import { ServiceFilter, type ServiceInfo } from './service-filter'; @@ -114,22 +115,7 @@ const OPERATOR_OPTIONS: { value: AttributeOperator; label: string }[] = [ { value: 'not_equals', label: 'not equals' }, ]; -// Time range configuration -type TimeRangeConfig = { - value: string; - label: string; -}; - -const TIME_RANGES: TimeRangeConfig[] = [ - { value: '5m', label: 'Last 5 minutes' }, - { value: '15m', label: 'Last 15 minutes' }, - { value: '30m', label: 'Last 30 minutes' }, - { value: '1h', label: 'Last 1 hour' }, - { value: '3h', label: 'Last 3 hours' }, - { value: '6h', label: 'Last 6 hours' }, - { value: '12h', label: 'Last 12 hours' }, - { value: '24h', label: 'Last 24 hours' }, -]; +const TIME_RANGES = getTimeRanges(24 * 60 * 60 * 1000); // Up to 24 hours // Jumped state type (matching transcript-list-page) type JumpedState = { diff --git a/frontend/src/components/pages/transcripts/transcript-list-page.tsx b/frontend/src/components/pages/transcripts/transcript-list-page.tsx index 04a082857..0f48f6862 100644 --- a/frontend/src/components/pages/transcripts/transcript-list-page.tsx +++ b/frontend/src/components/pages/transcripts/transcript-list-page.tsx @@ -29,6 +29,7 @@ import { ONE_MINUTE } from 'react-query/react-query.utils'; import { appGlobal } from 'state/app-global'; import { uiState } from 'state/ui-state'; import { pluralize } from 'utils/string'; +import { getTimeRanges } from 'utils/time-range'; import { z } from 'zod'; import { LinkedTraceBanner } from './components/linked-trace-banner'; @@ -38,16 +39,7 @@ import { type SpanFilter, type SpanFilterPreset, TranscriptFilterBar } from './c import { TranscriptsTable } from './components/transcripts-table'; import { calculateVisibleWindow } from './utils/transcript-statistics'; -const TIME_RANGES = [ - { value: '5m', label: 'Last 5 minutes', ms: 5 * 60 * 1000 }, - { value: '15m', label: 'Last 15 minutes', ms: 15 * 60 * 1000 }, - { value: '30m', label: 'Last 30 minutes', ms: 30 * 60 * 1000 }, - { value: '1h', label: 'Last 1 hour', ms: 60 * 60 * 1000 }, - { value: '3h', label: 'Last 3 hours', ms: 3 * 60 * 60 * 1000 }, - { value: '6h', label: 'Last 6 hours', ms: 6 * 60 * 60 * 1000 }, - { value: '12h', label: 'Last 12 hours', ms: 12 * 60 * 60 * 1000 }, - { value: '24h', label: 'Last 24 hours', ms: 24 * 60 * 60 * 1000 }, -]; +const TIME_RANGES = getTimeRanges(24 * 60 * 60 * 1000); // Up to 24 hours export const TRANSCRIPTS_PAGE_SIZE = 100; diff --git a/frontend/src/components/redpanda-ui/components/chart.tsx b/frontend/src/components/redpanda-ui/components/chart.tsx new file mode 100644 index 000000000..f58d7387b --- /dev/null +++ b/frontend/src/components/redpanda-ui/components/chart.tsx @@ -0,0 +1,296 @@ +'use client'; + +import React from 'react'; +import { Legend, type LegendProps, ResponsiveContainer, Tooltip } from 'recharts'; + +import { cn, type SharedProps } from '../lib/utils'; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: '', dark: '.dark' } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ({ color?: string; theme?: never } | { color?: never; theme: Record }); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error('useChart must be used within a '); + } + + return context; +} + +function ChartContainer({ + id, + className, + children, + config, + testId, + ...props +}: React.ComponentProps<'div'> & + SharedProps & { + config: ChartConfig; + children: React.ComponentProps['children']; + }) { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`; + + return ( + +
+ + {children} +
+
+ ); +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter(([, itemConfig]) => itemConfig.theme || itemConfig.color); + + if (!colorConfig.length) { + return null; + } + + return ( +