diff --git a/.github/workflows/frontend-ui-audit.yml b/.github/workflows/frontend-ui-audit.yml index 5796908d6c..e1b87de876 100644 --- a/.github/workflows/frontend-ui-audit.yml +++ b/.github/workflows/frontend-ui-audit.yml @@ -25,7 +25,12 @@ permissions: jobs: audit: name: Audit registry usage - timeout-minutes: 5 + # Advisory, and that has to hold for infrastructure too: a PR that moves a lot of registry files + # (a data-table resync, say) gives the audit far more work, and a job that overruns its timeout + # reports as a failed check no matter how forgiving the steps below are. Hence the generous + # budget here, a tighter cap on the audit itself, and continue-on-error at both levels. + timeout-minutes: 15 + continue-on-error: true runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout console @@ -54,11 +59,11 @@ jobs: ref: ${{ env.UI_REGISTRY_REF }} # The audit resolves component versions via `git show v:`, # so it needs full history and all v* tags (fetch-depth: 0). - # filter: blob:none makes it a blobless partial clone — commit and tag - # metadata is fetched, but file blobs are pulled lazily only when - # `git show` touches them, keeping the checkout fast. + # + # Deliberately NOT a blobless clone: `git show` per component per tag is exactly the + # access pattern that defeats filter=blob:none, turning each file read into its own + # lazy fetch. The whole repo is ~75MB in one transfer, against hundreds of round trips. fetch-depth: 0 - filter: blob:none path: ui-registry token: ${{ env.ACTIONS_BOT_TOKEN }} - name: Setup Bun @@ -72,6 +77,10 @@ jobs: bun run registry:build - name: Run audit (markdown report + exit code) id: audit + # Capped below the job budget so a slow or hung audit is contained here, leaving the + # steps after it free to report what did come back. + timeout-minutes: 8 + continue-on-error: true run: | set +e # Exit code only feeds the warning step below; the job never fails on findings. @@ -83,11 +92,18 @@ jobs: > /tmp/audit-body.md echo "exit_code=$?" >> "$GITHUB_OUTPUT" - name: Post or update sticky PR comment + if: always() + continue-on-error: true env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | set -euo pipefail + # The audit can be cut short by its own cap, leaving no report — say so rather than + # letting `cat` of a missing file take the job down with it. + if [ ! -s /tmp/audit-body.md ]; then + echo "_UI audit did not finish within its time budget — no findings reported._" > /tmp/audit-body.md + fi marker='' { echo "$marker"; cat /tmp/audit-body.md; } > /tmp/body.md existing=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ diff --git a/frontend/bun.lock b/frontend/bun.lock index 605fbb9158..a303179791 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -26,6 +26,8 @@ "@connectrpc/connect-web": "^2.1.0", "@dagrejs/dagre": "^3.0.0", "@emotion/css": "^11.13.5", + "@fontsource/geist-mono": "^5.3.0", + "@fontsource/inter": "^5.3.0", "@hello-pangea/dnd": "^18.0.1", "@hookform/resolvers": "^5.2.2", "@icons-pack/react-simple-icons": "^13.8.0", @@ -54,6 +56,7 @@ "es-cookie": "^1.5.0", "hast": "^1.0.0", "hast-util-to-jsx-runtime": "^2.3.6", + "inter-ui": "^4.1.1", "js-base64": "^3.7.8", "json-bigint": "^1.0.0", "lottie-react": "^2.4.1", @@ -471,7 +474,9 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], - "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], + "@fontsource/geist-mono": ["@fontsource/geist-mono@5.3.0", "", {}, "sha512-UtJ1BBBCVpMYdIcW7nEB45UAoAw5M53ZXs2t0ciPW+IokuAAIc56M8+kW5tXbRJCTpDw4XTtU7proT6NdQAHTg=="], + + "@fontsource/inter": ["@fontsource/inter@5.3.0", "", {}, "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g=="], "@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="], @@ -1905,6 +1910,8 @@ "inline-style-parser": ["inline-style-parser@0.1.1", "", {}, "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q=="], + "inter-ui": ["inter-ui@4.1.1", "", {}, "sha512-451h0J29HyOmA+JXgSi/6M12tL7ZCZ8arYKZUXiOXTJpJbAKqJvFh3k5SiV3x7tKe0C0KyrKUUiQIvvZ2PQDcA=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -3159,6 +3166,8 @@ "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "@redpanda-data/ui/@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], + "@redpanda-data/ui/@hookform/resolvers": ["@hookform/resolvers@3.10.0", "", { "peerDependencies": { "react-hook-form": "^7.0.0" } }, "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag=="], "@redpanda-data/ui/react-day-picker": ["react-day-picker@8.10.1", "", { "peerDependencies": { "date-fns": "^2.28.0 || ^3.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA=="], diff --git a/frontend/package.json b/frontend/package.json index d975108cbd..4f9145bd5c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -67,6 +67,8 @@ "@connectrpc/connect-web": "^2.1.0", "@dagrejs/dagre": "^3.0.0", "@emotion/css": "^11.13.5", + "@fontsource/geist-mono": "^5.3.0", + "@fontsource/inter": "^5.3.0", "@hello-pangea/dnd": "^18.0.1", "@hookform/resolvers": "^5.2.2", "@icons-pack/react-simple-icons": "^13.8.0", @@ -95,6 +97,7 @@ "es-cookie": "^1.5.0", "hast": "^1.0.0", "hast-util-to-jsx-runtime": "^2.3.6", + "inter-ui": "^4.1.1", "js-base64": "^3.7.8", "json-bigint": "^1.0.0", "lottie-react": "^2.4.1", diff --git a/frontend/src/components/layout/header.tsx b/frontend/src/components/layout/header.tsx index 75b5e63895..3ed55320eb 100644 --- a/frontend/src/components/layout/header.tsx +++ b/frontend/src/components/layout/header.tsx @@ -9,7 +9,7 @@ * by the Apache License, Version 2.0 */ -import { Button, ColorModeSwitch, CopyButton } from '@redpanda-data/ui'; +import { ColorModeSwitch } from '@redpanda-data/ui'; import { Link, useLocation, useMatchRoute, useRouter } from '@tanstack/react-router'; import { cn } from 'components/redpanda-ui/lib/utils'; import { ChevronLeft } from 'lucide-react'; @@ -28,8 +28,10 @@ import { BreadcrumbSeparator, } from '../redpanda-ui/components/breadcrumb'; import { Button as RegistryButton } from '../redpanda-ui/components/button'; +import { CopyButton } from '../redpanda-ui/components/copy-button'; import { Separator } from '../redpanda-ui/components/separator'; import { SidebarTrigger } from '../redpanda-ui/components/sidebar'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../redpanda-ui/components/tooltip'; type BreadcrumbHeaderRowProps = { useNewSidebar: boolean; @@ -97,9 +99,8 @@ function AppPageHeader() { return null; } - // Embedded, the breadcrumb row holds nothing — the host draws the breadcrumb and there - // is no sidebar trigger — so with the title row hidden this would be a bare divider - // above a page that already has its own title bar. + // Embedded, the breadcrumb row holds nothing (the host draws the breadcrumb, and there is + // no sidebar trigger), so without the title row the header is a bare divider. if (hideTitleRow && isEmbedded()) { return null; } @@ -143,21 +144,27 @@ function AppPageHeader() {
- {!isEmbedded() && api.isRedpanda && ( - - - - )} + {!isEmbedded() && + api.isRedpanda && + (api.userData?.canViewDebugBundle ? ( + Debug bundle} variant="ghost" /> + ) : ( + + + {/* span wrapper: a disabled button swallows pointer events, so it can't anchor the tooltip itself */} + + + Debug bundle + + + } + /> + You need RedpandaCapability.MANAGE_DEBUG_BUNDLE permission + + + ))} {IsDev && !isEmbedded() && }
@@ -169,11 +176,10 @@ function AppPageHeader() { export default AppPageHeader; /** - * Whether the matched route draws its own title bar (`staticData.breadcrumbOnlyHeader`), - * so the header shows only the breadcrumb row. + * Whether the matched route draws its own title bar (`staticData.breadcrumbOnlyHeader`). * * Resolved from the pathname rather than `useMatches()`: committed matches lag the - * location by a render on soft navigation, which would flash the title row on the way in. + * location by a render on soft navigation, flashing the title row on the way in. */ function useRouteOwnsTitleRow() { const router = useRouter(); diff --git a/frontend/src/components/misc/buttons/data-refresh/component.tsx b/frontend/src/components/misc/buttons/data-refresh/component.tsx index b48340a4d7..eeeefa8a02 100644 --- a/frontend/src/components/misc/buttons/data-refresh/component.tsx +++ b/frontend/src/components/misc/buttons/data-refresh/component.tsx @@ -9,8 +9,10 @@ * by the Apache License, Version 2.0 */ -import { Box, Flex, IconButton, Popover, Spinner, Text } from '@redpanda-data/ui'; -import { PauseIcon, PlayIcon, RefreshIcon } from 'components/icons'; +import { Button } from 'components/redpanda-ui/components/button'; +import { Spinner } from 'components/redpanda-ui/components/spinner'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; +import { Pause, Play, RefreshCw } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { appGlobal } from '../../../../state/app-global'; @@ -43,19 +45,15 @@ export const DataRefreshButton = () => { let newRemainingSeconds = 0; if (stateRef.current.isActive && currentRequests === 0) { - if (currentRequests > 0) { - // Active requests — delay the next refresh - stateRef.current.nextRefresh = Date.now() + AUTO_REFRESH_INTERVAL_SECS * 1000; + const timeUntilRefresh = stateRef.current.nextRefresh - Date.now(); + if (timeUntilRefresh > 0) { + newRemainingSeconds = Math.ceil(timeUntilRefresh / 1000); } else { - const timeUntilRefresh = stateRef.current.nextRefresh - Date.now(); - if (timeUntilRefresh > 0) { - newRemainingSeconds = Math.ceil(timeUntilRefresh / 1000); - } else { - stateRef.current.nextRefresh = Date.now() + AUTO_REFRESH_INTERVAL_SECS * 1000; - appGlobal.onRefresh(); - } + stateRef.current.nextRefresh = Date.now() + AUTO_REFRESH_INTERVAL_SECS * 1000; + appGlobal.onRefresh(); } } else if (stateRef.current.isActive && currentRequests > 0) { + // Active requests — delay the next refresh stateRef.current.nextRefresh = Date.now() + AUTO_REFRESH_INTERVAL_SECS * 1000; } @@ -83,62 +81,63 @@ export const DataRefreshButton = () => { const countStr = maxRequestCount > 1 ? `${maxRequestCount - activeRequests} / ${maxRequestCount}` : ''; return ( -
- - - Enable or disable automatic refresh every {AUTO_REFRESH_INTERVAL_SECS}s. -
- } - hideCloseButton={true} - isInPortal - placement="bottom" - title="Auto Refresh" - > - : } - onClick={toggleAutorefresh} - p={0} - size="xs" - variant="ghost" + +
+ + + {isActive ? : } + + } /> - - - + +
+ Auto refresh + Automatically refresh the data on this page every {AUTO_REFRESH_INTERVAL_SECS}s. +
+
+
{isActive || activeRequests > 0 ? ( - + ) : ( - - Click to force a refresh of the data shown in the current page. When switching pages, any data older - than {prettyMilliseconds(REST_CACHE_DURATION_SEC * 1000)} will be - refreshed automatically. -
- } - hideCloseButton={true} - isInPortal - placement="bottom" - title="Force Refresh" - > - } - onClick={() => appGlobal.onRefresh()} - p={0} - size="xs" - variant="ghost" + + appGlobal.onRefresh()} + size="icon" + variant="ghost" + > + + + } /> - + +
+ Force refresh + + Refresh the data shown on this page. When switching pages, any data older than{' '} + {prettyMilliseconds(REST_CACHE_DURATION_SEC * 1000)} is refreshed automatically. + +
+
+
)} - - - {isActive && activeRequests === 0 && <>Refreshing in {remainingSeconds} secs} - {activeRequests > 0 && <>Fetching data... {countStr}} - - + + {isActive && activeRequests === 0 ? <>Refreshing in {remainingSeconds} secs : null} + {activeRequests > 0 ? <>Fetching data... {countStr} : null} + + +
); }; diff --git a/frontend/src/components/misc/page-content.tsx b/frontend/src/components/misc/page-content.tsx index f6d25eaef3..247bd9a4ac 100644 --- a/frontend/src/components/misc/page-content.tsx +++ b/frontend/src/components/misc/page-content.tsx @@ -1,4 +1,3 @@ -import { Stack } from '@redpanda-data/ui'; import { motion } from 'motion/react'; import type { ReactNode } from 'react'; @@ -12,7 +11,7 @@ export type PageContentProps = { function PageContent(props: PageContentProps) { return ( - {props.children} +
{props.children}
); } diff --git a/frontend/src/components/pages/connect/overview.test.tsx b/frontend/src/components/pages/connect/overview.test.tsx new file mode 100644 index 0000000000..f6257c6db0 --- /dev/null +++ b/frontend/src/components/pages/connect/overview.test.tsx @@ -0,0 +1,53 @@ +/** + * 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 { createRouterTransport } from '@connectrpc/connect'; +import { isEmbedded } from 'config'; +import { ListPipelinesResponseSchema } from 'protogen/redpanda/api/console/v1alpha1/pipeline_pb'; +import { listPipelines } from 'protogen/redpanda/api/console/v1alpha1/pipeline-PipelineService_connectquery'; +import { renderWithFileRoutes, screen, waitFor } from 'test-utils'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('config', async (importOriginal) => ({ + ...(await importOriginal()), + isEmbedded: vi.fn(), +})); + +import KafkaConnectOverview from './overview'; +import { useSupportedFeaturesStore } from '../../../state/supported-features'; + +const NEW_LIST_CTA = 'Create a pipeline'; + +const transport = createRouterTransport(({ rpc }) => { + rpc(listPipelines, () => create(ListPipelinesResponseSchema, { response: {} })); +}); + +const renderPage = (embedded: boolean) => { + vi.mocked(isEmbedded).mockReturnValue(embedded); + renderWithFileRoutes(, { transport }); +}; + +describe('Connect overview mount', () => { + it('renders the new pipelines list in Cloud', async () => { + renderPage(true); + + await waitFor(() => expect(screen.getByRole('button', { name: NEW_LIST_CTA })).toBeInTheDocument()); + }); + + it('keeps the legacy path when not embedded', async () => { + useSupportedFeaturesStore.setState({ pipelinesApi: false }); + renderPage(false); + + await waitFor(() => expect(screen.getByText('Using Redpanda Connect')).toBeInTheDocument()); + expect(screen.queryByRole('button', { name: NEW_LIST_CTA })).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/pages/connect/overview.tsx b/frontend/src/components/pages/connect/overview.tsx index af42c009e9..64fb3ca171 100644 --- a/frontend/src/components/pages/connect/overview.tsx +++ b/frontend/src/components/pages/connect/overview.tsx @@ -10,7 +10,7 @@ */ import { create } from '@bufbuild/protobuf'; -import { Box, DataTable, Stack, Tooltip } from '@redpanda-data/ui'; +import { Box, DataTable, Tooltip } from '@redpanda-data/ui'; import ErrorResult from 'components/misc/error-result'; import { Badge } from 'components/redpanda-ui/components/badge'; import { Link } from 'components/redpanda-ui/components/typography'; @@ -29,7 +29,7 @@ import { TaskState, TasksColumn, } from './helper'; -import { isServerless } from '../../../config'; +import { isEmbedded, isServerless } from '../../../config'; import { ListSecretScopesRequestSchema } from '../../../protogen/redpanda/api/dataplane/v1/secret_pb'; import { appGlobal } from '../../../state/app-global'; import { api, rpcnSecretManagerApi } from '../../../state/backend-api'; @@ -42,6 +42,7 @@ import SearchBar from '../../misc/search-bar'; import Section from '../../misc/section'; import Tabs, { type Tab } from '../../misc/tabs/tabs'; import { PageComponent, type PageInitHelper } from '../page'; +import { PipelineListPage } from '../rp-connect/pipeline/list'; import RpConnectPipelinesList from '../rp-connect/pipelines-list'; import { RedpandaConnectIntro } from '../rp-connect/redpanda-connect-intro'; @@ -140,6 +141,10 @@ class KafkaConnectOverview extends PageComponent<{ } render() { + // Cloud gets the new list; self-hosted keeps the tabs below. + if (isEmbedded()) { + return ; + } if (this.props.isLoadingKafkaConnectors) { return ; } @@ -482,13 +487,13 @@ export const TabKafkaConnect = (_p: {}) => { } return ( - +
settings.selectedTab} selectedTabKey={settings.selectedTab} tabs={connectTabs} />
- +
); }; diff --git a/frontend/src/components/pages/rp-connect/pipeline/index.tsx b/frontend/src/components/pages/rp-connect/pipeline/index.tsx index 5f61310ccf..9c9c48480e 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/index.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/index.tsx @@ -542,7 +542,7 @@ function YamlViewPanel({ function ViewModePanel({ pipeline }: { pipeline: Pipeline | undefined }) { if (!pipeline) { return ( -
Loading pipeline...
+
Loading pipeline...
); } const showThroughput = @@ -551,7 +551,9 @@ function ViewModePanel({ pipeline }: { pipeline: Pipeline | undefined }) { ? isFeatureFlagEnabled('enableDataplaneObservabilityServerless') : isFeatureFlagEnabled('enableDataplaneObservability')); return ( -
+ // Natural height on purpose: this lane scrolls with the page (see the container note in + // PipelinePageContent), so logs pagination sits right below the table. +
{showThroughput ? ( <> @@ -1140,6 +1142,8 @@ function PipelinePageContent() { [mode, selectedNodeId, requestRevealNode, setActiveViewLane, setActiveEditLane, editorStore] ); + const isMonitorLane = mode === 'view' && activeViewLane === 'monitor'; + // Empty while a view-mode pipeline is still loading, or in edit mode without the visual editor. const lanes = useMemo(() => { if (mode === 'view') { @@ -1165,11 +1169,15 @@ function PipelinePageContent() { }, [mode, pipeline, isVisualEditorEnabled, goToYamlNode, setActiveViewLane, setActiveEditLane]); return ( - // Viewport-bounded height (page-fill-viewport, globals.css) so a tall lane scrolls - // within the framed panel. + // Editor lanes are viewport-bounded (page-fill-viewport, globals.css): Monaco needs a bounded + // box, and a tall lane scrolls inside the framed panel. The Monitor lane instead flows with + // the document, keeping its logs pagination out from behind an inner fold. // The -ml-3.5/pl-3.5 pair keeps the back button's overhang inside the overflow-x-clip region.
{mode === 'view' && pipeline ? ( @@ -1235,16 +1243,23 @@ function PipelinePageContent() { {/* min-w-0 + overflow-hidden keep the editor region from propagating width upward. */}
{showSidebar ? ( - setAddConnectorType(type)} - onBrowseTemplates={isTemplateGalleryEnabled ? () => setIsTemplateDialogOpen(true) : undefined} - onOpenCommandMenu={handleCommandMenuOpen} - unsavedNodeIds={unsavedNodeIds} - yamlContent={yamlContent} - /> + // The monitor lane is document-height, so the structure tree must contribute no + // intrinsic height — a huge pipeline would stretch the page far past the metrics. + // Positioned absolutely, it adopts the metrics column's height and scrolls itself. +
+
+ setAddConnectorType(type)} + onBrowseTemplates={isTemplateGalleryEnabled ? () => setIsTemplateDialogOpen(true) : undefined} + onOpenCommandMenu={handleCommandMenuOpen} + unsavedNodeIds={unsavedNodeIds} + yamlContent={yamlContent} + /> +
+
) : null}
{mode === 'view' && activeViewLane === 'monitor' ? : null} diff --git a/frontend/src/components/pages/rp-connect/pipeline/list-utils.test.ts b/frontend/src/components/pages/rp-connect/pipeline/list-utils.test.ts new file mode 100644 index 0000000000..92861b3ec0 --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/list-utils.test.ts @@ -0,0 +1,104 @@ +/** + * 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 { Pipeline_State } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; +import { describe, expect, it } from 'vitest'; + +import { + aggregateConnectors, + countPipelinesPerTab, + matchesNameOrId, + PIPELINE_STATE_TABS, + pipelineListEmptyText, +} from './list-utils'; + +describe('aggregateConnectors', () => { + it('collapses duplicates into counts, preserving first-appearance order', () => { + expect(aggregateConnectors(['redpanda', 'redpanda', 's3', 'redpanda', 'http_client'])).toEqual([ + { name: 'redpanda', count: 3 }, + { name: 's3', count: 1 }, + { name: 'http_client', count: 1 }, + ]); + }); + + it('returns an empty array for no connectors', () => { + expect(aggregateConnectors([])).toEqual([]); + }); + + it('keeps single connectors at count 1', () => { + expect(aggregateConnectors(['generate'])).toEqual([{ name: 'generate', count: 1 }]); + }); +}); + +describe('PIPELINE_STATE_TABS', () => { + it('assigns transitional states to their destination tab', () => { + const running = PIPELINE_STATE_TABS.find((t) => t.id === 'running'); + const stopped = PIPELINE_STATE_TABS.find((t) => t.id === 'stopped'); + expect(running?.states).toContain(Pipeline_State.STARTING); + expect(stopped?.states).toContain(Pipeline_State.STOPPING); + }); + + it('covers every state except UNSPECIFIED across the non-all tabs', () => { + const covered = new Set(PIPELINE_STATE_TABS.flatMap((t) => t.states ?? [])); + const allStates = Object.values(Pipeline_State).filter((v): v is Pipeline_State => typeof v === 'number'); + for (const state of allStates) { + if (state !== Pipeline_State.UNSPECIFIED) { + expect(covered).toContain(state); + } + } + }); +}); + +describe('countPipelinesPerTab', () => { + it('counts states per tab with all as the total', () => { + const counts = countPipelinesPerTab([ + Pipeline_State.RUNNING, + Pipeline_State.STARTING, + Pipeline_State.STOPPED, + Pipeline_State.ERROR, + Pipeline_State.UNSPECIFIED, + ]); + expect(counts).toEqual({ all: 5, running: 2, stopped: 1, error: 1 }); + }); +}); + +describe('pipelineListEmptyText', () => { + it('reports the filter miss whenever filters are active', () => { + expect(pipelineListEmptyText({ hasActiveFilters: true, activeTab: 'all', totalPipelines: 12 })).toBe( + 'No pipelines match the current filters' + ); + }); + + it('stays silent on an unfiltered All view that still has pipelines (page index about to clamp)', () => { + expect(pipelineListEmptyText({ hasActiveFilters: false, activeTab: 'all', totalPipelines: 12 })).toBeNull(); + }); + + it('uses the tab copy for an empty tab and for no pipelines at all', () => { + expect(pipelineListEmptyText({ hasActiveFilters: false, activeTab: 'error', totalPipelines: 12 })).toBe( + 'No pipelines with errors' + ); + expect(pipelineListEmptyText({ hasActiveFilters: false, activeTab: 'all', totalPipelines: 0 })).toBe( + 'You have no Redpanda Connect pipelines' + ); + }); +}); + +describe('matchesNameOrId', () => { + it('matches case-insensitively on name and id', () => { + expect(matchesNameOrId('ORDERS', 'orders-enrichment', 'd9abc')).toBe(true); + expect(matchesNameOrId('d9ab', 'orders-enrichment', 'D9ABC')).toBe(true); + expect(matchesNameOrId('nope', 'orders-enrichment', 'd9abc')).toBe(false); + }); + + it('treats blank searches as match-all', () => { + expect(matchesNameOrId(' ', 'anything', 'id')).toBe(true); + }); +}); diff --git a/frontend/src/components/pages/rp-connect/pipeline/list-utils.ts b/frontend/src/components/pages/rp-connect/pipeline/list-utils.ts new file mode 100644 index 0000000000..004035fc0c --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/list-utils.ts @@ -0,0 +1,111 @@ +/** + * 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 { Pipeline_State } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; + +export type ConnectorCount = { + name: string; + count: number; +}; + +/** ["redpanda", "redpanda", "s3"] → [{ name: "redpanda", count: 2 }, { name: "s3", count: 1 }]. */ +export function aggregateConnectors(names: string[]): ConnectorCount[] { + const byName = new Map(); + for (const name of names) { + const existing = byName.get(name); + if (existing) { + existing.count += 1; + } else { + byName.set(name, { name, count: 1 }); + } + } + return [...byName.values()]; +} + +export type PipelineStateTabId = 'all' | 'running' | 'stopped' | 'error'; + +export type PipelineStateTab = { + id: PipelineStateTabId; + label: string; + /** States the tab shows; undefined means no state filtering. */ + states?: Pipeline_State[]; + emptyText: string; +}; + +// Transitional states ride with their destination: starting counts as running, stopping as stopped. +export const PIPELINE_STATE_TABS: PipelineStateTab[] = [ + { id: 'all', label: 'All', emptyText: 'You have no Redpanda Connect pipelines' }, + { + id: 'running', + label: 'Running', + states: [Pipeline_State.RUNNING, Pipeline_State.STARTING], + emptyText: 'No running pipelines', + }, + { + id: 'stopped', + label: 'Stopped', + states: [Pipeline_State.STOPPED, Pipeline_State.STOPPING, Pipeline_State.COMPLETED], + emptyText: 'No stopped pipelines', + }, + { + id: 'error', + label: 'Error', + states: [Pipeline_State.ERROR], + emptyText: 'No pipelines with errors', + }, +]; + +// Inverted once, so counting is a single pass over the rows rather than one scan per tab. +const TAB_BY_STATE = new Map( + PIPELINE_STATE_TABS.flatMap((tab) => (tab.states ?? []).map((state) => [state, tab.id] as const)) +); + +export function countPipelinesPerTab(states: Pipeline_State[]): Record { + const counts: Record = { all: states.length, running: 0, stopped: 0, error: 0 }; + for (const state of states) { + const tabId = TAB_BY_STATE.get(state); + if (tabId) { + counts[tabId] += 1; + } + } + return counts; +} + +/** + * What to show when no rows are visible. Null for an unfiltered All view that has pipelines — a + * stale page index about to be clamped, which must not flash an empty message. + */ +export function pipelineListEmptyText({ + hasActiveFilters, + activeTab, + totalPipelines, +}: { + hasActiveFilters: boolean; + activeTab: PipelineStateTabId; + totalPipelines: number; +}): string | null { + if (hasActiveFilters) { + return 'No pipelines match the current filters'; + } + if (activeTab === 'all' && totalPipelines > 0) { + return null; + } + return PIPELINE_STATE_TABS.find((t) => t.id === activeTab)?.emptyText ?? null; +} + +/** Case-insensitive substring match over a pipeline's display name and id. */ +export function matchesNameOrId(search: string, name: string, id: string): boolean { + const needle = search.trim().toLowerCase(); + if (!needle) { + return true; + } + return name.toLowerCase().includes(needle) || id.toLowerCase().includes(needle); +} diff --git a/frontend/src/components/pages/rp-connect/pipeline/list.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/list.test.tsx new file mode 100644 index 0000000000..16a7dcd7ee --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/list.test.tsx @@ -0,0 +1,290 @@ +/** + * 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 { createRouterTransport } from '@connectrpc/connect'; +import userEvent from '@testing-library/user-event'; +import { ListPipelinesResponseSchema } from 'protogen/redpanda/api/console/v1alpha1/pipeline_pb'; +import { listPipelines } from 'protogen/redpanda/api/console/v1alpha1/pipeline-PipelineService_connectquery'; +import { + ListPipelinesResponseSchema as DataPlaneListPipelinesResponseSchema, + Pipeline_State, + PipelineSchema, +} from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; +import { renderWithFileRoutes, screen, waitFor, within } from 'test-utils'; + +vi.mock('config', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + config: { jwt: 'test-jwt-token' }, + isEmbedded: vi.fn(() => false), + isFeatureFlagEnabled: vi.fn(() => false), + }; +}); + +import { PipelineListPage } from './list'; + +const yamlFor = ({ inputs, outputs }: { inputs: string[]; outputs: string[] }) => ` +input: + broker: + inputs: +${inputs.map((i) => ` - ${i}: {}`).join('\n')} +output: + broker: + outputs: +${outputs.map((o) => ` - ${o}: {}`).join('\n')} +`; + +type Fixture = { + id: string; + displayName: string; + state: Pipeline_State; + inputs: string[]; + outputs: string[]; + tags?: Record; +}; + +const FIXTURES: Fixture[] = [ + { + id: 'aaa111', + displayName: 'orders-enrichment', + state: Pipeline_State.RUNNING, + inputs: ['redpanda', 'redpanda'], + outputs: ['s3'], + tags: { env: 'prod' }, + }, + { + id: 'bbb222', + displayName: 'clickstream-sink', + state: Pipeline_State.ERROR, + inputs: ['http_client'], + outputs: ['redpanda'], + }, + { + id: 'ccc333', + displayName: 'nightly-export', + state: Pipeline_State.STOPPED, + inputs: ['generate'], + outputs: ['gcp_cloud_storage'], + }, + { + id: 'ddd444', + displayName: 'agent-helper', + state: Pipeline_State.RUNNING, + inputs: ['generate'], + outputs: ['s3'], + // Agents are filtered out of the pipeline list entirely. + tags: { __redpanda_cloud_pipeline_type: 'agent' }, + }, +]; + +// Two pages, so the test exercises the drain the list renders behind. +const buildTransport = () => + createRouterTransport(({ rpc }) => { + rpc(listPipelines, (req) => { + const pageToken = req.request?.pageToken ?? ''; + const page = pageToken === '' ? FIXTURES.slice(0, 2) : FIXTURES.slice(2); + return create(ListPipelinesResponseSchema, { + response: create(DataPlaneListPipelinesResponseSchema, { + pipelines: page.map((f) => + create(PipelineSchema, { + id: f.id, + displayName: f.displayName, + state: f.state, + tags: f.tags ?? {}, + configYaml: yamlFor(f), + }) + ), + nextPageToken: pageToken === '' ? 'page2' : '', + }), + }); + }); + }); + +const renderList = () => renderWithFileRoutes(, { transport: buildTransport() }); + +const tab = (name: string) => screen.getByRole('tab', { name: new RegExp(`^${name}`) }); + +const rowFor = (displayName: string) => { + const row = screen.getByText(displayName).closest('tr'); + if (!row) { + throw new Error(`no row for ${displayName}`); + } + return row; +}; + +const SEARCH_INPUT_RE = /search pipelines/i; +const CLEAR_FILTERS_RE = /clear filters/i; + +// Every row links to its pipeline, so the link text is the visible row set. +const visibleLinkNames = () => + screen + .getAllByRole('link') + .map((link) => link.textContent ?? '') + .filter((text) => text !== 'Learn more'); + +describe('PipelineListPage', () => { + it('renders every drained page and skips agent pipelines', async () => { + renderList(); + + await waitFor(() => { + expect(screen.getByText('nightly-export')).toBeInTheDocument(); + }); + + // Default sort puts problems first: error, then running, then stopped. + expect(visibleLinkNames()).toEqual(['clickstream-sink', 'orders-enrichment', 'nightly-export']); + expect(screen.queryByText('agent-helper')).not.toBeInTheDocument(); + }); + + it('counts each status tab over the drained rows', async () => { + renderList(); + + await waitFor(() => { + expect(screen.getByText('nightly-export')).toBeInTheDocument(); + }); + + expect(tab('All')).toHaveTextContent('3'); + expect(tab('Running')).toHaveTextContent('1'); + expect(tab('Stopped')).toHaveTextContent('1'); + expect(tab('Error')).toHaveTextContent('1'); + }); + + it('narrows rows to the selected status tab', async () => { + const user = userEvent.setup(); + renderList(); + + await waitFor(() => { + expect(screen.getByText('nightly-export')).toBeInTheDocument(); + }); + + await user.click(tab('Error')); + + await waitFor(() => { + expect(visibleLinkNames()).toEqual(['clickstream-sink']); + }); + + await user.click(tab('Stopped')); + + await waitFor(() => { + expect(visibleLinkNames()).toEqual(['nightly-export']); + }); + }); + + it('filters on name or id, and the tab counts follow the search', async () => { + const user = userEvent.setup(); + renderList(); + + await waitFor(() => { + expect(screen.getByText('nightly-export')).toBeInTheDocument(); + }); + + // Matches an id, not a display name — the search covers both. + await user.type(screen.getByRole('textbox', { name: SEARCH_INPUT_RE }), 'bbb2'); + + await waitFor(() => { + expect(visibleLinkNames()).toEqual(['clickstream-sink']); + }); + expect(tab('All')).toHaveTextContent('1'); + expect(tab('Running')).toHaveTextContent('0'); + + await user.click(screen.getByRole('button', { name: CLEAR_FILTERS_RE })); + + await waitFor(() => { + expect(visibleLinkNames()).toHaveLength(3); + }); + }); + + it('shows the tab-specific empty message when a status has no pipelines', async () => { + const user = userEvent.setup(); + renderList(); + + await waitFor(() => { + expect(screen.getByText('nightly-export')).toBeInTheDocument(); + }); + + await user.type(screen.getByRole('textbox', { name: SEARCH_INPUT_RE }), 'orders'); + await user.click(tab('Error')); + + await waitFor(() => { + expect(screen.getByText('No pipelines match the current filters')).toBeInTheDocument(); + }); + }); + + it('labels the status from the pipeline state, not the badge variant', async () => { + renderList(); + + await waitFor(() => { + expect(screen.getByText('nightly-export')).toBeInTheDocument(); + }); + + expect(within(rowFor('orders-enrichment')).getByText('Running')).toBeInTheDocument(); + expect(within(rowFor('clickstream-sink')).getByText('Error')).toBeInTheDocument(); + expect(within(rowFor('nightly-export')).getByText('Stopped')).toBeInTheDocument(); + }); + + it('points every status tab at the table region, labelled by the active tab', async () => { + const user = userEvent.setup(); + renderList(); + + await waitFor(() => { + expect(screen.getByText('nightly-export')).toBeInTheDocument(); + }); + + // One panel, filtered per tab — so every tab hands the reader off to the same region. + const panel = screen.getByRole('tabpanel'); + expect(panel).toContainElement(rowFor('nightly-export')); + for (const name of ['All', 'Running', 'Stopped', 'Error']) { + expect(tab(name)).toHaveAttribute('aria-controls', panel.id); + } + expect(panel).toHaveAttribute('aria-labelledby', tab('All').id); + + // The label follows the selection. + await user.click(tab('Error')); + await waitFor(() => { + expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', tab('Error').id); + }); + }); + + it('leaves modified clicks to the browser and navigates on a plain one', async () => { + const user = userEvent.setup(); + const { router } = renderList(); + + await waitFor(() => { + expect(screen.getByText('nightly-export')).toBeInTheDocument(); + }); + + // ⌘-click means "open in a new tab" — soft-navigating here would swallow it. + const description = within(rowFor('orders-enrichment')).getByText('aaa111'); + await user.keyboard('{Meta>}'); + await user.click(description); + await user.keyboard('{/Meta}'); + expect(router.state.location.pathname).toBe('/'); + + await user.click(description); + await waitFor(() => { + expect(router.state.location.pathname).toBe('/rp-connect/aaa111'); + }); + }); + + it('collapses repeated connectors into a single badge with a multiplier', async () => { + renderList(); + + await waitFor(() => { + expect(screen.getByText('orders-enrichment')).toBeInTheDocument(); + }); + const row = rowFor('orders-enrichment'); + + // Two `redpanda` inputs render as one badge carrying ×2. + expect(within(row).getByText('×2')).toBeInTheDocument(); + expect(within(row).getAllByText('redpanda')).toHaveLength(1); + }); +}); diff --git a/frontend/src/components/pages/rp-connect/pipeline/list.tsx b/frontend/src/components/pages/rp-connect/pipeline/list.tsx index 4c2419352f..8feb38f7f6 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/list.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/list.tsx @@ -12,7 +12,7 @@ import { create } from '@bufbuild/protobuf'; import { ConnectError } from '@connectrpc/connect'; import { Link as TanStackRouterLink, useNavigate } from '@tanstack/react-router'; -import type { ColumnDef } from '@tanstack/react-table'; +import type { ColumnDef, FilterFn, SortingState } from '@tanstack/react-table'; import { flexRender, getCoreRowModel, @@ -20,6 +20,7 @@ import { getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, + getSortedRowModel, useReactTable, } from '@tanstack/react-table'; import type { ComponentName } from 'assets/connectors/component-logo-map'; @@ -27,8 +28,12 @@ import { getUserTagEntries } from 'components/constants'; import { Badge } from 'components/redpanda-ui/components/badge'; import { BadgeGroup } from 'components/redpanda-ui/components/badge-group'; import { Button } from 'components/redpanda-ui/components/button'; -import { DataTablePagination } from 'components/redpanda-ui/components/data-table'; -import { DataTableFilter, type FilterColumnConfig } from 'components/redpanda-ui/components/data-table-filter'; +import { + DataTableColumnHeader, + DataTableFacetedFilter, + DataTablePagination, + isRowActivationClick, +} from 'components/redpanda-ui/components/data-table'; import { DropdownMenu, DropdownMenuContent, @@ -36,26 +41,34 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from 'components/redpanda-ui/components/dropdown-menu'; +import { Input, InputStart } from 'components/redpanda-ui/components/input'; import { Skeleton } from 'components/redpanda-ui/components/skeleton'; import { Spinner } from 'components/redpanda-ui/components/spinner'; import { StatusBadge, type StatusBadgeVariant } from 'components/redpanda-ui/components/status-badge'; -import { StatusDot } from 'components/redpanda-ui/components/status-dot'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'components/redpanda-ui/components/table'; import { Tabs, TabsContent, TabsContents, TabsList, TabsTrigger } from 'components/redpanda-ui/components/tabs'; import { Link, List, ListItem } from 'components/redpanda-ui/components/typography'; -import { createFilterFn } from 'components/redpanda-ui/lib/filter-utils'; -import { useDataTableFilter } from 'components/redpanda-ui/lib/use-data-table-filter'; import { cn } from 'components/redpanda-ui/lib/utils'; import { DeleteResourceAlertDialog, DeleteResourceMenuItem } from 'components/ui/delete-resource-alert-dialog'; -import { PIPELINE_STATE_OPTIONS, STARTABLE_STATES, STOPPABLE_STATES } from 'components/ui/pipeline/constants'; -import { AlertCircle, Box, MoreHorizontal } from 'lucide-react'; +import { FadePresence } from 'components/ui/fade-presence'; +import { PIPELINE_STATE_LABELS, STARTABLE_STATES, STOPPABLE_STATES } from 'components/ui/pipeline/constants'; +import { AlertCircle, Box, MoreHorizontal, Search, X } from 'lucide-react'; import { DeletePipelineRequestSchema, StartPipelineRequestSchema, StopPipelineRequestSchema, } from 'protogen/redpanda/api/console/v1alpha1/pipeline_pb'; import { type Pipeline as APIPipeline, Pipeline_State } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; -import { memo, useCallback, useMemo, useState } from 'react'; +import { + type MouseEvent, + memo, + type ReactElement, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useState, +} from 'react'; import { useKafkaConnectConnectorsQuery } from 'react-query/api/kafka-connect'; import { useDeletePipelineMutation, @@ -68,6 +81,14 @@ import { useResetRpcnWizardStore } from 'state/rpcn-wizard-store'; import { docsLinks } from 'utils/docs-links'; import { formatToastErrorMessageGRPC } from 'utils/toast.utils'; +import { + aggregateConnectors, + countPipelinesPerTab, + matchesNameOrId, + PIPELINE_STATE_TABS, + type PipelineStateTabId, + pipelineListEmptyText, +} from './list-utils'; import { TabKafkaConnect } from '../../connect/overview'; import { ConnectorLogo } from '../onboarding/connector-logo'; import { parseConfigComponents } from '../utils/yaml'; @@ -79,44 +100,86 @@ type Pipeline = { name: string; description: string; state: Pipeline_State; - configYaml: string; inputs: string[]; - processors: string[]; outputs: string[]; tags: TagPair[]; }; +// parseConfigComponents is a full YAML parse, and the transform re-runs over every row on each +// drain step and poll tick. Memoized per config text to keep that pass O(n). +const configComponentsCache = new Map>(); +const CONFIG_COMPONENTS_CACHE_LIMIT = 10_000; + +const parseConfigComponentsCached = (configYaml: string): ReturnType => { + const cached = configComponentsCache.get(configYaml); + if (cached) { + return cached; + } + if (configComponentsCache.size >= CONFIG_COMPONENTS_CACHE_LIMIT) { + // Oldest half (Map preserves insertion order) — clearing all would reparse everything next refresh. + let surplus = CONFIG_COMPONENTS_CACHE_LIMIT / 2; + for (const key of configComponentsCache.keys()) { + configComponentsCache.delete(key); + surplus -= 1; + if (surplus <= 0) { + break; + } + } + } + const parsed = parseConfigComponents(configYaml); + configComponentsCache.set(configYaml, parsed); + return parsed; +}; + const transformAPIPipeline = (apiPipeline: APIPipeline): Pipeline => { - const { inputs, processors, outputs } = parseConfigComponents(apiPipeline.configYaml); + const { inputs, outputs } = parseConfigComponentsCached(apiPipeline.configYaml); const tags = getUserTagEntries(apiPipeline.tags); return { id: apiPipeline.id, name: apiPipeline.displayName, description: apiPipeline.description, state: apiPipeline.state, - configYaml: apiPipeline.configYaml, inputs, - processors, outputs, tags, }; }; -/** - * Pairs each name with a unique React key by suffixing its occurrence index, - * since component names can repeat (e.g. two `redpanda` inputs). - * - * @param names - Component names, possibly containing duplicates. - * @returns One entry per input name, e.g. `["redpanda", "redpanda"]` → - * `[{ name: "redpanda", key: "redpanda-0" }, { name: "redpanda", key: "redpanda-1" }]`. - */ -const toKeyedNames = (names: string[]): { name: string; key: string }[] => { - const seen = new Map(); - return names.map((name) => { - const occurrence = seen.get(name) ?? 0; - seen.set(name, occurrence + 1); - return { name, key: `${name}-${occurrence}` }; - }); +const EmptyCell = () => ; + +// One string per tag pair, so `arrIncludesSome` and the facet counts agree on the value. +const tagFilterValue = (tag: TagPair) => `${tag.key}:${tag.value}`; + +// Duplicate connectors collapse into one badge with a multiplier ("redpanda ×2"). +const ConnectorBadges = ({ names }: { names: string[] }) => { + // `names` comes from the memoized transform, so this recomputes only when the pipeline changes. + const connectors = useMemo(() => aggregateConnectors(names), [names]); + if (connectors.length === 0) { + return ; + } + return ( + ( + + {connectors.slice(-overflow.length).map((c) => ( + {c.count > 1 ? `${c.name} ×${c.count}` : c.name} + ))} + + )} + > + {connectors.map((c) => ( + + + {/* One text node so name and multiplier share a baseline (as siblings they sit a pixel apart). */} + + {c.name} + {c.count > 1 ? ×{c.count} : null} + + + ))} + + ); }; const pipelineStateToStatusVariant: Record = { @@ -128,75 +191,115 @@ const pipelineStateToStatusVariant: Record = [Pipeline_State.RUNNING]: 'success', [Pipeline_State.UNSPECIFIED]: 'disabled', }; -const pipelineStateFilterIcon: Record> = { - [String(Pipeline_State.COMPLETED)]: (props) => , - [String(Pipeline_State.STARTING)]: (props) => , - [String(Pipeline_State.STOPPING)]: (props) => , - [String(Pipeline_State.STOPPED)]: (props) => , - [String(Pipeline_State.ERROR)]: (props) => , - [String(Pipeline_State.RUNNING)]: (props) => , - [String(Pipeline_State.UNSPECIFIED)]: (props) => , + +// autoRemove mirrors the built-in array filters: an empty selection means "no filter", not "match nothing". +const stateInFilterFn: FilterFn = (row, columnId, filterValue: string[]) => + filterValue.includes(row.getValue(columnId)); +stateInFilterFn.autoRemove = (value) => !value || (Array.isArray(value) && value.length === 0); + +// Status column order: problems and transitions before healthy pipelines, idle last. +const pipelineStateSortPriority: Record = { + [Pipeline_State.ERROR]: 0, + [Pipeline_State.STARTING]: 1, + [Pipeline_State.STOPPING]: 2, + [Pipeline_State.RUNNING]: 3, + [Pipeline_State.COMPLETED]: 4, + [Pipeline_State.STOPPED]: 5, + [Pipeline_State.UNSPECIFIED]: 6, }; const PAGE_SIZE = 20; +// Modified and non-primary clicks mean "open elsewhere", so a row leaves them to the browser — +// the name cell is a real link, so ⌘-click and middle-click still work there. +const isModifiedClick = (event: MouseEvent) => + event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0; + +// One table filtered per tab, not four panels, so the tabs own no panel of their own. Without an +// `aria-controls` target a screen reader announces "tab, 1 of 4" with nowhere to move into. +const STATUS_PANEL_ID = 'pipeline-status-panel'; +const statusTabId = (tabId: PipelineStateTabId) => `pipeline-status-tab-${tabId}`; + +/** + * Screen-reader counterpart to the status lines below the table. Those unmount as they animate, and a + * live region only announces changes made while already mounted. `sr-only` costs no layout. + */ +const ListStatusAnnouncements = ({ + isLoadingMorePages, + listErrorMessage, +}: { + isLoadingMorePages: boolean; + listErrorMessage: string | null; +}) => ( + <> +
+ {isLoadingMorePages ? 'Loading more pipelines' : ''} +
+
+ {listErrorMessage ?? ''} +
+ +); + +// One entry per real column, in order: header bar width paired with the cell placeholder beneath it. +const SKELETON_COLUMNS: { head: string; cell: ReactElement }[] = [ + { + head: 'w-24', + cell: ( +
+ + +
+ ), + }, + { head: 'w-16', cell: }, + { + head: 'w-20', + cell: ( +
+ + +
+ ), + }, + { head: 'w-16', cell: }, + { head: 'w-16', cell: }, + { head: 'w-8', cell: }, +]; + +const SKELETON_ROW_COUNT = 5; + const PipelineListSkeleton = () => (
+
+ + + + +
- - - - - - - - - - - - - - - - - - + {SKELETON_COLUMNS.map((column, columnIndex) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeletons + + + + ))} - {Array.from({ length: 5 }).map((_, i) => ( + {Array.from({ length: SKELETON_ROW_COUNT }, (_, rowIndex) => ( // biome-ignore lint/suspicious/noArrayIndexKey: static skeletons - - -
- - -
-
- - - - -
- - -
-
- - - - - - - - - + + {SKELETON_COLUMNS.map((column, columnIndex) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeletons + {column.cell} + ))} ))}
@@ -336,12 +439,27 @@ type CreateColumnsOptions = { isDeletingPipeline: boolean; }; -const ComponentBadge = ({ name }: { name: string }) => ( - - - {name} - -); +// Facet options are rebuilt on every drain page and poll tick, and a fresh function type would +// remount every logo in the open popover — so the icon component is cached per connector name. +const connectorIcons = new Map ReactElement>(); + +const connectorIcon = (name: string) => { + const cached = connectorIcons.get(name); + if (cached) { + return cached; + } + const Icon = (props: { className?: string }) => ( + + ); + connectorIcons.set(name, Icon); + return Icon; +}; + +const connectorOption = (name: string) => ({ + value: name, + label: name, + icon: connectorIcon(name), +}); const createColumns = ({ navigate, @@ -352,16 +470,17 @@ const createColumns = ({ }: CreateColumnsOptions): ColumnDef[] => [ { accessorKey: 'name', - header: 'Pipeline', - filterFn: createFilterFn('text'), + header: ({ column }) => , + filterFn: (row, _columnId, filterValue: string) => matchesNameOrId(filterValue, row.original.name, row.original.id), cell: ({ row }) => { const id = row.original.id; const name = row.getValue('name') as string; return ( -
+
+ {/* Rows navigate on click, so the name link underlines on hover only. */} {id !== name ? ( - + // select-all: one click selects the whole id for copying; the row's + // selection guard keeps that click from navigating. + {id} ) : null} @@ -380,101 +501,33 @@ const createColumns = ({ { accessorKey: 'inputs', header: 'Input', - filterFn: createFilterFn('multiOption'), - cell: ({ row }) => { - const inputs = toKeyedNames(row.getValue('inputs') as string[]); - if (inputs.length === 0) { - return null; - } - return ( - ( - - {inputs.slice(-overflow.length).map((o) => ( - {o.name} - ))} - - )} - > - {inputs.map((input) => ( - - ))} - - ); - }, - }, - { - accessorKey: 'processors', - header: 'Processors', - filterFn: createFilterFn('multiOption'), - cell: ({ row }) => { - const processors = toKeyedNames(row.getValue('processors') as string[]); - if (processors.length === 0) { - return null; - } - return ( - ( - - {processors.slice(-overflow.length).map((o) => ( - {o.name} - ))} - - )} - > - {processors.map((p) => ( - - ))} - - ); - }, + filterFn: 'arrIncludesSome', + // Without this, faceting keys on the array itself and the popover's per-option counts never + // resolve. Deduplicated per row, or two `redpanda` inputs would count 2 against a single row. + getUniqueValues: (row) => [...new Set(row.inputs)], + cell: ({ row }) => , }, { accessorKey: 'outputs', header: 'Output', - filterFn: createFilterFn('multiOption'), - cell: ({ row }) => { - const outputs = toKeyedNames(row.getValue('outputs') as string[]); - if (outputs.length === 0) { - return null; - } - return ( - ( - - {outputs.slice(-overflow.length).map((o) => ( - {o.name} - ))} - - )} - > - {outputs.map((o) => ( - - ))} - - ); - }, + filterFn: 'arrIncludesSome', + getUniqueValues: (row) => [...new Set(row.outputs)], + cell: ({ row }) => , }, { id: 'tags', - accessorFn: (row) => row.tags.map((t) => `${t.key}:${t.value}`), + accessorFn: (row) => row.tags.map(tagFilterValue), header: 'Tags', - filterFn: createFilterFn('multiOption'), + filterFn: 'arrIncludesSome', + getUniqueValues: (row) => row.tags.map(tagFilterValue), cell: ({ row }) => { const tags = row.original.tags; if (tags.length === 0) { - return null; + return ; } return ( ( {tags.slice(-overflow.length).map((t) => ( @@ -498,9 +551,19 @@ const createColumns = ({ { id: 'state', accessorFn: (row) => String(row.state), - header: 'Status', - filterFn: createFilterFn('option'), - cell: ({ row }) => , + header: ({ column }) => , + filterFn: stateInFilterFn, + // Enum values the generated Pipeline_State doesn't know yet sort last, not NaN. + sortingFn: (rowA, rowB) => + (pipelineStateSortPriority[rowA.original.state] ?? Number.MAX_SAFE_INTEGER) - + (pipelineStateSortPriority[rowB.original.state] ?? Number.MAX_SAFE_INTEGER), + // Label from the state, not from the badge variant: COMPLETED and RUNNING share the + // `success` variant, whose default copy is "Running" — wrong for a completed pipeline. + cell: ({ row }) => ( + + {PIPELINE_STATE_LABELS[row.original.state] ?? 'Unknown'} + + ), }, { id: 'actions', @@ -521,11 +584,14 @@ const createColumns = ({ const PipelineListPageContent = () => { const navigate = useNavigate(); const resetRpcnWizardStore = useResetRpcnWizardStore(); + // Sort by status so error and transitioning pipelines land on page 1 of a large cluster. + const [sorting, setSorting] = useState([{ id: 'state', desc: false }]); const { data: pipelinesData, isLoading, error, + hasNextPage, } = useListPipelinesQuery(undefined, { enableSmartPolling: true, }); @@ -555,79 +621,42 @@ const PipelineListPageContent = () => { [navigate, deleteMutation, startMutation, stopMutation, isDeletingPipeline] ); - const filterColumns = useMemo(() => { - const inputOptions = [...new Set(pipelines.flatMap((p) => p.inputs))].map((v) => ({ - value: v, - label: v, - })); - const processorOptions = [...new Set(pipelines.flatMap((p) => p.processors))].map((v) => ({ - value: v, - label: v, - })); - const outputOptions = [...new Set(pipelines.flatMap((p) => p.outputs))].map((v) => ({ - value: v, - label: v, - })); - const tagOptions = [...new Set(pipelines.flatMap((p) => p.tags.map((t) => `${t.key}:${t.value}`)))].map((v) => ({ - value: v, - label: v, - })); - const stateOptions = PIPELINE_STATE_OPTIONS.map((o) => ({ - value: o.value, - label: o.label, - icon: pipelineStateFilterIcon[o.value], - })); - - return [ - { - id: 'name', - displayName: 'Name', - type: 'text' as const, - placeholder: 'Search by name...', - }, - { - id: 'inputs', - displayName: 'Input', - type: 'multiOption' as const, - options: inputOptions, - }, - { - id: 'processors', - displayName: 'Processors', - type: 'multiOption' as const, - options: processorOptions, - }, - { - id: 'outputs', - displayName: 'Output', - type: 'multiOption' as const, - options: outputOptions, - }, - { - id: 'tags', - displayName: 'Tag', - displayNamePlural: 'Tags', - type: 'multiOption' as const, - options: tagOptions, - }, - { - id: 'state', - displayName: 'Status', - displayNamePlural: 'Statuses', - type: 'option' as const, - options: stateOptions, - }, - ]; - }, [pipelines]); + const inputOptions = useMemo( + () => [...new Set(pipelines.flatMap((p) => p.inputs))].map(connectorOption), + [pipelines] + ); + const outputOptions = useMemo( + () => [...new Set(pipelines.flatMap((p) => p.outputs))].map(connectorOption), + [pipelines] + ); + const tagOptions = useMemo( + () => [...new Set(pipelines.flatMap((p) => p.tags.map(tagFilterValue)))].map((v) => ({ value: v, label: v })), + [pipelines] + ); const table = useReactTable({ data: pipelines, columns, + // Id rather than row index keeps a row's identity fixed while pages stream in and the sort + // re-runs, so React reuses its DOM instead of repainting a shifted window. + getRowId: (row) => row.id, + // No column-visibility UI here; this also drops Hide from the column header menus. + enableHiding: false, + // Also drops the pagination footer's "X of N row(s) selected." text. + enableRowSelection: false, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getFacetedRowModel: getFacetedRowModel(), getFacetedUniqueValues: getFacetedUniqueValues(), getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + onSortingChange: setSorting, + // autoResetPageIndex would yank the user to page 1 on every drained page. The layout effects + // below reset on filter/sort changes instead, and clamp a shrinking row set before paint. + autoResetPageIndex: false, + state: { + sorting, + }, initialState: { pagination: { pageSize: PAGE_SIZE, @@ -635,21 +664,104 @@ const PipelineListPageContent = () => { }, }); - const { filters, actions } = useDataTableFilter({ - columns: filterColumns, - table, - }); + const pageCount = table.getPageCount(); + useLayoutEffect(() => { + const pageIndex = table.getState().pagination.pageIndex; + if (pageIndex > 0 && pageIndex >= pageCount) { + table.setPageIndex(Math.max(pageCount - 1, 0)); + } + }, [pageCount, table]); + + const { columnFilters } = table.getState(); + // biome-ignore lint/correctness/useExhaustiveDependencies: columnFilters and sorting are intentional change-triggers — when the user edits either, jump back to page 1 (autoResetPageIndex is off). + useLayoutEffect(() => { + if (table.getState().pagination.pageIndex !== 0) { + table.setPageIndex(0); + } + }, [table, columnFilters, sorting]); + + const [activeTab, setActiveTab] = useState('all'); + const [search, setSearch] = useState(''); + + // Each tab counts what selecting it would yield: the state column's faceted model applies every + // filter except its own. + const stateFacetedRows = table.getColumn('state')?.getFacetedRowModel().flatRows; + const tabCounts = useMemo( + () => countPipelinesPerTab((stateFacetedRows ?? []).map((r) => r.original.state)), + [stateFacetedRows] + ); + + const handleTabChange = useCallback( + (tabId: PipelineStateTabId) => { + if (tabId === activeTab) { + return; + } + setActiveTab(tabId); + const states = PIPELINE_STATE_TABS.find((t) => t.id === tabId)?.states; + table.getColumn('state')?.setFilterValue(states ? states.map(String) : undefined); + }, + [table, activeTab] + ); + + useEffect(() => { + const timer = setTimeout(() => { + const column = table.getColumn('name'); + const next = search.trim() ? search : undefined; + // setFilterValue(undefined) on an unfiltered column still makes a new columnFilters array, + // tripping the page-reset effect — so skip no-op writes, including the post-mount tick. + if (column && column.getFilterValue() !== next) { + column.setFilterValue(next); + } + }, 200); + return () => clearTimeout(timer); + }, [search, table]); + + // Status tabs are views, not filters — only search and the facet pickers count as "filtered". + // Read from `search` too: the committed filter lands 200ms later, and Clear filters must be + // clickable as soon as the user has typed. + const hasActiveFilters = search.trim() !== '' || columnFilters.some((f) => f.id !== 'state'); + const clearFilters = useCallback(() => { + setSearch(''); + for (const columnId of ['name', 'inputs', 'outputs', 'tags']) { + table.getColumn(columnId)?.setFilterValue(undefined); + } + }, [table]); + + const handleRowClick = useCallback( + (pipelineId: string, event: MouseEvent) => { + if (isModifiedClick(event)) { + return; + } + // Registry guard, same one DataTable's rows use: drops portaled children (open menus, the + // delete-confirm backdrop) that bubble through React but sit outside the
, and clicks a + // control in the row has already handled. + if (!isRowActivationClick(event.target, event.currentTarget)) { + return; + } + // A mouseup that ends a text selection (copying the id) isn't navigation intent. + if (window.getSelection()?.toString()) { + return; + } + navigate({ to: '/rp-connect/$pipelineId', params: { pipelineId: encodeURIComponent(pipelineId) } }); + }, + [navigate] + ); const handleCreateClick = useCallback(() => { resetRpcnWizardStore(); navigate({ to: '/rp-connect/create', search: { serverless: undefined } }); }, [resetRpcnWizardStore, navigate]); - if (isLoading) { + // The hook holds isLoading until every page is drained, so render the first page and stream the + // rest in behind it. A mid-drain error halts the drain for good, so it replaces the spinner. + const isInitialLoading = isLoading && pipelines.length === 0 && !error; + const isLoadingMorePages = isLoading && pipelines.length > 0 && !error; + + if (isInitialLoading) { return ; } - if (error) { + if (error && pipelines.length === 0) { return (
@@ -658,51 +770,121 @@ const PipelineListPageContent = () => { ); } + const rows = table.getRowModel().rows; + + // Pages still unfetched means partial data; otherwise a background refresh failed and it's stale. + let listErrorMessage: string | null = null; + if (error) { + listErrorMessage = hasNextPage + ? `Failed to load all pipelines: ${error.message}` + : `Couldn't refresh pipelines: ${error.message}`; + } + return (
- + handleTabChange(value as PipelineStateTabId)} value={activeTab}> + + {PIPELINE_STATE_TABS.map((tab) => ( + + {tab.label} + + {tabCounts[tab.id]} + + + ))} + +
-
- - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - {(() => { - const rows = table.getRowModel().rows; - if (rows.length === 0) { - const isFiltered = filters.length > 0; - return ( - - - {isFiltered ? 'No pipelines match the current filters' : 'You have no Redpanda Connect pipelines'} - - - ); - } - return rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ setSearch(e.target.value)} + placeholder="Search by name or ID..." + value={search} + > + + + + + + + + + + +
+
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + ))} - )); - })()} - -
- {/* Hide the pagination footer's "X of N selected" text (no row selection here) but keep its space so controls stay right-aligned. */} -
- + ))} + + + {rows.length === 0 ? ( + + + {isLoadingMorePages ? ( +
+ Loading pipelines... +
+ ) : ( + pipelineListEmptyText({ hasActiveFilters, activeTab, totalPipelines: pipelines.length }) + )} +
+
+ ) : ( + rows.map((row) => ( + // Pointer shortcut only — no tab stop or Enter handler, since the name cell already + // holds the same link and a row tab stop would duplicate it on every row. + handleRowClick(row.original.id, event)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + )} +
+
+ 0} + > + Loading more pipelines... + + 0} + > + + {listErrorMessage} + + +
); }; @@ -757,7 +939,9 @@ export const PipelineListPage = () => { - + {/* keepMounted: panels unmount by default, dropping the user's search/facets/page on a + trip to the Kafka Connect tab and back. */} + diff --git a/frontend/src/components/pages/rp-connect/pipeline/pipeline-throughput-card.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/pipeline-throughput-card.test.tsx index 248abdbdf7..90088b99cb 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/pipeline-throughput-card.test.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/pipeline-throughput-card.test.tsx @@ -113,7 +113,7 @@ function buildTransport({ // --------------------------------------------------------------------------- describe('PipelineThroughputCard', () => { - it('shows warning alert when range queries error', async () => { + it('shows calm unavailable state with retry when range queries error', async () => { const listQueriesMock = vi .fn() .mockReturnValue(createListQueriesResponse(['connect_input_received', 'connect_output_sent'])); @@ -128,13 +128,35 @@ describe('PipelineThroughputCard', () => { await waitFor( () => { - expect(screen.getByText('Failed to load throughput metrics')).toBeInTheDocument(); + expect(screen.getByText("Throughput metrics aren't available right now")).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); }, { timeout: 5000 } ); }); - it('shows "not available" text when queries return empty results', async () => { + it('shows the unavailable state, not the empty state, when the query catalog fails', async () => { + const listQueriesMock = vi.fn().mockImplementation(() => { + throw new ConnectError('metrics service unreachable', Code.Internal); + }); + const executeRangeQueryMock = vi.fn(); + + const transport = buildTransport({ listQueriesMock, executeRangeQueryMock }); + + renderWithFileRoutes(, { transport }); + + await waitFor( + () => { + expect(screen.getByText("Throughput metrics aren't available right now")).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); + }, + { timeout: 5000 } + ); + expect(screen.queryByText('No throughput data yet')).not.toBeInTheDocument(); + expect(executeRangeQueryMock).not.toHaveBeenCalled(); + }); + + it('shows empty state when queries return empty results', async () => { const listQueriesMock = vi .fn() .mockImplementation(() => createListQueriesResponse(['connect_input_received', 'connect_output_sent'])); @@ -151,7 +173,7 @@ describe('PipelineThroughputCard', () => { await waitFor( () => { - expect(screen.getByText('Throughput metrics not available')).toBeInTheDocument(); + expect(screen.getByText('No throughput data yet')).toBeInTheDocument(); }, { timeout: 5000 } ); @@ -176,7 +198,7 @@ describe('PipelineThroughputCard', () => { // Wait for the component to settle (empty state) await waitFor(() => { - expect(screen.getByText('Throughput metrics not available')).toBeInTheDocument(); + expect(screen.getByText('No throughput data yet')).toBeInTheDocument(); }); await user.click(screen.getByRole('button', { name: 'Refresh' })); @@ -185,7 +207,7 @@ describe('PipelineThroughputCard', () => { expect(screen.getByText('Throughput')).toBeInTheDocument(); }); - it('shows "not available" when listQueries returns no matching queries', async () => { + it('shows empty state when listQueries returns no matching queries', async () => { // When listQueries has no connect queries, range queries are disabled (enabled: false), // so they never load and never error -- resulting in empty chart data. const listQueriesMock = vi.fn().mockReturnValue(createListQueriesResponse([])); @@ -197,7 +219,7 @@ describe('PipelineThroughputCard', () => { renderWithFileRoutes(, { transport }); await waitFor(() => { - expect(screen.getByText('Throughput metrics not available')).toBeInTheDocument(); + expect(screen.getByText('No throughput data yet')).toBeInTheDocument(); }); // Range queries should never have been called since they're disabled @@ -235,9 +257,9 @@ describe('PipelineThroughputCard', () => { }); // Verify the warning alert is NOT present - expect(screen.queryByText('Failed to load throughput metrics')).not.toBeInTheDocument(); + expect(screen.queryByText("Throughput metrics aren't available right now")).not.toBeInTheDocument(); // Verify the empty state is NOT present - expect(screen.queryByText('Throughput metrics not available')).not.toBeInTheDocument(); + expect(screen.queryByText('No throughput data yet')).not.toBeInTheDocument(); }); it('passes pipeline_id filter in range query params', async () => { diff --git a/frontend/src/components/pages/rp-connect/pipeline/pipeline-throughput-card.tsx b/frontend/src/components/pages/rp-connect/pipeline/pipeline-throughput-card.tsx index 3531341571..4deaec850e 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/pipeline-throughput-card.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/pipeline-throughput-card.tsx @@ -10,7 +10,7 @@ */ import { timestampFromMs } from '@bufbuild/protobuf/wkt'; -import { Alert, AlertDescription } from 'components/redpanda-ui/components/alert'; +import { Button } from 'components/redpanda-ui/components/button'; import { type ChartConfig, ChartContainer, @@ -28,7 +28,7 @@ import { } from 'components/redpanda-ui/components/select'; import { ChartSkeleton } from 'components/ui/chart-skeleton'; import { RefreshButton } from 'components/ui/refresh-button'; -import type { FC } from 'react'; +import type { FC, ReactNode } from 'react'; import { useCallback, useId, useMemo, useState } from 'react'; import { useExecuteRangeQuery, useListQueries } from 'react-query/api/observability'; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; @@ -56,23 +56,56 @@ type ThroughputContentProps = { id: string; // Full selected window [start, end] in ms, so the axis spans it even when data is sparse. domain: [number, number]; + onRetry: () => void; }; -const ThroughputContent: FC = ({ isLoading, isError, hasData, chartData, id, domain }) => { +// Keeps the chart's footprint so the section doesn't jump between states. +const ThroughputPlaceholder: FC<{ title: string; description: string; action?: ReactNode }> = ({ + title, + description, + action, +}) => ( +
+
{title}
+
{description}
+ {action} +
+); + +const ThroughputContent: FC = ({ + isLoading, + isError, + hasData, + chartData, + id, + domain, + onRetry, +}) => { if (isLoading) { return ; } if (isError) { return ( - - Failed to load throughput metrics - + + Try again + + } + description="The metrics service didn't respond. Data will appear once it's reachable." + title="Throughput metrics aren't available right now" + /> ); } if (!hasData) { - return
Throughput metrics not available
; + return ( + + ); } return ( @@ -143,7 +176,12 @@ export const PipelineThroughputCard: FC = ({ pipeli const [selectedTimeRange, setSelectedTimeRange] = useState('1h'); const [refreshKey, setRefreshKey] = useState(0); - const { data: queriesData, isLoading: isLoadingQueries } = useListQueries({ + const { + data: queriesData, + isLoading: isLoadingQueries, + isError: isErrorQueries, + refetch: refetchQueries, + } = useListQueries({ filter: { tags: { component: 'redpanda-connect', @@ -188,8 +226,11 @@ export const PipelineThroughputCard: FC = ({ pipeli ); const handleRefresh = useCallback(() => { + // The query-catalog key carries no timestamps, so the refreshKey bump + // alone would never retry a failed ListQueries — refetch it explicitly. + refetchQueries(); setRefreshKey((prev) => prev + 1); - }, []); + }, [refetchQueries]); const chartData = useMemo( () => mergeTimeSeries(ingressData?.results ?? [], egressData?.results ?? []), @@ -198,7 +239,9 @@ export const PipelineThroughputCard: FC = ({ pipeli // isPending stays true for disabled queries, so only count enabled ones to avoid an infinite skeleton. const isLoading = isLoadingQueries || (hasInputQuery && isPendingIngress) || (hasOutputQuery && isPendingEgress); - const isError = isErrorIngress || isErrorEgress; + // A failed catalog lookup means the metrics service is unreachable — that is + // the error state, not "no data yet" (the range queries never even run). + const isError = isErrorQueries || isErrorIngress || isErrorEgress; const isFetching = isFetchingIngress || isFetchingEgress; const hasData = chartData.length > 0; @@ -236,6 +279,7 @@ export const PipelineThroughputCard: FC = ({ pipeli id={id} isError={isError} isLoading={isLoading} + onRetry={handleRefresh} /> ); diff --git a/frontend/src/components/redpanda-ui/components/badge-group.tsx b/frontend/src/components/redpanda-ui/components/badge-group.tsx index e1b411757b..735627321d 100644 --- a/frontend/src/components/redpanda-ui/components/badge-group.tsx +++ b/frontend/src/components/redpanda-ui/components/badge-group.tsx @@ -51,7 +51,7 @@ const BadgeGroup = React.forwardRef( wrap, testId, children, - maxVisible, + maxVisible = 3, size = 'sm', tone, variant = 'subtle', diff --git a/frontend/src/components/redpanda-ui/components/button-group.tsx b/frontend/src/components/redpanda-ui/components/button-group.tsx new file mode 100644 index 0000000000..7533f8f184 --- /dev/null +++ b/frontend/src/components/redpanda-ui/components/button-group.tsx @@ -0,0 +1,80 @@ +import { mergeProps } from '@base-ui/react/merge-props'; +import { useRender } from '@base-ui/react/use-render'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { Separator } from './separator'; +import { cn, type SharedProps } from '../lib/utils'; + +const buttonGroupVariants = cva( + "flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1", + { + variants: { + orientation: { + horizontal: + '[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none', + vertical: + 'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none', + }, + }, + defaultVariants: { + orientation: 'horizontal', + }, + } +); + +function ButtonGroup({ + className, + orientation, + testId, + ...props +}: React.ComponentProps<'div'> & VariantProps & SharedProps) { + return ( + // biome-ignore lint/a11y/useSemanticElements: part of button group implementation +
+ ); +} + +function ButtonGroupText({ className, render, testId, ...props }: useRender.ComponentProps<'div'> & SharedProps) { + return useRender({ + defaultTagName: 'div', + render, + props: mergeProps<'div'>( + { + className: cn( + "flex items-center gap-2 rounded-md border bg-muted px-4 font-medium text-sm shadow-xs [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none", + className + ), + 'data-slot': 'button-group-text', + 'data-testid': testId, + } as React.ComponentPropsWithRef<'div'>, + props + ), + }); +} + +function ButtonGroupSeparator({ + className, + orientation = 'vertical', + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants }; diff --git a/frontend/src/components/redpanda-ui/components/data-table/data-table-column-header.tsx b/frontend/src/components/redpanda-ui/components/data-table/data-table-column-header.tsx new file mode 100644 index 0000000000..32b5d8fefc --- /dev/null +++ b/frontend/src/components/redpanda-ui/components/data-table/data-table-column-header.tsx @@ -0,0 +1,72 @@ +'use client'; + +import type { Column } from '@tanstack/react-table'; +import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from 'lucide-react'; +import type React from 'react'; + +import { Button } from '../button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '../dropdown-menu'; +import { cn, type SharedProps } from '../../lib/utils'; + +interface DataTableColumnHeaderProps extends React.HTMLAttributes, SharedProps { + column: Column; + title: string; +} + +export function DataTableColumnHeader({ + column, + title, + className, + testId, + ...props +}: DataTableColumnHeaderProps) { + if (!column.getCanSort()) { + return ( +
+ {title} +
+ ); + } + + return ( +
+ + + {title} + {column.getIsSorted() === 'desc' && } + {column.getIsSorted() === 'asc' && } + {!column.getIsSorted() && } + + } + /> + + column.toggleSorting(false)}> + + Asc + + column.toggleSorting(true)}> + + Desc + + {column.getCanHide() && ( + <> + + column.toggleVisibility(false)}> + + Hide + + + )} + + +
+ ); +} diff --git a/frontend/src/components/redpanda-ui/components/data-table/data-table-faceted-filter.tsx b/frontend/src/components/redpanda-ui/components/data-table/data-table-faceted-filter.tsx new file mode 100644 index 0000000000..49b0f19894 --- /dev/null +++ b/frontend/src/components/redpanda-ui/components/data-table/data-table-faceted-filter.tsx @@ -0,0 +1,138 @@ +'use client'; + +import type { Column } from '@tanstack/react-table'; +import { Check } from 'lucide-react'; +import type React from 'react'; + +import { Badge } from '../badge'; +import { Button } from '../button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '../command'; +import { Popover, PopoverContent, PopoverTrigger } from '../popover'; +import { Separator } from '../separator'; +import { cn, type SharedProps } from '../../lib/utils'; + +interface DataTableFacetedFilterProps extends SharedProps { + column?: Column; + title?: string; + options: { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; + }[]; + labelClassName?: string; +} + +export function DataTableFacetedFilter({ + column, + title, + options, + testId, + labelClassName, +}: DataTableFacetedFilterProps) { + const facets = column?.getFacetedUniqueValues(); + const filterValue = column?.getFilterValue(); + const selectedValues = new Set(Array.isArray(filterValue) ? (filterValue as string[]) : []); + + return ( + + + {title} + {selectedValues.size > 0 && ( + <> + + + {selectedValues.size} + +
+ {selectedValues.size > 2 ? ( + + {selectedValues.size} selected + + ) : ( + options + .filter((option) => selectedValues.has(option.value)) + .map((option) => ( + + {option.icon ? : null} + {option.label} + + )) + )} +
+ + )} + + } + /> + + + + + No results found. + + {options.map((option) => { + const isSelected = selectedValues.has(option.value); + return ( + { + const filterValues = isSelected + ? Array.from(selectedValues).filter((v) => v !== option.value) + : [...Array.from(selectedValues), option.value]; + column?.setFilterValue(filterValues.length ? filterValues : undefined); + }} + // Explicit value keeps cmdk typeahead off the sr-only marker and facet count. + value={option.label} + > +
+ +
+ {option.icon ? : null} + {option.label} + {/* cmdk reserves aria-selected for highlight, so this is the only SR signal. */} + {isSelected ? , selected : null} + {facets?.get(option.value) ? ( + + {facets.get(option.value)} + + ) : null} +
+ ); + })} +
+ {selectedValues.size > 0 && ( + <> + + + column?.setFilterValue(undefined)} + > + Clear filters + + + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/redpanda-ui/components/data-table/data-table-pagination.tsx b/frontend/src/components/redpanda-ui/components/data-table/data-table-pagination.tsx new file mode 100644 index 0000000000..d01f7305ee --- /dev/null +++ b/frontend/src/components/redpanda-ui/components/data-table/data-table-pagination.tsx @@ -0,0 +1,101 @@ +'use client'; + +import type { Table } from '@tanstack/react-table'; +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; + +import { Button } from '../button'; +import { ButtonGroup } from '../button-group'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select'; +import type { SharedProps } from '../../lib/utils'; + +interface DataTablePaginationProps extends SharedProps { + table: Table; + pageSizeOptions?: number[]; +} + +const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 25, 30, 40, 50]; + +export function DataTablePagination({ + table, + testId, + pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS, +}: DataTablePaginationProps) { + return ( +
+ {table.options.enableRowSelection !== false && ( +
+ {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) + selected. +
+ )} + {/* ml-auto holds the controls right when the selection count is not rendered. */} +
+
+
Rows per page
+ +
+
+ Page {table.getPageCount() === 0 ? 0 : table.getState().pagination.pageIndex + 1} of {table.getPageCount()} +
+ + + + + + +
+
+ ); +} diff --git a/frontend/src/components/redpanda-ui/components/data-table/data-table-utils.ts b/frontend/src/components/redpanda-ui/components/data-table/data-table-utils.ts index 69a083ae5e..9005c4fe18 100644 --- a/frontend/src/components/redpanda-ui/components/data-table/data-table-utils.ts +++ b/frontend/src/components/redpanda-ui/components/data-table/data-table-utils.ts @@ -36,17 +36,45 @@ export const resolveSortingMode = (sorting: false | true | SortingState | undefi export type DisplayState = 'loading' | 'empty' | 'data'; -// When isLoading but rows already exist (background refetch), returns 'data' so stale rows show instead of a spinner. -export const deriveDisplayState = (rowCount: number, isLoading: boolean): DisplayState => { - if (isLoading && rowCount === 0) { +// Takes the filtered count, not the page count: a stale page index leaves the page empty while +// matches exist, which must not read as 'empty'. Rows + isLoading is a background refetch, so it +// returns 'data' and stale rows stay visible instead of a spinner. +export const deriveDisplayState = (filteredRowCount: number, isLoading: boolean): DisplayState => { + if (isLoading && filteredRowCount === 0) { return 'loading'; } - if (rowCount === 0) { + if (filteredRowCount === 0) { return 'empty'; } return 'data'; }; -export const isPaginationState = ( - pagination: false | true | PaginationState | undefined -): pagination is PaginationState => typeof pagination === 'object' && pagination !== null && 'pageIndex' in pagination; +// The filtered count can be non-zero while the page slice is empty. Mid-clamp that page index is +// about to change, so 'empty' would claim "no results" about a set with matches — hold 'loading'. +export const resolvePageDisplayState = ( + displayState: DisplayState, + pageRowCount: number, + clampPending: boolean +): DisplayState => { + if (displayState !== 'data' || pageRowCount > 0) { + return displayState; + } + return clampPending ? 'loading' : 'empty'; +}; + +const INTERACTIVE_TARGET_SELECTOR = + 'a,button,input,select,textarea,label,[role="button"],[role="checkbox"],[role="switch"],[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"],[role="option"],[role="combobox"]'; + +// `boundary` scopes the check so interactive ancestors outside the row never match. +export const isInteractiveTarget = (target: EventTarget | null, boundary?: Element | null): boolean => { + if (!(target instanceof Element)) { + return false; + } + const interactive = target.closest(INTERACTIVE_TARGET_SELECTOR); + return interactive !== null && (boundary ? boundary.contains(interactive) : true); +}; + +// Containment drops portaled content (menus, popovers) and targets already unmounted; the +// interactive check drops clicks a control in the row has already handled. +export const isRowActivationClick = (target: EventTarget | null, row: Element): boolean => + target instanceof Element && row.contains(target) && !isInteractiveTarget(target, row); diff --git a/frontend/src/components/redpanda-ui/components/data-table/data-table-view-options.tsx b/frontend/src/components/redpanda-ui/components/data-table/data-table-view-options.tsx new file mode 100644 index 0000000000..0c207aa15c --- /dev/null +++ b/frontend/src/components/redpanda-ui/components/data-table/data-table-view-options.tsx @@ -0,0 +1,54 @@ +'use client'; + +import type { Table } from '@tanstack/react-table'; +import { Settings2 } from 'lucide-react'; + +import { Button } from '../button'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '../dropdown-menu'; +import type { SharedProps } from '../../lib/utils'; + +export function DataTableViewOptions({ table, testId }: { table: Table } & SharedProps) { + return ( +
+ + + + View + + } + /> + + Toggle columns + + {table + .getAllColumns() + .filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide()) + .map((column) => { + // Loose cast, not a ColumnMeta augmentation: that would weak-type the interface and + // reject consumers who stash their own meta keys. + const label = (column.columnDef.meta as { label?: string } | undefined)?.label; + return ( + column.toggleVisibility(!!value)} + > + {label ?? column.id} + + ); + })} + + +
+ ); +} diff --git a/frontend/src/components/redpanda-ui/components/data-table/data-table.tsx b/frontend/src/components/redpanda-ui/components/data-table/data-table.tsx index 1f124de569..a043515291 100644 --- a/frontend/src/components/redpanda-ui/components/data-table/data-table.tsx +++ b/frontend/src/components/redpanda-ui/components/data-table/data-table.tsx @@ -25,12 +25,19 @@ import { Loader2 } from 'lucide-react'; import React from 'react'; import { Checkbox } from '../checkbox'; -import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from '../table'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../table'; +import { useLayoutEffect } from '../../lib/use-layout-effect'; import { cn } from '../../lib/utils'; +import { DataTablePagination } from './data-table-pagination'; import { createInitialState, type DataTableInitialConfig, dataTableReducer } from './data-table-reducer'; -import { deriveDisplayState, resolvePaginationMode, resolveSortingMode } from './data-table-utils'; -import { DataTablePagination } from './index'; +import { + deriveDisplayState, + isRowActivationClick, + resolvePageDisplayState, + resolvePaginationMode, + resolveSortingMode, +} from './data-table-utils'; export type DataTableClassNames = { root?: string; @@ -152,7 +159,7 @@ export function DataTable({ tableOptions: tableOptionsProp, className, testId, - // Destructure discriminated-union props explicitly: TS can't narrow intersected unions in the body, so this keeps renames compile-checked. + // The union props are destructured explicitly — TS can't narrow intersected unions in the body. pagination: paginationProp, onPaginationChange: onPaginationChangeProp, defaultPageSize: defaultPageSizeProp, @@ -171,7 +178,6 @@ export function DataTable({ const [state, dispatch] = React.useReducer(dataTableReducer, initialConfig, createInitialState); - // Controlled state wins over internal reducer state when provided. const effectivePagination = paginationMode.controlledState ?? state.pagination; const effectiveSorting = sortingMode.controlledState ?? state.sorting; const effectiveRowSelection = rowSelectionProp ?? state.rowSelection; @@ -293,14 +299,37 @@ export function DataTable({ const table = useReactTable(options); const rows = table.getRowModel().rows; - const displayState = deriveDisplayState(rows.length, isLoading); + const filteredRowCount = table.getFilteredRowModel().rows.length; const totalColumns = table.getVisibleFlatColumns().length; + // autoResetPageIndex is off, so a shrinking filtered set can strand the user past the last page. + // Only clamp when getPageCount() is trustworthy: under manualPagination without pageCount/rowCount + // it is derived from the current page and would fight the consumer's controlled state. + const pageCount = table.getPageCount(); + const pageIndex = effectivePagination.pageIndex; + const pageCountIsKnown = + !table.options.manualPagination || table.options.pageCount !== undefined || table.options.rowCount !== undefined; + const clampPending = paginationMode.enabled && pageCountIsKnown && pageCount > 0 && pageIndex >= pageCount; + // Layout, not passive: a post-paint clamp flashes a body holding neither rows nor a state. + useLayoutEffect(() => { + if (clampPending && !isLoading) { + table.setPageIndex(pageCount - 1); + } + }, [clampPending, isLoading, pageCount, table]); + + // Layered on the clamp, not replaced by it: the clamp only beats paint when it applies + // synchronously, and it never runs while isLoading or when onPaginationChange is async. + const displayState = resolvePageDisplayState( + deriveDisplayState(filteredRowCount, isLoading), + rows.length, + clampPending + ); + const toolbarContent = typeof toolbar === 'function' ? toolbar(table) : toolbar; return (
- {toolbarContent &&
{toolbarContent}
} + {toolbarContent ?
{toolbarContent}
: null} @@ -327,7 +356,7 @@ export function DataTable({ )} - {displayState === 'empty' && ( + {displayState === 'empty' ? (
@@ -336,50 +365,75 @@ export function DataTable({
- )} + ) : null} {displayState === 'data' && - rows.map((row) => ( - - row.getCanExpand() && row.toggleExpanded() - : onRow - ? () => onRow(row) - : undefined - } - style={expandRowByClick || onRow ? { cursor: 'pointer' } : undefined} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - {row.getIsExpanded() && subComponent && ( - - - {subComponent({ row })} - + rows.map((row) => { + const rowIsActivatable = expandRowByClick ? row.getCanExpand() : Boolean(onRow); + const activateRow = () => { + if (expandRowByClick) { + row.toggleExpanded(); + return; + } + onRow?.(row); + }; + + const handleClick = (event: React.MouseEvent) => { + if (isRowActivationClick(event.target, event.currentTarget)) { + activateRow(); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + const isActivationKey = event.key === 'Enter' || event.key === ' '; + // `repeat` filters auto-repeat from a held key: one press is one activation. + if (!isActivationKey || event.repeat || event.target !== event.currentTarget) { + return; + } + event.preventDefault(); + activateRow(); + }; + + return ( + + under border-collapse. + rowIsActivatable && + 'cursor-pointer focus-visible:outline-2 focus-visible:outline-primary focus-visible:-outline-offset-2', + classNames?.row, + rowClassName?.(row) + )} + data-state={row.getIsSelected() && 'selected'} + onClick={rowIsActivatable ? handleClick : undefined} + onKeyDown={rowIsActivatable ? handleKeyDown : undefined} + tabIndex={rowIsActivatable ? 0 : undefined} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} - )} - - ))} + {row.getIsExpanded() && subComponent && ( + + + {subComponent({ row })} + + + )} + + ); + })} - - {paginationMode.enabled && displayState === 'data' && ( - - - - - - - - )}
+ + {paginationMode.enabled ? ( +
+ +
+ ) : null}
); } diff --git a/frontend/src/components/redpanda-ui/components/data-table/index.tsx b/frontend/src/components/redpanda-ui/components/data-table/index.tsx index 135091fc04..f1d2670107 100644 --- a/frontend/src/components/redpanda-ui/components/data-table/index.tsx +++ b/frontend/src/components/redpanda-ui/components/data-table/index.tsx @@ -1,334 +1,8 @@ 'use client'; export { DataTable, type DataTableClassNames, type DataTableProps } from './data-table'; - -import type { Column, Table } from '@tanstack/react-table'; -import { - ArrowDown, - ArrowUp, - ChevronLeft, - ChevronRight, - ChevronsLeft, - ChevronsRight, - ChevronsUpDown, - EyeOff, - Settings2, -} from 'lucide-react'; -import React from 'react'; - -import { Badge } from '../badge'; -import { Button } from '../button'; -import { Checkbox } from '../checkbox'; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from '../command'; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '../dropdown-menu'; -import { Popover, PopoverContent, PopoverTrigger } from '../popover'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select'; -import { Separator } from '../separator'; -import { cn, type SharedProps } from '../../lib/utils'; - -interface DataTableColumnHeaderProps extends React.HTMLAttributes, SharedProps { - column: Column; - title: string; -} - -export function DataTableColumnHeader({ - column, - title, - className, - testId, -}: DataTableColumnHeaderProps) { - if (!column.getCanSort()) { - return ( -
- {title} -
- ); - } - - return ( -
- - - {title} - {column.getIsSorted() === 'desc' && } - {column.getIsSorted() === 'asc' && } - {!column.getIsSorted() && } - - } - /> - - column.toggleSorting(false)}> - - Asc - - column.toggleSorting(true)}> - - Desc - - - column.toggleVisibility(false)}> - - Hide - - - -
- ); -} - -interface DataTableFacetedFilterProps extends SharedProps { - column?: Column; - title?: string; - options: { - label: string; - value: string; - icon?: React.ComponentType<{ className?: string }>; - }[]; - labelClassName?: string; -} - -export function DataTableFacetedFilter({ - column, - title, - options, - testId, - labelClassName, -}: DataTableFacetedFilterProps) { - const facets = column?.getFacetedUniqueValues(); - const selectedValues = new Set(column?.getFilterValue() as string[]); - - return ( - - - {title} - {selectedValues?.size > 0 && ( - <> - - - {selectedValues.size} - -
- {selectedValues.size > 2 ? ( - - {selectedValues.size} selected - - ) : ( - options - .filter((option) => selectedValues.has(option.value)) - .map((option) => ( - - {option.label} - - )) - )} -
- - )} - - } - /> - - - - - No results found. - - {options.map((option) => { - const isSelected = selectedValues.has(option.value); - return ( - { - const filterValues = isSelected - ? Array.from(selectedValues).filter((v) => v !== option.value) - : [...Array.from(selectedValues), option.value]; - column?.setFilterValue(filterValues.length ? filterValues : undefined); - }} - > - { - const filterValues = checked - ? [...Array.from(selectedValues), option.value] - : Array.from(selectedValues).filter((v) => v !== option.value); - column?.setFilterValue(filterValues.length ? filterValues : undefined); - }} - /> -
- {option.icon ? : null} - {option.label} - {facets?.get(option.value) ? ( - - {facets.get(option.value)} - - ) : null} -
-
- ); - })} -
- {selectedValues.size > 0 && ( - <> - - - column?.setFilterValue(undefined)} - > - Clear filters - - - - )} -
-
-
-
- ); -} - -interface DataTablePaginationProps extends SharedProps { - table: Table; - pageSizeOptions?: number[]; -} - -const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 25, 30, 40, 50]; - -export function DataTablePagination({ - table, - testId, - pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS, -}: DataTablePaginationProps) { - return ( -
- {table.options.enableRowSelection !== false && ( -
- {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) - selected. -
- )} -
-
-
Rows per page
- -
-
- Page {table.getPageCount() === 0 ? 0 : table.getState().pagination.pageIndex + 1} of {table.getPageCount()} -
-
- - - - -
-
-
- ); -} - -export function DataTableViewOptions({ table, testId }: { table: Table } & SharedProps) { - return ( -
- - - - View - - } - /> - - Toggle columns - - {table - .getAllColumns() - .filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide()) - .map((column) => ( - column.toggleVisibility(!!value)} - > - {column.id} - - ))} - - -
- ); -} +export { DataTableColumnHeader } from './data-table-column-header'; +export { DataTableFacetedFilter } from './data-table-faceted-filter'; +export { DataTablePagination } from './data-table-pagination'; +export { isInteractiveTarget, isRowActivationClick } from './data-table-utils'; +export { DataTableViewOptions } from './data-table-view-options'; diff --git a/frontend/src/components/redpanda-ui/components/table.tsx b/frontend/src/components/redpanda-ui/components/table.tsx index 86c0c0c7d9..768ab8ef05 100644 --- a/frontend/src/components/redpanda-ui/components/table.tsx +++ b/frontend/src/components/redpanda-ui/components/table.tsx @@ -99,7 +99,7 @@ function TableHeader({ className, testId, ...props }: React.ComponentProps<'thea function TableBody({ className, testId, ...props }: React.ComponentProps<'tbody'> & SharedProps) { return ( void }) { const label = expanded ? 'Exit fullscreen' : 'Enter fullscreen'; diff --git a/frontend/src/components/ui/fade-presence.tsx b/frontend/src/components/ui/fade-presence.tsx new file mode 100644 index 0000000000..28cc146459 --- /dev/null +++ b/frontend/src/components/ui/fade-presence.tsx @@ -0,0 +1,47 @@ +/** + * 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 { AnimatePresence, motion, useReducedMotion } from 'motion/react'; +import type { ReactNode } from 'react'; + +/** + * Mount/unmount transition for conditional UI chrome — toolbar chips, status lines, + * inline hints. Exit is quicker than enter, `initial={false}` animates changes but not + * the first render, and reduced-motion collapses the movement to a pure fade. + */ +export function FadePresence({ + show, + children, + className, +}: { + show: boolean; + children: ReactNode; + className?: string; +}) { + const reducedMotion = useReducedMotion(); + const hidden = reducedMotion ? { opacity: 0 } : { opacity: 0, y: 2, scale: 0.98 }; + + return ( + + {show ? ( + + {children} + + ) : null} + + ); +} diff --git a/frontend/src/components/ui/pipeline/constants.ts b/frontend/src/components/ui/pipeline/constants.ts index b0bd824582..f3774b4bfc 100644 --- a/frontend/src/components/ui/pipeline/constants.ts +++ b/frontend/src/components/ui/pipeline/constants.ts @@ -31,18 +31,6 @@ export const PIPELINE_STATE_LABELS: Partial> = { [Pipeline_State.COMPLETED]: 'Completed', }; -/** - * Pipeline state options for filtering. - */ -export const PIPELINE_STATE_OPTIONS = [ - Pipeline_State.RUNNING, - Pipeline_State.STARTING, - Pipeline_State.STOPPING, - Pipeline_State.STOPPED, - Pipeline_State.ERROR, - Pipeline_State.COMPLETED, -].map((state) => ({ label: PIPELINE_STATE_LABELS[state] ?? 'Unknown', value: String(state) })); - /** * Issue filter options for filtering pipelines by log severity. */ diff --git a/frontend/src/federation/federated-routes.tsx b/frontend/src/federation/federated-routes.tsx index cca74eb277..b9dab89e1d 100644 --- a/frontend/src/federation/federated-routes.tsx +++ b/frontend/src/federation/federated-routes.tsx @@ -98,11 +98,10 @@ function FederatedRootLayout() { * - Stretches the layout to the viewport bottom so the footer's `margin-top: auto` * lands there instead of trailing short pages. * - * Three things the host (Cloud UI `common/layout/layout.tsx`) has to hold up: spacing - * expressed as `padding` — margin, gap or a narrower `max-width` isn't cancellable here - * and would double up with Console's gutter; no `overflow` on those ancestors, which - * would clip the negative margins; and the `html[data-page-expanded]` `max-width` - * release, the half of expanded mode Console can't do for itself. + * Requires of the host (Cloud UI `common/layout/layout.tsx`): spacing as `padding`, since + * margin, gap and `max-width` aren't cancellable here; no `overflow` on those ancestors, + * which clips the negative margins; and its own `html[data-page-expanded]` `max-width` + * release for expanded mode. */ const useHostShellFit = () => { const layoutRef = useRef(null); diff --git a/frontend/src/globals.css b/frontend/src/globals.css index c32259145a..df3b85f769 100644 --- a/frontend/src/globals.css +++ b/frontend/src/globals.css @@ -34,7 +34,7 @@ @layer utilities { /* A work surface filling the viewport below whatever chrome sits above it - (--console-page-top, measured by hooks/use-page-top-offset.ts). The 1rem keeps its + (--console-page-top, measured in components/layout/page-column.tsx). The 1rem keeps its bottom border off the viewport edge. Pair with a min-height for short viewports. */ .page-fill-viewport { height: calc(100dvh - var(--console-page-top, 7rem) - 1rem); @@ -56,7 +56,7 @@ } /* Chrome that stays put while the page beneath it goes edge-to-edge, re-inset by the - same amount the expanded pages give their own headers. */ + same amount expanded pages give their own headers. */ html[data-page-expanded] .page-expanded-inset { padding-left: 1rem; padding-right: 1rem; diff --git a/frontend/src/hooks/use-expanded-page-mode.ts b/frontend/src/hooks/use-expanded-page-mode.ts index ca5759475e..3204ffaddb 100644 --- a/frontend/src/hooks/use-expanded-page-mode.ts +++ b/frontend/src/hooks/use-expanded-page-mode.ts @@ -12,11 +12,10 @@ import { useCallback, useLayoutEffect, useState } from 'react'; /** - * Set on `` while an expanded page is on screen. Every shell releases its - * horizontal constraints off this attribute in CSS, in lockstep: Console's gutter and - * width cap (`page-expanded-*` in globals.css) and Cloud UI's embedded wrapper - * (`expandableWidth` in cloud-ui layout.tsx). It must never outlive the page — a stale - * attribute bleeds full width onto the next one. + * Set on `` while an expanded page is on screen. Each shell releases its horizontal + * constraints off it in CSS: Console's gutter and width cap (`page-expanded-*` in + * globals.css), Cloud UI's embedded wrapper (`expandableWidth` in cloud-ui layout.tsx). + * Must not outlive the page — a stale attribute bleeds full width onto the next one. */ const PAGE_EXPANDED_ATTR = 'data-page-expanded'; @@ -46,16 +45,14 @@ export function useExpandedPageMode({ storageKey }: { storageKey: string }): { // State rather than a ref, so attaching the node re-runs the effect below. const [pageRoot, setPageRoot] = useState(null); - // Layout effect: the attribute lands in the same frame as the page's own geometry - // change, so the shells and the page animate together. Its cleanup is the only unset — - // it covers unmount (navigating away), `ref` detaching and `expanded` flipping off. + // Layout effect so the attribute lands in the same frame as the page's own geometry change, + // and its cleanup is the only unset: it covers unmount, `ref` detaching and `expanded` off. useLayoutEffect(() => { if (!pageRoot) { return clearPageExpanded; } - // display:none collapses the root to 0x0, which fires the observer — that is the - // on-screen signal. + // display:none collapses the root to 0x0, which fires the observer — the on-screen signal. const sync = () => { const onScreen = pageRoot.getClientRects().length > 0; document.documentElement.toggleAttribute(PAGE_EXPANDED_ATTR, expanded && onScreen); diff --git a/frontend/src/react-query/api/pipeline.test.tsx b/frontend/src/react-query/api/pipeline.test.tsx index 6c744717fc..c0171ecbbc 100644 --- a/frontend/src/react-query/api/pipeline.test.tsx +++ b/frontend/src/react-query/api/pipeline.test.tsx @@ -126,4 +126,112 @@ describe('useListPipelinesQuery', () => { expect(callCount).toBe(1); expect(result.current.data.pipelines).toHaveLength(0); }); + + test('stops draining when the server repeats the page token it was given', async () => { + // A server that echoes back the token it just resolved would drain forever, appending the + // same page to the cache each round. + let callCount = 0; + + const transport = createRouterTransport(({ rpc }) => { + rpc(listPipelines, () => { + callCount += 1; + return create(ListPipelinesResponseSchema, { + response: create(DataPlaneListPipelinesResponseSchema, { + pipelines: [create(PipelineSchema, { id: 'pipeline-1', displayName: 'pipeline-1' })], + nextPageToken: 'stuck-token', + }), + }); + }); + }); + + const { wrapper } = connectQueryWrapper({ defaultOptions: { queries: { retry: false } } }, transport); + + const { result } = renderHook(() => useListPipelinesQuery(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // First page, then the one page that reveals the repeat — and no further. + expect(callCount).toBe(2); + expect(result.current.data.pipelines.map((p) => p.id)).toEqual(['pipeline-1']); + }); + + test('stops draining on a token cycle, not just an immediate repeat', async () => { + // A deletion mid-drain can send the keyset token back to an earlier page: page A points at B, + // B points back at A. Nothing repeats consecutively, so the drain would alternate forever. + let callCount = 0; + + const transport = createRouterTransport(({ rpc }) => { + rpc(listPipelines, (req) => { + callCount += 1; + const pageToken = req.request?.pageToken ?? ''; + return create(ListPipelinesResponseSchema, { + response: create(DataPlaneListPipelinesResponseSchema, { + pipelines: [create(PipelineSchema, { id: `pipeline-${pageToken || 'first'}`, displayName: 'cycling' })], + // '' → token-a, token-a → token-b, token-b → token-a, … + nextPageToken: pageToken === 'token-a' ? 'token-b' : 'token-a', + }), + }); + }); + }); + + const { wrapper } = connectQueryWrapper({ defaultOptions: { queries: { retry: false } } }, transport); + + const { result } = renderHook(() => useListPipelinesQuery(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Three calls: '' → token-a → token-b, whose token-a is already spent. The drain stops there. + expect(callCount).toBe(3); + expect(result.current.data.pipelines.map((p) => p.id)).toEqual([ + 'pipeline-first', + 'pipeline-token-a', + 'pipeline-token-b', + ]); + }); + + test('deduplicates pipelines a replayed page returns twice', async () => { + // A dataplane that resolves the keyset page token by exact id match restarts + // at page one when the token's pipeline is deleted mid-drain, so page two + // repeats page one. The drain still terminates; the rows must not double up. + const page = (ids: string[], nextPageToken: string) => + create(ListPipelinesResponseSchema, { + response: create(DataPlaneListPipelinesResponseSchema, { + pipelines: ids.map((id) => create(PipelineSchema, { id, displayName: id })), + nextPageToken, + }), + }); + + const transport = createRouterTransport(({ rpc }) => { + rpc(listPipelines, (req) => { + switch (req.request?.pageToken ?? '') { + case '': + return page(['pipeline-1', 'pipeline-2'], 'pipeline-3'); + // The boundary pipeline is gone, so this restarts at the top. + case 'pipeline-3': + return page(['pipeline-1', 'pipeline-2'], 'pipeline-4'); + default: + return page(['pipeline-4', 'pipeline-5'], ''); + } + }); + }); + + const { wrapper } = connectQueryWrapper({ defaultOptions: { queries: { retry: false } } }, transport); + + const { result } = renderHook(() => useListPipelinesQuery(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.data.pipelines.map((p) => p.id)).toEqual([ + 'pipeline-1', + 'pipeline-2', + 'pipeline-4', + 'pipeline-5', + ]); + }); }); diff --git a/frontend/src/react-query/api/pipeline.tsx b/frontend/src/react-query/api/pipeline.tsx index f91b8b9e10..78df4a88b8 100644 --- a/frontend/src/react-query/api/pipeline.tsx +++ b/frontend/src/react-query/api/pipeline.tsx @@ -34,12 +34,7 @@ import { } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; import type { Secret } from 'protogen/redpanda/api/dataplane/v1/secret_pb'; import { useMemo } from 'react'; -import { - MAX_PAGE_SIZE, - type MessageInit, - type QueryOptions, - SHORT_POLLING_INTERVAL, -} from 'react-query/react-query.utils'; +import { type MessageInit, type QueryOptions, SHORT_POLLING_INTERVAL } from 'react-query/react-query.utils'; import { useInfiniteQueryWithAllPages } from 'react-query/use-infinite-query-with-all-pages'; import { formatToastErrorMessageGRPC } from 'utils/toast.utils'; @@ -48,6 +43,10 @@ export const MAX_REDPANDA_CONNECT_LOGS_RESULT_COUNT = 1000; export const REDPANDA_CONNECT_LOGS_TIME_WINDOW_HOURS = 5; const transitionalStates: Pipeline_State[] = [Pipeline_State.STARTING, Pipeline_State.STOPPING]; +// The list drains page-by-page before rendering, and the server does the same work per call at any +// page size (it lists everything and slices). 500 matches the legacy page, under the proto max. +const LIST_PIPELINES_PAGE_SIZE = 500; + export const useGetPipelineQuery = ( { id }: { id: Pipeline['id'] }, options?: QueryOptions, GetPipelineResponse> & { @@ -86,7 +85,7 @@ export const useListPipelinesQuery = ( const listPipelinesRequestDataPlane = useMemo( () => create(ListPipelinesRequestSchemaDataPlane, { - pageSize: MAX_PAGE_SIZE, + pageSize: LIST_PIPELINES_PAGE_SIZE, pageToken: '', ...input, }), @@ -112,9 +111,12 @@ export const useListPipelinesQuery = ( return hasTransitional ? SHORT_POLLING_INTERVAL : false; } : false, - getNextPageParam: (lastPage) => { + getNextPageParam: (lastPage, _allPages, _lastPageParam, allPageParams) => { const nextPageToken = lastPage?.response?.nextPageToken; - if (!nextPageToken) { + // Any token already requested, not just the last one: keyset tokens only move forward, so a + // repeat means the server sent us backwards (A→A or a longer A→B→A cycle) and the drain would + // loop forever, adding a page per round. O(pages) per step, ~20 for 10k pipelines. + if (!nextPageToken || allPageParams.some((param) => param?.pageToken === nextPageToken)) { return; } return create(ListPipelinesRequestSchemaDataPlane, { @@ -125,9 +127,20 @@ export const useListPipelinesQuery = ( pageParamKey: 'request', }); + // Deduplicated by id: the page token names the next page's first id, so a server resolving it by + // exact match replays page one when that pipeline is deleted mid-drain. Later pages win. const pipelines = useMemo(() => { - const allPipelines = listPipelinesResult?.data?.pages?.flatMap((page) => page?.response?.pipelines ?? []); - return allPipelines ?? []; + const pages = listPipelinesResult?.data?.pages; + if (!pages) { + return []; + } + const byId = new Map(); + for (const page of pages) { + for (const pipeline of page?.response?.pipelines ?? []) { + byId.set(pipeline.id, pipeline); + } + } + return [...byId.values()]; }, [listPipelinesResult.data]); const data = useMemo(() => ({ pipelines }), [pipelines]); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index ab8ce3d08a..e2c71c1354 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1532,11 +1532,21 @@ resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz" integrity sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg== +"@fontsource/geist-mono@^5.3.0": + version "5.3.0" + resolved "https://registry.npmjs.org/@fontsource/geist-mono/-/geist-mono-5.3.0.tgz" + integrity sha512-UtJ1BBBCVpMYdIcW7nEB45UAoAw5M53ZXs2t0ciPW+IokuAAIc56M8+kW5tXbRJCTpDw4XTtU7proT6NdQAHTg== + "@fontsource/inter@^5.0.15": version "5.2.8" resolved "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz" integrity sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg== +"@fontsource/inter@^5.3.0": + version "5.3.0" + resolved "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz" + integrity sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g== + "@grpc/grpc-js@^1.11.1": version "1.14.4" resolved "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz" @@ -7047,6 +7057,11 @@ inline-style-parser@0.2.6: resolved "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.6.tgz" integrity sha512-gtGXVaBdl5mAes3rPcMedEBm12ibjt1kDMFfheul1wUAOVEJW60voNdMVzVkfLN06O7ZaD/rxhfKgtlgtTbMjg== +inter-ui@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/inter-ui/-/inter-ui-4.1.1.tgz" + integrity sha512-451h0J29HyOmA+JXgSi/6M12tL7ZCZ8arYKZUXiOXTJpJbAKqJvFh3k5SiV3x7tKe0C0KyrKUUiQIvvZ2PQDcA== + "internmap@1 - 2": version "2.0.3" resolved "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz"