From 5aa9e0dd9f8f4cd2f1471952c1177997099c38b3 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Tue, 28 Jul 2026 11:38:25 -0700 Subject: [PATCH 01/18] First phase --- .../pages/rp-connect/pipeline/list.tsx | 161 ++++++++++++------ frontend/src/react-query/api/pipeline.tsx | 15 +- 2 files changed, 117 insertions(+), 59 deletions(-) diff --git a/frontend/src/components/pages/rp-connect/pipeline/list.tsx b/frontend/src/components/pages/rp-connect/pipeline/list.tsx index b606876a18..f3175c98b9 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, 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,7 +28,7 @@ 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 { DataTableColumnHeader, DataTablePagination } from 'components/redpanda-ui/components/data-table'; import { DataTableFilter, type FilterColumnConfig } from 'components/redpanda-ui/components/data-table-filter'; import { DropdownMenu, @@ -56,7 +57,7 @@ import { 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 { memo, useCallback, useLayoutEffect, useMemo, useState } from 'react'; import { useKafkaConnectConnectorsQuery } from 'react-query/api/kafka-connect'; import { useDeletePipelineMutation, @@ -80,24 +81,39 @@ type Pipeline = { name: string; description: string; state: Pipeline_State; - configYaml: string; inputs: string[]; - processors: string[]; outputs: string[]; tags: TagPair[]; }; +// parseConfigComponents runs a full YAML parse, and the query cache hands back +// fresh page arrays on every drain step and poll tick, so the list transform +// re-runs over all rows. Memoize 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) { + configComponentsCache.clear(); + } + 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, }; @@ -139,6 +155,18 @@ const pipelineStateFilterIcon: Record , }; +// Attention-first ordering for the Status column: problems and transitions +// surface before healthy pipelines, idle ones sink to the bottom. +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; const PipelineListSkeleton = () => ( @@ -353,7 +381,7 @@ const createColumns = ({ }: CreateColumnsOptions): ColumnDef[] => [ { accessorKey: 'name', - header: 'Pipeline', + header: ({ column }) => , filterFn: createFilterFn('text'), cell: ({ row }) => { const id = row.original.id; @@ -406,34 +434,6 @@ const createColumns = ({ ); }, }, - { - 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) => ( - - ))} - - ); - }, - }, { accessorKey: 'outputs', header: 'Output', @@ -499,8 +499,10 @@ const createColumns = ({ { id: 'state', accessorFn: (row) => String(row.state), - header: 'Status', + header: ({ column }) => , filterFn: createFilterFn('option'), + sortingFn: (rowA, rowB) => + pipelineStateSortPriority[rowA.original.state] - pipelineStateSortPriority[rowB.original.state], cell: ({ row }) => , }, { @@ -522,6 +524,9 @@ const createColumns = ({ const PipelineListPageContent = () => { const navigate = useNavigate(); const resetRpcnWizardStore = useResetRpcnWizardStore(); + // Default to attention-first status order so error/transitioning pipelines + // surface on page 1 and stopped ones sink, even on large clusters. + const [sorting, setSorting] = useState([{ id: 'state', desc: false }]); const { data: pipelinesData, @@ -561,10 +566,6 @@ const PipelineListPageContent = () => { 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, @@ -592,12 +593,6 @@ const PipelineListPageContent = () => { type: 'multiOption' as const, options: inputOptions, }, - { - id: 'processors', - displayName: 'Processors', - type: 'multiOption' as const, - options: processorOptions, - }, { id: 'outputs', displayName: 'Output', @@ -629,6 +624,17 @@ const PipelineListPageContent = () => { getFacetedRowModel: getFacetedRowModel(), getFacetedUniqueValues: getFacetedUniqueValues(), getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + onSortingChange: setSorting, + // Pages stream in while the list drains; autoResetPageIndex would yank the + // user back to page 1 on every arrival. Filter and sort changes still + // reset the page (layout effect below, keyed on user-facing filter state — + // table columnFilters churn identity on every data refresh), and a + // shrinking row set is clamped before paint. + autoResetPageIndex: false, + state: { + sorting, + }, initialState: { pagination: { pageSize: PAGE_SIZE, @@ -636,11 +642,26 @@ const PipelineListPageContent = () => { }, }); + 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 { filters, actions } = useDataTableFilter({ columns: filterColumns, table, }); + // biome-ignore lint/correctness/useExhaustiveDependencies: filters 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, filters, sorting]); + const handleCreateClick = useCallback(() => { resetRpcnWizardStore(); // enablePipelineDiagrams skips the wizard and goes straight to the editor. @@ -651,11 +672,18 @@ const PipelineListPageContent = () => { } }, [resetRpcnWizardStore, navigate]); - if (isLoading) { + // The hook keeps isLoading true until every page is drained; render as soon + // as the first page has rows and stream the rest in behind the table. On a + // mid-drain error the drain halts for good, so the error line replaces the + // spinner rather than showing next to it. + 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 (
@@ -686,11 +714,29 @@ const PipelineListPageContent = () => { {(() => { const rows = table.getRowModel().rows; if (rows.length === 0) { - const isFiltered = filters.length > 0; + if (isLoadingMorePages) { + return ( + + +
+ Loading pipelines... +
+
+
+ ); + } + // Unfiltered but non-empty data means a stale page index is + // about to be clamped — don't flash the empty-state message. + let emptyText: string | null = null; + if (filters.length > 0) { + emptyText = 'No pipelines match the current filters'; + } else if (pipelines.length === 0) { + emptyText = 'You have no Redpanda Connect pipelines'; + } return ( - {isFiltered ? 'No pipelines match the current filters' : 'You have no Redpanda Connect pipelines'} + {emptyText} ); @@ -705,6 +751,17 @@ const PipelineListPageContent = () => { })()} + {isLoadingMorePages ? ( +
+ Loading more pipelines... +
+ ) : null} + {error && pipelines.length > 0 ? ( +
+ + Failed to load all pipelines: {error.message} +
+ ) : null} {/* Hide the pagination footer's "X of N selected" text (no row selection here) but keep its space so controls stay right-aligned. */}
diff --git a/frontend/src/react-query/api/pipeline.tsx b/frontend/src/react-query/api/pipeline.tsx index f91b8b9e10..5a335fd841 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,12 @@ 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 is drained page-by-page before it can render, so larger pages mean +// fewer sequential round trips. The server does the same work per call at any +// page size (it lists everything and slices); 500 matches the legacy list page +// and stays under the proto max of 1000. +const LIST_PIPELINES_PAGE_SIZE = 500; + export const useGetPipelineQuery = ( { id }: { id: Pipeline['id'] }, options?: QueryOptions, GetPipelineResponse> & { @@ -86,7 +87,7 @@ export const useListPipelinesQuery = ( const listPipelinesRequestDataPlane = useMemo( () => create(ListPipelinesRequestSchemaDataPlane, { - pageSize: MAX_PAGE_SIZE, + pageSize: LIST_PIPELINES_PAGE_SIZE, pageToken: '', ...input, }), From f3167eabbe144f9b0307134afc51e5af41fd945d Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Wed, 29 Jul 2026 08:20:55 -0700 Subject: [PATCH 02/18] More throughout improvements --- .../pages/rp-connect/pipeline/index.tsx | 53 ++++++++++++++----- .../pipeline-throughput-card.test.tsx | 19 +++---- .../pipeline/pipeline-throughput-card.tsx | 49 ++++++++++++++--- 3 files changed, 91 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/pages/rp-connect/pipeline/index.tsx b/frontend/src/components/pages/rp-connect/pipeline/index.tsx index 06a9580122..aa2fb7a415 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/index.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/index.tsx @@ -540,7 +540,7 @@ function YamlViewPanel({ function ViewModePanel({ pipeline }: { pipeline: Pipeline | undefined }) { if (!pipeline) { return ( -
Loading pipeline...
+
Loading pipeline...
); } const showThroughput = @@ -549,7 +549,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 + // PipelineEditorPage), so logs pagination sits reachable right below the table. +
{showThroughput ? ( <> @@ -1129,10 +1131,20 @@ function PipelinePageContent() { [mode, selectedNodeId, requestRevealNode, setActiveViewLane, setActiveEditLane, editorStore] ); + const isMonitorLane = mode === 'view' && activeViewLane === 'monitor'; + return ( - // Viewport-bounded height (7rem = app header + pt-8) so a tall lane scrolls within the framed panel. + // Editor lanes get a viewport-bounded height (7rem = app header + pt-8): Monaco needs a bounded + // box, and a tall lane scrolls within the framed panel. The Monitor lane instead flows with the + // document — chart, logs, and pagination at natural height, one page-level scroll context — so + // its controls are never trapped 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 ? ( {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 not + // contribute intrinsic height (a huge pipeline would stretch the page far + // past the metrics). Absolutely positioned, it adopts the metrics/logs + // column's height and scrolls its tree internally. +
+
+ 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/pipeline-throughput-card.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/pipeline-throughput-card.test.tsx index 248abdbdf7..dd56279f37 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,14 @@ 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 empty state when queries return empty results', async () => { const listQueriesMock = vi .fn() .mockImplementation(() => createListQueriesResponse(['connect_input_received', 'connect_output_sent'])); @@ -151,7 +152,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 +177,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 +186,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 +198,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 +236,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..c6d0f1be5e 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,57 @@ 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 }) => { +// Placeholder that keeps the chart's footprint so the section doesn't jump +// between the loading, empty, error, and chart 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 ( @@ -236,6 +270,7 @@ export const PipelineThroughputCard: FC = ({ pipeli id={id} isError={isError} isLoading={isLoading} + onRetry={handleRefresh} /> ); From df6250e7e8adc257e9584df679871138f2385057 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Wed, 29 Jul 2026 08:46:32 -0700 Subject: [PATCH 03/18] cleanup --- .../pages/rp-connect/pipeline/index.tsx | 7 +---- .../pages/rp-connect/pipeline/list.tsx | 29 +++++++++++++++---- .../pipeline-throughput-card.test.tsx | 21 ++++++++++++++ .../pipeline/pipeline-throughput-card.tsx | 16 ++++++++-- 4 files changed, 59 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/pages/rp-connect/pipeline/index.tsx b/frontend/src/components/pages/rp-connect/pipeline/index.tsx index aa2fb7a415..a055136b57 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/index.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/index.tsx @@ -1213,12 +1213,7 @@ function PipelinePageContent() { // contribute intrinsic height (a huge pipeline would stretch the page far // past the metrics). Absolutely positioned, it adopts the metrics/logs // column's height and scrolls its tree internally. -
+
= CONFIG_COMPONENTS_CACHE_LIMIT) { - configComponentsCache.clear(); + // Evict the oldest half (Map preserves insertion order): clearing + // everything mid-pass would make each refresh of a >10k dataset reparse + // the entire list — the exact cost this cache exists to avoid. + 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); @@ -501,8 +511,11 @@ const createColumns = ({ accessorFn: (row) => String(row.state), header: ({ column }) => , filterFn: createFilterFn('option'), + // The ?? guards against enum values a newer server may send that the + // generated Pipeline_State doesn't know yet — they sort last, not NaN. sortingFn: (rowA, rowB) => - pipelineStateSortPriority[rowA.original.state] - pipelineStateSortPriority[rowB.original.state], + (pipelineStateSortPriority[rowA.original.state] ?? Number.MAX_SAFE_INTEGER) - + (pipelineStateSortPriority[rowB.original.state] ?? Number.MAX_SAFE_INTEGER), cell: ({ row }) => , }, { @@ -532,6 +545,7 @@ const PipelineListPageContent = () => { data: pipelinesData, isLoading, error, + hasNextPage, } = useListPipelinesQuery(undefined, { enableSmartPolling: true, }); @@ -692,6 +706,8 @@ const PipelineListPageContent = () => { ); } + const rows = table.getRowModel().rows; + return (
@@ -712,7 +728,6 @@ const PipelineListPageContent = () => { {(() => { - const rows = table.getRowModel().rows; if (rows.length === 0) { if (isLoadingMorePages) { return ( @@ -751,7 +766,7 @@ const PipelineListPageContent = () => { })()} - {isLoadingMorePages ? ( + {isLoadingMorePages && rows.length > 0 ? (
Loading more pipelines...
@@ -759,7 +774,11 @@ const PipelineListPageContent = () => { {error && pipelines.length > 0 ? (
- Failed to load all pipelines: {error.message} + {/* With pages still unfetched the shown data is partial; otherwise a + background refresh failed and the data is merely stale. */} + {hasNextPage + ? `Failed to load all pipelines: ${error.message}` + : `Couldn't refresh pipelines: ${error.message}`}
) : null} {/* Hide the pagination footer's "X of N selected" text (no row selection here) but keep its space so controls stay right-aligned. */} 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 dd56279f37..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 @@ -135,6 +135,27 @@ describe('PipelineThroughputCard', () => { ); }); + 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() 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 c6d0f1be5e..21b1c00da1 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 @@ -177,7 +177,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', @@ -222,8 +227,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 ?? []), @@ -232,7 +240,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; From 6f292349fb8d44623a62b17e8e58c618ad607b79 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Wed, 29 Jul 2026 10:46:13 -0700 Subject: [PATCH 04/18] Improcing pipeline page --- .../rp-connect/pipeline/list-utils.test.ts | 77 ++++ .../pages/rp-connect/pipeline/list-utils.ts | 88 +++++ .../pages/rp-connect/pipeline/list.tsx | 363 ++++++++++-------- .../pipeline/sortable-column-header.tsx | 47 +++ .../components/data-table/index.tsx | 2 +- .../components/redpanda-ui/style/theme.css | 2 +- .../src/components/ui/pipeline/constants.ts | 12 - 7 files changed, 414 insertions(+), 177 deletions(-) create mode 100644 frontend/src/components/pages/rp-connect/pipeline/list-utils.test.ts create mode 100644 frontend/src/components/pages/rp-connect/pipeline/list-utils.ts create mode 100644 frontend/src/components/pages/rp-connect/pipeline/sortable-column-header.tsx 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..5855c65c20 --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/list-utils.test.ts @@ -0,0 +1,77 @@ +/** + * 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 } 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('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..3125e94e5b --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/list-utils.ts @@ -0,0 +1,88 @@ +/** + * 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; +}; + +/** + * Collapses repeated connector names into one entry with a count, preserving + * first-appearance order: ["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: a pipeline that is +// starting belongs with the running ones, a stopping one with the 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', + }, +]; + +export function countPipelinesPerTab(states: Pipeline_State[]): Record { + const counts: Record = { all: states.length, running: 0, stopped: 0, error: 0 }; + for (const tab of PIPELINE_STATE_TABS) { + if (tab.states) { + counts[tab.id] = states.filter((s) => tab.states?.includes(s)).length; + } + } + return counts; +} + +/** 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.tsx b/frontend/src/components/pages/rp-connect/pipeline/list.tsx index 81cebff153..4e1f358867 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, SortingState } from '@tanstack/react-table'; +import type { ColumnDef, FilterFn, SortingState } from '@tanstack/react-table'; import { flexRender, getCoreRowModel, @@ -28,8 +28,7 @@ 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 { DataTableColumnHeader, DataTablePagination } from 'components/redpanda-ui/components/data-table'; -import { DataTableFilter, type FilterColumnConfig } from 'components/redpanda-ui/components/data-table-filter'; +import { DataTableFacetedFilter, DataTablePagination } from 'components/redpanda-ui/components/data-table'; import { DropdownMenu, DropdownMenuContent, @@ -37,27 +36,24 @@ 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 { STARTABLE_STATES, STOPPABLE_STATES } from 'components/ui/pipeline/constants'; import { isEmbedded, isFeatureFlagEnabled } from 'config'; -import { AlertCircle, Box, MoreHorizontal } from 'lucide-react'; +import { AlertCircle, Box, MoreHorizontal, Search } 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, useLayoutEffect, useMemo, useState } from 'react'; +import { type MouseEvent, memo, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { useKafkaConnectConnectorsQuery } from 'react-query/api/kafka-connect'; import { useDeletePipelineMutation, @@ -70,6 +66,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, +} from './list-utils'; +import { SortableColumnHeader } from './sortable-column-header'; import { TabKafkaConnect } from '../../connect/overview'; import { ConnectorLogo } from '../onboarding/connector-logo'; import { parseConfigComponents } from '../utils/yaml'; @@ -129,21 +133,35 @@ const transformAPIPipeline = (apiPipeline: APIPipeline): Pipeline => { }; }; -/** - * 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 = () => ; + +// Duplicate connectors collapse into one badge with a multiplier ("redpanda ×2") +// so wide fan-in/fan-out pipelines don't spend the column on repeats. +const ConnectorBadges = ({ names }: { names: string[] }) => { + const connectors = aggregateConnectors(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) => ( + + + {c.name} + {c.count > 1 ? ×{c.count} : null} + + ))} + + ); }; const pipelineStateToStatusVariant: Record = { @@ -155,15 +173,12 @@ 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) => , -}; + +// Scalar-in-set matcher for the status tabs. 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); // Attention-first ordering for the Status column: problems and transitions // surface before healthy pipelines, idle ones sink to the bottom. @@ -185,6 +200,12 @@ const PipelineListSkeleton = () => (
+
+ + + + +
@@ -375,12 +396,11 @@ type CreateColumnsOptions = { isDeletingPipeline: boolean; }; -const ComponentBadge = ({ name }: { name: string }) => ( - - - {name} - -); +const connectorOption = (name: string) => ({ + value: name, + label: name, + icon: (props: { className?: string }) => , +}); const createColumns = ({ navigate, @@ -391,8 +411,8 @@ const createColumns = ({ }: CreateColumnsOptions): ColumnDef[] => [ { accessorKey: 'name', - header: ({ column }) => , - 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; @@ -419,73 +439,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) => ( - - ))} - - ); - }, + filterFn: 'arrIncludesSome', + // Without this, faceting keys on the array itself and per-option counts + // in the filter popover never resolve. + getUniqueValues: (row) => 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) => row.outputs, + cell: ({ row }) => , }, { id: 'tags', accessorFn: (row) => row.tags.map((t) => `${t.key}:${t.value}`), header: 'Tags', - filterFn: createFilterFn('multiOption'), + filterFn: 'arrIncludesSome', + getUniqueValues: (row) => row.tags.map((t) => `${t.key}:${t.value}`), cell: ({ row }) => { const tags = row.original.tags; if (tags.length === 0) { - return null; + return ; } return ( ( {tags.slice(-overflow.length).map((t) => ( @@ -509,8 +489,8 @@ const createColumns = ({ { id: 'state', accessorFn: (row) => String(row.state), - header: ({ column }) => , - filterFn: createFilterFn('option'), + header: ({ column }) => , + filterFn: stateInFilterFn, // The ?? guards against enum values a newer server may send that the // generated Pipeline_State doesn't know yet — they sort last, not NaN. sortingFn: (rowA, rowB) => @@ -575,60 +555,23 @@ 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 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: '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((t) => `${t.key}:${t.value}`)))].map((v) => ({ + value: v, + label: v, + })), + [pipelines] + ); + const tabCounts = useMemo(() => countPipelinesPerTab(pipelines.map((p) => p.state)), [pipelines]); const table = useReactTable({ data: pipelines, @@ -642,9 +585,8 @@ const PipelineListPageContent = () => { onSortingChange: setSorting, // Pages stream in while the list drains; autoResetPageIndex would yank the // user back to page 1 on every arrival. Filter and sort changes still - // reset the page (layout effect below, keyed on user-facing filter state — - // table columnFilters churn identity on every data refresh), and a - // shrinking row set is clamped before paint. + // reset the page via the layout effect below, and a shrinking row set is + // clamped before paint. autoResetPageIndex: false, state: { sorting, @@ -664,17 +606,76 @@ const PipelineListPageContent = () => { } }, [pageCount, table]); - const { filters, actions } = useDataTableFilter({ - columns: filterColumns, - table, - }); - - // biome-ignore lint/correctness/useExhaustiveDependencies: filters and sorting are intentional change-triggers — when the user edits either, jump back to page 1 (autoResetPageIndex is off). + // Only user actions (tabs, search, facets, sort) mutate filter state here, so + // keying on columnFilters identity is a safe back-to-page-1 trigger. + 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, filters, sorting]); + }, [table, columnFilters, sorting]); + + const [activeTab, setActiveTab] = useState('all'); + const [search, setSearch] = useState(''); + + 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 produces a new + // columnFilters array, which would trip the page-reset effect — skip + // writes that don't change anything (including the post-mount tick). + if (column && column.getFilterValue() !== next) { + column.setFilterValue(next); + } + }, 200); + return () => clearTimeout(timer); + }, [search, table]); + + // The status tabs are views, not filters — only search and the facet + // pickers count toward "filtered" (and get wiped by Clear filters). + const hasActiveFilters = 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) => { + const target = event.target as Node; + // Clicks on portaled children (menus, dialogs, tooltips) bubble through + // the React tree but live outside the in the DOM — never navigate + // for those, e.g. a click on the delete-confirm backdrop. + if (!event.currentTarget.contains(target)) { + return; + } + // Links and buttons inside the row handle their own clicks; a mouseup + // that ends a text selection (copying the id) isn't a navigation intent. + if ((target as HTMLElement).closest('a') || (target as HTMLElement).closest('button')) { + return; + } + if (window.getSelection()?.toString()) { + return; + } + navigate({ to: '/rp-connect/$pipelineId', params: { pipelineId: encodeURIComponent(pipelineId) } }); + }, + [navigate] + ); const handleCreateClick = useCallback(() => { resetRpcnWizardStore(); @@ -711,9 +712,38 @@ const PipelineListPageContent = () => { return (
- + handleTabChange(value as PipelineStateTabId)} value={activeTab}> + + {PIPELINE_STATE_TABS.map((tab) => ( + + {tab.label} + {tabCounts[tab.id]} + + ))} + +
+
+ setSearch(e.target.value)} + placeholder="Search by name or ID..." + value={search} + > + + + + + + + + {hasActiveFilters ? ( + + ) : null} +
{table.getHeaderGroups().map((headerGroup) => ( @@ -743,8 +773,10 @@ const PipelineListPageContent = () => { // Unfiltered but non-empty data means a stale page index is // about to be clamped — don't flash the empty-state message. let emptyText: string | null = null; - if (filters.length > 0) { + if (hasActiveFilters) { emptyText = 'No pipelines match the current filters'; + } else if (activeTab !== 'all') { + emptyText = PIPELINE_STATE_TABS.find((t) => t.id === activeTab)?.emptyText ?? null; } else if (pipelines.length === 0) { emptyText = 'You have no Redpanda Connect pipelines'; } @@ -757,7 +789,12 @@ const PipelineListPageContent = () => { ); } return rows.map((row) => ( - + handleRowClick(row.original.id, event)} + > {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} diff --git a/frontend/src/components/pages/rp-connect/pipeline/sortable-column-header.tsx b/frontend/src/components/pages/rp-connect/pipeline/sortable-column-header.tsx new file mode 100644 index 0000000000..285b2e0318 --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/sortable-column-header.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 type { Column } from '@tanstack/react-table'; +import { Button } from 'components/redpanda-ui/components/button'; +import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react'; + +/** + * Sort-only column header: one click cycles ascending → descending → default + * order. Unlike the registry DataTableColumnHeader there is no dropdown and no + * "Hide" item — pages without a column-visibility control have no way to bring + * a hidden column back. Candidate for upstreaming into the registry. + */ +export function SortableColumnHeader({ + column, + title, +}: { + column: Column; + title: string; +}) { + if (!column.getCanSort()) { + return
{title}
; + } + + const sorted = column.getIsSorted(); + let sortIcon = ; + if (sorted === 'asc') { + sortIcon = ; + } else if (sorted === 'desc') { + sortIcon = ; + } + + return ( + + ); +} 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..4b63247282 100644 --- a/frontend/src/components/redpanda-ui/components/data-table/index.tsx +++ b/frontend/src/components/redpanda-ui/components/data-table/index.tsx @@ -123,7 +123,7 @@ export function DataTableFacetedFilter({ {title} {selectedValues?.size > 0 && ( <> - + {selectedValues.size} diff --git a/frontend/src/components/redpanda-ui/style/theme.css b/frontend/src/components/redpanda-ui/style/theme.css index 10193497e6..9c3b913a01 100644 --- a/frontend/src/components/redpanda-ui/style/theme.css +++ b/frontend/src/components/redpanda-ui/style/theme.css @@ -182,7 +182,7 @@ --color-primary-brand-300: #f4b9ae; --color-primary-brand-400: #ee9281; --color-primary-brand-500: #e86b54; - --color-primary-brand-600: #e24328; + --color-primary-brand-600: #e2401b; --color-primary-brand-700: #c1331a; --color-primary-brand-800: #9e2e1a; --color-primary-brand-900: #711e0f; 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. */ From 4b7b6cd83f5ce9b9cd338d5cb9c6a9bee95d0427 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Thu, 30 Jul 2026 07:00:13 -0700 Subject: [PATCH 05/18] More progress on pipeline listings --- frontend/src/components/layout/header.tsx | 42 +++-- .../misc/buttons/data-refresh/component.tsx | 125 ++++++++------- frontend/src/components/misc/page-content.tsx | 3 +- .../src/components/pages/connect/overview.tsx | 6 +- .../rp-connect/pipeline/faceted-filter.tsx | 149 ++++++++++++++++++ .../pages/rp-connect/pipeline/list.tsx | 84 ++++++---- frontend/src/components/ui/fade-presence.tsx | 49 ++++++ 7 files changed, 344 insertions(+), 114 deletions(-) create mode 100644 frontend/src/components/pages/rp-connect/pipeline/faceted-filter.tsx create mode 100644 frontend/src/components/ui/fade-presence.tsx diff --git a/frontend/src/components/layout/header.tsx b/frontend/src/components/layout/header.tsx index b31b271eff..a91ff60263 100644 --- a/frontend/src/components/layout/header.tsx +++ b/frontend/src/components/layout/header.tsx @@ -9,7 +9,9 @@ * by the Apache License, Version 2.0 */ -import { Button, ColorModeSwitch, CopyButton } from '@redpanda-data/ui'; +// ColorModeSwitch is the last Chakra holdout here: dev-only, standalone-only, +// and pointless to port until the standalone theme toggle moves off Chakra. +import { ColorModeSwitch } from '@redpanda-data/ui'; import { Link, useLocation, useMatches, useMatchRoute } from '@tanstack/react-router'; import { cn } from 'components/redpanda-ui/lib/utils'; import { ChevronLeft } from 'lucide-react'; @@ -28,8 +30,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; @@ -140,21 +144,27 @@ function AppPageHeader({ breadcrumbOnly = false }: { breadcrumbOnly?: boolean })
- {!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() && }
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.tsx b/frontend/src/components/pages/connect/overview.tsx index d777b5e0a1..fcbbf9c25d 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'; @@ -486,13 +486,13 @@ export const TabKafkaConnect = (_p: {}) => { } return ( - +
settings.selectedTab} selectedTabKey={settings.selectedTab} tabs={connectTabs} />
- +
); }; diff --git a/frontend/src/components/pages/rp-connect/pipeline/faceted-filter.tsx b/frontend/src/components/pages/rp-connect/pipeline/faceted-filter.tsx new file mode 100644 index 0000000000..f5d97c51cc --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/faceted-filter.tsx @@ -0,0 +1,149 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import type { Column } from '@tanstack/react-table'; +import { Badge } from 'components/redpanda-ui/components/badge'; +import { Button } from 'components/redpanda-ui/components/button'; +import { Checkbox } from 'components/redpanda-ui/components/checkbox'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from 'components/redpanda-ui/components/command'; +import { Popover, PopoverContent, PopoverTrigger } from 'components/redpanda-ui/components/popover'; +import { Separator } from 'components/redpanda-ui/components/separator'; +import { cn, type SharedProps } from 'components/redpanda-ui/lib/utils'; + +interface FacetedFilterProps extends SharedProps { + column?: Column; + title?: string; + options: { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; + }[]; + labelClassName?: string; +} + +/** + * Verbatim copy of the registry's DataTableFacetedFilter with exactly ONE + * delta: selected values shown in the trigger render their option icon (the + * two lines marked DELTA below). Delete this file and switch back to the + * registry component once that lands upstream. + */ +export function FacetedFilter({ + column, + title, + options, + testId, + labelClassName, +}: FacetedFilterProps) { + 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) => ( + + {/* DELTA: show the option's icon alongside its label */} + {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); + }} + > + { + 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 + + + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/pages/rp-connect/pipeline/list.tsx b/frontend/src/components/pages/rp-connect/pipeline/list.tsx index 4e1f358867..17f5bfd6de 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/list.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/list.tsx @@ -28,7 +28,7 @@ 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 { DataTableFacetedFilter, DataTablePagination } from 'components/redpanda-ui/components/data-table'; +import { DataTablePagination } from 'components/redpanda-ui/components/data-table'; import { DropdownMenu, DropdownMenuContent, @@ -44,9 +44,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'c import { Tabs, TabsContent, TabsContents, TabsList, TabsTrigger } from 'components/redpanda-ui/components/tabs'; import { Link, List, ListItem } from 'components/redpanda-ui/components/typography'; import { DeleteResourceAlertDialog, DeleteResourceMenuItem } from 'components/ui/delete-resource-alert-dialog'; +import { FadePresence } from 'components/ui/fade-presence'; import { STARTABLE_STATES, STOPPABLE_STATES } from 'components/ui/pipeline/constants'; import { isEmbedded, isFeatureFlagEnabled } from 'config'; -import { AlertCircle, Box, MoreHorizontal, Search } from 'lucide-react'; +import { AlertCircle, Box, MoreHorizontal, Search, X } from 'lucide-react'; import { DeletePipelineRequestSchema, StartPipelineRequestSchema, @@ -66,6 +67,7 @@ import { useResetRpcnWizardStore } from 'state/rpcn-wizard-store'; import { docsLinks } from 'utils/docs-links'; import { formatToastErrorMessageGRPC } from 'utils/toast.utils'; +import { FacetedFilter } from './faceted-filter'; import { aggregateConnectors, countPipelinesPerTab, @@ -156,8 +158,12 @@ const ConnectorBadges = ({ names }: { names: string[] }) => { {connectors.map((c) => ( - {c.name} - {c.count > 1 ? ×{c.count} : null} + {/* One text node so name and multiplier share a baseline — as sibling + flex items they get box-centered a pixel apart. */} + + {c.name} + {c.count > 1 ? ×{c.count} : null} + ))} @@ -417,10 +423,12 @@ const createColumns = ({ const id = row.original.id; const name = row.getValue('name') as string; return ( -
+
+ {/* Rows navigate on click, so the name link stays quiet until hovered + — twenty dotted underlines per page read as noise. */} {id !== name ? ( - + // select-all: one click selects the whole id for copying; the row's + // selection guard keeps that click from navigating. + {id} ) : null} @@ -709,6 +719,15 @@ const PipelineListPageContent = () => { const rows = table.getRowModel().rows; + // With pages still unfetched the shown data is partial; otherwise a + // background refresh failed and the data is merely stale. + let listErrorMessage: string | null = null; + if (error) { + listErrorMessage = hasNextPage + ? `Failed to load all pipelines: ${error.message}` + : `Couldn't refresh pipelines: ${error.message}`; + } + return (
@@ -717,7 +736,9 @@ const PipelineListPageContent = () => { {PIPELINE_STATE_TABS.map((tab) => ( {tab.label} - {tabCounts[tab.id]} + + {tabCounts[tab.id]} + ))} @@ -735,14 +756,17 @@ const PipelineListPageContent = () => { - - - - {hasActiveFilters ? ( + + + + + + {table.getFilteredRowModel().rows.length.toLocaleString()} of {pipelines.length.toLocaleString()} pipelines + - ) : null} +
@@ -796,28 +820,28 @@ const PipelineListPageContent = () => { onClick={(event) => handleRowClick(row.original.id, event)} > {row.getVisibleCells().map((cell) => ( - {flexRender(cell.column.columnDef.cell, cell.getContext())} + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} )); })()}
- {isLoadingMorePages && rows.length > 0 ? ( -
- Loading more pipelines... -
- ) : null} - {error && pipelines.length > 0 ? ( -
- - {/* With pages still unfetched the shown data is partial; otherwise a - background refresh failed and the data is merely stale. */} - {hasNextPage - ? `Failed to load all pipelines: ${error.message}` - : `Couldn't refresh pipelines: ${error.message}`} -
- ) : null} + 0} + > + Loading more pipelines... + + 0} + > + + {listErrorMessage} + {/* Hide the pagination footer's "X of N selected" text (no row selection here) but keep its space so controls stay right-aligned. */}
diff --git a/frontend/src/components/ui/fade-presence.tsx b/frontend/src/components/ui/fade-presence.tsx new file mode 100644 index 0000000000..35d4753272 --- /dev/null +++ b/frontend/src/components/ui/fade-presence.tsx @@ -0,0 +1,49 @@ +/** + * 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'; + +/** + * Subtle mount/unmount transition for conditional UI chrome — toolbar chips, + * status lines, inline hints. Enter rises gently on the house ease-out curve; + * exit is quicker so disappearing elements never lag the interaction that + * dismissed them. `initial={false}` keeps the first page render static (only + * changes animate), and reduced-motion preferences collapse it 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} + + ); +} From 60dae200a95e83996aa5164e84321ffd3ee75a14 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Thu, 30 Jul 2026 11:12:56 -0700 Subject: [PATCH 06/18] Improving data table a bit --- .../rp-connect/pipeline/faceted-filter.tsx | 149 -------- .../pages/rp-connect/pipeline/list.tsx | 35 +- .../pipeline/sortable-column-header.tsx | 47 --- .../redpanda-ui/components/button-group.tsx | 80 +++++ .../data-table/data-table-column-header.tsx | 71 ++++ .../data-table/data-table-faceted-filter.tsx | 134 +++++++ .../data-table/data-table-pagination.tsx | 100 ++++++ .../components/data-table/data-table-utils.ts | 22 +- .../data-table/data-table-view-options.tsx | 53 +++ .../components/data-table/data-table.tsx | 125 ++++--- .../components/data-table/index.tsx | 335 +----------------- .../redpanda-ui/components/table.tsx | 2 +- 12 files changed, 564 insertions(+), 589 deletions(-) delete mode 100644 frontend/src/components/pages/rp-connect/pipeline/faceted-filter.tsx delete mode 100644 frontend/src/components/pages/rp-connect/pipeline/sortable-column-header.tsx create mode 100644 frontend/src/components/redpanda-ui/components/button-group.tsx create mode 100644 frontend/src/components/redpanda-ui/components/data-table/data-table-column-header.tsx create mode 100644 frontend/src/components/redpanda-ui/components/data-table/data-table-faceted-filter.tsx create mode 100644 frontend/src/components/redpanda-ui/components/data-table/data-table-pagination.tsx create mode 100644 frontend/src/components/redpanda-ui/components/data-table/data-table-view-options.tsx diff --git a/frontend/src/components/pages/rp-connect/pipeline/faceted-filter.tsx b/frontend/src/components/pages/rp-connect/pipeline/faceted-filter.tsx deleted file mode 100644 index f5d97c51cc..0000000000 --- a/frontend/src/components/pages/rp-connect/pipeline/faceted-filter.tsx +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Copyright 2026 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import type { Column } from '@tanstack/react-table'; -import { Badge } from 'components/redpanda-ui/components/badge'; -import { Button } from 'components/redpanda-ui/components/button'; -import { Checkbox } from 'components/redpanda-ui/components/checkbox'; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from 'components/redpanda-ui/components/command'; -import { Popover, PopoverContent, PopoverTrigger } from 'components/redpanda-ui/components/popover'; -import { Separator } from 'components/redpanda-ui/components/separator'; -import { cn, type SharedProps } from 'components/redpanda-ui/lib/utils'; - -interface FacetedFilterProps extends SharedProps { - column?: Column; - title?: string; - options: { - label: string; - value: string; - icon?: React.ComponentType<{ className?: string }>; - }[]; - labelClassName?: string; -} - -/** - * Verbatim copy of the registry's DataTableFacetedFilter with exactly ONE - * delta: selected values shown in the trigger render their option icon (the - * two lines marked DELTA below). Delete this file and switch back to the - * registry component once that lands upstream. - */ -export function FacetedFilter({ - column, - title, - options, - testId, - labelClassName, -}: FacetedFilterProps) { - 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) => ( - - {/* DELTA: show the option's icon alongside its label */} - {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); - }} - > - { - 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 - - - - )} -
-
-
-
- ); -} diff --git a/frontend/src/components/pages/rp-connect/pipeline/list.tsx b/frontend/src/components/pages/rp-connect/pipeline/list.tsx index 17f5bfd6de..e56f5d3fe2 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/list.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/list.tsx @@ -28,7 +28,11 @@ 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 { + DataTableColumnHeader, + DataTableFacetedFilter, + DataTablePagination, +} from 'components/redpanda-ui/components/data-table'; import { DropdownMenu, DropdownMenuContent, @@ -67,7 +71,6 @@ import { useResetRpcnWizardStore } from 'state/rpcn-wizard-store'; import { docsLinks } from 'utils/docs-links'; import { formatToastErrorMessageGRPC } from 'utils/toast.utils'; -import { FacetedFilter } from './faceted-filter'; import { aggregateConnectors, countPipelinesPerTab, @@ -75,7 +78,6 @@ import { PIPELINE_STATE_TABS, type PipelineStateTabId, } from './list-utils'; -import { SortableColumnHeader } from './sortable-column-header'; import { TabKafkaConnect } from '../../connect/overview'; import { ConnectorLogo } from '../onboarding/connector-logo'; import { parseConfigComponents } from '../utils/yaml'; @@ -417,7 +419,7 @@ const createColumns = ({ }: CreateColumnsOptions): ColumnDef[] => [ { accessorKey: 'name', - header: ({ column }) => , + header: ({ column }) => , filterFn: (row, _columnId, filterValue: string) => matchesNameOrId(filterValue, row.original.name, row.original.id), cell: ({ row }) => { const id = row.original.id; @@ -499,7 +501,7 @@ const createColumns = ({ { id: 'state', accessorFn: (row) => String(row.state), - header: ({ column }) => , + header: ({ column }) => , filterFn: stateInFilterFn, // The ?? guards against enum values a newer server may send that the // generated Pipeline_State doesn't know yet — they sort last, not NaN. @@ -581,11 +583,12 @@ const PipelineListPageContent = () => { })), [pipelines] ); - const tabCounts = useMemo(() => countPipelinesPerTab(pipelines.map((p) => p.state)), [pipelines]); const table = useReactTable({ data: pipelines, columns, + // No column-visibility UI on this page; disabling hiding also drops the Hide item from the column header menus. + enableHiding: false, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getFacetedRowModel: getFacetedRowModel(), @@ -629,6 +632,15 @@ const PipelineListPageContent = () => { const [activeTab, setActiveTab] = useState('all'); const [search, setSearch] = useState(''); + // GitHub-style tab counts: each tab shows how many rows selecting it would + // yield under the current search/facets. The state column's faceted model + // applies every filter except its own — exactly those semantics. + 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) { @@ -756,13 +768,10 @@ const PipelineListPageContent = () => { - - - - - - {table.getFilteredRowModel().rows.length.toLocaleString()} of {pipelines.length.toLocaleString()} pipelines - + + + + diff --git a/frontend/src/components/pages/rp-connect/pipeline/sortable-column-header.tsx b/frontend/src/components/pages/rp-connect/pipeline/sortable-column-header.tsx deleted file mode 100644 index 285b2e0318..0000000000 --- a/frontend/src/components/pages/rp-connect/pipeline/sortable-column-header.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Copyright 2026 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import type { Column } from '@tanstack/react-table'; -import { Button } from 'components/redpanda-ui/components/button'; -import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react'; - -/** - * Sort-only column header: one click cycles ascending → descending → default - * order. Unlike the registry DataTableColumnHeader there is no dropdown and no - * "Hide" item — pages without a column-visibility control have no way to bring - * a hidden column back. Candidate for upstreaming into the registry. - */ -export function SortableColumnHeader({ - column, - title, -}: { - column: Column; - title: string; -}) { - if (!column.getCanSort()) { - return
{title}
; - } - - const sorted = column.getIsSorted(); - let sortIcon = ; - if (sorted === 'asc') { - sortIcon = ; - } else if (sorted === 'desc') { - sortIcon = ; - } - - return ( - - ); -} 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..ee433e0bc0 --- /dev/null +++ b/frontend/src/components/redpanda-ui/components/data-table/data-table-column-header.tsx @@ -0,0 +1,71 @@ +'use client'; + +import type { Column } from '@tanstack/react-table'; +import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from 'lucide-react'; + +import { Button } from '../button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '../dropdown-menu'; +import { cn, type SharedProps } from '../../lib/utils'; + +interface DataTableColumnHeaderProps extends SharedProps { + column: Column; + title: string; + className?: 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.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..3f6b067065 --- /dev/null +++ b/frontend/src/components/redpanda-ui/components/data-table/data-table-faceted-filter.tsx @@ -0,0 +1,134 @@ +'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); + }} + > +
+ +
+ {option.icon ? : null} + {option.label} + {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..62d0f1fbe0 --- /dev/null +++ b/frontend/src/components/redpanda-ui/components/data-table/data-table-pagination.tsx @@ -0,0 +1,100 @@ +'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. +
+ )} +
+
+
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..97e3d36654 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,27 @@ export const resolveSortingMode = (sorting: false | true | SortingState | undefi export type DisplayState = 'loading' | 'empty' | 'data'; +// Feed this the FILTERED row count, not the page row count: a stale page index can leave the +// current page empty while matches exist, and that transient state must not read as 'empty'. // 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) { +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; +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); +}; 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..95d33280b9 --- /dev/null +++ b/frontend/src/components/redpanda-ui/components/data-table/data-table-view-options.tsx @@ -0,0 +1,53 @@ +'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: augmenting TanStack's ColumnMeta would weak-type it and break consumers with their own unaugmented 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..44643286bd 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,12 @@ 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 { 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, isInteractiveTarget, resolvePaginationMode, resolveSortingMode } from './data-table-utils'; export type DataTableClassNames = { root?: string; @@ -171,7 +171,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,9 +292,23 @@ 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 displayState = deriveDisplayState(filteredRowCount, isLoading); 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: with manualPagination and no pageCount/rowCount + // it is derived from the current page's data 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; + React.useEffect(() => { + if (paginationMode.enabled && pageCountIsKnown && !isLoading && pageCount > 0 && pageIndex >= pageCount) { + table.setPageIndex(pageCount - 1); + } + }, [paginationMode.enabled, pageCountIsKnown, isLoading, pageCount, pageIndex, table]); + const toolbarContent = typeof toolbar === 'function' ? toolbar(table) : toolbar; return ( @@ -339,47 +352,75 @@ export function DataTable({ )} {displayState === 'data' && - rows.map((row) => ( - - row.getCanExpand() && row.toggleExpanded() - : onRow - ? () => onRow(row) + rows.map((row) => { + const rowIsActivatable = expandRowByClick ? row.getCanExpand() : Boolean(onRow); + const activateRow = () => { + if (expandRowByClick) { + row.toggleExpanded(); + return; + } + onRow?.(row); + }; + + return ( + + { + // Content portaled out of the row (menus, popovers) still bubbles here + // through the React tree — containment filters it out. + const target = event.target as Node; + if (!event.currentTarget.contains(target)) { + return; + } + if (isInteractiveTarget(event.target, event.currentTarget)) { + return; + } + activateRow(); + } : undefined - } - style={expandRowByClick || onRow ? { cursor: 'pointer' } : undefined} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - {row.getIsExpanded() && subComponent && ( - - - {subComponent({ row })} - + } + onKeyDown={ + rowIsActivatable + ? (event) => { + if ((event.key !== 'Enter' && event.key !== ' ') || event.target !== event.currentTarget) { + return; + } + event.preventDefault(); + activateRow(); + } + : 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 4b63247282..62ca61699b 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,7 @@ '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 { 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 ( Date: Tue, 4 Aug 2026 07:37:32 -0700 Subject: [PATCH 07/18] Console - Full-screen page mode for SQL and RPCN editors (#2576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Full-screen page mode for SQL and RPCN editors, console-owned layout - Footer pins to the viewport bottom on short pages (CSS flex chain in standalone, measured min-height in embedded) and keeps centering to the content column; bottom padding 8px -> 16px. - Topics and security-tab pages drop ListLayout's forced min-h-screen (min-h-0 override), removing large dead whitespace. - Embedded Console cancels the Cloud UI host gutters with measured negative margins and owns its page gutter (px-12) — deploy-order-safe with cloud-ui removing its p-10 later. - New expanded-page mode: data-page-expanded on (utils/page-expanded) + useExpandedPageMode hook release every shell's horizontal constraints via global CSS while the page stays in document flow, footer below. The SQL studio's fixed-overlay fullscreen is replaced by this in-flow mode, and the RPCN pipeline editor gains the same toggle; both place the shared ExpandedPageToggle at the top-right of their work surface, clear of Save. - /sql becomes a normal route; new breadcrumbOnlyHeader staticData flag keeps the app header breadcrumb-only for pages with their own title bar. Co-Authored-By: Claude Fable 5 * Comment reduction pass * some Pr feedback * Code review and cleanup passes * Small improvements from review * More changes from code review * More simplification --------- Co-authored-by: Claude Fable 5 --- frontend/src/app.tsx | 4 +- frontend/src/components/layout/footer.tsx | 1 + frontend/src/components/layout/header.tsx | 37 ++- .../components/layout/page-column.test.tsx | 57 ++++ .../src/components/layout/page-column.tsx | 87 +++++ .../pages/rp-connect/pipeline/index.tsx | 116 ++++--- .../rp-connect/pipeline/pipeline-header.tsx | 12 +- .../tabs/permissions-list-tab-new.tsx | 2 +- .../pages/security/tabs/roles-tab-new.tsx | 2 +- .../pages/security/tabs/users-tab-new.tsx | 2 +- .../components/pages/sql/sql-workspace.tsx | 303 ++---------------- .../pages/topics/topic-list-new.tsx | 2 +- .../components/ui/expanded-page-toggle.tsx | 27 ++ frontend/src/federation/federated-routes.tsx | 113 +++++-- frontend/src/globals.css | 31 ++ .../src/hooks/use-expanded-page-mode.test.tsx | 86 +++++ frontend/src/hooks/use-expanded-page-mode.ts | 86 +++++ frontend/src/index.scss | 2 +- frontend/src/routes/__root.tsx | 33 +- frontend/src/routes/sql.tsx | 3 +- frontend/src/utils/dom-position.ts | 30 ++ frontend/src/utils/fullscreen-routes.test.tsx | 112 ------- frontend/src/utils/fullscreen-routes.ts | 90 ------ 23 files changed, 655 insertions(+), 583 deletions(-) create mode 100644 frontend/src/components/layout/page-column.test.tsx create mode 100644 frontend/src/components/layout/page-column.tsx create mode 100644 frontend/src/components/ui/expanded-page-toggle.tsx create mode 100644 frontend/src/hooks/use-expanded-page-mode.test.tsx create mode 100644 frontend/src/hooks/use-expanded-page-mode.ts create mode 100644 frontend/src/utils/dom-position.ts delete mode 100644 frontend/src/utils/fullscreen-routes.test.tsx delete mode 100644 frontend/src/utils/fullscreen-routes.ts diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index f6e9d8b8ff..2f91099679 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -98,8 +98,8 @@ declare module '@tanstack/react-router' { title?: string; /** Lucide icon for the route's sidebar entry. */ icon?: LucideIcon; - /** Render the route with minimal chrome (no page header/footer/padding). */ - fullscreen?: boolean; + /** Route has its own title bar: the app header shows only the breadcrumb row. */ + breadcrumbOnlyHeader?: boolean; } } diff --git a/frontend/src/components/layout/footer.tsx b/frontend/src/components/layout/footer.tsx index 0da374ed0b..ac6d7726a7 100644 --- a/frontend/src/components/layout/footer.tsx +++ b/frontend/src/components/layout/footer.tsx @@ -53,6 +53,7 @@ export const VersionInfo = () => { ); }; +// Bottom placement is CSS: `.footer` has `margin-top: auto` inside #mainLayout's flex column. export const AppFooter = () => { const gitHub = (link: string, title: string) => ( <> diff --git a/frontend/src/components/layout/header.tsx b/frontend/src/components/layout/header.tsx index a9ba6a8719..e66581f481 100644 --- a/frontend/src/components/layout/header.tsx +++ b/frontend/src/components/layout/header.tsx @@ -12,7 +12,7 @@ // ColorModeSwitch is the last Chakra holdout here: dev-only, standalone-only, // and pointless to port until the standalone theme toggle moves off Chakra. import { ColorModeSwitch } from '@redpanda-data/ui'; -import { Link, useLocation, useMatches, useMatchRoute } from '@tanstack/react-router'; +import { Link, useLocation, useMatchRoute, useRouter } from '@tanstack/react-router'; import { cn } from 'components/redpanda-ui/lib/utils'; import { ChevronLeft } from 'lucide-react'; import { Fragment, useMemo } from 'react'; @@ -69,14 +69,9 @@ function BreadcrumbHeaderRow({ useNewSidebar, breadcrumbItems }: BreadcrumbHeade ); } -function AppPageHeader({ breadcrumbOnly = false }: { breadcrumbOnly?: boolean }) { +function AppPageHeader() { useApiStoreHook((s) => s.userData); // re-render when userData changes - // Fullscreen routes (e.g. the SQL studio) carry their own title bar/toolbar, so - // they never want the title+actions row — only the breadcrumb. Robust to which - // layout branch renders the header (standalone vs embedded misdetection). - const matches = useMatches(); - const isFullscreenRoute = matches.some((m) => m.staticData.fullscreen); - const hideTitleRow = breadcrumbOnly || isFullscreenRoute; + const hideTitleRow = useRouteOwnsTitleRow(); const showRefresh = useShouldShowRefresh(); const shouldHideHeader = useShouldHideHeader(); const useNewSidebar = !isEmbedded(); @@ -106,8 +101,16 @@ function AppPageHeader({ breadcrumbOnly = false }: { breadcrumbOnly?: boolean }) 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. + if (hideTitleRow && isEmbedded()) { + return null; + } + return ( -
+ // Expanded pages release #mainLayout's gutter; keep the header off the viewport edge. +
{/* Title + actions row. Hidden for breadcrumb-only headers (e.g. the SQL studio, which carries its own title bar and toolbar). */} @@ -175,6 +178,22 @@ function AppPageHeader({ breadcrumbOnly = false }: { breadcrumbOnly?: boolean }) export default AppPageHeader; +/** + * Whether the matched route draws its own title bar (`staticData.breadcrumbOnlyHeader`), + * so the header shows only the breadcrumb row. + * + * 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. + */ +function useRouteOwnsTitleRow() { + const router = useRouter(); + const { pathname } = useLocation(); + + return router + .getMatchedRoutes(pathname) + .matchedRoutes.some((route) => route.options.staticData?.breadcrumbOnlyHeader); +} + /** * Custom React Hook: Determines whether to show the refresh button based on route matches. * It checks various routes and conditions to decide if the refresh button should be displayed diff --git a/frontend/src/components/layout/page-column.test.tsx b/frontend/src/components/layout/page-column.test.tsx new file mode 100644 index 0000000000..468bf8a1c1 --- /dev/null +++ b/frontend/src/components/layout/page-column.test.tsx @@ -0,0 +1,57 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { PageColumn } from './page-column'; + +const PAGE_TOP_VAR = '--console-page-top'; + +// Tailwind isn't loaded here, so stand in for the column's `pt-8`. +const PADDING_TOP = 32; +const styleEl = document.createElement('style'); +styleEl.textContent = `.pt-8 { padding-top: ${PADDING_TOP}px; }`; +document.head.appendChild(styleEl); + +// Mirrors the layout roots: chrome, then the page column. ErrorDisplay swaps the column +// out while the API is erroring, so it can mount more than once. +const Harness = ({ erroring = false }: { erroring?: boolean }) => ( +
+
+ {erroring ? ( +
+ ) : ( + +
+ + )} +
+); + +const column = (container: HTMLElement) => container.querySelector('.pt-8') as HTMLElement; +const readVar = (el: HTMLElement) => el.style.getPropertyValue(PAGE_TOP_VAR); + +describe('PageColumn', () => { + // happy-dom does no layout, so every offsetTop is 0 — the padding is the observable part. + it("publishes an offset including the column's own top padding", () => { + const { container } = render(); + + expect(readVar(column(container))).toBe(`${PADDING_TOP}px`); + }); + + it('re-measures when the column remounts after an error page', () => { + const { container, rerender } = render(); + + rerender(); + + expect(readVar(column(container))).toBe(`${PADDING_TOP}px`); + }); + + it('clears the variable on unmount, so no stale height survives the page', () => { + const { container, unmount } = render(); + const el = column(container); + expect(readVar(el)).toBe(`${PADDING_TOP}px`); + + unmount(); + + expect(readVar(el)).toBe(''); + }); +}); diff --git a/frontend/src/components/layout/page-column.tsx b/frontend/src/components/layout/page-column.tsx new file mode 100644 index 0000000000..6161cf0c15 --- /dev/null +++ b/frontend/src/components/layout/page-column.tsx @@ -0,0 +1,87 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { type ReactNode, useLayoutEffect, useState } from 'react'; + +import { chainToBody, documentTop } from '../../utils/dom-position'; + +/** Read by `.page-fill-viewport` (globals.css). */ +const PAGE_TOP_VAR = '--console-page-top'; + +/** + * Publishes `--console-page-top`: where page content starts, from the document top. + * Measured, not hardcoded — the chrome above a page differs per shell and per route, and + * the host's share of it can change without Console shipping. + */ +const usePublishPageTop = () => { + // State, not a ref, so re-attaching re-runs the effect: ErrorDisplay swaps the column + // out while the API is erroring, and it must be re-measured when it comes back. + const [columnEl, setColumnEl] = useState(null); + + useLayoutEffect(() => { + const layoutEl = columnEl?.parentElement; + if (!(columnEl && layoutEl)) { + return; + } + + // The write resizes the page, which resizes observed elements, so only write on change. + let lastValue = ''; + const publish = () => { + const paddingTop = Number.parseFloat(getComputedStyle(columnEl).paddingTop) || 0; + const value = `${documentTop(columnEl) + paddingTop}px`; + if (value !== lastValue) { + lastValue = value; + columnEl.style.setProperty(PAGE_TOP_VAR, value); + } + }; + + // Only what sits above can push the page down: the shell's wrappers (the host's too, + // when embedded) and the preceding chrome inside #mainLayout. + const observer = new ResizeObserver(publish); + const observeAll = () => { + observer.disconnect(); + for (const el of chainToBody(layoutEl)) { + observer.observe(el); + } + for (let el = columnEl.previousElementSibling; el; el = el.previousElementSibling) { + observer.observe(el); + } + publish(); + }; + observeAll(); + + // That chrome mounts and unmounts per route, changing the set to observe. + const mutationObserver = new MutationObserver(observeAll); + mutationObserver.observe(layoutEl, { childList: true }); + + return () => { + observer.disconnect(); + mutationObserver.disconnect(); + columnEl.style.removeProperty(PAGE_TOP_VAR); + }; + }, [columnEl]); + + return setColumnEl; +}; + +/** + * The column every page renders into: the gap below the app header, plus the offset + * `page-fill-viewport` pages size against. Must sit directly inside `#mainLayout`. + */ +export const PageColumn = ({ children }: { children: ReactNode }) => { + const ref = usePublishPageTop(); + + return ( +
+ {children} +
+ ); +}; diff --git a/frontend/src/components/pages/rp-connect/pipeline/index.tsx b/frontend/src/components/pages/rp-connect/pipeline/index.tsx index a055136b57..dccff818f7 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/index.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/index.tsx @@ -36,9 +36,11 @@ import { Tabs, TabsList, TabsTrigger } from 'components/redpanda-ui/components/t import { cn } from 'components/redpanda-ui/lib/utils'; import { LogExplorer } from 'components/ui/connect/log-explorer'; import { DeleteResourceAlertDialog } from 'components/ui/delete-resource-alert-dialog'; +import { ExpandedPageToggle } from 'components/ui/expanded-page-toggle'; import { LintHintList } from 'components/ui/lint-hint/lint-hint-list'; import { YamlEditor } from 'components/ui/yaml/yaml-editor'; import { isEmbedded, isFeatureFlagEnabled, isServerless } from 'config'; +import { useExpandedPageMode } from 'hooks/use-expanded-page-mode'; import { useRefFormDialog } from 'hooks/use-ref-form-dialog'; import { KeyRound, LayoutGrid, Plus, User, Zap } from 'lucide-react'; import type { editor } from 'monaco-editor'; @@ -841,6 +843,9 @@ function SidebarPanel({ ); } +/** One tab of the editor surface's lane strip. */ +type LaneTab = { value: string; label: string; onSelect: () => void }; + // The visual editor builds on the diagram parsing, so it also requires the diagrams flag and the // embedded Cloud UI. const isVisualEditorFeatureEnabled = (): boolean => @@ -1112,6 +1117,12 @@ function PipelinePageContent() { const isEditVisualLane = mode !== 'view' && activeEditLane === 'visual'; const showSidebar = !(isViewVisualLane || isEditVisualLane); + const { + expanded, + toggleExpanded, + ref: expandedModeRef, + } = useExpandedPageMode({ storageKey: 'rp-pipeline-editor-mode' }); + // Open the YAML lane and reveal a node: explicit id, else the selected node. Routes per mode. const goToYamlNode = useCallback( (nodeId?: string) => { @@ -1133,27 +1144,54 @@ function PipelinePageContent() { 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') { + if (!pipeline) { + return []; + } + const viewLanes: LaneTab[] = [ + { value: 'monitor', label: 'Monitor', onSelect: () => setActiveViewLane('monitor') }, + { value: 'configuration', label: 'YAML', onSelect: () => goToYamlNode() }, + ]; + if (isVisualEditorEnabled) { + viewLanes.push({ value: 'visual', label: 'Visual', onSelect: () => setActiveViewLane('visual') }); + } + return viewLanes; + } + if (!isVisualEditorEnabled) { + return []; + } + return [ + { value: 'yaml', label: 'YAML', onSelect: () => goToYamlNode() }, + { value: 'visual', label: 'Visual', onSelect: () => setActiveEditLane('visual') }, + ]; + }, [mode, pipeline, isVisualEditorEnabled, goToYamlNode, setActiveViewLane, setActiveEditLane]); + return ( - // Editor lanes get a viewport-bounded height (7rem = app header + pt-8): Monaco needs a bounded - // box, and a tall lane scrolls within the framed panel. The Monitor lane instead flows with the - // document — chart, logs, and pagination at natural height, one page-level scroll context — so - // its controls are never trapped behind an inner fold. + // Editor lanes get a viewport-bounded height (page-fill-viewport, globals.css): Monaco needs a + // bounded box, and a tall lane scrolls within the framed panel. The Monitor lane instead flows + // with the document — chart, logs, and pagination at natural height, one page-level scroll + // context — so its controls are never trapped 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 ? ( setIsViewConfigDialogOpen(true)} pipeline={pipeline} /> ) : null} {mode === 'view' && !pipeline ? ( -
+ // Same inset as the loaded header, so nothing shifts when the pipeline arrives. +
@@ -1162,6 +1200,7 @@ function PipelinePageContent() { ) : null} {mode !== 'view' ? ( - {/* Framed panel: the lane tabs sit flush at the top, their underline as the internal divider. */} -
- {mode === 'view' && pipeline ? ( - - {/* Full-width list (so the underline divider spans) with content-width triggers so tabs pack left. */} - - setActiveViewLane('monitor')} value="monitor" variant="underline"> - Monitor - - goToYamlNode()} value="configuration" variant="underline"> - YAML - - {isVisualEditorEnabled ? ( - setActiveViewLane('visual')} value="visual" variant="underline"> - Visual - - ) : null} - - - ) : null} - {mode !== 'view' && isVisualEditorEnabled ? ( - - - goToYamlNode()} value="yaml" variant="underline"> - YAML - - setActiveEditLane('visual')} value="visual" variant="underline"> - Visual - - - - ) : null} + {/* Boxed: rounded frame. Fullscreen: flush sides, top/bottom borders kept so the + clipped scroll area still has a visible edge. */} +
+ {/* Lane tabs with the fullscreen toggle overlaid at the right end (pr-12 keeps the + triggers clear of it). Lane-less modes keep an empty strip so it stays put. */} +
+ {lanes.length > 0 ? ( + + + {lanes.map((lane) => ( + + {lane.label} + + ))} + + + ) : ( +
+ )} +
+ +
+
{/* min-w-0 + overflow-hidden keep the editor region from propagating width upward. */}
{showSidebar ? ( @@ -1276,7 +1311,10 @@ function PipelinePageContent() {
{tipsContext ? ( - + // Match the header's fullscreen inset. +
+ +
) : null}
diff --git a/frontend/src/components/pages/rp-connect/pipeline/pipeline-header.tsx b/frontend/src/components/pages/rp-connect/pipeline/pipeline-header.tsx index f19b3ddde0..edb5597c6f 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/pipeline-header.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/pipeline-header.tsx @@ -171,6 +171,10 @@ const BackButton = ({ onClick }: { onClick: () => void }) => ( ); +// Expanded mode insets the header while the panel below it goes flush. +const headerClassName = (expanded: boolean) => + cn('flex flex-col gap-3 transition-[padding] duration-300 ease-in-out', expanded && 'px-4'); + // Inline-editable pipeline name, bound to the same form field as the settings dialog. const EditableTitle = ({ form, placeholder }: { form: UseFormReturn; placeholder: string }) => ( void; onViewDetails: () => void; + expanded: boolean; }) { const navigate = useNavigate(); const name = pipeline.displayName || pipeline.id; @@ -228,7 +234,7 @@ export function PipelineViewHeader({ ]; return ( -
+
@@ -281,6 +287,7 @@ export function PipelineEditHeader({ onEditSettings, isSaving, hasUnsavedChanges, + expanded, }: { form: UseFormReturn; mode: 'edit' | 'create'; @@ -290,6 +297,7 @@ export function PipelineEditHeader({ onEditSettings: () => void; isSaving?: boolean; hasUnsavedChanges?: boolean; + expanded: boolean; }) { const description = useWatch({ control: form.control, name: 'description' })?.trim(); const units = useWatch({ control: form.control, name: 'computeUnits' }); @@ -301,7 +309,7 @@ export function PipelineEditHeader({ ]; return ( -
+
diff --git a/frontend/src/components/pages/security/tabs/permissions-list-tab-new.tsx b/frontend/src/components/pages/security/tabs/permissions-list-tab-new.tsx index 895cc7c9cb..ea252111ff 100644 --- a/frontend/src/components/pages/security/tabs/permissions-list-tab-new.tsx +++ b/frontend/src/components/pages/security/tabs/permissions-list-tab-new.tsx @@ -506,7 +506,7 @@ export const PermissionsListTabNew: FC = () => { return ( <> - +
{ return ( <> - +
diff --git a/frontend/src/components/pages/security/tabs/users-tab-new.tsx b/frontend/src/components/pages/security/tabs/users-tab-new.tsx index b5f350e40d..53e1215c47 100644 --- a/frontend/src/components/pages/security/tabs/users-tab-new.tsx +++ b/frontend/src/components/pages/security/tabs/users-tab-new.tsx @@ -345,7 +345,7 @@ export const UsersTabNew: FC = () => { <> - +
These users are SASL-SCRAM users managed by your cluster. View permissions for other authentication diff --git a/frontend/src/components/pages/sql/sql-workspace.tsx b/frontend/src/components/pages/sql/sql-workspace.tsx index b3e9c42327..85d80e8b28 100644 --- a/frontend/src/components/pages/sql/sql-workspace.tsx +++ b/frontend/src/components/pages/sql/sql-workspace.tsx @@ -13,11 +13,11 @@ import { create } from '@bufbuild/protobuf'; import { timestampFromDate } from '@bufbuild/protobuf/wkt'; import { useNavigate } from '@tanstack/react-router'; import { Badge } from 'components/redpanda-ui/components/badge'; -import { Button } from 'components/redpanda-ui/components/button'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'components/redpanda-ui/components/resizable'; import { cn } from 'components/redpanda-ui/lib/utils'; -import { isEmbedded } from 'config'; -import { Database, Maximize2, Minimize2 } from 'lucide-react'; +import { ExpandedPageToggle } from 'components/ui/expanded-page-toggle'; +import { useExpandedPageMode } from 'hooks/use-expanded-page-mode'; +import { Database } from 'lucide-react'; import { CatalogType, ExecuteQueryRequestSchema, @@ -25,7 +25,7 @@ import { type Row as SqlRow, type Value as SqlValue, } from 'protogen/redpanda/api/dataplane/v1alpha3/sql_pb'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useExecuteInstantQuery } from 'react-query/api/observability'; import { useExecuteQueryMutation, @@ -97,206 +97,12 @@ function resultRowFromProto(row: SqlRow, columns: ColumnDef[]): ResultRow { return result; } -// Studio layout mode. 'boxed' caps the studio to the standard page width like -// every other page; 'full' is edge-to-edge. Persisted per browser. -type StudioMode = 'boxed' | 'full'; - -const STUDIO_MODE_KEY = 'rp-sql-studio-mode'; -// Generic cloud-ui shell contract: any embedded page that sets this -// attribute makes the layout wrapper (and the breadcrumb header inside it) -// animate to full width via CSS — see cloud-ui layout.tsx `expandableWidth`. -// Presence = expanded. Set synchronously with the studio's own geometry change -// so the wrapper and the studio animate in lockstep. -const PAGE_EXPANDED_ATTR = 'data-page-expanded'; -const STUDIO_MAX_WIDTH = 1500; // boxed mode caps to the standard page column -const STUDIO_SIDE_GAP = 40; // boxed inset from the centered max-width column -const STUDIO_TOP_GAP = 16; // boxed gap between the breadcrumb header and the studio header; full mode has none -const STUDIO_BOTTOM_GAP = 32; -const STUDIO_EASE = '0.3s cubic-bezier(0.4, 0, 0.2, 1)'; -const SCROLLABLE_OVERFLOW_RE = /(auto|scroll)/; -// Root animates geometry only; the body card animates its own border/radius/shadow -// via Tailwind (same easing/duration) so the studio header stays outside the box. -const STUDIO_TRANSITION = `top ${STUDIO_EASE}, left ${STUDIO_EASE}, right ${STUDIO_EASE}, bottom ${STUDIO_EASE}`; - -const readStudioMode = (): StudioMode => - (typeof localStorage !== 'undefined' ? localStorage.getItem(STUDIO_MODE_KEY) : null) === 'full' ? 'full' : 'boxed'; - -const setPageExpanded = (expanded: boolean) => { - const root = document.documentElement; - if (expanded) { - root.setAttribute(PAGE_EXPANDED_ATTR, ''); - } else { - root.removeAttribute(PAGE_EXPANDED_ATTR); - } -}; - -// Standalone console renders its own breadcrumb/title header for the SQL route; -// populate it the way other pages do (no-op visually when embedded, where the -// host supplies the header). +// Standalone renders its own breadcrumb header; the embedded host supplies its own. const setStudioPageHeader = () => { uiState.pageTitle = 'SQL'; uiState.pageBreadcrumbs = [{ title: 'SQL', linkTo: '/sql', heading: 'SQL' }]; }; -// Renders the workspace as a fixed overlay filling the area right of the -// cluster sidebar, below the page header. This gives a true full-width, -// full-height editor WITHOUT mutating any shared cloud-ui layout nodes — so it -// never leaves residue on other pages (e.g. Overview) when you navigate away. -// Works in both standalone console and embedded cloud-ui. `getMode` is read on -// every layout pass so a mode toggle re-runs geometry; returns teardown + -// relayout (the latter called by the component when the mode changes). -function setupOverlayLayout( - el: HTMLDivElement, - getMode: () => StudioMode -): { relayout: () => void; teardown: () => void } { - // Natural (in-flow) top sits just below the page header. Measured once - // while still in flow; horizontal resizes don't change it. - const naturalTop = el.getBoundingClientRect().top; - - const findRegionLeft = () => { - // The content region is the INNERMOST ancestor that spans to the - // viewport's right edge — i.e. the main column right of the sidebar. - // (Outer ancestors like the sidebar wrapper also reach the right edge but - // start at x=0 and would put the editor under the sidebar.) - let node = el.parentElement; - while (node && node !== document.body) { - const r = node.getBoundingClientRect(); - if (Math.abs(r.right - window.innerWidth) <= 2 && r.width > 200) { - return r.left; - } - node = node.parentElement; - } - return el.getBoundingClientRect().left; - }; - - const layout = () => { - const regionLeft = findRegionLeft(); - el.style.position = 'fixed'; - el.style.height = 'auto'; - if (getMode() === 'full') { - // Edge-to-edge: fill the region right of the sidebar, flush under the header. - el.style.top = `${naturalTop}px`; - el.style.left = `${regionLeft}px`; - el.style.right = '0px'; - el.style.bottom = '0px'; - return; - } - // Boxed: centre a max-width column in the region, inset by the side gap, with a - // bottom gap. Standalone adds a top gap below the thin breadcrumb; embedded - // skips it because the cloud-ui shell's own header spacing already supplies the - // gap. The card border/radius/shadow live on the body element, so the studio - // header sits outside the box. - const regionWidth = window.innerWidth - regionLeft; - const capped = Math.min(STUDIO_MAX_WIDTH, regionWidth); - const centeredLeft = regionLeft + (regionWidth - capped) / 2; - el.style.top = `${naturalTop + (isEmbedded() ? 0 : STUDIO_TOP_GAP)}px`; - el.style.left = `${centeredLeft + STUDIO_SIDE_GAP}px`; - el.style.right = `${window.innerWidth - (centeredLeft + capped) + STUDIO_SIDE_GAP}px`; - el.style.bottom = `${STUDIO_BOTTOM_GAP}px`; - }; - - // The overlay is fixed, but the host page (cloud-ui chrome when embedded) - // still scrolls behind it — dragging the host page header up/down/sideways - // while the pinned editor stays put. Lock every scrollable ancestor (plus - // the document scroller) so nothing behind the overlay can scroll, keeping - // the host header static. No-op in standalone console, where nothing scrolls. - const locked: Array<{ node: HTMLElement; overflow: string }> = []; - const lock = (node: HTMLElement) => { - locked.push({ node, overflow: node.style.overflow }); - node.style.overflow = 'hidden'; - }; - const lockAll = () => { - let node: HTMLElement | null = el.parentElement; - while (node && node !== document.body) { - const c = getComputedStyle(node); - const scrollable = SCROLLABLE_OVERFLOW_RE.test(c.overflowY + c.overflowX); - if (scrollable && (node.scrollHeight > node.clientHeight || node.scrollWidth > node.clientWidth)) { - lock(node); - } - node = node.parentElement; - } - const scroller = (document.scrollingElement ?? document.documentElement) as HTMLElement; - lock(scroller); - if (document.body) { - lock(document.body); - } - }; - const unlockAll = () => { - for (const { node, overflow } of locked) { - node.style.overflow = overflow; - } - locked.length = 0; - }; - - // When embedded, the host keeps Console MOUNTED but display:none while on - // its own routes (e.g. /overview) — unmount cleanup never runs there, which - // would strand the scroll locks on a page that needs to scroll. Hold the - // locks only while actually on screen: display:none collapses the overlay - // to 0x0, which fires the ResizeObserver, and we release until shown again. - const isVisible = () => el.getClientRects().length > 0; - let active = false; - const sync = () => { - if (isVisible() && !active) { - layout(); - lockAll(); - // Re-assert the host expand attr on show — embedded cloud-ui keeps Console - // mounted+hidden, so this is the lifecycle that owns the attribute. - setPageExpanded(getMode() === 'full'); - active = true; - } else if (!isVisible() && active) { - unlockAll(); - // Clear it on hide so other cloud-ui pages don't render stuck full-width. - setPageExpanded(false); - active = false; - } - }; - sync(); - const visibilityObserver = new ResizeObserver(sync); - visibilityObserver.observe(el); - - // Enable transitions one frame after first geometry so the initial mount - // snaps into place instead of animating from the unstyled position. - let transitionsReady = false; - requestAnimationFrame(() => - requestAnimationFrame(() => { - el.style.transition = STUDIO_TRANSITION; - transitionsReady = true; - }) - ); - - const onWindowResize = () => { - if (!active) { - return; - } - // Reposition instantly during resize — animating every resize tick trails. - if (transitionsReady) { - el.style.transition = 'none'; - } - layout(); - if (transitionsReady) { - requestAnimationFrame(() => { - el.style.transition = STUDIO_TRANSITION; - }); - } - }; - window.addEventListener('resize', onWindowResize); - - return { - relayout: () => { - if (active) { - layout(); - } - }, - teardown: () => { - visibilityObserver.disconnect(); - window.removeEventListener('resize', onWindowResize); - if (active) { - unlockAll(); - } - }, - }; -} - export type SqlWorkspaceProps = { /** * Effective role of the caller. When omitted it's derived from the @@ -306,59 +112,6 @@ export type SqlWorkspaceProps = { sqlRole?: SqlRole; }; -// Studio layout mode + the callback ref that wires it to the imperative overlay. -// The ref mirrors the mode so the overlay (set up once) reads the latest value -// during async relayouts; toggleMode drives the geometry change directly — react -// state only mirrors it so the toggle button's icon re-renders. -function useStudioMode(): { - attachOverlay: (el: HTMLDivElement | null) => void; - mode: StudioMode; - toggleMode: () => void; -} { - const [mode, setMode] = useState(readStudioMode); - const modeRef = useRef(mode); - const overlayCleanup = useRef<(() => void) | null>(null); - const overlayRelayout = useRef<(() => void) | null>(null); - - // Callback ref (no effect): React calls it with the node on mount and null - // on unmount, which maps 1:1 onto the overlay's setup/teardown. Must be - // identity-stable, or React would detach/reattach the overlay every render. - const attachOverlay = useCallback((el: HTMLDivElement | null) => { - overlayCleanup.current?.(); - if (el) { - // Reflect the mode for the cloud-ui shell while the studio is mounted, and - // clear it on unmount so it leaves no residue on other pages. - setPageExpanded(modeRef.current === 'full'); - setStudioPageHeader(); - const handle = setupOverlayLayout(el, () => modeRef.current); - overlayCleanup.current = handle.teardown; - overlayRelayout.current = handle.relayout; - } else { - setPageExpanded(false); - overlayCleanup.current = null; - overlayRelayout.current = null; - } - }, []); - - const toggleMode = useCallback(() => { - const next: StudioMode = modeRef.current === 'boxed' ? 'full' : 'boxed'; - modeRef.current = next; - try { - localStorage.setItem(STUDIO_MODE_KEY, next); - } catch { - // ignore storage failures (private mode / quota) - } - // Update the attr and the studio geometry in the same synchronous - // tick: the cloud-ui shell's wrapper transitions off the attr via CSS while - // the overlay transitions its inline geometry, so both animate together. - setPageExpanded(next === 'full'); - overlayRelayout.current?.(); - setMode(next); - }, []); - - return { attachOverlay, mode, toggleMode }; -} - export function SqlWorkspace({ sqlRole: sqlRoleProp }: SqlWorkspaceProps) { const navigate = useNavigate(); // The route guard skips the redirect while endpoint compatibility is still @@ -383,7 +136,18 @@ export function SqlWorkspace({ sqlRole: sqlRoleProp }: SqlWorkspaceProps) { // Per-instance monotonic run token: drops out-of-order responses without // sharing state across concurrently-mounted SqlWorkspace instances. const latestRunToken = useRef(0); - const { mode, toggleMode, attachOverlay } = useStudioMode(); + const { + expanded, + toggleExpanded, + ref: expandedModeRef, + } = useExpandedPageMode({ + storageKey: 'rp-sql-studio-mode', + }); + + // Pre-paint so the previous route's title doesn't flash in the app header. + useLayoutEffect(() => { + setStudioPageHeader(); + }, []); const { data: catalogsData, isLoading } = useListCatalogsQuery(); const executeQuery = useExecuteQueryMutation(); @@ -619,14 +383,13 @@ export function SqlWorkspace({ sqlRole: sqlRoleProp }: SqlWorkspaceProps) { return (
-
+
Redpanda SQL · Studio
@@ -634,28 +397,16 @@ export function SqlWorkspace({ sqlRole: sqlRoleProp }: SqlWorkspaceProps) { {sqlRole === 'admin' ? 'Admin' : 'Viewer · read-only'} - +
diff --git a/frontend/src/components/pages/topics/topic-list-new.tsx b/frontend/src/components/pages/topics/topic-list-new.tsx index a14a6e3ddc..72a7335bd9 100644 --- a/frontend/src/components/pages/topics/topic-list-new.tsx +++ b/frontend/src/components/pages/topics/topic-list-new.tsx @@ -398,7 +398,7 @@ const TopicList: FC = () => { ) : null} - +
{( [ diff --git a/frontend/src/components/ui/expanded-page-toggle.tsx b/frontend/src/components/ui/expanded-page-toggle.tsx new file mode 100644 index 0000000000..cfe57fea28 --- /dev/null +++ b/frontend/src/components/ui/expanded-page-toggle.tsx @@ -0,0 +1,27 @@ +/** + * 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 { Button } from 'components/redpanda-ui/components/button'; +import { Maximize2, Minimize2 } from 'lucide-react'; + +/** + * Fullscreen toggle for useExpandedPageMode pages. Belongs at the top-right corner of + * the work surface it expands — never among the page actions next to Save. + */ +export function ExpandedPageToggle({ expanded, onToggle }: { expanded: boolean; onToggle: () => void }) { + const label = expanded ? 'Exit fullscreen' : 'Enter fullscreen'; + + return ( + + ); +} diff --git a/frontend/src/federation/federated-routes.tsx b/frontend/src/federation/federated-routes.tsx index 5fd3c3b568..cca74eb277 100644 --- a/frontend/src/federation/federated-routes.tsx +++ b/frontend/src/federation/federated-routes.tsx @@ -11,12 +11,14 @@ import type { Transport } from '@connectrpc/connect'; import type { QueryClient } from '@tanstack/react-query'; -import { createRootRouteWithContext, Outlet, useLocation, useMatches } from '@tanstack/react-router'; +import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'; import { NuqsAdapter } from 'nuqs/adapters/tanstack-router'; +import { useLayoutEffect, useRef } from 'react'; import { DebugHelper } from '../components/debug-helper/debug-helper'; import AppFooter from '../components/layout/footer'; import AppPageHeader from '../components/layout/header'; +import { PageColumn } from '../components/layout/page-column'; import { LicenseNotification } from '../components/license/license-notification'; import { ErrorBoundary } from '../components/misc/error-boundary'; import { ErrorDisplay } from '../components/misc/error-display'; @@ -26,7 +28,7 @@ import { RouterSync } from '../components/misc/router-sync'; import { Toaster } from '../components/redpanda-ui/components/sonner'; import RequireAuth from '../components/require-auth'; import { useIsDarkMode } from '../hooks/use-is-dark-mode'; -import { isFullscreenPath } from '../utils/fullscreen-routes'; +import { chainToBody, documentTop } from '../utils/dom-position'; import { ModalContainer } from '../utils/modal-container'; /** @@ -85,40 +87,107 @@ function FederatedRootLayout() { ); } +/** + * Fits `#mainLayout` into the host's shell. Neither half can be CSS: every wrapper + * above it belongs to the host app, so there is no chain to inherit from. + * + * - Cancels the host's side/bottom padding with equal negative margins, leaving + * Console's own gutter as the only one. Measured, not hardcoded, so either project + * can deploy first. Top padding stays — cancelling it would pull Console under the + * host's header; pages size themselves off it instead (layout/page-column.tsx). + * - 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. + */ +const useHostShellFit = () => { + const layoutRef = useRef(null); + + useLayoutEffect(() => { + const layoutEl = layoutRef.current; + if (!layoutEl) { + return; + } + + const hostWrappers = chainToBody(layoutEl.parentElement); + + // Both writes resize the wrappers being observed, so only write on change. + let lastMargin = ''; + let lastMinHeight = ''; + const applyFit = () => { + let left = 0; + let right = 0; + let bottom = 0; + for (const el of hostWrappers) { + const style = getComputedStyle(el); + left += Number.parseFloat(style.paddingLeft) || 0; + right += Number.parseFloat(style.paddingRight) || 0; + bottom += Number.parseFloat(style.paddingBottom) || 0; + } + + const margin = `0px ${-right}px ${-bottom}px ${-left}px`; + if (margin !== lastMargin) { + lastMargin = margin; + layoutEl.style.margin = margin; + } + // dvh, so viewport changes need no JS; only the offset from the top can move. + const minHeight = `calc(100dvh - ${documentTop(layoutEl)}px)`; + if (minHeight !== lastMinHeight) { + lastMinHeight = minHeight; + layoutEl.style.minHeight = minHeight; + } + }; + + applyFit(); + // Padding changes alter a wrapper's content box even at a fixed outer size. + const observer = new ResizeObserver(applyFit); + observer.observe(document.documentElement); + for (const el of hostWrappers) { + observer.observe(el); + } + return () => { + observer.disconnect(); + layoutEl.style.margin = ''; + layoutEl.style.minHeight = ''; + }; + }, []); + + return layoutRef; +}; + /** * App content for federated mode. * Similar to EmbeddedLayout from __root.tsx but optimized for MF v2.0. */ function FederatedAppContent() { - const matches = useMatches(); - const { pathname } = useLocation(); - // Fullscreen routes (SQL studio) own their chrome — breadcrumb-only header, no - // padding/footer. staticData is the source of truth, but on soft navigation - // useMatches() lags useLocation() by a render or two (matches resolve after - // pathname flips), so fall back to a path check to avoid flashing full chrome on - // the way in. Single return with stable element positions: toggling props/classes - // (not branching the tree) keeps the mounted across fullscreen↔normal - // navigation, so the embedded router doesn't reset to its default route. - const isFullscreen = matches.some((m) => m.staticData.fullscreen) || isFullscreenPath(pathname); const toasterTheme = useIsDarkMode() ? 'dark' : 'light'; + const layoutRef = useHostShellFit(); return ( -
- {!isFullscreen && ( - - - - )} + // Flex column so the footer's `margin-top: auto` pins it to the bottom. px-12 is + // Console's own gutter, released while a page is expanded (globals.css). +
+ + + - + -
+ -
+
- {!isFullscreen && } + diff --git a/frontend/src/globals.css b/frontend/src/globals.css index a92b022bcb..c32259145a 100644 --- a/frontend/src/globals.css +++ b/frontend/src/globals.css @@ -32,6 +32,37 @@ --chakra-colors-chakra-border-color: var(--color-border); } +@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 + 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); + } +} + +/* Expanded page mode (hooks/use-expanded-page-mode.ts) releases the page gutter and width + cap. Not a Tailwind variant: the generated variant CSS went missing on some routes in + the federated build. Layered, so it beats those utilities on specificity alone. */ +@layer utilities { + /* 100%, not none — so the release animates. */ + html[data-page-expanded] .page-expanded-uncap { + max-width: 100%; + } + + html[data-page-expanded] .page-expanded-flush { + padding-left: 0; + padding-right: 0; + } + + /* 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. */ + html[data-page-expanded] .page-expanded-inset { + padding-left: 1rem; + padding-right: 1rem; + } +} + /* Dev-only bottom-right cluster for floating debug triggers, raised clear of the host's Feedback tab: router-devtools pill at the bottom, query-devtools circle and the debug-helpers launcher (positioned via its own classes) in a row above. diff --git a/frontend/src/hooks/use-expanded-page-mode.test.tsx b/frontend/src/hooks/use-expanded-page-mode.test.tsx new file mode 100644 index 0000000000..fd412bc4c4 --- /dev/null +++ b/frontend/src/hooks/use-expanded-page-mode.test.tsx @@ -0,0 +1,86 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { useExpandedPageMode } from './use-expanded-page-mode'; + +const STORAGE_KEY = 'rp-test-page-mode'; +const PAGE_EXPANDED_ATTR = 'data-page-expanded'; + +const isExpanded = () => document.documentElement.hasAttribute(PAGE_EXPANDED_ATTR); + +// The hook reads `getClientRects()` for its on-screen check; happy-dom does no layout. +const createPageRoot = ({ onScreen }: { onScreen: boolean }) => { + const el = document.createElement('div'); + el.getClientRects = () => (onScreen ? [new DOMRect(0, 0, 800, 600)] : []) as unknown as DOMRectList; + document.body.appendChild(el); + return el; +}; + +const renderExpandedPage = ({ onScreen = true }: { onScreen?: boolean } = {}) => { + const pageRoot = createPageRoot({ onScreen }); + const view = renderHook(() => useExpandedPageMode({ storageKey: STORAGE_KEY })); + act(() => { + view.result.current.ref(pageRoot); + }); + return { ...view, pageRoot }; +}; + +describe('useExpandedPageMode', () => { + afterEach(() => { + document.documentElement.removeAttribute(PAGE_EXPANDED_ATTR); + document.body.innerHTML = ''; + localStorage.clear(); + }); + + it('starts collapsed and sets no attribute when nothing is stored', () => { + const { result } = renderExpandedPage(); + + expect(result.current.expanded).toBe(false); + expect(isExpanded()).toBe(false); + }); + + it('restores the stored expanded preference and marks the document', () => { + localStorage.setItem(STORAGE_KEY, 'full'); + + const { result } = renderExpandedPage(); + + expect(result.current.expanded).toBe(true); + expect(isExpanded()).toBe(true); + }); + + it('persists both directions of the toggle', () => { + const { result } = renderExpandedPage(); + + act(() => { + result.current.toggleExpanded(); + }); + expect(isExpanded()).toBe(true); + expect(localStorage.getItem(STORAGE_KEY)).toBe('full'); + + act(() => { + result.current.toggleExpanded(); + }); + expect(isExpanded()).toBe(false); + expect(localStorage.getItem(STORAGE_KEY)).toBe('boxed'); + }); + + it('clears the attribute when the page unmounts, so it cannot bleed onto the next page', () => { + localStorage.setItem(STORAGE_KEY, 'full'); + const { unmount } = renderExpandedPage(); + expect(isExpanded()).toBe(true); + + unmount(); + + expect(isExpanded()).toBe(false); + }); + + it('holds the preference but not the attribute while the page root is off screen', () => { + localStorage.setItem(STORAGE_KEY, 'full'); + + // Embedded Cloud UI keeps Console mounted and hidden on host routes. + const { result } = renderExpandedPage({ onScreen: false }); + + expect(result.current.expanded).toBe(true); + expect(isExpanded()).toBe(false); + }); +}); diff --git a/frontend/src/hooks/use-expanded-page-mode.ts b/frontend/src/hooks/use-expanded-page-mode.ts new file mode 100644 index 0000000000..ca5759475e --- /dev/null +++ b/frontend/src/hooks/use-expanded-page-mode.ts @@ -0,0 +1,86 @@ +/** + * 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 { 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. + */ +const PAGE_EXPANDED_ATTR = 'data-page-expanded'; + +const clearPageExpanded = () => document.documentElement.removeAttribute(PAGE_EXPANDED_ATTR); + +const readStoredExpanded = (storageKey: string): boolean => { + try { + return localStorage.getItem(storageKey) === 'full'; + } catch { + return false; // storage blocked (private mode / cookie settings) + } +}; + +/** + * Full-width ("expanded") mode for a work-surface page, in normal document flow. + * Persisted per browser under `storageKey`. + * + * Attach `ref` to the page root: the attribute is held only while that root is on + * screen, because embedded Cloud UI keeps Console mounted but hidden on host routes. + */ +export function useExpandedPageMode({ storageKey }: { storageKey: string }): { + expanded: boolean; + toggleExpanded: () => void; + ref: (el: HTMLElement | null) => void; +} { + const [expanded, setExpanded] = useState(() => readStoredExpanded(storageKey)); + // 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. + useLayoutEffect(() => { + if (!pageRoot) { + return clearPageExpanded; + } + + // display:none collapses the root to 0x0, which fires the observer — that is the + // on-screen signal. + const sync = () => { + const onScreen = pageRoot.getClientRects().length > 0; + document.documentElement.toggleAttribute(PAGE_EXPANDED_ATTR, expanded && onScreen); + }; + + sync(); + const observer = new ResizeObserver(sync); + observer.observe(pageRoot); + return () => { + observer.disconnect(); + clearPageExpanded(); + }; + }, [pageRoot, expanded]); + + const toggleExpanded = useCallback(() => { + setExpanded((current) => { + const next = !current; + try { + localStorage.setItem(storageKey, next ? 'full' : 'boxed'); + } catch { + // ignore storage failures (private mode / quota) + } + return next; + }); + }, [storageKey]); + + return { expanded, toggleExpanded, ref: setPageRoot }; +} diff --git a/frontend/src/index.scss b/frontend/src/index.scss index 439af24ed0..8d7011b373 100644 --- a/frontend/src/index.scss +++ b/frontend/src/index.scss @@ -1264,7 +1264,7 @@ $easeInOutCirc: cubic-bezier(0.785, 0.135, 0.15, 0.86); gap: 10px; padding: 0; padding-top: 3em; - padding-bottom: 8px; + padding-bottom: 16px; margin-top: auto; color: hsl(216deg, 11%, 66%); diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index 0f48290205..50e69e87d8 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -11,7 +11,7 @@ import type { Transport } from '@connectrpc/connect'; import type { QueryClient } from '@tanstack/react-query'; -import { createRootRouteWithContext, Outlet, useLocation, useMatches } from '@tanstack/react-router'; +import { createRootRouteWithContext, Outlet, useLocation } from '@tanstack/react-router'; import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'; import AnnouncementBar from 'components/builder-io/announcement-bar'; import { Toaster } from 'components/redpanda-ui/components/sonner'; @@ -22,6 +22,7 @@ import { NuqsAdapter } from 'nuqs/adapters/tanstack-router'; import { DebugHelper } from '../components/debug-helper/debug-helper'; import AppFooter from '../components/layout/footer'; import AppPageHeader from '../components/layout/header'; +import { PageColumn } from '../components/layout/page-column'; import { SidebarLayout } from '../components/layout/sidebar'; import { LicenseNotification } from '../components/license/license-notification'; import { ErrorBoundary } from '../components/misc/error-boundary'; @@ -33,7 +34,6 @@ import { SidebarInset } from '../components/redpanda-ui/components/sidebar'; import RequireAuth from '../components/require-auth'; import { useIsDarkMode } from '../hooks/use-is-dark-mode'; import { IsDev } from '../utils/env'; -import { isFullscreenPath } from '../utils/fullscreen-routes'; import { ModalContainer } from '../utils/modal-container'; export type RouterContext = { @@ -75,7 +75,8 @@ function SelfHostedLayout() { -
+ {/* Centered page column; `page-expanded-*` release the gutter and cap (globals.css). */} +
@@ -89,29 +90,11 @@ function EmbeddedLayout() { } function AppContent() { - const matches = useMatches(); - const { pathname } = useLocation(); - const isFullscreen = matches.some((m) => m.staticData.fullscreen) || isFullscreenPath(pathname); const toasterTheme = useIsDarkMode() ? 'dark' : 'light'; - if (isFullscreen) { - return ( -
- - - {!isEmbedded() && } - - - - - - -
- ); - } - return ( -
+ // Flex column + flex-1 so the footer's `margin-top: auto` pins it to the bottom. +
{/* Page */} @@ -121,9 +104,9 @@ function AppContent() { -
+ -
+
diff --git a/frontend/src/routes/sql.tsx b/frontend/src/routes/sql.tsx index 501581593b..e163f74918 100644 --- a/frontend/src/routes/sql.tsx +++ b/frontend/src/routes/sql.tsx @@ -19,7 +19,8 @@ export const Route = createFileRoute('/sql')({ staticData: { title: 'SQL', icon: Database, - fullscreen: true, + // The studio has its own title bar; otherwise a normal in-flow page. + breadcrumbOnlyHeader: true, }, // Gate direct navigation to /sql on the same capability check as the sidebar. // isSupported() returns false both when SQLService is unsupported and when the diff --git a/frontend/src/utils/dom-position.ts b/frontend/src/utils/dom-position.ts new file mode 100644 index 0000000000..fc8726f6f4 --- /dev/null +++ b/frontend/src/utils/dom-position.ts @@ -0,0 +1,30 @@ +/** + * 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 + */ + +/** Distance from the document top. Unlike getBoundingClientRect, scroll-independent. */ +export const documentTop = (target: HTMLElement): number => { + let top = 0; + let el: HTMLElement | null = target; + while (el) { + top += el.offsetTop; + el = el.offsetParent instanceof HTMLElement ? el.offsetParent : null; + } + return top; +}; + +/** `from` and its ancestors, up to but excluding ``. */ +export const chainToBody = (from: HTMLElement | null): HTMLElement[] => { + const chain: HTMLElement[] = []; + for (let el = from; el && el !== document.body; el = el.parentElement) { + chain.push(el); + } + return chain; +}; diff --git a/frontend/src/utils/fullscreen-routes.test.tsx b/frontend/src/utils/fullscreen-routes.test.tsx deleted file mode 100644 index ebf3c50829..0000000000 --- a/frontend/src/utils/fullscreen-routes.test.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/** - * 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 { describe, expect, test } from 'vitest'; - -import { collectFullscreenPaths, isFullscreenPath, matchesFullscreenPath } from './fullscreen-routes'; -import { routeTree } from '../routeTree.gen'; - -describe('collectFullscreenPaths', () => { - test('collects paths from built route nodes (path/staticData under options)', () => { - const tree = { - children: { - SqlRoute: { options: { path: '/sql', staticData: { fullscreen: true } } }, - TopicsRoute: { options: { path: '/topics', staticData: { fullscreen: false } } }, - QuotasRoute: { options: { path: '/quotas' } }, - }, - }; - expect(collectFullscreenPaths(tree)).toEqual(['/sql']); - }); - - test('collects paths from raw route nodes (path/staticData at top level)', () => { - const tree = { - children: { - SqlRoute: { path: '/sql', staticData: { fullscreen: true } }, - }, - }; - expect(collectFullscreenPaths(tree)).toEqual(['/sql']); - }); - - test('recurses into nested children', () => { - const tree = { - children: { - Parent: { - options: { path: '/parent' }, - children: { - Child: { options: { path: '/parent/studio', staticData: { fullscreen: true } } }, - }, - }, - }, - }; - expect(collectFullscreenPaths(tree)).toEqual(['/parent/studio']); - }); - - test('ignores a fullscreen route with no path', () => { - const tree = { children: { Bad: { options: { staticData: { fullscreen: true } } } } }; - expect(collectFullscreenPaths(tree)).toEqual([]); - }); - - test('tolerates non-object input', () => { - expect(collectFullscreenPaths(null)).toEqual([]); - expect(collectFullscreenPaths(undefined)).toEqual([]); - }); - - test('derives /sql from the real route tree', () => { - // Guards against route-tree shape changes and against the SQL route losing - // its staticData.fullscreen flag. - expect(collectFullscreenPaths(routeTree)).toContain('/sql'); - }); -}); - -describe('matchesFullscreenPath', () => { - const paths = ['/sql']; - - test('matches the exact path', () => { - expect(matchesFullscreenPath('/sql', paths)).toBe(true); - }); - - test('matches a nested path', () => { - expect(matchesFullscreenPath('/sql/query/123', paths)).toBe(true); - }); - - test('matches an embedded path with a host cluster prefix', () => { - expect(matchesFullscreenPath('/clusters/abc123/sql', paths)).toBe(true); - }); - - test('does not match a path that merely starts with the segment text', () => { - expect(matchesFullscreenPath('/sqlx', paths)).toBe(false); - expect(matchesFullscreenPath('/mysql', paths)).toBe(false); - }); - - test('does not match an interior segment that merely happens to be named sql', () => { - expect(matchesFullscreenPath('/clusters/sql/overview', paths)).toBe(false); - }); - - test('does not match unrelated paths', () => { - expect(matchesFullscreenPath('/topics', paths)).toBe(false); - }); - - test('returns false when there are no fullscreen paths', () => { - expect(matchesFullscreenPath('/sql', [])).toBe(false); - }); -}); - -describe('isFullscreenPath (wired to the real route tree)', () => { - test('recognizes the SQL studio, standalone and embedded', () => { - expect(isFullscreenPath('/sql')).toBe(true); - expect(isFullscreenPath('/clusters/abc123/sql')).toBe(true); - }); - - test('rejects non-fullscreen routes', () => { - expect(isFullscreenPath('/topics')).toBe(false); - expect(isFullscreenPath('/overview')).toBe(false); - }); -}); diff --git a/frontend/src/utils/fullscreen-routes.ts b/frontend/src/utils/fullscreen-routes.ts deleted file mode 100644 index 67bfa90bc0..0000000000 --- a/frontend/src/utils/fullscreen-routes.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * 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 { routeTree } from '../routeTree.gen'; - -/** - * Fullscreen routes (e.g. the SQL studio) render minimal chrome. The layout - * components detect them from `staticData.fullscreen` on the resolved route - * matches — but on soft navigation `useLocation().pathname` flips to the new - * route synchronously while `useMatches()` still holds the *previous* route's - * matches until the new match resolves. During that window staticData reports - * `fullscreen: false`, so the studio would flash full chrome (header row + top - * padding) on every in-app navigation into it. - * - * This path-based check bridges that gap. The fullscreen paths are derived from - * the route tree (single source of truth: `staticData.fullscreen` in the route - * definition) rather than hardcoded, so any future fullscreen route is covered - * automatically. - */ - -type StaticData = { fullscreen?: boolean }; - -/** - * The slice of a TanStack route node this module reads. A built route exposes - * `path`/`staticData` under `options`; a raw route definition exposes them at - * the top level — we tolerate both. `children` is keyed by generated route name. - */ -interface FullscreenRouteNode { - path?: string; - staticData?: StaticData; - options?: { path?: string; staticData?: StaticData }; - children?: { [routeName: string]: FullscreenRouteNode }; -} - -const isRouteNode = (value: unknown): value is FullscreenRouteNode => typeof value === 'object' && value !== null; - -/** Walk the route tree and collect the paths of routes marked `staticData.fullscreen`. */ -export function collectFullscreenPaths(node: unknown): string[] { - const paths: string[] = []; - const visit = (value: unknown) => { - if (!isRouteNode(value)) { - return; - } - const path = value.options?.path ?? value.path; - const fullscreen = value.options?.staticData?.fullscreen ?? value.staticData?.fullscreen; - if (path && fullscreen) { - paths.push(path); - } - if (value.children) { - for (const child of Object.values(value.children)) { - visit(child); - } - } - }; - visit(node); - return paths; -} - -/** - * True when `pathname` is the fullscreen route itself, a route nested under it - * (standalone `/sql/query/123`), or the same trailing segment behind a host - * prefix (embedded Cloud UI `/clusters//sql`). Anchoring to the start or the - * trailing segment avoids matching an interior segment that merely happens to be - * named `sql` (e.g. `/clusters/sql/overview`), and never matches `/mysql`/`/sqlx`. - */ -export function matchesFullscreenPath(pathname: string, paths: string[]): boolean { - return paths.some((path) => pathname === path || pathname.startsWith(`${path}/`) || pathname.endsWith(path)); -} - -// Computed lazily, not at module load: `__root.tsx` imports this module and is -// itself imported by `routeTree.gen`, so reading `routeTree` at module-eval time -// races the circular import and sees it undefined. By first call (render time) -// the tree is fully built. The result is stable, so memoize it. -let cachedFullscreenPaths: string[] | null = null; - -/** Whether the given pathname belongs to a fullscreen route. See module docs. */ -export const isFullscreenPath = (pathname: string): boolean => { - if (cachedFullscreenPaths === null) { - cachedFullscreenPaths = collectFullscreenPaths(routeTree); - } - return matchesFullscreenPath(pathname, cachedFullscreenPaths); -}; From 115200264427c9dc9f9b29275ea144bd6c006bf9 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Tue, 4 Aug 2026 14:50:25 -0700 Subject: [PATCH 08/18] Improvements from review --- .../pages/rp-connect/pipeline/list.tsx | 53 +++++++++++++++---- .../data-table/data-table-column-header.tsx | 9 ++-- .../data-table/data-table-faceted-filter.tsx | 4 ++ .../data-table/data-table-pagination.tsx | 3 +- .../src/react-query/api/pipeline.test.tsx | 42 +++++++++++++++ frontend/src/react-query/api/pipeline.tsx | 17 +++++- 6 files changed, 112 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/pages/rp-connect/pipeline/list.tsx b/frontend/src/components/pages/rp-connect/pipeline/list.tsx index e56f5d3fe2..3eb50a5fee 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/list.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/list.tsx @@ -33,6 +33,7 @@ import { DataTableFacetedFilter, DataTablePagination, } from 'components/redpanda-ui/components/data-table'; +import { isInteractiveTarget } from 'components/redpanda-ui/components/data-table/data-table-utils'; import { DropdownMenu, DropdownMenuContent, @@ -58,7 +59,16 @@ import { 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 { type MouseEvent, memo, useCallback, useEffect, useLayoutEffect, 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, @@ -404,10 +414,28 @@ type CreateColumnsOptions = { isDeletingPipeline: boolean; }; +// Facet options are rebuilt whenever the row set changes — every drain page and +// every poll tick. Cache the icon component per connector name so its identity +// survives that: a fresh function type would make React remount every logo in +// the open popover, flashing them mid-poll. +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: (props: { className?: string }) => , + icon: connectorIcon(name), }); const createColumns = ({ @@ -587,8 +615,16 @@ const PipelineListPageContent = () => { const table = useReactTable({ data: pipelines, columns, + // Pipeline ids are unique and stable, so keying rows on them (rather than the + // default row index) keeps a row's identity fixed while pages stream in and + // the sort re-runs — React reuses each row's DOM for the same pipeline instead + // of repainting a shifted window of them. + getRowId: (row) => row.id, // No column-visibility UI on this page; disabling hiding also drops the Hide item from the column header menus. enableHiding: false, + // Rows aren't selectable, which also drops the pagination footer's + // "X of N row(s) selected." text — its ml-auto keeps the controls right-aligned. + enableRowSelection: false, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getFacetedRowModel: getFacetedRowModel(), @@ -686,11 +722,13 @@ const PipelineListPageContent = () => { if (!event.currentTarget.contains(target)) { return; } - // Links and buttons inside the row handle their own clicks; a mouseup - // that ends a text selection (copying the id) isn't a navigation intent. - if ((target as HTMLElement).closest('a') || (target as HTMLElement).closest('button')) { + // Interactive descendants handle their own clicks. Same helper DataTable + // uses for its row-click guard, so this row behaves like every other + // clickable row and covers more than links and buttons. + if (isInteractiveTarget(target, event.currentTarget)) { return; } + // A mouseup that ends a text selection (copying the id) isn't navigation intent. if (window.getSelection()?.toString()) { return; } @@ -851,10 +889,7 @@ const PipelineListPageContent = () => { {listErrorMessage} - {/* Hide the pagination footer's "X of N selected" text (no row selection here) but keep its space so controls stay right-aligned. */} -
- -
+
); }; 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 index ee433e0bc0..32b5d8fefc 100644 --- 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 @@ -2,6 +2,7 @@ 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 { @@ -13,10 +14,9 @@ import { } from '../dropdown-menu'; import { cn, type SharedProps } from '../../lib/utils'; -interface DataTableColumnHeaderProps extends SharedProps { +interface DataTableColumnHeaderProps extends React.HTMLAttributes, SharedProps { column: Column; title: string; - className?: string; } export function DataTableColumnHeader({ @@ -24,17 +24,18 @@ export function DataTableColumnHeader({ title, className, testId, + ...props }: DataTableColumnHeaderProps) { if (!column.getCanSort()) { return ( -
+
{title}
); } return ( -
+
({ : [...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)} 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 index 62d0f1fbe0..d01f7305ee 100644 --- 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 @@ -28,7 +28,8 @@ export function DataTablePagination({ selected.
)} -
+ {/* ml-auto holds the controls right when the selection count is not rendered. */} +
Rows per page
setSearch(e.target.value)} placeholder="Search by name or ID..." @@ -827,6 +836,9 @@ const PipelineListPageContent = () => { ) : ( rows.map((row) => ( + // Click-to-navigate is a pointer shortcut only — no tabIndex/Enter handler like + // DataTable's activatable rows carry, because the name cell already holds the same + // link. A row tab stop here would just duplicate it on every row. ( wrap, testId, children, - maxVisible, + maxVisible = 3, size = 'sm', tone, variant = 'subtle', 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 97e3d36654..3973dff14e 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,9 +36,9 @@ export const resolveSortingMode = (sorting: false | true | SortingState | undefi export type DisplayState = 'loading' | 'empty' | 'data'; -// Feed this the FILTERED row count, not the page row count: a stale page index can leave the -// current page empty while matches exist, and that transient state must not read as 'empty'. -// When isLoading but rows already exist (background refetch), returns 'data' so stale rows show instead of a spinner. +// 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'; 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 index 95d33280b9..0c207aa15c 100644 --- 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 @@ -26,14 +26,15 @@ export function DataTableViewOptions({ table, testId }: { table: Table } /> - + Toggle columns {table .getAllColumns() .filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide()) .map((column) => { - // Loose cast: augmenting TanStack's ColumnMeta would weak-type it and break consumers with their own unaugmented meta keys. + // 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 ( ({ 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, @@ -297,23 +297,30 @@ export function DataTable({ 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: with manualPagination and no pageCount/rowCount - // it is derived from the current page's data and would fight the consumer's controlled state. + // 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; React.useEffect(() => { - if (paginationMode.enabled && pageCountIsKnown && !isLoading && pageCount > 0 && pageIndex >= pageCount) { + if (clampPending && !isLoading) { table.setPageIndex(pageCount - 1); } - }, [paginationMode.enabled, pageCountIsKnown, isLoading, pageCount, pageIndex, table]); + }, [clampPending, isLoading, pageCount, table]); + + // The filtered count can be non-zero while the page slice is empty (e.g. `pageCount` without + // `manualPagination`, which leaves rows locally sliced) — show the empty state, not a bare header. + // Not while a clamp is pending, though: that page index is about to change, and "no results" for + // the frame in between is the flash this whole guard exists to prevent. + const showEmptyState = displayState === 'empty' || (displayState === 'data' && rows.length === 0 && !clampPending); const toolbarContent = typeof toolbar === 'function' ? toolbar(table) : toolbar; return (
- {toolbarContent &&
{toolbarContent}
} + {toolbarContent ?
{toolbarContent}
: null} @@ -340,7 +347,7 @@ export function DataTable({ )} - {displayState === 'empty' && ( + {showEmptyState ? (
@@ -349,7 +356,7 @@ export function DataTable({
- )} + ) : null} {displayState === 'data' && rows.map((row) => { @@ -362,39 +369,41 @@ export function DataTable({ onRow?.(row); }; + const handleClick = (event: React.MouseEvent) => { + // Containment drops portaled content (menus, popovers) and targets already unmounted. + if (!event.currentTarget.contains(event.target as Node)) { + return; + } + if (isInteractiveTarget(event.target, event.currentTarget)) { + return; + } + 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 - ? (event) => { - // Content portaled out of the row (menus, popovers) still bubbles here - // through the React tree — containment filters it out. - const target = event.target as Node; - if (!event.currentTarget.contains(target)) { - return; - } - if (isInteractiveTarget(event.target, event.currentTarget)) { - return; - } - activateRow(); - } - : undefined - } - onKeyDown={ - rowIsActivatable - ? (event) => { - if ((event.key !== 'Enter' && event.key !== ' ') || event.target !== event.currentTarget) { - return; - } - event.preventDefault(); - activateRow(); - } - : undefined - } + onClick={rowIsActivatable ? handleClick : undefined} + onKeyDown={rowIsActivatable ? handleKeyDown : undefined} tabIndex={rowIsActivatable ? 0 : undefined} > {row.getVisibleCells().map((cell) => ( diff --git a/frontend/src/components/redpanda-ui/style/theme.css b/frontend/src/components/redpanda-ui/style/theme.css index 600737ea06..ced24e4943 100644 --- a/frontend/src/components/redpanda-ui/style/theme.css +++ b/frontend/src/components/redpanda-ui/style/theme.css @@ -1,3 +1,11 @@ +/* + * Fonts ship with the theme so the font tokens below always resolve without + * any host-app configuration: + * - Inter / Geist Mono: @fontsource packages (unicode-range subset files, + * one @font-face per weight, served from node_modules at build time). + * - Inter Display: inter-ui (rsms's official npm distribution — no + * @fontsource equivalent exists). Family name is "InterDisplay" (no space). + */ @import '@fontsource/inter/400.css'; @import '@fontsource/inter/500.css'; @import '@fontsource/inter/600.css'; From bbddbb03b9db21fcb54052275f2685292bcb464a Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Wed, 5 Aug 2026 11:06:35 -0700 Subject: [PATCH 13/18] Screenreader improvements --- .../pages/rp-connect/pipeline/list.test.tsx | 53 ++++++- .../pages/rp-connect/pipeline/list.tsx | 142 ++++++++++++------ .../src/react-query/api/pipeline.test.tsx | 4 +- 3 files changed, 147 insertions(+), 52 deletions(-) diff --git a/frontend/src/components/pages/rp-connect/pipeline/list.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/list.test.tsx index d4fbc5789b..16a7dcd7ee 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/list.test.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/list.test.tsx @@ -122,6 +122,9 @@ const rowFor = (displayName: string) => { 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 @@ -185,7 +188,7 @@ describe('PipelineListPage', () => { }); // Matches an id, not a display name — the search covers both. - await user.type(screen.getByRole('textbox', { name: /search pipelines/i }), 'bbb2'); + await user.type(screen.getByRole('textbox', { name: SEARCH_INPUT_RE }), 'bbb2'); await waitFor(() => { expect(visibleLinkNames()).toEqual(['clickstream-sink']); @@ -193,7 +196,7 @@ describe('PipelineListPage', () => { expect(tab('All')).toHaveTextContent('1'); expect(tab('Running')).toHaveTextContent('0'); - await user.click(screen.getByRole('button', { name: /clear filters/i })); + await user.click(screen.getByRole('button', { name: CLEAR_FILTERS_RE })); await waitFor(() => { expect(visibleLinkNames()).toHaveLength(3); @@ -208,7 +211,7 @@ describe('PipelineListPage', () => { expect(screen.getByText('nightly-export')).toBeInTheDocument(); }); - await user.type(screen.getByRole('textbox', { name: /search pipelines/i }), 'orders'); + await user.type(screen.getByRole('textbox', { name: SEARCH_INPUT_RE }), 'orders'); await user.click(tab('Error')); await waitFor(() => { @@ -228,6 +231,50 @@ describe('PipelineListPage', () => { 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(); diff --git a/frontend/src/components/pages/rp-connect/pipeline/list.tsx b/frontend/src/components/pages/rp-connect/pipeline/list.tsx index 6aaff3cfe4..d9858d9cf0 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/list.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/list.tsx @@ -212,6 +212,41 @@ const pipelineStateSortPriority: Record = { const PAGE_SIZE = 20; +// Modified and non-primary clicks mean "open elsewhere" — a row must leave those to the browser +// rather than soft-navigating the current tab. The name cell is a real link, so ⌘-click and +// middle-click still open a new tab from there. +const isModifiedClick = (event: MouseEvent) => + event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0; + +// The rows render outside the Tabs subtree (one table, filtered per tab — not four panels), so the +// tabs carry no panel of their own. These wire each tab to the table region explicitly: 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 drain and refresh-failure lines below the table. Those mount and + * unmount as they animate, and a live region only announces changes made while it is already in the + * DOM — so the announcement lives here, always present. `sr-only` is absolutely positioned, so + * these cost no layout. + */ +const ListStatusAnnouncements = ({ + isLoadingMorePages, + listErrorMessage, +}: { + isLoadingMorePages: boolean; + listErrorMessage: string | null; +}) => ( + <> +
+ {isLoadingMorePages ? 'Loading more pipelines' : ''} +
+
+ {listErrorMessage ?? ''} +
+ +); + const PipelineListSkeleton = () => (
@@ -700,7 +735,9 @@ const PipelineListPageContent = () => { // The status tabs are views, not filters — only search and the facet // pickers count toward "filtered" (and get wiped by Clear filters). - const hasActiveFilters = columnFilters.some((f) => f.id !== 'state'); + // Read from `search`, not just the committed column filter: the filter lands 200ms behind the + // input, and Clear filters must be there to click as soon as the user has typed something. + const hasActiveFilters = search.trim() !== '' || columnFilters.some((f) => f.id !== 'state'); const clearFilters = useCallback(() => { setSearch(''); for (const columnId of ['name', 'inputs', 'outputs', 'tags']) { @@ -710,6 +747,9 @@ const PipelineListPageContent = () => { const handleRowClick = useCallback( (pipelineId: string, event: MouseEvent) => { + if (isModifiedClick(event)) { + return; + } const target = event.target as Node; // Clicks on portaled children (menus, dialogs, tooltips) bubble through the React tree // but live outside the
in the DOM — a click on the delete-confirm backdrop, say. @@ -771,7 +811,13 @@ const PipelineListPageContent = () => { handleTabChange(value as PipelineStateTabId)} value={activeTab}> {PIPELINE_STATE_TABS.map((tab) => ( - + {tab.label} {tabCounts[tab.id]} @@ -803,52 +849,55 @@ const PipelineListPageContent = () => { -
- - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - {rows.length === 0 ? ( - - - {isLoadingMorePages ? ( -
- Loading pipelines... -
- ) : ( - pipelineListEmptyText({ hasActiveFilters, activeTab, totalPipelines: pipelines.length }) - )} -
-
- ) : ( - rows.map((row) => ( - // Click-to-navigate is a pointer shortcut only — no tabIndex/Enter handler like - // DataTable's activatable rows carry, because the name cell already holds the same - // link. A row tab stop here would just duplicate it on every row. - handleRowClick(row.original.id, event)} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - + {/* The status tabs' panel (see STATUS_PANEL_ID) — the region a tab hands the reader off to. */} +
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + ))} - )) - )} - -
+ ))} + + + {rows.length === 0 ? ( + + + {isLoadingMorePages ? ( +
+ Loading pipelines... +
+ ) : ( + pipelineListEmptyText({ hasActiveFilters, activeTab, totalPipelines: pipelines.length }) + )} +
+
+ ) : ( + rows.map((row) => ( + // Click-to-navigate is a pointer shortcut only — no tabIndex/Enter handler like + // DataTable's activatable rows carry, because the name cell already holds the same + // link. A row tab stop here would just duplicate it on every row. + // No `data-state`: row selection is off, so it only ever rendered "false". + handleRowClick(row.original.id, event)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + )} +
+ +
0} @@ -862,6 +911,7 @@ const PipelineListPageContent = () => { {listErrorMessage} +
); diff --git a/frontend/src/react-query/api/pipeline.test.tsx b/frontend/src/react-query/api/pipeline.test.tsx index 4694040147..c0171ecbbc 100644 --- a/frontend/src/react-query/api/pipeline.test.tsx +++ b/frontend/src/react-query/api/pipeline.test.tsx @@ -168,9 +168,7 @@ describe('useListPipelinesQuery', () => { const pageToken = req.request?.pageToken ?? ''; return create(ListPipelinesResponseSchema, { response: create(DataPlaneListPipelinesResponseSchema, { - pipelines: [ - create(PipelineSchema, { id: `pipeline-${pageToken || 'first'}`, displayName: 'cycling' }), - ], + 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', }), From 14ffa54e19522655ab2ca7d79e845a76dc8d1381 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Wed, 5 Aug 2026 11:17:59 -0700 Subject: [PATCH 14/18] Re-show the new pipeline listing page --- .../pages/connect/overview.test.tsx | 68 +++++++++++++++++++ .../src/components/pages/connect/overview.tsx | 25 +++++-- 2 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/pages/connect/overview.test.tsx 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..f888a1a21b --- /dev/null +++ b/frontend/src/components/pages/connect/overview.test.tsx @@ -0,0 +1,68 @@ +/** + * 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 { act, renderWithFileRoutes, screen, waitFor } from 'test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; + +import KafkaConnectOverview from './overview'; +import { type EndpointCompatibility, Feature, useSupportedFeaturesStore } from '../../../state/supported-features'; + +// The Connect page swaps wholesale between the new pipelines list and the legacy tabs, keyed on +// whether the backend serves the managed pipelines API. Self-hosted reports it unsupported, and its +// install intro lives behind the legacy path — so a mount regression here silently hides one or the +// other. Asserted through the real store rather than a mock, since the branch reads it reactively. +const setPipelineServiceSupport = (isSupported: boolean) => { + const compatibility: EndpointCompatibility = { + kafkaVersion: '3.6.0', + endpoints: [ + { + endpoint: Feature.PipelineService.endpoint, + method: Feature.PipelineService.method, + isSupported, + }, + ], + }; + act(() => { + useSupportedFeaturesStore.getState().setEndpointCompatibility(compatibility); + }); +}; + +// The status tabs belong to the new list and nothing else on this page renders them. +const RUNNING_TAB_RE = /^Running/; +const newListMarker = () => screen.queryByRole('tab', { name: RUNNING_TAB_RE }); + +afterEach(() => { + act(() => { + useSupportedFeaturesStore.getState().setEndpointCompatibility(null as unknown as EndpointCompatibility); + }); +}); + +describe('Connect overview mount', () => { + it('renders the new pipelines list wherever the pipelines API is served', async () => { + renderWithFileRoutes(); + setPipelineServiceSupport(true); + + await waitFor(() => { + expect(newListMarker()).toBeInTheDocument(); + }); + }); + + it('keeps the legacy path when the backend does not serve it', async () => { + renderWithFileRoutes(); + setPipelineServiceSupport(false); + + // Self-hosted lands on the legacy path's install intro, not an errored pipelines table. + await waitFor(() => { + expect(screen.getByText('Using Redpanda Connect')).toBeInTheDocument(); + }); + expect(newListMarker()).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/pages/connect/overview.tsx b/frontend/src/components/pages/connect/overview.tsx index dcfcc61a60..6da8741216 100644 --- a/frontend/src/components/pages/connect/overview.tsx +++ b/frontend/src/components/pages/connect/overview.tsx @@ -29,7 +29,7 @@ import { TaskState, TasksColumn, } from './helper'; -import { isEmbedded, isFeatureFlagEnabled, isServerless } from '../../../config'; +import { 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'; @@ -83,12 +83,18 @@ const WrapKafkaConnectOverview: FunctionComponent<{ defaultTab?: ConnectView; }> = (props) => { const { data: kafkaConnectors, isLoading: isLoadingKafkaConnectors } = useKafkaConnectConnectorsQuery(); + // Read through the store (not `Features`) so the class below re-renders when endpoint + // detection resolves — these arrive as props for exactly that reason. + const hasPipelinesApi = useSupportedFeaturesStore((s) => s.pipelinesApi); + const isDetectingFeatures = useSupportedFeaturesStore((s) => s.endpointCompatibility === null); const isKafkaConnectEnabled = kafkaConnectors?.isConfigured === true; return ( { class KafkaConnectOverview extends PageComponent<{ defaultView: string; + hasPipelinesApi: boolean; + isDetectingFeatures: boolean; isKafkaConnectEnabled: boolean; isLoadingKafkaConnectors: boolean; }> { @@ -141,13 +149,18 @@ class KafkaConnectOverview extends PageComponent<{ } render() { - // Tier 1: enablePipelineDiagrams → new pipelines list (it draws the Kafka Connect tab itself). - if (isFeatureFlagEnabled('enablePipelineDiagrams') && isEmbedded()) { - return ; - } - if (this.props.isLoadingKafkaConnectors) { + // The managed pipelines API is the new list's only hard requirement, so it renders wherever + // that API exists — no feature flag. Self-hosted advertises PipelineService as unsupported + // (backend endpoint_compatibility.go, OSS defaults) and keeps the tabs + install intro below. + // Waiting on detection first: rendering the legacy tabs and then swapping would flip the whole + // page a frame in, and this page already shows the same spinner while Kafka Connect loads. + if (this.props.isDetectingFeatures || this.props.isLoadingKafkaConnectors) { return ; } + if (this.props.hasPipelinesApi) { + // Draws its own Kafka Connect tab, so it replaces this page rather than sitting in a tab. + return ; + } const tabs = [ { key: ConnectView.RedpandaConnect, From 0fc5763bc24f7a5fbdfeeb88d2d0eeb034a7c8d7 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Wed, 5 Aug 2026 11:34:52 -0700 Subject: [PATCH 15/18] More improvements from reviews --- .../pages/connect/overview.test.tsx | 15 +- .../src/components/pages/connect/overview.tsx | 24 ++- .../pages/rp-connect/pipeline/list-utils.ts | 24 +-- .../pages/rp-connect/pipeline/list.tsx | 176 ++++++++---------- frontend/src/react-query/api/pipeline.tsx | 17 +- 5 files changed, 112 insertions(+), 144 deletions(-) diff --git a/frontend/src/components/pages/connect/overview.test.tsx b/frontend/src/components/pages/connect/overview.test.tsx index f888a1a21b..95aeead2af 100644 --- a/frontend/src/components/pages/connect/overview.test.tsx +++ b/frontend/src/components/pages/connect/overview.test.tsx @@ -10,15 +10,14 @@ */ import { act, renderWithFileRoutes, screen, waitFor } from 'test-utils'; -import { afterEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import KafkaConnectOverview from './overview'; import { type EndpointCompatibility, Feature, useSupportedFeaturesStore } from '../../../state/supported-features'; -// The Connect page swaps wholesale between the new pipelines list and the legacy tabs, keyed on -// whether the backend serves the managed pipelines API. Self-hosted reports it unsupported, and its -// install intro lives behind the legacy path — so a mount regression here silently hides one or the -// other. Asserted through the real store rather than a mock, since the branch reads it reactively. +// The page swaps wholesale between the new list and the legacy tabs on whether the backend serves +// the pipelines API, so a mount regression silently hides one or the other. Driven through the real +// store, since the branch reads it reactively. const setPipelineServiceSupport = (isSupported: boolean) => { const compatibility: EndpointCompatibility = { kafkaVersion: '3.6.0', @@ -39,12 +38,6 @@ const setPipelineServiceSupport = (isSupported: boolean) => { const RUNNING_TAB_RE = /^Running/; const newListMarker = () => screen.queryByRole('tab', { name: RUNNING_TAB_RE }); -afterEach(() => { - act(() => { - useSupportedFeaturesStore.getState().setEndpointCompatibility(null as unknown as EndpointCompatibility); - }); -}); - describe('Connect overview mount', () => { it('renders the new pipelines list wherever the pipelines API is served', async () => { renderWithFileRoutes(); diff --git a/frontend/src/components/pages/connect/overview.tsx b/frontend/src/components/pages/connect/overview.tsx index 6da8741216..aa68ffe437 100644 --- a/frontend/src/components/pages/connect/overview.tsx +++ b/frontend/src/components/pages/connect/overview.tsx @@ -83,10 +83,8 @@ const WrapKafkaConnectOverview: FunctionComponent<{ defaultTab?: ConnectView; }> = (props) => { const { data: kafkaConnectors, isLoading: isLoadingKafkaConnectors } = useKafkaConnectConnectorsQuery(); - // Read through the store (not `Features`) so the class below re-renders when endpoint - // detection resolves — these arrive as props for exactly that reason. + // Read through the store, not `Features`, so the class below re-renders when detection resolves. const hasPipelinesApi = useSupportedFeaturesStore((s) => s.pipelinesApi); - const isDetectingFeatures = useSupportedFeaturesStore((s) => s.endpointCompatibility === null); const isKafkaConnectEnabled = kafkaConnectors?.isConfigured === true; @@ -94,7 +92,6 @@ const WrapKafkaConnectOverview: FunctionComponent<{ { class KafkaConnectOverview extends PageComponent<{ defaultView: string; hasPipelinesApi: boolean; - isDetectingFeatures: boolean; isKafkaConnectEnabled: boolean; isLoadingKafkaConnectors: boolean; }> { @@ -149,18 +145,20 @@ class KafkaConnectOverview extends PageComponent<{ } render() { - // The managed pipelines API is the new list's only hard requirement, so it renders wherever - // that API exists — no feature flag. Self-hosted advertises PipelineService as unsupported - // (backend endpoint_compatibility.go, OSS defaults) and keeps the tabs + install intro below. - // Waiting on detection first: rendering the legacy tabs and then swapping would flip the whole - // page a frame in, and this page already shows the same spinner while Kafka Connect loads. - if (this.props.isDetectingFeatures || this.props.isLoadingKafkaConnectors) { - return ; - } + // The pipelines API is the new list's only hard requirement, so it renders wherever that API + // exists — no feature flag. Self-hosted reports PipelineService unsupported (backend + // endpoint_compatibility.go) and keeps the tabs + install intro below. + // + // Ahead of the spinner deliberately: the list handles a pending Kafka Connect probe itself, and + // a failed `/console/endpoints` leaves detection pending forever — falling through beats + // blocking on a flag that may never arrive. if (this.props.hasPipelinesApi) { // Draws its own Kafka Connect tab, so it replaces this page rather than sitting in a tab. return ; } + if (this.props.isLoadingKafkaConnectors) { + return ; + } const tabs = [ { key: ConnectView.RedpandaConnect, diff --git a/frontend/src/components/pages/rp-connect/pipeline/list-utils.ts b/frontend/src/components/pages/rp-connect/pipeline/list-utils.ts index 73ac64264d..004035fc0c 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/list-utils.ts +++ b/frontend/src/components/pages/rp-connect/pipeline/list-utils.ts @@ -16,10 +16,7 @@ export type ConnectorCount = { count: number; }; -/** - * Collapses repeated connector names into one counted entry, in first-appearance order: - * ["redpanda", "redpanda", "s3"] → [{ name: "redpanda", count: 2 }, { name: "s3", count: 1 }]. - */ +/** ["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) { @@ -43,8 +40,7 @@ export type PipelineStateTab = { emptyText: string; }; -// Transitional states ride with their destination: starting counts as running, stopping as -// stopped. +// 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' }, { @@ -67,19 +63,25 @@ export const PIPELINE_STATE_TABS: PipelineStateTab[] = [ }, ]; +// 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 tab of PIPELINE_STATE_TABS) { - if (tab.states) { - counts[tab.id] = states.filter((s) => tab.states?.includes(s)).length; + 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 when an unfiltered All view has pipelines but shows - * none — a stale page index about to be clamped, which must not flash an empty message. + * 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, diff --git a/frontend/src/components/pages/rp-connect/pipeline/list.tsx b/frontend/src/components/pages/rp-connect/pipeline/list.tsx index d9858d9cf0..b8fdd9276a 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/list.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/list.tsx @@ -48,6 +48,7 @@ import { StatusBadge, type StatusBadgeVariant } from 'components/redpanda-ui/com 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 { cn } from 'components/redpanda-ui/lib/utils'; import { DeleteResourceAlertDialog, DeleteResourceMenuItem } from 'components/ui/delete-resource-alert-dialog'; import { FadePresence } from 'components/ui/fade-presence'; import { PIPELINE_STATE_LABELS, STARTABLE_STATES, STOPPABLE_STATES } from 'components/ui/pipeline/constants'; @@ -104,9 +105,8 @@ type Pipeline = { tags: TagPair[]; }; -// parseConfigComponents runs a full YAML parse, and the query cache hands back -// fresh page arrays on every drain step and poll tick, so the list transform -// re-runs over all rows. Memoize per config text to keep that pass O(n). +// 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; @@ -116,8 +116,7 @@ const parseConfigComponentsCached = (configYaml: string): ReturnType= CONFIG_COMPONENTS_CACHE_LIMIT) { - // Evict the oldest half (Map preserves insertion order). Clearing everything instead - // would make every refresh of a >10k dataset reparse the whole list. + // 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); @@ -153,7 +152,8 @@ const tagFilterValue = (tag: TagPair) => `${tag.key}:${tag.value}`; // Duplicate connectors collapse into one badge with a multiplier ("redpanda ×2"). const ConnectorBadges = ({ names }: { names: string[] }) => { - const connectors = aggregateConnectors(names); + // `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 ; } @@ -171,8 +171,7 @@ const ConnectorBadges = ({ names }: { names: string[] }) => { {connectors.map((c) => ( - {/* One text node so name and multiplier share a baseline — as sibling - flex items they get box-centered a pixel apart. */} + {/* 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} @@ -193,8 +192,7 @@ const pipelineStateToStatusVariant: Record = [Pipeline_State.UNSPECIFIED]: 'disabled', }; -// Scalar-in-set matcher for the status tabs. autoRemove mirrors the built-in -// array filters: an empty selection means "no filter", not "match nothing". +// 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); @@ -212,23 +210,19 @@ const pipelineStateSortPriority: Record = { const PAGE_SIZE = 20; -// Modified and non-primary clicks mean "open elsewhere" — a row must leave those to the browser -// rather than soft-navigating the current tab. The name cell is a real link, so ⌘-click and -// middle-click still open a new tab from there. +// 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; -// The rows render outside the Tabs subtree (one table, filtered per tab — not four panels), so the -// tabs carry no panel of their own. These wire each tab to the table region explicitly: without an +// 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 drain and refresh-failure lines below the table. Those mount and - * unmount as they animate, and a live region only announces changes made while it is already in the - * DOM — so the announcement lives here, always present. `sr-only` is absolutely positioned, so - * these cost no layout. + * 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, @@ -247,6 +241,34 @@ const ListStatusAnnouncements = ({ ); +// 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 = () => (
@@ -262,54 +284,22 @@ 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} + ))} ))}
@@ -449,9 +439,8 @@ type CreateColumnsOptions = { isDeletingPipeline: boolean; }; -// Facet options are rebuilt on every drain page and poll tick. Cache the icon component -// per connector name so its identity survives that — a fresh function type would remount -// every logo in the open popover, flashing them mid-poll. +// 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) => { @@ -513,10 +502,8 @@ const createColumns = ({ accessorKey: 'inputs', header: 'Input', filterFn: 'arrIncludesSome', - // Without this, faceting keys on the array itself and per-option counts - // in the filter popover never resolve. Deduplicated per row: the facet count - // sums these arrays, so two `redpanda` inputs on one pipeline would report 2 - // against a filter that yields a single row. + // 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 }) => , }, @@ -650,9 +637,8 @@ const PipelineListPageContent = () => { const table = useReactTable({ data: pipelines, columns, - // Keying rows on the pipeline id rather than the 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 of rows. + // 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, @@ -665,9 +651,8 @@ const PipelineListPageContent = () => { getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), onSortingChange: setSorting, - // Pages stream in while the list drains, and autoResetPageIndex would yank the user back - // to page 1 on every arrival. The layout effects below reset the page on filter and sort - // changes, and clamp a shrinking row set before paint. + // 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, @@ -687,8 +672,6 @@ const PipelineListPageContent = () => { } }, [pageCount, table]); - // Only user actions (tabs, search, facets, sort) mutate filter state here, so - // keying on columnFilters identity is a safe back-to-page-1 trigger. 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(() => { @@ -700,8 +683,8 @@ const PipelineListPageContent = () => { const [activeTab, setActiveTab] = useState('all'); const [search, setSearch] = useState(''); - // Each tab counts the rows selecting it would yield under the current search and facets. - // The state column's faceted model applies every filter except its own — those semantics. + // 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)), @@ -724,8 +707,8 @@ const PipelineListPageContent = () => { const timer = setTimeout(() => { const column = table.getColumn('name'); const next = search.trim() ? search : undefined; - // setFilterValue(undefined) on an unfiltered column still produces a new columnFilters - // array, tripping the page-reset effect — skip no-op writes (the post-mount tick too). + // 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); } @@ -733,10 +716,9 @@ const PipelineListPageContent = () => { return () => clearTimeout(timer); }, [search, table]); - // The status tabs are views, not filters — only search and the facet - // pickers count toward "filtered" (and get wiped by Clear filters). - // Read from `search`, not just the committed column filter: the filter lands 200ms behind the - // input, and Clear filters must be there to click as soon as the user has typed something. + // 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(''); @@ -756,8 +738,7 @@ const PipelineListPageContent = () => { if (!event.currentTarget.contains(target)) { return; } - // Interactive descendants handle their own clicks. Same helper DataTable's row-click - // guard uses, so this row behaves like every other clickable row. + // Interactive descendants handle their own clicks — same helper DataTable's guard uses. if (isInteractiveTarget(target, event.currentTarget)) { return; } @@ -775,9 +756,8 @@ const PipelineListPageContent = () => { navigate({ to: '/rp-connect/create', search: { serverless: undefined } }); }, [resetRpcnWizardStore, navigate]); - // The hook keeps isLoading true until every page is drained, so render as soon as the first - // page has rows and stream the rest in behind the table. A mid-drain error halts the drain - // for good, so the error line replaces the spinner rather than sitting next to it. + // 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; @@ -796,8 +776,7 @@ const PipelineListPageContent = () => { const rows = table.getRowModel().rows; - // With pages still unfetched the shown data is partial; otherwise a - // background refresh failed and the data is merely stale. + // Pages still unfetched means partial data; otherwise a background refresh failed and it's stale. let listErrorMessage: string | null = null; if (error) { listErrorMessage = hasNextPage @@ -849,7 +828,6 @@ const PipelineListPageContent = () => { - {/* The status tabs' panel (see STATUS_PANEL_ID) — the region a tab hands the reader off to. */}
@@ -878,10 +856,8 @@ const PipelineListPageContent = () => { ) : ( rows.map((row) => ( - // Click-to-navigate is a pointer shortcut only — no tabIndex/Enter handler like - // DataTable's activatable rows carry, because the name cell already holds the same - // link. A row tab stop here would just duplicate it on every row. - // No `data-state`: row selection is off, so it only ever rendered "false". + // 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. { - + {/* 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/react-query/api/pipeline.tsx b/frontend/src/react-query/api/pipeline.tsx index 19750a4299..78df4a88b8 100644 --- a/frontend/src/react-query/api/pipeline.tsx +++ b/frontend/src/react-query/api/pipeline.tsx @@ -43,9 +43,8 @@ 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 it can render, so larger pages mean fewer sequential -// round trips, and the server does the same work per call at any page size (it lists -// everything and slices). 500 matches the legacy list page and stays under the proto max. +// 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 = ( @@ -114,10 +113,9 @@ export const useListPipelinesQuery = ( : false, getNextPageParam: (lastPage, _allPages, _lastPageParam, allPageParams) => { const nextPageToken = lastPage?.response?.nextPageToken; - // Stop on any token this drain already requested, not just the one it was handed last. - // Keyset tokens only ever move forward, so a token we've seen means the server sent us - // backwards — A→A, or a longer A→B→A cycle — and the drain would loop forever, adding a - // page to the query cache every round. O(pages) per step, ~20 for 10k pipelines. + // 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; } @@ -129,9 +127,8 @@ export const useListPipelinesQuery = ( pageParamKey: 'request', }); - // Deduplicated by id: the keyset page token names the first id of the next page, and a - // server resolving it by exact match restarts at page one when that pipeline is deleted - // mid-drain, replaying rows. Later pages win, so a row reflects the freshest response. + // 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 pages = listPipelinesResult?.data?.pages; if (!pages) { From cc2f0287fb04d4ab9edc03e66d24a5b936f6eed8 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Wed, 5 Aug 2026 12:04:28 -0700 Subject: [PATCH 16/18] Use latest registry version --- .../pages/rp-connect/pipeline/list.tsx | 14 +++----- .../components/data-table/data-table-utils.ts | 18 ++++++++++ .../components/data-table/data-table.tsx | 36 ++++++++++--------- .../components/data-table/index.tsx | 1 + 4 files changed, 44 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/pages/rp-connect/pipeline/list.tsx b/frontend/src/components/pages/rp-connect/pipeline/list.tsx index b8fdd9276a..8feb38f7f6 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/list.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/list.tsx @@ -32,8 +32,8 @@ import { DataTableColumnHeader, DataTableFacetedFilter, DataTablePagination, + isRowActivationClick, } from 'components/redpanda-ui/components/data-table'; -import { isInteractiveTarget } from 'components/redpanda-ui/components/data-table/data-table-utils'; import { DropdownMenu, DropdownMenuContent, @@ -732,14 +732,10 @@ const PipelineListPageContent = () => { if (isModifiedClick(event)) { return; } - const target = event.target as Node; - // Clicks on portaled children (menus, dialogs, tooltips) bubble through the React tree - // but live outside the in the DOM — a click on the delete-confirm backdrop, say. - if (!event.currentTarget.contains(target)) { - return; - } - // Interactive descendants handle their own clicks — same helper DataTable's guard uses. - if (isInteractiveTarget(target, event.currentTarget)) { + // 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. 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 3973dff14e..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 @@ -49,6 +49,19 @@ export const deriveDisplayState = (filteredRowCount: number, isLoading: boolean) return 'data'; }; +// 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"]'; @@ -60,3 +73,8 @@ export const isInteractiveTarget = (target: EventTarget | null, boundary?: Eleme 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.tsx b/frontend/src/components/redpanda-ui/components/data-table/data-table.tsx index c8b3ad6ebe..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 @@ -26,11 +26,18 @@ import React from 'react'; import { Checkbox } from '../checkbox'; 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, isInteractiveTarget, resolvePaginationMode, resolveSortingMode } from './data-table-utils'; +import { + deriveDisplayState, + isRowActivationClick, + resolvePageDisplayState, + resolvePaginationMode, + resolveSortingMode, +} from './data-table-utils'; export type DataTableClassNames = { root?: string; @@ -293,7 +300,6 @@ export function DataTable({ const table = useReactTable(options); const rows = table.getRowModel().rows; const filteredRowCount = table.getFilteredRowModel().rows.length; - const displayState = deriveDisplayState(filteredRowCount, isLoading); const totalColumns = table.getVisibleFlatColumns().length; // autoResetPageIndex is off, so a shrinking filtered set can strand the user past the last page. @@ -304,17 +310,20 @@ export function DataTable({ const pageCountIsKnown = !table.options.manualPagination || table.options.pageCount !== undefined || table.options.rowCount !== undefined; const clampPending = paginationMode.enabled && pageCountIsKnown && pageCount > 0 && pageIndex >= pageCount; - React.useEffect(() => { + // 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]); - // The filtered count can be non-zero while the page slice is empty (e.g. `pageCount` without - // `manualPagination`, which leaves rows locally sliced) — show the empty state, not a bare header. - // Not while a clamp is pending, though: that page index is about to change, and "no results" for - // the frame in between is the flash this whole guard exists to prevent. - const showEmptyState = displayState === 'empty' || (displayState === 'data' && rows.length === 0 && !clampPending); + // 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; @@ -347,7 +356,7 @@ export function DataTable({ )} - {showEmptyState ? ( + {displayState === 'empty' ? (
@@ -370,14 +379,9 @@ export function DataTable({ }; const handleClick = (event: React.MouseEvent) => { - // Containment drops portaled content (menus, popovers) and targets already unmounted. - if (!event.currentTarget.contains(event.target as Node)) { - return; + if (isRowActivationClick(event.target, event.currentTarget)) { + activateRow(); } - if (isInteractiveTarget(event.target, event.currentTarget)) { - return; - } - activateRow(); }; const handleKeyDown = (event: React.KeyboardEvent) => { 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 62ca61699b..f1d2670107 100644 --- a/frontend/src/components/redpanda-ui/components/data-table/index.tsx +++ b/frontend/src/components/redpanda-ui/components/data-table/index.tsx @@ -4,4 +4,5 @@ export { DataTable, type DataTableClassNames, type DataTableProps } from './data 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'; From 85f6331490acdffeda7de4797234fb7cd0d55302 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Wed, 5 Aug 2026 12:30:16 -0700 Subject: [PATCH 17/18] Improve audit workflow --- .github/workflows/frontend-ui-audit.yml | 26 ++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) 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" \ From 6e50683cbbf9ddc85cecd2b4b5d01e08f9929d16 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Thu, 6 Aug 2026 12:44:56 -0700 Subject: [PATCH 18/18] Pipeline fix from other branch' --- .../pages/connect/overview.test.tsx | 70 ++++++++----------- .../src/components/pages/connect/overview.tsx | 17 +---- 2 files changed, 34 insertions(+), 53 deletions(-) diff --git a/frontend/src/components/pages/connect/overview.test.tsx b/frontend/src/components/pages/connect/overview.test.tsx index 95aeead2af..f6257c6db0 100644 --- a/frontend/src/components/pages/connect/overview.test.tsx +++ b/frontend/src/components/pages/connect/overview.test.tsx @@ -9,53 +9,45 @@ * by the Apache License, Version 2.0 */ -import { act, renderWithFileRoutes, screen, waitFor } from 'test-utils'; -import { describe, expect, it } from 'vitest'; +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 { type EndpointCompatibility, Feature, useSupportedFeaturesStore } from '../../../state/supported-features'; - -// The page swaps wholesale between the new list and the legacy tabs on whether the backend serves -// the pipelines API, so a mount regression silently hides one or the other. Driven through the real -// store, since the branch reads it reactively. -const setPipelineServiceSupport = (isSupported: boolean) => { - const compatibility: EndpointCompatibility = { - kafkaVersion: '3.6.0', - endpoints: [ - { - endpoint: Feature.PipelineService.endpoint, - method: Feature.PipelineService.method, - isSupported, - }, - ], - }; - act(() => { - useSupportedFeaturesStore.getState().setEndpointCompatibility(compatibility); - }); -}; +import { useSupportedFeaturesStore } from '../../../state/supported-features'; + +const NEW_LIST_CTA = 'Create a pipeline'; -// The status tabs belong to the new list and nothing else on this page renders them. -const RUNNING_TAB_RE = /^Running/; -const newListMarker = () => screen.queryByRole('tab', { name: RUNNING_TAB_RE }); +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 wherever the pipelines API is served', async () => { - renderWithFileRoutes(); - setPipelineServiceSupport(true); + it('renders the new pipelines list in Cloud', async () => { + renderPage(true); - await waitFor(() => { - expect(newListMarker()).toBeInTheDocument(); - }); + await waitFor(() => expect(screen.getByRole('button', { name: NEW_LIST_CTA })).toBeInTheDocument()); }); - it('keeps the legacy path when the backend does not serve it', async () => { - renderWithFileRoutes(); - setPipelineServiceSupport(false); + it('keeps the legacy path when not embedded', async () => { + useSupportedFeaturesStore.setState({ pipelinesApi: false }); + renderPage(false); - // Self-hosted lands on the legacy path's install intro, not an errored pipelines table. - await waitFor(() => { - expect(screen.getByText('Using Redpanda Connect')).toBeInTheDocument(); - }); - expect(newListMarker()).not.toBeInTheDocument(); + 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 aa68ffe437..64fb3ca171 100644 --- a/frontend/src/components/pages/connect/overview.tsx +++ b/frontend/src/components/pages/connect/overview.tsx @@ -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'; @@ -83,15 +83,12 @@ const WrapKafkaConnectOverview: FunctionComponent<{ defaultTab?: ConnectView; }> = (props) => { const { data: kafkaConnectors, isLoading: isLoadingKafkaConnectors } = useKafkaConnectConnectorsQuery(); - // Read through the store, not `Features`, so the class below re-renders when detection resolves. - const hasPipelinesApi = useSupportedFeaturesStore((s) => s.pipelinesApi); const isKafkaConnectEnabled = kafkaConnectors?.isConfigured === true; return ( { class KafkaConnectOverview extends PageComponent<{ defaultView: string; - hasPipelinesApi: boolean; isKafkaConnectEnabled: boolean; isLoadingKafkaConnectors: boolean; }> { @@ -145,15 +141,8 @@ class KafkaConnectOverview extends PageComponent<{ } render() { - // The pipelines API is the new list's only hard requirement, so it renders wherever that API - // exists — no feature flag. Self-hosted reports PipelineService unsupported (backend - // endpoint_compatibility.go) and keeps the tabs + install intro below. - // - // Ahead of the spinner deliberately: the list handles a pending Kafka Connect probe itself, and - // a failed `/console/endpoints` leaves detection pending forever — falling through beats - // blocking on a flag that may never arrive. - if (this.props.hasPipelinesApi) { - // Draws its own Kafka Connect tab, so it replaces this page rather than sitting in a tab. + // Cloud gets the new list; self-hosted keeps the tabs below. + if (isEmbedded()) { return ; } if (this.props.isLoadingKafkaConnectors) {