- {mode === 'view' && activeViewLane === 'monitor' ?
: null}
- {mode === 'view' && pipeline && activeViewLane === 'configuration' ? (
-
+ {/* Editor frame flexes to fill the column; the tips strip is pinned just beneath so it stays visible. */}
+
+ {/* 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' ? null : (
-
setSlashTipVisible(false)}
- onEditorMount={setEditorInstance}
- onYamlChange={setYamlContent}
- slashTipVisible={slashTipVisible}
- yamlContent={yamlContent}
- yamlEditorSchema={yamlEditorSchema}
- />
- )}
+ {mode !== 'view' && isVisualEditorEnabled ? (
+
+
+ goToYamlNode()} value="yaml" variant="underline">
+ YAML
+
+ setActiveEditLane('visual')} value="visual" variant="underline">
+ Visual
+
+
+
+ ) : null}
+ {/* min-w-0 + overflow-hidden keep the editor region from propagating width upward. */}
+
+ {showSidebar ? (
+
setAddConnectorType(type)}
+ onBrowseTemplates={isTemplateGalleryEnabled ? () => setIsTemplateDialogOpen(true) : undefined}
+ onOpenCommandMenu={handleCommandMenuOpen}
+ unsavedNodeIds={unsavedNodeIds}
+ yamlContent={yamlContent}
+ />
+ ) : null}
+
+ {mode === 'view' && activeViewLane === 'monitor' ? : null}
+ {mode === 'view' && pipeline && activeViewLane === 'configuration' ? (
+
+ ) : null}
+ {mode === 'view' && pipeline && activeViewLane === 'visual' ? (
+
+ ) : null}
+ {mode !== 'view' && activeEditLane === 'visual' ? (
+ setAddConnectorType(type)}
+ onAddSasl={handleAddSasl}
+ onAddTopic={handleAddTopic}
+ onBrowseTemplates={isTemplateGalleryEnabled ? () => setIsTemplateDialogOpen(true) : undefined}
+ onNavigateToYaml={goToYamlNode}
+ onYamlChange={setYamlContent}
+ yamlContent={yamlContent}
+ />
+ ) : null}
+ {mode === 'view' || activeEditLane === 'visual' ? null : (
+
+ )}
+
+
+ {tipsContext ? (
+
+ ) : null}
diff --git a/frontend/src/components/pages/rp-connect/pipeline/invalid-config-notice.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/invalid-config-notice.test.tsx
new file mode 100644
index 0000000000..cc40c8c008
--- /dev/null
+++ b/frontend/src/components/pages/rp-connect/pipeline/invalid-config-notice.test.tsx
@@ -0,0 +1,37 @@
+/**
+ * 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 { render, screen } from 'test-utils';
+import { describe, expect, it } from 'vitest';
+
+import { InvalidConfigNotice } from './invalid-config-notice';
+
+describe('InvalidConfigNotice', () => {
+ it('renders its message inside a warning alert', () => {
+ render(
Can't visualize the latest YAML. );
+ expect(screen.getByRole('alert')).toHaveTextContent("Can't visualize the latest YAML.");
+ });
+
+ it('stays content-width (w-auto) rather than the Alert default w-full, so the floating canvas banner does not cover the toolbars', () => {
+ render(
notice );
+ const alert = screen.getByRole('alert');
+ expect(alert).toHaveClass('w-auto');
+ expect(alert).not.toHaveClass('w-full');
+ });
+
+ it('forwards positioning/padding classes from the caller', () => {
+ render(
notice );
+ const alert = screen.getByRole('alert');
+ expect(alert).toHaveClass('absolute', 'px-3');
+ // Caller padding wins over the Alert's default on the same axis.
+ expect(alert).not.toHaveClass('px-4');
+ });
+});
diff --git a/frontend/src/components/pages/rp-connect/pipeline/invalid-config-notice.tsx b/frontend/src/components/pages/rp-connect/pipeline/invalid-config-notice.tsx
new file mode 100644
index 0000000000..60a72f9071
--- /dev/null
+++ b/frontend/src/components/pages/rp-connect/pipeline/invalid-config-notice.tsx
@@ -0,0 +1,28 @@
+/**
+ * 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 { Alert } from 'components/redpanda-ui/components/alert';
+import { cn } from 'components/redpanda-ui/lib/utils';
+import { TriangleAlert } from 'lucide-react';
+import type { ReactNode } from 'react';
+
+/**
+ * Warning banner for a pipeline config that can't be shown as-is, shared by the visual canvas and the
+ * sidebar outline. Overrides the Alert's `w-full` to `w-auto` so it stays content-width when floated
+ * over the canvas; callers pass positioning, padding and text size via `className`.
+ */
+export function InvalidConfigNotice({ children, className }: { children: ReactNode; className?: string }) {
+ return (
+
} variant="warning">
+
{children}
+
+ );
+}
diff --git a/frontend/src/components/pages/rp-connect/pipeline/node-config-form.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/node-config-form.test.tsx
new file mode 100644
index 0000000000..f81463fdd0
--- /dev/null
+++ b/frontend/src/components/pages/rp-connect/pipeline/node-config-form.test.tsx
@@ -0,0 +1,290 @@
+/**
+ * Copyright 2026 Redpanda Data, Inc.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
+ *
+ * As of the Change Date specified in that file, in accordance with
+ * the Business Source License, use of this software will be governed
+ * by the Apache License, Version 2.0
+ */
+
+import userEvent from '@testing-library/user-event';
+import { render, screen } from 'test-utils';
+import { describe, expect, test, vi } from 'vitest';
+
+import { NodeConfigForm } from './node-config-form';
+import type { ConnectComponentSpec } from '../types/schema';
+import { mockKafkaOutput } from '../utils/__fixtures__/component-schemas';
+
+const spec = mockKafkaOutput as unknown as ConnectComponentSpec;
+
+// The form has no Apply button: it REPORTS the assembled config via onConfigChange (null when
+// clean) and the inspector auto-commits on leave/save. Tests assert the latest reported config.
+function renderForm(value: Record
, onConfigChange = vi.fn()) {
+ render( );
+ return onConfigChange;
+}
+
+// The most recent config reported by the form (undefined if never called, null when clean).
+function lastReported(onConfigChange: ReturnType): unknown {
+ return onConfigChange.mock.calls.at(-1)?.[0];
+}
+
+describe('NodeConfigForm — full schema', () => {
+ test('renders required scalar fields, a scalar-array field, and nested object groups', async () => {
+ const user = userEvent.setup();
+ renderForm({ kafka: { topic: 't', addresses: ['a:9092'] } });
+
+ // Required scalars + the scalar array.
+ expect(screen.getByText('topic')).toBeInTheDocument();
+ expect(screen.getByText('addresses')).toBeInTheDocument();
+ // A non-advanced nested object is exposed as its own sub-section (not raw YAML).
+ expect(screen.getByText('batching')).toBeInTheDocument();
+ // Optional/advanced groupings exist.
+ expect(screen.getByText('Optional')).toBeInTheDocument();
+ expect(screen.getByText('Advanced')).toBeInTheDocument();
+
+ // Advanced nested objects appear once the Advanced section is expanded.
+ await user.click(screen.getByText('Advanced'));
+ expect(screen.getByText('sasl')).toBeInTheDocument();
+ expect(screen.getByText('tls')).toBeInTheDocument();
+ });
+
+ test('marks only no-default fields required; a defaulted field (sasl.mechanism) is not', async () => {
+ const user = userEvent.setup();
+ renderForm({ kafka: { topic: 't', addresses: ['a:9092'] } });
+
+ // A scalar with no default is genuinely required.
+ expect(screen.getByText('topic').closest('div')?.querySelector('[title="Required"]')).not.toBeNull();
+
+ // `mechanism` has a default (`none`), so even though the backend left `optional`
+ // unset it must NOT be flagged required.
+ await user.click(screen.getByText('Advanced'));
+ await user.click(screen.getByText('sasl'));
+ const mechRow = screen.getByText('mechanism').closest('div');
+ expect(mechRow).not.toBeNull();
+ expect(mechRow?.querySelector('[title="Required"]')).toBeNull();
+ });
+
+ test('shows the schema default as a hint for optional fields', () => {
+ renderForm({ kafka: { topic: 't', addresses: ['a:9092'] } });
+ // partitioner defaults to fnv1a_hash.
+ expect(screen.getByText('fnv1a_hash')).toBeInTheDocument();
+ });
+
+ test('reports a config only once something changes', async () => {
+ const user = userEvent.setup();
+ const onConfigChange = renderForm({ kafka: { topic: 'orig', addresses: ['a:9092'] } });
+ // Clean on mount → reports null (nothing to commit).
+ expect(lastReported(onConfigChange)).toBeNull();
+
+ await user.type(screen.getByDisplayValue('orig'), 'X');
+ expect(lastReported(onConfigChange)).not.toBeNull();
+ });
+
+ test('reports a changed scalar and keeps the YAML minimal (no empty optionals)', async () => {
+ const user = userEvent.setup();
+ const onConfigChange = renderForm({ kafka: { topic: 'orig', addresses: ['a:9092'] } });
+
+ const topic = screen.getByDisplayValue('orig');
+ await user.clear(topic);
+ await user.type(topic, 'new-topic');
+
+ const next = lastReported(onConfigChange) as { kafka: Record };
+ expect(next.kafka.topic).toBe('new-topic');
+ expect(next.kafka.addresses).toEqual(['a:9092']);
+ // Untouched optional fields are not written out.
+ expect(next.kafka).not.toHaveProperty('key');
+ expect(next.kafka).not.toHaveProperty('partitioner');
+ });
+
+ test('preserves complex/untouched settings when reporting an unrelated edit', async () => {
+ const user = userEvent.setup();
+ // `metadata` is not in the schema; `count: 1000$` is a malformed int — both must survive.
+ const onConfigChange = renderForm({
+ kafka: {
+ topic: 'orig',
+ addresses: ['a:9092'],
+ metadata: { include_patterns: ['.*'] },
+ batching: { count: '1000$' },
+ },
+ });
+
+ await user.type(screen.getByDisplayValue('orig'), '-2');
+
+ const next = lastReported(onConfigChange) as { kafka: { metadata: unknown; batching: { count: unknown } } };
+ expect(next.kafka.metadata).toEqual({ include_patterns: ['.*'] });
+ // Malformed value is preserved exactly — not parseInt-ed to 1000.
+ expect(next.kafka.batching.count).toBe('1000$');
+ });
+
+ test('shows a malformed numeric value instead of blanking it (text input, not type=number)', async () => {
+ const user = userEvent.setup();
+ renderForm({ kafka: { topic: 't', addresses: ['a:9092'], batching: { count: '1000$' } } });
+ await user.click(screen.getByText('batching'));
+ expect(screen.getByDisplayValue('1000$')).toBeInTheDocument();
+ // …and flags it inline as not a valid integer (with the not-saved warning).
+ expect(screen.getByText(/Not a valid integer/)).toBeInTheDocument();
+ });
+
+ test('does not commit a malformed numeric value (the saved value is kept)', async () => {
+ const user = userEvent.setup();
+ const onConfigChange = renderForm({ kafka: { topic: 't', addresses: ['a:9092'], batching: { count: 5 } } });
+ await user.click(screen.getByText('batching'));
+ const countInput = screen.getByDisplayValue('5');
+ await user.clear(countInput);
+ await user.type(countInput, '10x');
+
+ // The field is flagged, and the reported config keeps the saved value — NOT `10` (parseInt
+ // truncation) and NOT dropped.
+ expect(screen.getByText(/won't be saved until fixed/)).toBeInTheDocument();
+ const next = lastReported(onConfigChange) as { kafka: { batching: { count: unknown } } };
+ expect(next.kafka.batching.count).toBe(5);
+ });
+
+ test('masks credential-named fields and offers a secret-reference tip', async () => {
+ const user = userEvent.setup();
+ renderForm({ kafka: { topic: 't', addresses: ['a:9092'], sasl: { password: 'hunter2' } } });
+ await user.click(screen.getByText('Advanced'));
+ await user.click(screen.getByText('sasl'));
+
+ const password = screen.getByDisplayValue('hunter2');
+ expect(password).toHaveAttribute('type', 'password');
+ // The reveal toggle is the registry Input's built-in one (label "Show password").
+ expect(screen.getAllByRole('button', { name: 'Show password' }).length).toBeGreaterThan(0);
+ expect(screen.getAllByText(/reference a secret/i).length).toBeGreaterThan(0);
+ });
+
+ test('keeps the saved label when a resource label field is cleared (references depend on it)', async () => {
+ const user = userEvent.setup();
+ const onConfigChange = vi.fn();
+ render(
+
+ );
+
+ await user.clear(screen.getByDisplayValue('shared'));
+ expect(screen.getByText(/A resource needs a label/)).toBeInTheDocument();
+ const next = lastReported(onConfigChange) as { label?: string };
+ expect(next.label).toBe('shared');
+ });
+
+ test('does not flag secret/env interpolations in numeric fields', async () => {
+ const user = userEvent.setup();
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: testing literal interpolation syntax
+ renderForm({ kafka: { topic: 't', addresses: ['a:9092'], batching: { count: '${secrets.BATCH_COUNT}' } } });
+ await user.click(screen.getByText('batching'));
+ expect(screen.queryByText(/Not a valid integer/)).not.toBeInTheDocument();
+ });
+
+ test('keeps an interpolation typed into a numeric field (not coerced to NaN and dropped)', async () => {
+ const user = userEvent.setup();
+ const onConfigChange = renderForm({ kafka: { topic: 't', addresses: ['a:9092'], batching: { count: 5 } } });
+ await user.click(screen.getByText('batching'));
+ const countInput = screen.getByDisplayValue('5');
+ await user.clear(countInput);
+ // `{{` is userEvent's escape for a literal `{`; this types `${env.COUNT}`.
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: testing literal interpolation syntax
+ await user.type(countInput, '${{env.COUNT}');
+
+ const next = lastReported(onConfigChange) as { kafka: { batching: { count: unknown } } };
+ // The interpolation is preserved verbatim, not NaN-coerced to '' and deleted.
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: testing literal interpolation syntax
+ expect(next.kafka.batching.count).toBe('${env.COUNT}');
+ });
+
+ test('does not render nested-component fields and preserves them on edit', async () => {
+ const user = userEvent.setup();
+ // A `branch`-like spec: a scalar (request_map) + a nested processor sub-pipeline.
+ const branchSpec = {
+ config: {
+ children: [
+ { name: 'request_map', type: 'string', kind: 'scalar', optional: false },
+ { name: 'processors', type: 'processor', kind: 'array', optional: false },
+ ],
+ },
+ } as unknown as ConnectComponentSpec;
+ const onConfigChange = vi.fn();
+ render(
+
+ );
+
+ // The scalar renders as a control; the nested component renders nothing here — it's its own
+ // canvas node (no inline control, and no redundant "select it on the canvas" hint).
+ expect(screen.getByText('request_map')).toBeInTheDocument();
+ expect(screen.queryByText('processors')).not.toBeInTheDocument();
+
+ await user.type(screen.getByDisplayValue('root = this'), '!');
+
+ const next = lastReported(onConfigChange) as { branch: Record };
+ // The sub-pipeline survives untouched; the scalar edit is written.
+ expect(next.branch.processors).toEqual([{ http: { url: 'http://x' } }]);
+ expect(next.branch.request_map).toBe('root = this!');
+ });
+
+ test('round-trips a scalar array edited as one-per-line text', async () => {
+ const user = userEvent.setup();
+ const onConfigChange = renderForm({ kafka: { topic: 't', addresses: ['a:9092'] } });
+
+ const addresses = screen.getByPlaceholderText('One value per line');
+ await user.clear(addresses);
+ await user.type(addresses, 'b:9092\nc:9092');
+
+ const next = lastReported(onConfigChange) as { kafka: Record };
+ expect(next.kafka.addresses).toEqual(['b:9092', 'c:9092']);
+ });
+});
+
+describe('NodeConfigForm — list-valued components (switch/try/…)', () => {
+ // A switch's value is an array of cases, not an object of fields. Its schema lists a
+ // single case's fields, so the form must NOT render them or rebuild the value.
+ const switchSpec = {
+ name: 'switch',
+ type: 'processor',
+ config: {
+ name: 'root',
+ type: 'object',
+ kind: 'scalar',
+ children: [
+ { name: 'check', type: 'string', kind: 'scalar', optional: false },
+ { name: 'processors', type: 'processor', kind: 'array' },
+ ],
+ },
+ } as unknown as ConnectComponentSpec;
+
+ const switchValue = () => ({
+ switch: [
+ { check: 'this.region == "us"', processors: [{ mapping: 'root = this' }] },
+ { processors: [{ log: { message: 'default' } }] },
+ ],
+ });
+
+ test('editing the label preserves the array of cases (no data loss)', async () => {
+ const user = userEvent.setup();
+ const onConfigChange = vi.fn();
+ const value = switchValue();
+ render( );
+
+ await user.type(screen.getByPlaceholderText('Optional identifier for this component'), 'router');
+
+ expect(lastReported(onConfigChange)).toEqual({ label: 'router', switch: value.switch });
+ });
+
+ test('hides the (misleading) per-case fields and shows a canvas hint instead', () => {
+ render( );
+ // The case-level `check` field must not appear on the container.
+ expect(screen.queryByText('check')).toBeNull();
+ expect(screen.getByText(/edited on the canvas/i)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/pages/rp-connect/pipeline/node-config-form.tsx b/frontend/src/components/pages/rp-connect/pipeline/node-config-form.tsx
new file mode 100644
index 0000000000..86ba4df158
--- /dev/null
+++ b/frontend/src/components/pages/rp-connect/pipeline/node-config-form.tsx
@@ -0,0 +1,944 @@
+/**
+ * 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from 'components/redpanda-ui/components/collapsible';
+import { CountDot } from 'components/redpanda-ui/components/count-dot';
+import { Input } from 'components/redpanda-ui/components/input';
+import { Label } from 'components/redpanda-ui/components/label';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from 'components/redpanda-ui/components/select';
+import { Switch } from 'components/redpanda-ui/components/switch';
+import { Textarea } from 'components/redpanda-ui/components/textarea';
+import { Text } from 'components/redpanda-ui/components/typography';
+import { cn } from 'components/redpanda-ui/lib/utils';
+import { YamlEditor } from 'components/ui/yaml/yaml-editor';
+import { ChevronDown, ChevronRight, Plus } from 'lucide-react';
+import { createContext, useContext, useEffect, useId } from 'react';
+import { type Control, Controller, type FieldPath, useForm, useWatch } from 'react-hook-form';
+import { parse as parseYaml, stringify as yamlStringify } from 'yaml';
+
+import { ScrollShadow } from './scroll-shadow';
+import { getSecretSyntax } from '../types/constants';
+import type { ConnectComponentSpec, RawFieldSpec } from '../types/schema';
+import {
+ checkRequired,
+ fieldHasOptions,
+ isComponentField,
+ isFormField,
+ isObjectGroupField,
+ isScalarArrayField,
+ isScalarField,
+} from '../utils/schema';
+import type { EditTarget, ResourceKind } from '../utils/yaml';
+import { resourceKindForComponentName } from '../utils/yaml';
+
+// A direct child (case / step) of a control-flow component, shown as a clickable row to its full config.
+export type InspectorChildItem = {
+ id: string;
+ target: EditTarget;
+ caseTarget?: EditTarget;
+ name: string;
+ condition?: string;
+ isDefault?: boolean;
+ isErrorPath?: boolean;
+ lintCount?: number;
+};
+
+const childItemConditionText = (item: InspectorChildItem): string | undefined => {
+ if (item.condition) {
+ return `if ${item.condition}`;
+ }
+ if (item.isDefault) {
+ return 'default';
+ }
+ if (item.isErrorPath) {
+ return 'on error';
+ }
+ return;
+};
+
+const childItemCondColor = (item: InspectorChildItem): string => {
+ if (item.isErrorPath) {
+ return 'text-destructive';
+ }
+ if (item.isDefault) {
+ return 'text-muted-foreground';
+ }
+ return 'text-warning';
+};
+
+const ChildItemRow = ({
+ item,
+ onSelect,
+}: {
+ item: InspectorChildItem;
+ onSelect: (item: InspectorChildItem) => void;
+}) => {
+ const condText = childItemConditionText(item);
+ return (
+ onSelect(item)}
+ type="button"
+ >
+
+ {condText ? (
+
+ {condText}
+
+ ) : null}
+
+ {item.name}
+
+
+ {item.lintCount ? : null}
+
+
+ );
+};
+
+export const ChildItemsList = ({
+ items,
+ onSelect,
+ label,
+}: {
+ items: InspectorChildItem[];
+ onSelect: (item: InspectorChildItem) => void;
+ label: string;
+}) => (
+ // A heading, not a : the rows are buttons with their own accessible text; nothing for htmlFor.
+
+
+ {label}
+
+
+ {items.map((item) => (
+
+ ))}
+
+
+ Select one to open its full configuration.
+
+
+);
+
+// Resource-link context (existing labels + create-and-link), provided by the inspector so the form
+// stays a pure config editor.
+type ResourceFieldContextValue = {
+ labels: Record;
+ onCreateResource?: (kind: ResourceKind) => void;
+ // Kind of the edited component, so a plainly-typed `resource:` string field still reads as a link.
+ componentResourceKind?: ResourceKind;
+};
+const ResourceFieldContext = createContext({ labels: { cache: [], rate_limit: [] } });
+
+const CREATE_RESOURCE_VALUE = '__create_resource__';
+
+// Resource kind a field links to: by field type, or by the enclosing cache/rate_limit component for
+// a field named `resource` (some schemas type it as a plain string).
+function resolveResourceKind(spec: RawFieldSpec, componentResourceKind?: ResourceKind): ResourceKind | undefined {
+ if (spec.type === 'cache' || spec.type === 'rate_limit') {
+ return spec.type;
+ }
+ if (spec.name === 'resource' && spec.kind === 'scalar' && componentResourceKind) {
+ return componentResourceKind;
+ }
+ return;
+}
+
+// Dropdown for a `resource:` link — never free text, so the reference can't be mistyped; a stale
+// value is still shown, flagged missing.
+const ResourceReferenceSelect = ({
+ kind,
+ value = '',
+ onChange,
+ id,
+}: {
+ kind: ResourceKind;
+ value?: string;
+ onChange: (value: unknown) => void;
+ id?: string;
+}) => {
+ const { labels, onCreateResource } = useContext(ResourceFieldContext);
+ const options = labels[kind];
+ const isMissing = value !== '' && !options.includes(value);
+ return (
+ {
+ if (v === CREATE_RESOURCE_VALUE) {
+ onCreateResource?.(kind);
+ return;
+ }
+ onChange(v);
+ }}
+ value={value}
+ >
+
+
+
+
+ {isMissing ? {value} (missing) : null}
+ {options.map((label) => (
+
+ {label}
+
+ ))}
+ {onCreateResource ? (
+
+
+
+ Create new {kind === 'cache' ? 'cache' : 'rate limit'}…
+
+
+ ) : null}
+
+
+ );
+};
+
+// Path helpers for the plain-JSON config object (not the YAML AST).
+function getInObj(obj: Record, path: string[]): unknown {
+ let cur: unknown = obj;
+ for (const key of path) {
+ if (!cur || typeof cur !== 'object') {
+ return;
+ }
+ cur = (cur as Record)[key];
+ }
+ return cur;
+}
+
+function setInObj(obj: Record, path: string[], value: unknown): void {
+ let cur = obj;
+ for (const key of path.slice(0, -1)) {
+ if (!cur[key] || typeof cur[key] !== 'object' || Array.isArray(cur[key])) {
+ cur[key] = {};
+ }
+ cur = cur[key] as Record;
+ }
+ cur[path.at(-1) as string] = value;
+}
+
+function deleteInObj(obj: Record, path: string[]): void {
+ const parent = path.slice(0, -1).reduce | undefined>((cur, key) => {
+ const next = cur?.[key];
+ return next && typeof next === 'object' ? (next as Record) : undefined;
+ }, obj);
+ if (parent) {
+ delete parent[path.at(-1) as string];
+ }
+}
+
+// Drop objects that became empty after clearing their fields, so the YAML stays tidy.
+function pruneEmptyObjects(obj: Record): void {
+ for (const [key, val] of Object.entries(obj)) {
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
+ pruneEmptyObjects(val as Record);
+ if (Object.keys(val).length === 0) {
+ delete obj[key];
+ }
+ }
+ }
+}
+
+function initialScalar(spec: RawFieldSpec, current: unknown): string | boolean {
+ const isMissing = current === undefined || current === null;
+ if (spec.type === 'bool') {
+ return isMissing ? spec.defaultValue === 'true' : Boolean(current);
+ }
+ if (isMissing) {
+ return '';
+ }
+ return String(current);
+}
+
+function coerceScalar(spec: RawFieldSpec, raw: string | boolean): string | number | boolean {
+ if (spec.type === 'bool') {
+ return Boolean(raw);
+ }
+ const text = String(raw);
+ // Interpolations (`${ENV}`, secrets, Bloblang) are valid even in numeric fields — keep verbatim
+ // rather than coercing to NaN (→ '' → dropped on commit). Matches numericHint.
+ if ((spec.type === 'int' || spec.type === 'float') && text.includes('${')) {
+ return text;
+ }
+ if (spec.type === 'int') {
+ const n = Number.parseInt(text, 10);
+ return Number.isNaN(n) ? '' : n;
+ }
+ if (spec.type === 'float') {
+ const n = Number(text);
+ return text === '' || Number.isNaN(n) ? '' : n;
+ }
+ return text;
+}
+
+function coerceArrayItems(spec: RawFieldSpec, text: string): unknown[] {
+ const lines = text
+ .split('\n')
+ .map((l) => l.trim())
+ .filter((l) => l !== '');
+ if (spec.type === 'int' || spec.type === 'float') {
+ return lines.map(Number).filter((n) => !Number.isNaN(n));
+ }
+ return lines;
+}
+
+type Leaf = { spec: RawFieldSpec; path: string[]; key: string };
+
+/** All scalar + scalar-array leaves (recursing into object groups), keyed by path. */
+function collectLeaves(fields: RawFieldSpec[], base: string[] = []): { scalars: Leaf[]; arrays: Leaf[] } {
+ const scalars: Leaf[] = [];
+ const arrays: Leaf[] = [];
+ for (const f of fields) {
+ const path = [...base, f.name];
+ const leaf: Leaf = { spec: f, path, key: path.join('/') };
+ if (isScalarField(f)) {
+ scalars.push(leaf);
+ } else if (isScalarArrayField(f)) {
+ arrays.push(leaf);
+ } else if (isObjectGroupField(f)) {
+ const nested = collectLeaves(f.children ?? [], path);
+ scalars.push(...nested.scalars);
+ arrays.push(...nested.arrays);
+ }
+ }
+ return { scalars, arrays };
+}
+
+type FormValues = {
+ label: string;
+ raw: string;
+ fields: Record;
+ arrays: Record;
+};
+
+// Parse the "Other settings (YAML)" text: the mapping, `{}` when empty (intentional clear), or
+// `null` when invalid / not a mapping — so callers preserve existing keys instead of wiping them.
+function parseRawSection(showRaw: boolean, raw: string): Record | null {
+ if (!(showRaw && raw.trim())) {
+ return {};
+ }
+ try {
+ const parsed = parseYaml(raw);
+ // Empty / comments-only parses to nullish — treat as an intentional clear, not invalid.
+ if (parsed === null || parsed === undefined) {
+ return {};
+ }
+ return typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : null;
+ } catch {
+ return null;
+ }
+}
+
+// Which form fields the user actually edited (react-hook-form dirty state).
+type DirtyState = { fields?: Record; arrays?: Record; raw?: unknown };
+
+type BuildArgs = {
+ componentName: string;
+ /** The full component entry being edited, e.g. `{ label, switch: [...] }`. */
+ value: Record;
+ inner: Record;
+ leaves: { scalars: Leaf[]; arrays: Leaf[] };
+ rawKeys: string[];
+ showRaw: boolean;
+ data: FormValues;
+ dirty: DirtyState;
+ /** Resources are referenced by label — never drop it, even if the field is cleared. */
+ requireLabel?: boolean;
+};
+
+function applyScalarEdits(config: Record, scalars: Leaf[], data: FormValues, dirty: DirtyState): void {
+ for (const { spec, path, key } of scalars) {
+ if (!dirty.fields?.[key]) {
+ continue;
+ }
+ // A malformed numeric literal is flagged and NOT committed — coercing would silently truncate
+ // (`10x` → 10) or drop it; the saved value is kept until fixed.
+ if (numericHint(spec, data.fields[key])) {
+ continue;
+ }
+ const coerced = coerceScalar(spec, data.fields[key]);
+ if (spec.type === 'bool' || coerced !== '') {
+ setInObj(config, path, coerced);
+ } else {
+ deleteInObj(config, path);
+ }
+ }
+}
+
+function applyArrayEdits(config: Record, arrays: Leaf[], data: FormValues, dirty: DirtyState): void {
+ for (const { spec, path, key } of arrays) {
+ if (!dirty.arrays?.[key]) {
+ continue;
+ }
+ const items = coerceArrayItems(spec, data.arrays[key] ?? '');
+ if (items.length > 0) {
+ setInObj(config, path, items);
+ } else {
+ deleteInObj(config, path);
+ }
+ }
+}
+
+// Assemble the component entry: start from the existing config (so unrendered and untouched
+// fields round-trip byte-for-byte) and overlay only the fields the user actually changed.
+function buildComponentEntry({
+ componentName,
+ value,
+ inner,
+ leaves,
+ rawKeys,
+ showRaw,
+ data,
+ dirty,
+ requireLabel,
+}: BuildArgs): Record {
+ const next: Record = {};
+ if (data.label.trim()) {
+ next.label = data.label.trim();
+ } else if (requireLabel && typeof value.label === 'string' && value.label) {
+ // Clearing a resource's label would strand its `resource:` references — keep the saved label.
+ next.label = value.label;
+ }
+
+ const original = value[componentName];
+ // A list/scalar-valued component (switch/try/catch/for_each): preserve the value verbatim and only
+ // patch the label — rebuilding would write `{}` over the array and drop the children.
+ if (!(original && typeof original === 'object') || Array.isArray(original)) {
+ next[componentName] = original ?? {};
+ return next;
+ }
+
+ const config: Record = structuredClone(inner);
+ if (showRaw && dirty.raw) {
+ const parsedRaw = parseRawSection(showRaw, data.raw);
+ // Invalid YAML → null: keep the existing raw keys rather than wiping them (the editor shows the error).
+ if (parsedRaw) {
+ for (const key of rawKeys) {
+ delete config[key];
+ }
+ Object.assign(config, parsedRaw);
+ }
+ }
+
+ applyScalarEdits(config, leaves.scalars, data, dirty);
+ applyArrayEdits(config, leaves.arrays, data, dirty);
+ pruneEmptyObjects(config);
+
+ next[componentName] = config;
+ return next;
+}
+
+const FieldLabel = ({ spec, htmlFor }: { spec: RawFieldSpec; htmlFor?: string }) => (
+
+
+ {spec.name}
+
+ {checkRequired(spec) ? (
+
+ *
+
+ ) : null}
+ {spec.type && spec.type !== 'string' ? {spec.type} : null}
+ {spec.defaultValue ? (
+
+ default: {spec.defaultValue}
+
+ ) : null}
+
+);
+
+const FieldDescription = ({ spec }: { spec: RawFieldSpec }) =>
+ spec.description ? (
+
+ {spec.description}
+
+ ) : null;
+
+// The schema has no secret flag, so mask plausibly-credential fields by name heuristic.
+const SECRET_NAME_RE = /(password|secret|token|private_key|api_key|passphrase)$/i;
+
+const isSecretField = (spec: RawFieldSpec): boolean =>
+ spec.type === 'string' && !fieldHasOptions(spec) && SECRET_NAME_RE.test(spec.name ?? '');
+
+// Masked credential input. `type="password"` gives the registry Input's reveal toggle; a `${…}`
+// interpolation isn't a literal credential, so it's shown in the clear.
+const SecretInput = (props: { id?: string; value: string; onChange: (value: unknown) => void; required?: boolean }) => (
+
+);
+
+const OptionsSelect = ({
+ spec,
+ value,
+ onChange,
+ id,
+ required,
+}: {
+ spec: RawFieldSpec;
+ value: string | boolean;
+ onChange: (value: unknown) => void;
+ id?: string;
+ required?: boolean;
+}) => (
+
+
+
+
+
+ {spec.annotatedOptions?.map((option) => (
+
+ {option.value}
+
+ ))}
+
+
+);
+
+const ScalarControl = ({
+ spec,
+ value,
+ onChange,
+ id,
+ invalid,
+}: {
+ spec: RawFieldSpec;
+ value: string | boolean;
+ onChange: (value: unknown) => void;
+ id?: string;
+ invalid?: boolean;
+}) => {
+ const { componentResourceKind } = useContext(ResourceFieldContext);
+ const required = checkRequired(spec);
+ if (spec.type === 'bool') {
+ return ;
+ }
+ const resourceKind = resolveResourceKind(spec, componentResourceKind);
+ if (resourceKind) {
+ return ;
+ }
+ if (fieldHasOptions(spec)) {
+ return ;
+ }
+ if (isSecretField(spec)) {
+ return ;
+ }
+ // Text input (numeric inputMode), not type="number": a number input blanks values the browser
+ // can't parse (e.g. `1000$`); text keeps them visible and fixable.
+ const numericMode = spec.type === 'int' ? 'numeric' : 'decimal';
+ return (
+
+ );
+};
+
+const INT_VALUE_RE = /^-?\d+$/;
+
+// A non-blocking validity hint for numeric fields. `${…}` values are interpolations (env vars,
+// secrets, Bloblang) — legitimate anywhere — so only plainly malformed literals are flagged.
+function numericHint(spec: RawFieldSpec, value: string | boolean): string | null {
+ const text = String(value ?? '').trim();
+ if (text === '' || typeof value === 'boolean' || text.includes('${')) {
+ return null;
+ }
+ if (spec.type === 'int' && !INT_VALUE_RE.test(text)) {
+ return "Not a valid integer — this change won't be saved until fixed.";
+ }
+ if (spec.type === 'float' && Number.isNaN(Number(text))) {
+ return "Not a valid number — this change won't be saved until fixed.";
+ }
+ return null;
+}
+
+const SECRET_REF_EXAMPLE = getSecretSyntax('MY_SECRET');
+
+const ScalarField = ({ leaf, control }: { leaf: Leaf; control: Control }) => {
+ const inputId = useId();
+ return (
+ }
+ render={({ field }) => {
+ const hint = numericHint(leaf.spec, field.value as string | boolean);
+ const showSecretTip = isSecretField(leaf.spec) && !String(field.value ?? '').includes('${');
+ return (
+
+
+
+ {hint ? (
+
+ {hint}
+
+ ) : null}
+ {showSecretTip ? (
+
+ Tip: reference a secret ({SECRET_REF_EXAMPLE} ) instead of a literal
+ value.
+
+ ) : null}
+
+
+ );
+ }}
+ />
+ );
+};
+
+const ArrayField = ({ leaf, control }: { leaf: Leaf; control: Control }) => {
+ const inputId = useId();
+ return (
+ }
+ render={({ field }) => (
+
+
+
+
+
+ )}
+ />
+ );
+};
+
+const FieldGroup = ({
+ label,
+ defaultOpen = true,
+ children,
+}: {
+ label: string;
+ defaultOpen?: boolean;
+ children: React.ReactNode;
+}) => (
+
+
+ {label}
+
+
+ {children}
+
+);
+
+// Render one field: scalar, scalar list, or nested object group; complex fields go to the raw section.
+const SchemaField = ({ spec, path, control }: { spec: RawFieldSpec; path: string[]; control: Control }) => {
+ const here = [...path, spec.name];
+ if (isScalarField(spec)) {
+ return ;
+ }
+ if (isScalarArrayField(spec)) {
+ return ;
+ }
+ if (isObjectGroupField(spec)) {
+ return (
+
+
+
+ );
+ }
+ return null;
+};
+
+type FieldGroupKey = 'required' | 'optional' | 'advanced';
+
+// Display buckets; `advanced` wins over `required`, so an advanced+required field stays below the fold.
+const fieldGroupKey = (spec: RawFieldSpec): FieldGroupKey => {
+ if (spec.advanced) {
+ return 'advanced';
+ }
+ if (checkRequired(spec)) {
+ return 'required';
+ }
+ return 'optional';
+};
+
+function groupFormFields(fields: RawFieldSpec[]): Record {
+ const groups: Record = { required: [], optional: [], advanced: [] };
+ for (const spec of fields) {
+ if (isFormField(spec)) {
+ groups[fieldGroupKey(spec)].push(spec);
+ }
+ }
+ return groups;
+}
+
+const SchemaFields = ({
+ fields,
+ path,
+ control,
+}: {
+ fields: RawFieldSpec[];
+ path: string[];
+ control: Control;
+}) => {
+ const { required, optional, advanced } = groupFormFields(fields);
+ const ordered = [...required, ...optional, ...advanced];
+ return (
+ <>
+ {ordered.map((f) => (
+
+ ))}
+ >
+ );
+};
+
+type NodeConfigFormProps = {
+ spec: ConnectComponentSpec;
+ componentName: string;
+ /** The full component entry, e.g. `{ label, kafka: {...} }`. */
+ value: Record;
+ /** Existing resource labels offered by `resource:` dropdowns. */
+ resourceLabels?: Record;
+ /** Create a new resource of a kind and link it to the field being edited. */
+ onCreateResource?: (kind: ResourceKind) => void;
+ /** Rendered at the top of the scroll area, so it scrolls with the form rather than sticking above it. */
+ headerSlot?: React.ReactNode;
+ /** A control-flow component's direct children (cases / steps), shown as a clickable list. */
+ childItems?: InspectorChildItem[];
+ onSelectChild?: (item: InspectorChildItem) => void;
+ /** Reports the assembled config on change (null when clean) so the inspector can auto-commit on leave/save. */
+ onConfigChange?: (config: Record | null) => void;
+ /** Resource nodes are referenced by label — the label field must not be cleared. */
+ requireLabel?: boolean;
+};
+
+export function NodeConfigForm({
+ spec,
+ componentName,
+ value,
+ resourceLabels,
+ onCreateResource,
+ headerSlot,
+ childItems,
+ onSelectChild,
+ onConfigChange,
+ requireLabel,
+}: NodeConfigFormProps) {
+ const labelId = useId();
+ const hasChildList = Boolean(childItems && childItems.length > 0 && onSelectChild);
+ const fields = spec.config?.children ?? [];
+ const componentValue = value[componentName];
+ // A list-valued component (switch/try/catch/for_each) is edited on the canvas, not via object
+ // fields — rendering them would mislead and saving would clobber the array. Show a hint instead.
+ const isListValued = Array.isArray(componentValue);
+ const inner =
+ componentValue && typeof componentValue === 'object' && !isListValued
+ ? (componentValue as Record)
+ : {};
+
+ const { required, optional, advanced } = groupFormFields(fields);
+ // Nested-component fields are their own canvas nodes — offered as a clickable "Steps" list, never inline.
+ const componentFields = fields.filter(isComponentField);
+
+ // Leaves drive form defaults + assembly. Complex schema fields and unknown keys go to the
+ // raw-YAML fallback; nested-component fields are preserved untouched (the form clones the config).
+ const leaves = collectLeaves(fields);
+ const schemaKeys = new Set(fields.map((f) => f.name).filter(Boolean));
+ const complexSchemaKeys = fields.filter((f) => f.name && !(isFormField(f) || isComponentField(f))).map((f) => f.name);
+ const unknownKeys = Object.keys(inner).filter((k) => !schemaKeys.has(k));
+ const rawKeys = [...new Set([...complexSchemaKeys, ...unknownKeys])].filter((k) => inner[k] !== undefined);
+ const showRaw = rawKeys.length > 0;
+ const rawObject = Object.fromEntries(rawKeys.map((k) => [k, inner[k]]));
+
+ const { control, formState, getValues } = useForm({
+ defaultValues: {
+ label: typeof value.label === 'string' ? value.label : '',
+ raw: showRaw ? yamlStringify(rawObject) : '',
+ fields: Object.fromEntries(leaves.scalars.map((l) => [l.key, initialScalar(l.spec, getInObj(inner, l.path))])),
+ arrays: Object.fromEntries(
+ leaves.arrays.map((l) => {
+ const v = getInObj(inner, l.path);
+ return [l.key, Array.isArray(v) ? v.join('\n') : ''];
+ })
+ ),
+ },
+ });
+
+ // Read dirtyFields/isDirty during render so react-hook-form subscribes to (and
+ // keeps updating) them — otherwise they stay empty and every field looks untouched.
+ const { dirtyFields, isDirty } = formState;
+
+ const watched = useWatch({ control });
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `watched` is the change trigger; the config is rebuilt from the latest values/props read in the body.
+ useEffect(() => {
+ onConfigChange?.(
+ isDirty
+ ? buildComponentEntry({
+ componentName,
+ value,
+ inner,
+ leaves,
+ rawKeys,
+ showRaw,
+ data: getValues(),
+ dirty: dirtyFields,
+ requireLabel,
+ })
+ : null
+ );
+ }, [watched, isDirty, getValues, onConfigChange]);
+
+ const resourceCtx: ResourceFieldContextValue = {
+ labels: resourceLabels ?? { cache: [], rate_limit: [] },
+ onCreateResource,
+ componentResourceKind: resourceKindForComponentName(componentName),
+ };
+
+ return (
+
+
+
+ {/* Full-bleed to the scroll edges; padded fields follow. */}
+ {headerSlot ? {headerSlot}
: null}
+
+
+ label
+
+ (
+ <>
+
+ {requireLabel && !field.value.trim() ? (
+
+ A resource needs a label — nodes reference it by name. The saved label is kept.
+
+ ) : null}
+ >
+ )}
+ />
+
+
+ {isListValued && hasChildList ? (
+ void}
+ />
+ ) : null}
+ {isListValued && !hasChildList ? (
+
+
+ This component's items (cases / processors) are edited on the canvas — select one to edit it.
+
+
+ ) : null}
+
+ {isListValued ? null : required.map((f) => )}
+
+ {!isListValued && optional.length > 0 ? (
+
+ {optional.map((f) => (
+
+ ))}
+
+ ) : null}
+
+ {!isListValued && advanced.length > 0 ? (
+
+ {advanced.map((f) => (
+
+ ))}
+
+ ) : null}
+
+ {!isListValued && componentFields.length > 0 && hasChildList ? (
+ void}
+ />
+ ) : null}
+
+ {!isListValued && showRaw ? (
+
+ {
+ const invalid = field.value.trim() !== '' && parseRawSection(true, field.value) === null;
+ return (
+
+
+ field.onChange(v || '')}
+ options={{ minimap: { enabled: false } }}
+ transparentBackground
+ value={field.value}
+ />
+
+ {invalid ? (
+
+ Invalid YAML — these settings won't be saved until fixed.
+
+ ) : null}
+
+ );
+ }}
+ />
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/frontend/src/components/pages/rp-connect/pipeline/node-inspector.tsx b/frontend/src/components/pages/rp-connect/pipeline/node-inspector.tsx
new file mode 100644
index 0000000000..7930a65aa3
--- /dev/null
+++ b/frontend/src/components/pages/rp-connect/pipeline/node-inspector.tsx
@@ -0,0 +1,871 @@
+/**
+ * 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 { LintHint } from '@buf/redpandadata_common.bufbuild_es/redpanda/api/common/v1/linthint_pb';
+import type { ComponentName } from 'assets/connectors/component-logo-map';
+import { Alert, AlertAction, AlertDescription, AlertTitle } from 'components/redpanda-ui/components/alert';
+import { Button } from 'components/redpanda-ui/components/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from 'components/redpanda-ui/components/dropdown-menu';
+import { Input } from 'components/redpanda-ui/components/input';
+import { Label } from 'components/redpanda-ui/components/label';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'components/redpanda-ui/components/tooltip';
+import { Text } from 'components/redpanda-ui/components/typography';
+import { YamlEditor } from 'components/ui/yaml/yaml-editor';
+import {
+ AlertCircle,
+ BookOpenIcon,
+ Box,
+ EllipsisVertical,
+ FileCode2,
+ Info,
+ type LucideIcon,
+ MousePointerClick,
+ MousePointerSquareDashed,
+ Plus,
+ Split,
+ Trash2,
+ X,
+} from 'lucide-react';
+import { type MutableRefObject, type ReactNode, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
+import { toast } from 'sonner';
+import { pluralizeWithNumber } from 'utils/string';
+import { LineCounter, parseDocument, parse as parseYaml, stringify as yamlStringify } from 'yaml';
+
+import { ChildItemsList, type InspectorChildItem, NodeConfigForm } from './node-config-form';
+import { ConnectorLogo } from '../onboarding/connector-logo';
+import type { ConnectComponentSpec, ConnectComponentType } from '../types/schema';
+import { getConnectorDocsUrl } from '../utils/connector-docs';
+import {
+ appendResource,
+ buildInsertableComponent,
+ countResourceReferences,
+ type EditTarget,
+ editTargetPath,
+ firstKey,
+ getComponentAt,
+ listResourceLabels,
+ type ResourceKind,
+ renameResourceReferences,
+ resourceArrayKey,
+ resourceKindForComponentName,
+ resourceRefKindForTarget,
+ setComponentAt,
+ targetComponentType,
+} from '../utils/yaml';
+
+// Default component per kind for the dangling-reference quick-fix.
+const DEFAULT_RESOURCE_COMPONENT: Record = { cache: 'memory', rate_limit: 'local' };
+
+const COMPONENT_TYPE_LABEL: Partial> = {
+ input: 'Input',
+ output: 'Output',
+ processor: 'Processor',
+ cache: 'Cache',
+ rate_limit: 'Rate limit',
+ buffer: 'Buffer',
+ metrics: 'Metrics',
+ tracer: 'Tracer',
+};
+
+type NodeInspectorProps = {
+ target: EditTarget | null;
+ /** For a switch-case entry node: the case's routing-condition target, shown at the top of the panel. */
+ caseTarget?: EditTarget | null;
+ /** Canonical pipeline YAML — read from and written back to. */
+ yaml: string;
+ /** Specs that drive the schema form. */
+ components: ConnectComponentSpec[];
+ onApply: (yaml: string) => void;
+ onDelete?: (target: EditTarget) => void;
+ /** Open the picker to create + link a new resource of a kind (memory/redis/memcached…). */
+ onCreateResource?: (kind: ResourceKind) => void;
+ /** Read-only inspection (view lane): show config without editing. */
+ readOnly?: boolean;
+ /** Lint problems that map to the selected node. */
+ lintHints?: LintHint[];
+ /** Jump to this node's lines in the YAML lane (footer "View in YAML" action). */
+ onOpenInYaml?: () => void;
+ /** Close the inspector (deselect). */
+ onClose?: () => void;
+ /** A control-flow node's direct children (cases / steps), shown as a clickable list. */
+ childItems?: InspectorChildItem[];
+ /** Navigate the inspector to a child node. */
+ onSelectChild?: (item: InspectorChildItem) => void;
+ /** The selected node's pending-edit hooks; the panel flushes them on node-leave / save — no per-node Apply button. */
+ commitRef?: MutableRefObject;
+};
+
+/** The inspector's pending-edit hooks, registered into the panel's `commitRef`. */
+export type PendingNodeCommit = {
+ /** Apply the node's pending edits into `yaml` and consume them (idempotent once consumed). */
+ commit: (yaml: string) => string;
+ /** Drop the pending edits without applying (delete / undo / redo). */
+ discard: () => void;
+};
+
+// Lint message falling on a switch case's routing `check` line, so the condition field can show its own error.
+function lintMessageOnCaseCheck(yaml: string, caseTarget: EditTarget, lintHints?: LintHint[]): string | undefined {
+ if (!lintHints?.length) {
+ return;
+ }
+ try {
+ const lineCounter = new LineCounter();
+ const doc = parseDocument(yaml, { lineCounter });
+ const checkNode = doc.getIn([...editTargetPath(caseTarget), 'check'], true) as
+ | { range?: [number, number, number] }
+ | undefined;
+ if (!checkNode?.range) {
+ return;
+ }
+ const line = lineCounter.linePos(checkNode.range[0]).line;
+ return lintHints.find((h) => h.line === line)?.hint;
+ } catch {
+ return;
+ }
+}
+
+/**
+ * The always-present right rail: the selected node's identity plus either a schema-driven form
+ * or scoped YAML (read-only / unknown schema). Edits write back into the pipeline YAML at `target`.
+ */
+export function NodeInspector({
+ target,
+ caseTarget,
+ yaml,
+ components,
+ onApply,
+ onCreateResource,
+ onDelete,
+ readOnly,
+ lintHints,
+ onOpenInYaml,
+ onClose,
+ childItems,
+ onSelectChild,
+ commitRef,
+}: NodeInspectorProps) {
+ const component = useMemo(() => (target ? getComponentAt(yaml, target) : undefined), [yaml, target]);
+ // The routing condition for a case-entry node, read from its switch case.
+ const caseObject = useMemo(() => (caseTarget ? getComponentAt(yaml, caseTarget) : undefined), [yaml, caseTarget]);
+
+ // Pending edits from the active editors (null when clean). Refs, not state, so per-keystroke
+ // reporting doesn't re-render. `component` applies at `target`; `condition` at `caseTarget`.
+ const componentDraftRef = useRef | null>(null);
+ const conditionDraftRef = useRef | null>(null);
+ // Stable draft reporters — they sit in the editors' effect deps, so an inline arrow would re-fire it every render.
+ const reportComponentDraft = useCallback((next: Record | null) => {
+ componentDraftRef.current = next;
+ }, []);
+ const reportConditionDraft = useCallback((next: Record | null) => {
+ conditionDraftRef.current = next;
+ }, []);
+ // The resource's original label, captured for the rename cascade when committing a resource
+ // edit — for cache/rate_limit `resource` targets and `*_resources` items alike.
+ const resourceLabel0 =
+ target && resourceRefKindForTarget(target) && component && typeof component.label === 'string'
+ ? component.label
+ : undefined;
+
+ // Commit pending edits, condition before component — the condition replaces the whole case, so
+ // written second it would clobber the component edit. Drafts are consumed as applied (re-call = no-op).
+ const commit = useCallback(
+ (input: string): string => {
+ let next = input;
+ const cond = conditionDraftRef.current;
+ if (cond && caseTarget) {
+ const applied = setComponentAt(next, caseTarget, cond);
+ if (applied === null) {
+ // Surgical editor couldn't rewrite this YAML; keep the draft (next flush retries) and warn.
+ toast.error('Couldn’t apply the condition edit to the YAML — edit it in the YAML view instead.');
+ } else {
+ next = applied;
+ conditionDraftRef.current = null;
+ }
+ }
+ const comp = componentDraftRef.current;
+ if (comp && target) {
+ const applied = applyComponentDraft(next, target, comp, resourceLabel0);
+ if (applied !== null) {
+ next = applied;
+ componentDraftRef.current = null;
+ }
+ }
+ return next;
+ },
+ [target, caseTarget, resourceLabel0]
+ );
+ useEffect(() => {
+ if (!commitRef) {
+ return;
+ }
+ commitRef.current = {
+ commit,
+ discard: () => {
+ conditionDraftRef.current = null;
+ componentDraftRef.current = null;
+ },
+ };
+ return () => {
+ commitRef.current = null;
+ };
+ }, [commit, commitRef]);
+ // Clear pending drafts when the selected node changes — the inspector instance is reused.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: target/caseTarget are change triggers; the body only resets refs.
+ useEffect(() => {
+ componentDraftRef.current = null;
+ conditionDraftRef.current = null;
+ }, [target, caseTarget]);
+ // Lint problem on the condition's `check` line, so the field shows its own error, not just the banner.
+ const conditionError = useMemo(
+ () => (caseTarget ? lintMessageOnCaseCheck(yaml, caseTarget, lintHints) : undefined),
+ [yaml, caseTarget, lintHints]
+ );
+ const componentName = component ? firstKey(component) : undefined;
+
+ const spec = useMemo(() => {
+ if (!(target && componentName)) {
+ return;
+ }
+ const type = targetComponentType(target);
+ return components.find((c) => c.type === type && c.name === componentName);
+ }, [components, target, componentName]);
+
+ if (!(target && component && componentName)) {
+ return ;
+ }
+
+ // A switch case is edited for its routing condition only; its body is separate nodes. Reported
+ // as a draft (at `target`), auto-committed on leave / save.
+ if (target.kind === 'switchCase') {
+ return (
+ // Key by the case's path so switching cases remounts the editor and resets the `check` draft —
+ // otherwise a typed value leaks to a sibling with an equal saved check, misrouting on save.
+ onDelete(target)}
+ onOpenInYaml={onOpenInYaml}
+ readOnly={readOnly}
+ />
+ );
+ }
+
+ const kind = targetComponentType(target);
+ const kindLabel = COMPONENT_TYPE_LABEL[kind] ?? 'Component';
+ const docsUrl = getConnectorDocsUrl(kind, componentName);
+ const useForm = (spec?.config?.children?.length ?? 0) > 0;
+ // Delete lives in the header's 3-dot menu.
+ const handleDelete = readOnly || !onDelete ? undefined : () => onDelete(target);
+
+ // Resource label + reference count ("Used by N"). Kind-scoped: a same-labelled resource of
+ // another kind must not inflate the count.
+ const resourceRefKind = resourceRefKindForTarget(target);
+ const resourceLabel = resourceRefKind && typeof component.label === 'string' ? component.label : undefined;
+ const usedByCount = resourceLabel ? countResourceReferences(yaml, resourceLabel, resourceRefKind) : 0;
+
+ // Resource labels for the `resource:` dropdowns in this component's form.
+ const resourceLabels: Record = {
+ cache: listResourceLabels(yaml, 'cache'),
+ rate_limit: listResourceLabels(yaml, 'rate_limit'),
+ };
+
+ // A `resource:` reference whose label has no matching resource — fixable here vs. failing at deploy.
+ const refKind = resourceKindForComponentName(componentName);
+ const innerConfig = component[componentName];
+ const refValue =
+ innerConfig && typeof innerConfig === 'object' && !Array.isArray(innerConfig)
+ ? (innerConfig as Record).resource
+ : undefined;
+ const danglingRef =
+ refKind && typeof refValue === 'string' && refValue !== '' && !resourceLabels[refKind].includes(refValue)
+ ? { ref: refValue, kind: refKind }
+ : undefined;
+
+ // Quick-fix: create the missing resource under that exact label so the reference resolves.
+ const handleCreateMissingResource = () => {
+ if (!danglingRef) {
+ return;
+ }
+ const obj = buildInsertableComponent(DEFAULT_RESOURCE_COMPONENT[danglingRef.kind], danglingRef.kind, components);
+ if (!obj) {
+ return;
+ }
+ obj.label = danglingRef.ref;
+ onApply(appendResource(yaml, resourceArrayKey(danglingRef.kind), obj) ?? yaml);
+ };
+
+ // A case-entry node's condition is edited at the top of the panel. The form scrolls it with the
+ // fields (headerSlot); raw/read-only render it above.
+ const conditionSection =
+ caseTarget && caseObject ? (
+ // Key by the case's path so selecting a sibling remounts + resets the draft (see above).
+
+ ) : null;
+
+ return (
+
+
+ {lintHints && lintHints.length > 0 ? : null}
+ {danglingRef && !readOnly ? (
+
+ ) : null}
+
+
+ );
+}
+
+type InspectorBodyProps = {
+ conditionSection: ReactNode;
+ component: Record;
+ componentName: string;
+ useForm: boolean;
+ spec?: ConnectComponentSpec;
+ target: EditTarget;
+ readOnly?: boolean;
+ childItems?: InspectorChildItem[];
+ onSelectChild?: (item: InspectorChildItem) => void;
+ onCreateResource?: (kind: ResourceKind) => void;
+ resourceLabels: Record;
+ reportComponentDraft: (next: Record | null) => void;
+};
+
+// Case-condition section (if any) plus the most specific of: schema form, read-only view, child list, raw YAML.
+const InspectorBody = ({
+ conditionSection,
+ component,
+ componentName,
+ useForm,
+ spec,
+ target,
+ readOnly,
+ childItems,
+ onSelectChild,
+ onCreateResource,
+ resourceLabels,
+ reportComponentDraft,
+}: InspectorBodyProps) => {
+ if (readOnly) {
+ return (
+ <>
+ {conditionSection}
+
+ >
+ );
+ }
+ if (useForm && spec) {
+ return (
+
+ );
+ }
+ // Fan-out container with no schema form (fallback / broker / output switch): list members instead of raw YAML.
+ if (childItems && childItems.length > 0 && onSelectChild) {
+ const listLabel = componentName === 'switch' || componentName === 'group_by' ? 'Cases' : 'Outputs';
+ return (
+ <>
+ {conditionSection}
+
+
+
+ >
+ );
+ }
+ return (
+ <>
+ {conditionSection}
+
+ >
+ );
+};
+
+// Secondary node actions (View in YAML, Delete) in a 3-dot menu.
+const InspectorActionsMenu = ({ onOpenInYaml, onDelete }: { onOpenInYaml?: () => void; onDelete?: () => void }) => {
+ if (!(onOpenInYaml || onDelete)) {
+ return null;
+ }
+ return (
+
+ {/* Base UI `render` (not Radix `asChild`): the trigger IS our icon-sm Button, not a nested one. */}
+ }
+ >
+
+
+
+ {onOpenInYaml ? (
+
+
+ View in YAML
+
+ ) : null}
+ {onOpenInYaml && onDelete ? : null}
+ {onDelete ? (
+
+
+ Delete
+
+ ) : null}
+
+
+ );
+};
+
+// Lint problems for the selected node, shown in context above its config. A registry Alert,
+// squared off to sit flush as an inspector banner.
+const InspectorLintErrors = ({ hints }: { hints: LintHint[] }) => (
+ } variant="destructive">
+ {pluralizeWithNumber(hints.length, 'problem')}
+
+
+ {hints.map((hint, i) => (
+
+ {hint.hint}
+ {hint.line > 0 ? (line {hint.line}) : null}
+
+ ))}
+
+
+
+);
+
+// Write a component-config draft at `target`, cascading a resource-label rename to every component
+// that references it (so the link is never silently broken).
+function applyComponentDraft(
+ yaml: string,
+ target: EditTarget,
+ config: Record,
+ originalLabel?: string
+): string | null {
+ const applied = setComponentAt(yaml, target, config);
+ if (applied === null) {
+ // Surgical editor couldn't rewrite this YAML (e.g. an anchor referenced elsewhere). Return null
+ // so the caller keeps the draft (next flush retries) instead of consuming an edit that never landed.
+ toast.error('Couldn’t apply this edit to the YAML — edit the component in the YAML view instead.');
+ return null;
+ }
+ const refKind = resourceRefKindForTarget(target);
+ if (refKind && originalLabel) {
+ const nextLabel = typeof config.label === 'string' ? config.label : undefined;
+ if (nextLabel && nextLabel !== originalLabel) {
+ // Kind-scoped: renaming a cache must not rewrite a same-labelled rate limit's (or input's) references.
+ return renameResourceReferences(applied, originalLabel, nextLabel, refKind) ?? applied;
+ }
+ }
+ return applied;
+}
+
+// Apply a routing-condition `check` to a switch case, preserving key order: a non-empty check is
+// set in place, an empty one omitted (the default/else case).
+function caseWithCheck(caseObject: Record, check: string): Record {
+ const trimmed = check.trim();
+ const next: Record = {};
+ let placed = false;
+ for (const [key, value] of Object.entries(caseObject)) {
+ if (key === 'check') {
+ placed = true;
+ if (trimmed !== '') {
+ next.check = trimmed;
+ }
+ } else {
+ next[key] = value;
+ }
+ }
+ if (trimmed !== '' && !placed) {
+ next.check = trimmed;
+ }
+ return next;
+}
+
+// Shared draft state for a case's `check` field: tracks the value, re-syncs on saved-case change,
+// and reports the edited case up as a draft (null when clean).
+function useCaseCheckDraft(
+ caseObject: Record,
+ onConfigChange?: (next: Record | null) => void
+) {
+ const initial = typeof caseObject.check === 'string' ? caseObject.check : '';
+ const [check, setCheck] = useState(initial);
+ useEffect(() => setCheck(initial), [initial]);
+ const dirty = check !== initial;
+ useEffect(() => {
+ onConfigChange?.(dirty ? caseWithCheck(caseObject, check) : null);
+ }, [check, dirty, caseObject, onConfigChange]);
+ return { check, setCheck, dirty };
+}
+
+const CASE_CHECK_PLACEHOLDER = 'e.g. this.region == "us"';
+
+// The mono `check` text field shared by the switch-case editor and the inline case-condition section.
+const CaseCheckInput = ({
+ id,
+ check,
+ setCheck,
+ readOnly,
+ invalid,
+}: {
+ id: string;
+ check: string;
+ setCheck: (value: string) => void;
+ readOnly?: boolean;
+ invalid?: boolean;
+}) => (
+ setCheck(e.target.value)}
+ placeholder={CASE_CHECK_PLACEHOLDER}
+ value={check}
+ />
+);
+
+// Edits a switch case's routing condition (`check`); empty makes it the default/else case. The
+// case's body is separate canvas nodes — this rail only owns the condition.
+const SwitchCaseEditor = ({
+ caseObject,
+ onConfigChange,
+ onDelete,
+ onOpenInYaml,
+ onClose,
+ readOnly,
+}: {
+ caseObject: Record;
+ onConfigChange?: (next: Record | null) => void;
+ onDelete?: () => void;
+ onOpenInYaml?: () => void;
+ onClose?: () => void;
+ readOnly?: boolean;
+}) => {
+ const { check, setCheck } = useCaseCheckDraft(caseObject, onConfigChange);
+ const inputId = useId();
+
+ return (
+
+
+
+
+
+
+
+ Switch
+
+
+ Routing condition
+
+
+
+
+ {onClose ? (
+
+
+
+ ) : null}
+
+
+
+
+ Condition (check)
+
+
+
+ A Bloblang expression. Messages route to this case when it's true. Leave empty for the default (else) case.
+
+
+
+ );
+};
+
+// The routing condition of a case-entry node, edited inline at the top of its inspector panel.
+// Writes the case's `check` (empty = default/else); reported as a draft, auto-committed with the node.
+const CaseConditionSection = ({
+ caseObject,
+ onConfigChange,
+ readOnly,
+ error,
+}: {
+ caseObject: Record;
+ onConfigChange?: (next: Record | null) => void;
+ readOnly?: boolean;
+ /** A lint message on this condition — renders the input in its error state. */
+ error?: string;
+}) => {
+ const { check, setCheck, dirty } = useCaseCheckDraft(caseObject, onConfigChange);
+ const inputId = useId();
+ return (
+
+
+
+
+ Routing condition
+
+
+
+
+ }
+ >
+
+
+
+ A Bloblang expression evaluated per message — this branch runs when it's true. Leave it empty to make this
+ the default (else) case.
+
+
+
+
+
+ {/* The field's own error, shown where it's fixed. Hidden once editing starts — they're addressing it. */}
+ {error && !dirty ? (
+
+
+ {error}
+
+ ) : null}
+
+ );
+};
+
+// Banner for a dangling `resource:` reference, with a one-click fix that creates it under that
+// label. A registry Alert, squared off to sit flush as an inspector banner; the title reserves
+// right padding so it never runs under the absolutely-positioned action.
+const DanglingRefBanner = ({ refLabel, onCreate }: { refLabel: string; onCreate: () => void }) => (
+ } variant="warning">
+
+ Resource {refLabel} doesn't exist.
+
+
+
+ Create it
+
+
+
+);
+
+const EmptyHint = ({ icon: Icon, children }: { icon: LucideIcon; children: React.ReactNode }) => (
+
+
+
+
+
+ {children}
+
+
+);
+
+const InspectorEmptyState = ({ readOnly }: { readOnly?: boolean }) => (
+
+
+
+
+
+ No node selected
+
+ {readOnly
+ ? 'Select a node on the canvas to inspect its configuration.'
+ : 'Select a node on the canvas to view and edit its configuration.'}
+
+
+
+ Click any node to {readOnly ? 'inspect' : 'edit'} it
+ {readOnly ? null : Use the + on a connector line to insert a step }
+
+
+);
+
+const InspectorHeader = ({
+ kindLabel,
+ componentName,
+ docsUrl,
+ onClose,
+ onOpenInYaml,
+ onDelete,
+ usedByCount,
+}: {
+ kindLabel: string;
+ componentName: string;
+ docsUrl?: string;
+ onClose?: () => void;
+ onOpenInYaml?: () => void;
+ onDelete?: () => void;
+ usedByCount?: number;
+}) => (
+
+
+
+
+ {kindLabel}
+ {typeof usedByCount === 'number' ? ` · used by ${pluralizeWithNumber(usedByCount, 'node')}` : ''}
+
+
+ {componentName}
+
+
+
+ {docsUrl ? (
+
+
+
+ ) : null}
+
+ {onClose ? (
+
+
+
+ ) : null}
+
+
+);
+
+// Read-only inspection (view lane): the component as scoped YAML.
+const ReadOnlyComponent = ({ component }: { component: Record }) => (
+
+);
+
+// Fallback editor for components without a known schema (e.g. bloblang mappings): scoped, editable YAML.
+const RawComponentEditor = ({
+ component,
+ onConfigChange,
+}: {
+ component: Record;
+ onConfigChange?: (next: Record | null) => void;
+}) => {
+ // No prop→state resync: the sole call site keys this editor on the component value, so a
+ // changed `component` always remounts a fresh instance (same contract as NodeConfigForm).
+ const initial = useMemo(() => yamlStringify(component), [component]);
+ const [draft, setDraft] = useState(initial);
+ const [error, setError] = useState(null);
+
+ // Parse the draft live: a valid mapping is reported as pending config; a clean draft or parse
+ // error reports nothing, so invalid YAML is never committed.
+ useEffect(() => {
+ if (draft === initial) {
+ setError(null);
+ onConfigChange?.(null);
+ return;
+ }
+ let parsed: unknown;
+ try {
+ parsed = parseYaml(draft);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Invalid YAML');
+ onConfigChange?.(null);
+ return;
+ }
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ setError('Configuration must be a YAML mapping.');
+ onConfigChange?.(null);
+ return;
+ }
+ setError(null);
+ onConfigChange?.(parsed as Record);
+ }, [draft, initial, onConfigChange]);
+
+ return (
+
+ {/* Flex column so the editor shrinks to leave room for the error row below it. */}
+
+
+ setDraft(v || '')}
+ options={{ minimap: { enabled: false } }}
+ transparentBackground
+ value={draft}
+ />
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ );
+};
diff --git a/frontend/src/components/pages/rp-connect/pipeline/pipeline-canvas-command-palette-utils.test.ts b/frontend/src/components/pages/rp-connect/pipeline/pipeline-canvas-command-palette-utils.test.ts
new file mode 100644
index 0000000000..b8f09e0636
--- /dev/null
+++ b/frontend/src/components/pages/rp-connect/pipeline/pipeline-canvas-command-palette-utils.test.ts
@@ -0,0 +1,89 @@
+/**
+ * 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 { jumpableNodes, searchKeywords, searchValue } from './pipeline-canvas-command-palette-utils';
+import type { PipelineFlowNode } from '../utils/pipeline-flow-parser';
+import type { EditTarget } from '../utils/yaml';
+
+const ZWSP = '\u200B';
+const TARGET = { kind: 'component', section: 'input', path: ['input'] } as unknown as EditTarget;
+
+const node = (overrides: Partial): PipelineFlowNode => ({
+ id: 'n',
+ kind: 'leaf',
+ label: 'node',
+ ...overrides,
+});
+
+describe('jumpableNodes', () => {
+ test('keeps only nodes with both an editTarget and a section', () => {
+ const editable = node({ id: 'a', editTarget: TARGET, section: 'input' });
+ const structural = node({ id: 'b', section: 'input' }); // no editTarget (e.g. a merge dot)
+ const sectionless = node({ id: 'c', editTarget: TARGET }); // no section (e.g. a bare case wrapper)
+
+ expect(jumpableNodes([editable, structural, sectionless])).toEqual([editable]);
+ });
+
+ test('returns an empty array when nothing is reachable', () => {
+ expect(jumpableNodes([node({ section: 'input' })])).toEqual([]);
+ });
+});
+
+describe('searchValue', () => {
+ test('includes only visible fields — label, labelText, and section — never the id', () => {
+ const value = searchValue(
+ node({ id: 'secret-id-0', label: 'kafka_franz', labelText: 'my_input', section: 'input' }),
+ 0
+ );
+
+ expect(value.replace(new RegExp(ZWSP, 'g'), '')).toBe('kafka_franz my_input input');
+ expect(value).not.toContain('secret-id-0');
+ });
+
+ test('omits absent optional fields', () => {
+ expect(searchValue(node({ label: 'drop', section: 'output' }), 0).replace(new RegExp(ZWSP, 'g'), '')).toBe(
+ 'drop output'
+ );
+ });
+
+ test('disambiguates same-named nodes with an index-scaled zero-width suffix', () => {
+ const a = searchValue(node({ label: 'kafka' }), 0);
+ const b = searchValue(node({ label: 'kafka' }), 1);
+
+ // Distinct values so cmdk can track each row separately...
+ expect(a).not.toBe(b);
+ // ...but identical once the invisible suffix is stripped.
+ expect(a.replace(new RegExp(ZWSP, 'g'), '')).toBe(b.replace(new RegExp(ZWSP, 'g'), ''));
+ // Suffix length is index-derived (index + 1 zero-width spaces).
+ expect(a.match(new RegExp(ZWSP, 'g'))).toHaveLength(1);
+ expect(b.match(new RegExp(ZWSP, 'g'))).toHaveLength(2);
+ });
+});
+
+describe('searchKeywords', () => {
+ test('surfaces non-empty meta values', () => {
+ const withMeta = node({
+ meta: [
+ { label: 'topic', value: 'orders' },
+ { label: 'group', value: '' },
+ { label: 'url', value: 'localhost:9092' },
+ ],
+ });
+ expect(searchKeywords(withMeta)).toEqual(['orders', 'localhost:9092']);
+ });
+
+ test('returns undefined when there is no searchable meta', () => {
+ expect(searchKeywords(node({}))).toBeUndefined();
+ expect(searchKeywords(node({ meta: [{ label: 'group', value: '' }] }))).toBeUndefined();
+ });
+});
diff --git a/frontend/src/components/pages/rp-connect/pipeline/pipeline-canvas-command-palette-utils.ts b/frontend/src/components/pages/rp-connect/pipeline/pipeline-canvas-command-palette-utils.ts
new file mode 100644
index 0000000000..6d3ad07273
--- /dev/null
+++ b/frontend/src/components/pages/rp-connect/pipeline/pipeline-canvas-command-palette-utils.ts
@@ -0,0 +1,31 @@
+/**
+ * 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 { PipelineFlowNode } from '../utils/pipeline-flow-parser';
+
+// Palette-reachable nodes: ones with an editable target. Structural wrappers (bare switch cases,
+// merge dots) carry no target and are skipped.
+export function jumpableNodes(nodes: PipelineFlowNode[]): PipelineFlowNode[] {
+ return nodes.filter((n) => n.editTarget && n.section);
+}
+
+// Match on visible fields only — cmdk fuzzy-matches the whole `value`, so internal node ids would
+// cause invisible matches. Duplicate names stay unique via a zero-width-space suffix the user can't type.
+export function searchValue(node: PipelineFlowNode, index: number): string {
+ const visible = [node.label, node.labelText, node.section].filter(Boolean).join(' ');
+ return `${visible}${'\u200B'.repeat(index + 1)}`;
+}
+
+// Meta values (topics, urls, …) stay searchable via cmdk's `keywords` — user-authored, unlike node ids.
+export function searchKeywords(node: PipelineFlowNode): string[] | undefined {
+ const keywords = node.meta?.map((m) => m.value).filter(Boolean);
+ return keywords?.length ? keywords : undefined;
+}
diff --git a/frontend/src/components/pages/rp-connect/pipeline/pipeline-canvas-command-palette.tsx b/frontend/src/components/pages/rp-connect/pipeline/pipeline-canvas-command-palette.tsx
new file mode 100644
index 0000000000..56ddde68a3
--- /dev/null
+++ b/frontend/src/components/pages/rp-connect/pipeline/pipeline-canvas-command-palette.tsx
@@ -0,0 +1,142 @@
+/**
+ * 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 {
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from 'components/redpanda-ui/components/command';
+import { FileCode2, type LucideIcon, Redo2, Undo2 } from 'lucide-react';
+import { useMemo } from 'react';
+
+import { jumpableNodes, searchKeywords, searchValue } from './pipeline-canvas-command-palette-utils';
+import { sectionAccent } from './pipeline-flow-canvas-nodes';
+import { type PipelineFlowNode, SECTION_LABEL } from '../utils/pipeline-flow-parser';
+
+// A quick action in the palette (view in YAML, undo/redo, …); listed only when its handler prop was passed.
+type PaletteAction = {
+ id: string;
+ label: string;
+ icon: LucideIcon;
+ run: () => void;
+ /** When false the action is shown but disabled (e.g. nothing to undo). */
+ enabled?: boolean;
+};
+
+type CanvasCommandPaletteProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** Every parsed flow node — the searchable "go to" list is derived from the selectable ones. */
+ nodes: PipelineFlowNode[];
+ /** Select + center a node on the canvas. */
+ onJumpToNode: (node: PipelineFlowNode) => void;
+ // Edit-mode actions; omit to hide (e.g. view mode passes none).
+ onUndo?: () => void;
+ onRedo?: () => void;
+ canUndo?: boolean;
+ canRedo?: boolean;
+ /** "View in YAML" for the current selection — only when something is selected. */
+ onViewSelectedInYaml?: () => void;
+};
+
+/**
+ * The canvas command palette: fuzzy-search to jump to any node, plus global actions (view in YAML,
+ * undo/redo). Opens on `/` because ⌘K is reserved by the app-shell search.
+ */
+export function CanvasCommandPalette({
+ open,
+ onOpenChange,
+ nodes,
+ onJumpToNode,
+ onUndo,
+ onRedo,
+ canUndo,
+ canRedo,
+ onViewSelectedInYaml,
+}: CanvasCommandPaletteProps) {
+ const items = useMemo(() => jumpableNodes(nodes), [nodes]);
+
+ const actions = useMemo(() => {
+ const list: PaletteAction[] = [];
+ if (onViewSelectedInYaml) {
+ list.push({ id: 'view-yaml', label: 'View selection in YAML', icon: FileCode2, run: onViewSelectedInYaml });
+ }
+ if (onUndo) {
+ list.push({ id: 'undo', label: 'Undo', icon: Undo2, run: onUndo, enabled: canUndo });
+ }
+ if (onRedo) {
+ list.push({ id: 'redo', label: 'Redo', icon: Redo2, run: onRedo, enabled: canRedo });
+ }
+ return list;
+ }, [onViewSelectedInYaml, onUndo, onRedo, canUndo, canRedo]);
+
+ const pick = (run: () => void) => {
+ onOpenChange(false);
+ run();
+ };
+
+ return (
+
+
+
+ No matching nodes or actions.
+ {actions.length > 0 ? (
+
+ {actions.map((action) => (
+ pick(action.run)}
+ value={`action ${action.label}`}
+ >
+
+ {action.label}
+
+ ))}
+
+ ) : null}
+ {items.length > 0 ? (
+
+ {items.map((node, index) => (
+ pick(() => onJumpToNode(node))}
+ value={searchValue(node, index)}
+ >
+
+ {node.label}
+ {node.labelText ? (
+ {node.labelText}
+ ) : null}
+
+ {node.section ? SECTION_LABEL[node.section] : ''}
+
+
+ ))}
+
+ ) : null}
+
+
+ );
+}
diff --git a/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-canvas-nodes.tsx b/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-canvas-nodes.tsx
new file mode 100644
index 0000000000..3309b05ede
--- /dev/null
+++ b/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-canvas-nodes.tsx
@@ -0,0 +1,911 @@
+/**
+ * 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 { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath, Handle, Position } from '@xyflow/react';
+import type { ComponentName } from 'assets/connectors/component-logo-map';
+import { Badge } from 'components/redpanda-ui/components/badge';
+import { Button } from 'components/redpanda-ui/components/button';
+import { Text } from 'components/redpanda-ui/components/typography';
+import { cn } from 'components/redpanda-ui/lib/utils';
+import {
+ AlertCircle,
+ Box,
+ GitBranch,
+ GitFork,
+ GitMerge,
+ Layers,
+ type LucideIcon,
+ Network,
+ PlusIcon,
+ Repeat,
+ RotateCw,
+ Rows3,
+ ShieldAlert,
+ ShieldCheck,
+ Shuffle,
+ Split,
+ Workflow,
+} from 'lucide-react';
+import { motion } from 'motion/react';
+import { useEffect, useRef } from 'react';
+import { pluralize, pluralizeWithNumber } from 'utils/string';
+
+import { ConnectorLogo } from '../onboarding/connector-logo';
+import { FLOW_CARD_WIDTH, FLOW_SPINE_HANDLE_LEFT, type FlowInsertPayload } from '../utils/pipeline-flow-layout';
+import type { NodeMetaEntry } from '../utils/pipeline-flow-meta';
+import { sectionLabel } from '../utils/pipeline-flow-parser';
+import type { EditTarget } from '../utils/yaml';
+
+const invisibleHandle = '!w-1.5 !h-1.5 !border-0 !bg-transparent !min-w-0 !min-h-0';
+// Applied to the card body, never the RF node wrapper, whose `transform` drives positioning.
+const APPEAR_ANIM = 'fade-in zoom-in-95 animate-in duration-200';
+
+// RF drives pan/drag from native d3 listeners on ancestors; React's synthetic handlers run too late to
+// cancel. Stop propagation natively, only for presses on a real control, so the card body still pans.
+const CONTROL_SELECTOR = 'button, a, input, select, textarea, [role="button"]';
+const PRESS_EVENTS = ['mousedown', 'pointerdown', 'touchstart'] as const;
+
+function useStopPanOnControls() {
+ const ref = useRef(null);
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) {
+ return;
+ }
+ const handlePress = (event: Event) => {
+ if ((event.target as HTMLElement | null)?.closest(CONTROL_SELECTOR)) {
+ event.stopPropagation();
+ }
+ };
+ for (const name of PRESS_EVENTS) {
+ el.addEventListener(name, handlePress);
+ }
+ return () => {
+ for (const name of PRESS_EVENTS) {
+ el.removeEventListener(name, handlePress);
+ }
+ };
+ }, []);
+ return ref;
+}
+
+// Role colour identities as theme-aware tokens; output has no status token, so it borrows a chart accent.
+const SECTION_ACCENT: Record = {
+ input: 'var(--color-success)',
+ processor: 'var(--color-informative)',
+ output: 'var(--color-chart-1)',
+ resource: 'var(--color-warning)',
+};
+
+export function sectionAccent(section?: string): string | undefined {
+ return section ? SECTION_ACCENT[section] : undefined;
+}
+
+// A tinted title band, not a border, so it doesn't compete with selection/error rings; opaque over the card colour.
+function headerTintStyle(accent?: string): React.CSSProperties | undefined {
+ return accent ? { backgroundColor: `color-mix(in srgb, ${accent} 10%, var(--color-card))` } : undefined;
+}
+
+// The connector logo in a small elevated tile so it sits cleanly on the tinted header band.
+const LogoTile = ({ name }: { name: string }) => (
+
+
+
+);
+
+// Routing semantics as a chip: `if `, `default`, or `on error`. Display-only; edited in the inspector.
+const BranchConditionChip = ({ data, className }: { data: FlowCardData; className?: string }) => {
+ if (!(data.condition || data.isDefault || data.isErrorPath)) {
+ return null;
+ }
+ let text = 'on error';
+ if (data.condition) {
+ text = `if ${data.condition}`;
+ } else if (data.isDefault) {
+ text = 'default';
+ }
+ let tone: 'error' | 'muted' | 'condition' = 'condition';
+ if (data.isErrorPath) {
+ tone = 'error';
+ } else if (data.isDefault) {
+ tone = 'muted';
+ }
+ const cls = cn(
+ 'inline-flex min-w-0 max-w-full items-center rounded border px-1.5 py-0.5 font-medium text-caption-sm leading-none',
+ tone === 'error' && 'border-destructive/40 bg-destructive/5 text-destructive',
+ tone === 'muted' && 'border-warning/30 bg-warning/5 text-warning/80',
+ tone === 'condition' && 'border-warning/40 bg-warning/10 text-warning',
+ className
+ );
+ return (
+
+ {text}
+
+ );
+};
+
+// Routing conditions reuse the `warning` accent, distinct from section accents, so routing is easy to scan.
+const CONDITION_ROW_TONE: Record<'condition' | 'muted' | 'error', string> = {
+ condition: 'border-warning/30 bg-warning/10 text-warning',
+ muted: 'border-warning/20 bg-warning/5 text-warning/80',
+ error: 'border-destructive/20 bg-destructive/10 text-destructive',
+};
+
+// A switch case's condition on its own full-width row. Display-only; inset ring when selected.
+const ConditionRow = ({ data, selected }: { data: FlowCardData; selected?: boolean }) => {
+ let tone: 'condition' | 'muted' | 'error' = 'condition';
+ if (data.isErrorPath) {
+ tone = 'error';
+ } else if (data.isDefault && !data.condition) {
+ tone = 'muted';
+ }
+ let eyebrow = 'on error';
+ if (data.condition) {
+ eyebrow = 'when';
+ } else if (data.isDefault) {
+ eyebrow = 'default';
+ }
+ return (
+
+
+ {eyebrow}
+ {data.condition ? (
+
+ {data.condition}
+
+ ) : null}
+
+ );
+};
+
+export type FlowCardData = {
+ label: string;
+ section?: string;
+ collapsible?: boolean;
+ collapsed?: boolean;
+ childCount?: number;
+ labelText?: string;
+ topics?: string[];
+ meta?: NodeMetaEntry[];
+ missingTopic?: boolean;
+ missingSasl?: boolean;
+ // Routing into this branch (switch/fallback). Shown as a chip on the card.
+ condition?: string;
+ isDefault?: boolean;
+ isErrorPath?: boolean;
+ // A `switch` case wrapper — rendered as a condition-forward "case" card.
+ isCase?: boolean;
+ // Logical parent (parser parentId) for selection/scope; nodes carry no React Flow `parentId`.
+ ownerId?: string;
+ editTarget?: EditTarget;
+ /** Edit target for this node's switch CASE (routing condition), distinct from `editTarget`
+ (the component). Drives the clickable condition chip. */
+ caseEditTarget?: EditTarget;
+ /** Id of the case-wrapper node this entry stands in for (processor switch), so an unsaved
+ condition edit — attributed to the wrapper — marks this card. */
+ caseOwnerId?: string;
+ /** Highlighted because it's the node (component) selected in the inspector. */
+ selected?: boolean;
+ /** The CASE condition (not the component) is selected — highlight just the condition row. */
+ conditionSelected?: boolean;
+ /** Briefly pulse this node (e.g. after an undo/redo touched it). */
+ flash?: boolean;
+ /** Changes on each flash so the pulse animation replays. */
+ flashToken?: number;
+ /** New this render — fades and grows in place rather than sliding from the origin. */
+ appeared?: boolean;
+ /** Lint messages from the server that map to this node's config. */
+ lintErrors?: string[];
+ /** Config differs from the last-saved pipeline — flagged with a warning-toned "unsaved" dot. */
+ unsaved?: boolean;
+ // Injected by the canvas (edit mode only).
+ onToggle?: () => void;
+ onAddConnector?: (section: string) => void;
+ onAddTopic?: (section: string, componentName: string) => void;
+ onAddSasl?: (section: string, componentName: string) => void;
+ /** A fan construct's in-card add affordance ("Add case" / "Add input"): payload + label. */
+ addAction?: { payload: FlowInsertPayload; label: string };
+ /** Invoke the add affordance (wired by the canvas in edit mode). */
+ onAddChild?: () => void;
+};
+
+const HANDLE_IDS = [
+ { id: 'l', position: Position.Left, type: 'target' },
+ { id: 't', position: Position.Top, type: 'target' },
+ { id: 'r', position: Position.Right, type: 'source' },
+ { id: 'b', position: Position.Bottom, type: 'source' },
+] as const;
+
+const NodeHandles = () => (
+ <>
+ {HANDLE_IDS.map((h) => {
+ const horizontal = h.id === 'l' || h.id === 'r';
+ // l/r handles keep RF's default centering so a same-rank spine reads straight; t/b pin to a
+ // fixed left offset so stacked cards of differing widths connect on a straight line.
+ const style = horizontal ? undefined : { left: FLOW_SPINE_HANDLE_LEFT, transform: 'none' };
+ return (
+
+ );
+ })}
+ >
+);
+
+// Topics as scannable chips, capped so a long list doesn't blow out the card.
+const TOPIC_CHIP_LIMIT = 4;
+const TopicChips = ({ topics }: { topics?: string[] }) => {
+ if (!topics?.length) {
+ return null;
+ }
+ const shown = topics.slice(0, TOPIC_CHIP_LIMIT);
+ const extra = topics.length - shown.length;
+ return (
+
+ {pluralize(topics.length, 'topic')}
+
+ {shown.map((topic) => (
+
+ {topic}
+
+ ))}
+ {extra > 0 ? +{extra} : null}
+
+
+ );
+};
+
+function cardHasMeta(data: FlowCardData): boolean {
+ return Boolean(data.meta?.length || data.topics?.length || data.missingTopic || data.missingSasl);
+}
+
+const MetaRows = ({ data }: { data: FlowCardData }) => {
+ if (!cardHasMeta(data)) {
+ return null;
+ }
+ return (
+
+
+ {data.meta?.map((entry) => (
+
+ {entry.label}
+
+ {entry.value}
+
+
+ ))}
+ {data.missingTopic || data.missingSasl ? (
+
+ {data.missingTopic ? (
+ data.onAddTopic?.(data.section ?? '', data.label) : undefined}
+ />
+ ) : null}
+ {data.missingSasl ? (
+ data.onAddSasl?.(data.section ?? '', data.label) : undefined}
+ />
+ ) : null}
+
+ ) : null}
+
+ );
+};
+
+const MissingChip = ({
+ addLabel,
+ missingLabel,
+ onAdd,
+}: {
+ addLabel: string;
+ missingLabel: string;
+ onAdd?: () => void;
+}) =>
+ onAdd ? (
+ }
+ onClick={(e) => {
+ // Adding the missing piece shouldn't also select the node.
+ e.stopPropagation();
+ onAdd();
+ }}
+ size="xs"
+ variant="secondary"
+ >
+ {addLabel}
+
+ ) : (
+
+ {missingLabel}
+
+ );
+
+const PlaceholderCard = ({ data }: { data: FlowCardData }) => {
+ const section = data.section ?? 'connector';
+ const onAdd = data.onAddConnector;
+ // Read-only (no handler): plain text, not a disabled button that reads as broken permissions.
+ if (!onAdd) {
+ return (
+
+
+ No {section} configured
+
+
+ );
+ }
+ return (
+ onAdd(data.section ?? '')}
+ type="button"
+ >
+
+
+
+
+ Add {section}
+
+
+ );
+};
+
+// The selection ring on the node the inspector is editing.
+const SELECTED_RING = 'ring-2 ring-primary ring-offset-1 ring-offset-background';
+// An error ring for nodes with lint problems (takes precedence over selection).
+const ERROR_RING = 'ring-2 ring-destructive ring-offset-1 ring-offset-background';
+
+function cardRing(data: FlowCardData): string {
+ return data.lintErrors?.length ? ERROR_RING : data.selected ? SELECTED_RING : '';
+}
+
+// A brief double-pulse ring after an undo/redo touched a node — the `brand` token, distinct from
+// selection (primary) and errors (destructive). Keyed by `token` so re-flashing replays it.
+const FlashPulse = ({ token }: { token?: number }) => (
+
+);
+
+// Lint-problem count on a node; the messages are the native tooltip.
+const LintBadge = ({ errors }: { errors?: string[] }) =>
+ errors && errors.length > 0 ? (
+
+
+ {errors.length}
+
+ ) : null;
+
+// A warning-toned dot marking a node whose config differs from the last-saved pipeline.
+const UnsavedDot = ({ show }: { show?: boolean }) =>
+ show ? (
+
+ Unsaved changes
+
+ ) : null;
+
+// The component's `label:` — shown on every node (leaf, container) when set.
+const LabelBadge = ({ label, className }: { label?: string; className?: string }) =>
+ label ? (
+
+ {label}
+
+ ) : null;
+
+// A leaf component card. The whole card is the click target; editing happens in the inspector rail.
+const ComponentCard = ({ data }: { data: FlowCardData }) => {
+ const kindLabel = sectionLabel(data.section);
+ const accent = SECTION_ACCENT[data.section ?? ''];
+ return (
+
+ {/* Routing condition at the TOP — the editable condition is the first, most obvious click target. */}
+ {data.caseEditTarget ?
: null}
+
+
+
+ {kindLabel}
+
+ {/* Non-case condition info stays an inline chip; a switch case's condition uses the row above. */}
+ {data.caseEditTarget ? null : }
+
+
+
+
+
+
+ {data.label}
+
+
+
+ {data.labelText ? (
+ // When no meta rows follow, the label badge is the last row — bottom inset so it isn't flush.
+
+
+
+ ) : null}
+
+
+ );
+};
+
+const FlowCardNode = ({ data }: { data: FlowCardData }) => {
+ const isPlaceholder = data.label === 'none';
+ const ref = useStopPanOnControls();
+ const card = isPlaceholder ? : ;
+
+ return (
+
+
+ {card}
+ {data.flash ? : null}
+
+ );
+};
+
+type FlowInsertData = {
+ label?: string;
+ payload?: FlowInsertPayload;
+ /** A "ghost branch" add (e.g. Add case) reached by a dashed connector — styled as a pill. */
+ ghost?: boolean;
+ // Injected by the canvas (edit mode only). Absent in read-only mode → not rendered.
+ onInsert?: (payload: FlowInsertPayload) => void;
+};
+
+// An "add" affordance. As a ghost branch it's a dashed pill reached by a faint connector; inline
+// it's a plain "+". Edit mode only; the l/r handles let the ghost connector edge attach.
+const FlowInsertNode = ({ data }: { data: FlowInsertData }) => {
+ if (!(data.onInsert && data.payload)) {
+ return null;
+ }
+ const payload = data.payload;
+ const handlePin = { top: 12, transform: 'none' } as const;
+ return (
+ <>
+
+
+ data.onInsert?.(payload)}
+ type="button"
+ >
+
+ {data.label}
+
+ >
+ );
+};
+
+type LinkTone = 'primary' | 'muted' | 'error';
+// Edge LABELS also support the warning-toned routing-condition tone (the line itself stays primary).
+type LinkLabelTone = LinkTone | 'condition';
+type FlowLinkData = {
+ label?: string;
+ /** Selection context: unrelated edges fade, connected ones render full strength. */
+ dimmed?: boolean;
+};
+
+const HIGHLIGHT_STROKE = 'var(--color-primary)';
+
+const LINK_STROKE: Record = {
+ primary: 'var(--color-primary)',
+ muted: 'var(--color-border)',
+ error: 'var(--color-destructive)',
+};
+
+// Tone-matched labels — solid pill, tinted border, text in the edge's colour — read clearly over region fills.
+const LINK_LABEL_STYLE: Record = {
+ primary: 'border-primary/40 text-primary',
+ error: 'border-destructive/40 text-destructive',
+ muted: 'border-border text-foreground',
+ // Routing conditions read as the warning accent, matching the legend and the cards' condition chips.
+ condition: 'border-warning/40 text-warning',
+};
+// Edge labels render into RF's shared layer below the nodes; lift the pill above the cards.
+const LINK_LABEL_Z = 1000;
+const LinkLabel = ({
+ d,
+ tone,
+ x,
+ y,
+ onClick,
+}: {
+ d: FlowLinkData;
+ tone: LinkLabelTone;
+ x: number;
+ y: number;
+ onClick?: () => void;
+}) => {
+ const className = cn(
+ // Condition labels are Bloblang code: mono, not uppercased, matching the card's condition chips.
+ 'nodrag nopan absolute max-w-[200px] truncate rounded border bg-background px-1.5 py-0.5 font-medium font-mono text-caption-sm shadow-sm',
+ LINK_LABEL_STYLE[tone],
+ // A condition label is clickable to edit its case — make that obvious.
+ onClick && 'pointer-events-auto cursor-pointer transition-colors hover:bg-muted'
+ );
+ const style = {
+ transform: `translate(-50%, -50%) translate(${x}px, ${y}px)`,
+ opacity: d.dimmed ? 0.25 : 1,
+ zIndex: LINK_LABEL_Z,
+ } as const;
+ return (
+
+ {onClick ? (
+
+ {d.label}
+
+ ) : (
+
+ {d.label}
+
+ )}
+
+ );
+};
+
+// Control-flow processors have no logo or config meta; each gets a glyph + one-line descriptor
+// (count of cases/steps, or routing role) so it reads as a router, not an empty card.
+type ControlFlowPresentation = { Icon: LucideIcon; descriptor: string };
+
+function plural(n: number, noun: string): string {
+ return pluralizeWithNumber(n, noun, noun.endsWith('h') ? 'es' : 's');
+}
+
+// Per-construct glyph + descriptor recipe: `noun` is counted (e.g. "3 cases"), `prefix` precedes
+// the count, `zero` is the fallback with no count (or no `noun`).
+type ControlFlowSpec = { icon: LucideIcon; noun?: string; prefix?: string; zero: string };
+const CONTROL_FLOW_SPECS: Record = {
+ switch: { icon: Split, noun: 'case', zero: 'router' },
+ branch: { icon: GitBranch, noun: 'step', prefix: 'enrich · ', zero: 'enrich' },
+ try: { icon: ShieldCheck, noun: 'step', zero: 'guarded' },
+ catch: { icon: ShieldAlert, zero: 'on error' },
+ for_each: { icon: Repeat, noun: 'step', prefix: 'per item · ', zero: 'per item' },
+ while: { icon: RotateCw, noun: 'step', prefix: 'loop · ', zero: 'loop' },
+ retry: { icon: RotateCw, noun: 'step', prefix: 'retry · ', zero: 'retry' },
+ group_by: { icon: Layers, noun: 'step', zero: 'grouped' },
+ parallel: { icon: Rows3, noun: 'branch', zero: 'parallel' },
+ workflow: { icon: Workflow, noun: 'stage', zero: 'workflow' },
+ fallback: { icon: Shuffle, noun: 'tier', zero: 'fallback' },
+};
+
+function controlFlowPresentation(data: FlowCardData): ControlFlowPresentation {
+ const count = data.childCount ?? 0;
+ // A broker/sequence counts its sinks or sources, per section.
+ if (data.label === 'broker' || data.label === 'sequence') {
+ const noun = data.section === 'output' ? 'output' : 'input';
+ return { Icon: Network, descriptor: count ? plural(count, noun) : 'broker' };
+ }
+ const spec = CONTROL_FLOW_SPECS[data.label];
+ if (!spec) {
+ return { Icon: GitFork, descriptor: count ? plural(count, 'step') : 'routes' };
+ }
+ if (!(spec.noun && count)) {
+ return { Icon: spec.icon, descriptor: spec.zero };
+ }
+ return { Icon: spec.icon, descriptor: `${spec.prefix ?? ''}${plural(count, spec.noun)}` };
+}
+
+// Filled accent tile — distinct from the white `LogoTile` so a router marker never reads as a data card.
+const ControlFlowIconTile = ({ Icon, accent }: { Icon: LucideIcon; accent: string }) => (
+
+
+
+);
+
+// A control-flow processor as a compact card. It never wraps children — in the Dagre graph its
+// branches fan out as labelled edges and reconverge at a merge node. This card is the fan-out marker.
+const FlowSplitNode = ({ data }: { data: FlowCardData }) => {
+ const ref = useStopPanOnControls();
+ const isError = Boolean(data.isErrorPath);
+ const accent = isError ? 'var(--color-destructive)' : (SECTION_ACCENT[data.section ?? ''] ?? 'var(--color-primary)');
+ const { Icon, descriptor } = controlFlowPresentation(data);
+ return (
+
+
+ {data.flash ?
: null}
+
+ {/* Routing condition at the TOP, as on leaf cards. */}
+ {data.caseEditTarget ?
: null}
+
+
+
+
+ {descriptor}
+
+
+ {data.label}
+
+
+ {data.labelText ? : null}
+
+
+
+ {/* "Add case / Add input" footer row. Edit mode only. */}
+ {data.addAction && data.onAddChild ? (
+
{
+ e.stopPropagation();
+ data.onAddChild?.();
+ }}
+ type="button"
+ >
+
+ {data.addAction.label}
+
+ ) : null}
+
+
+ );
+};
+
+// The join point where a fan's branches reconverge; a filled primary-tint disc so it reads as a deliberate "join".
+const FlowMergeNode = () => {
+ const handle = { top: 16, transform: 'none' } as const;
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+// A "+" button at an edge's midpoint (edit mode) for inserting a step there.
+const EdgeInsertButton = ({
+ x,
+ y,
+ onInsert,
+ zIndex,
+}: {
+ x: number;
+ y: number;
+ onInsert: () => void;
+ zIndex?: number;
+}) => (
+
+
+
+
+
+);
+
+type FlowGraphEdgeData = {
+ tone?: LinkTone;
+ dashed?: boolean;
+ label?: string;
+ points?: { x: number; y: number }[];
+ insertIndex?: number;
+ dimmed?: boolean;
+ emphasized?: boolean;
+ faint?: boolean;
+ onInsert?: () => void;
+ /** Click the (condition) label to select+edit its case. */
+ onLabelClick?: () => void;
+ /** Where along the edge (0–1) the label sits; defaults to the midpoint. */
+ labelT?: number;
+ /** A faint, decorative "ghost" edge — drawn lighter. */
+ ghost?: boolean;
+};
+
+// The point at fraction `t` (0–1) of the polyline's arc length — so an edge label / "+" sits ON
+// the line (a raw middle waypoint can sit well off a curved edge). t=0.5 = midpoint.
+function polylinePointAt(points: { x: number; y: number }[], t = 0.5): { x: number; y: number } {
+ if (points.length === 0) {
+ return { x: 0, y: 0 };
+ }
+ const segLen = points.slice(1).map((p, i) => Math.hypot(p.x - points[i].x, p.y - points[i].y));
+ const total = segLen.reduce((a, b) => a + b, 0);
+ let dist = total * Math.min(Math.max(t, 0), 1);
+ for (let i = 0; i < segLen.length; i += 1) {
+ if (dist <= segLen[i]) {
+ const f = segLen[i] === 0 ? 0 : dist / segLen[i];
+ return {
+ x: points[i].x + (points[i + 1].x - points[i].x) * f,
+ y: points[i].y + (points[i + 1].y - points[i].y) * f,
+ };
+ }
+ dist -= segLen[i];
+ }
+ return points.at(-1) as { x: number; y: number };
+}
+
+// Smooth a polyline through Dagre's node-avoiding waypoints (quadratic segments via midpoints).
+function smoothGraphPath(points: { x: number; y: number }[]): string {
+ if (points.length < 2) {
+ return '';
+ }
+ if (points.length === 2) {
+ return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`;
+ }
+ let d = `M ${points[0].x} ${points[0].y}`;
+ for (let i = 1; i < points.length - 1; i += 1) {
+ const mid = { x: (points[i].x + points[i + 1].x) / 2, y: (points[i].y + points[i + 1].y) / 2 };
+ d += ` Q ${points[i].x} ${points[i].y} ${mid.x} ${mid.y}`;
+ }
+ const last = points.at(-1) as { x: number; y: number };
+ return `${d} L ${last.x} ${last.y}`;
+}
+
+// An emphasized edge takes the highlight stroke — except error edges, which keep their own tone.
+function edgeStroke(d: FlowGraphEdgeData | undefined, tone: LinkTone): string {
+ if (d?.emphasized && tone !== 'error') {
+ return HIGHLIGHT_STROKE;
+ }
+ if (d?.faint) {
+ return 'var(--color-muted-foreground)';
+ }
+ return LINK_STROKE[tone];
+}
+
+// Opacity tiers: dimmed (unrelated to selection) < ghost (decorative) < faint (context line) < full strength.
+function edgeOpacity(d: FlowGraphEdgeData | undefined): number {
+ return d?.dimmed ? 0.25 : d?.ghost ? 0.4 : d?.faint ? 0.6 : 1;
+}
+
+// Every edge in the Dagre DAG, routed through Dagre's waypoints so lines avoid nodes. No
+// arrowheads — the left-to-right layout already reads as direction.
+function FlowGraphEdge({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, data }: EdgeProps) {
+ const d = data as FlowGraphEdgeData | undefined;
+ let path: string;
+ // `label*` is where a condition label sits (often near its target); `insert*` is the on-edge "+" (midpoint).
+ let labelX: number;
+ let labelY: number;
+ let insertX: number;
+ let insertY: number;
+ const dagrePts = d?.points;
+ if (dagrePts && dagrePts.length >= 2) {
+ // Cap Dagre's routed waypoints with the real handle endpoints for a clean attach.
+ const pts = [{ x: sourceX, y: sourceY }, ...dagrePts.slice(1, -1), { x: targetX, y: targetY }];
+ path = smoothGraphPath(pts);
+ const labelPos = polylinePointAt(pts, d?.labelT ?? 0.5);
+ const insertPos = polylinePointAt(pts, 0.5);
+ labelX = labelPos.x;
+ labelY = labelPos.y;
+ insertX = insertPos.x;
+ insertY = insertPos.y;
+ } else {
+ const [p, lx, ly] = getSmoothStepPath({
+ sourceX,
+ sourceY,
+ sourcePosition,
+ targetX,
+ targetY,
+ targetPosition,
+ borderRadius: 10,
+ });
+ path = p;
+ labelX = lx;
+ labelY = ly;
+ insertX = lx;
+ insertY = ly;
+ }
+ const tone: LinkTone = d?.tone ?? 'muted';
+ const width = d?.emphasized ? 4 : 3;
+ return (
+ <>
+
+ {d?.label ? (
+
+ ) : null}
+ {d?.onInsert ? : null}
+ >
+ );
+}
+
+export const flowNodeTypes = {
+ flowCard: FlowCardNode,
+ flowInsert: FlowInsertNode,
+ flowSplit: FlowSplitNode,
+ flowMerge: FlowMergeNode,
+};
+
+export const flowEdgeTypes = {
+ flowGraphEdge: FlowGraphEdge,
+};
diff --git a/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-canvas.render.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-canvas.render.test.tsx
new file mode 100644
index 0000000000..6bbd95191a
--- /dev/null
+++ b/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-canvas.render.test.tsx
@@ -0,0 +1,192 @@
+/**
+ * Copyright 2026 Redpanda Data, Inc.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
+ *
+ * As of the Change Date specified in that file, in accordance with
+ * the Business Source License, use of this software will be governed
+ * by the Apache License, Version 2.0
+ */
+
+import { render, screen } from 'test-utils';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+
+import { MAX_REGION_PIECE_PX, PipelineFlowCanvas } from './pipeline-flow-canvas';
+
+// The merge/join node's tooltip title; matched loosely so wording tweaks don't break the test.
+const JOIN_TITLE_RE = /branches reconverge/i;
+
+// React Flow needs ResizeObserver + measurable container dimensions to mount nodes in jsdom.
+class ResizeObserverMock {
+ observe() {}
+ disconnect() {}
+ unobserve() {}
+}
+
+let originalClientWidth: PropertyDescriptor | undefined;
+let originalClientHeight: PropertyDescriptor | undefined;
+
+beforeAll(() => {
+ vi.stubGlobal('ResizeObserver', ResizeObserverMock);
+ originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth');
+ originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight');
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 1200 });
+ Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, value: 800 });
+});
+
+afterAll(() => {
+ vi.unstubAllGlobals();
+ if (originalClientWidth) {
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth);
+ }
+ if (originalClientHeight) {
+ Object.defineProperty(HTMLElement.prototype, 'clientHeight', originalClientHeight);
+ }
+});
+
+// Exercises every control-flow construct: broker (fan-in), branch, switch (with a default and an
+// errored() error lane), and try/catch.
+const CONTROL_FLOW_YAML = `input:
+ broker:
+ inputs:
+ - kafka_franz: { topics: [in_a] }
+ - kafka_franz: { topics: [in_b] }
+pipeline:
+ processors:
+ - branch:
+ request_map: 'root = this.user'
+ processors:
+ - http: { url: http://enrich }
+ result_map: 'root.enriched = this'
+ - switch:
+ - check: this.region == "us"
+ processors: [{ mapping: 'root = this' }]
+ - check: errored()
+ processors: [{ log: { message: dropped } }]
+ - processors: [{ mapping: 'root.region = "other"' }]
+ - try:
+ - mapping: 'root = this'
+ - catch:
+ - log: { message: oops }
+output:
+ drop: {}`;
+
+// Valid pipeline with no output — the canvas renders an output placeholder card.
+const NO_OUTPUT_YAML = `input:
+ generate:
+ mapping: 'root = {}'`;
+const ADD_OUTPUT_RE = /add output/i;
+const YAML_TAB_HINT_RE = /fix the YAML in the YAML tab/i;
+const NO_OUTPUT_NOTE_RE = /no output configured/i;
+const STALE_LAYOUT_RE = /showing the last valid layout/i;
+
+// A complete valid pipeline used as the last-good baseline for the resilient-parse test.
+const VALID_PIPELINE_YAML = `input:
+ generate:
+ mapping: 'root = {}'
+output:
+ drop: {}`;
+
+describe('PipelineFlowCanvas — placeholder cards', () => {
+ it('renders a non-interactive "No output configured" note in read-only mode', async () => {
+ // No edit callbacks → viewer mode; a disabled "Add output" would read as broken permissions.
+ render( );
+ expect(await screen.findByText('No output configured')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: ADD_OUTPUT_RE })).not.toBeInTheDocument();
+ });
+
+ it('renders an enabled "Add output" button in edit mode', async () => {
+ render( );
+ expect(await screen.findByRole('button', { name: ADD_OUTPUT_RE })).toBeEnabled();
+ });
+});
+
+describe('PipelineFlowCanvas — invalid YAML from the start', () => {
+ it('shows a persistent (non-dismissible) banner pointing at the YAML tab', async () => {
+ // Unparseable from the first render: no last-good graph to fall back to, so the banner must
+ // stay up (no close button).
+ const { container } = render( );
+ expect(await screen.findByText(YAML_TAB_HINT_RE)).toBeInTheDocument();
+ expect(container.querySelectorAll('button')).toHaveLength(0);
+ });
+});
+
+describe('PipelineFlowCanvas — resilient parse', () => {
+ it('holds the last valid layout (flagged stale) when an edit leaves valid YAML that no longer describes a pipeline', async () => {
+ const { rerender } = render( );
+ // Baseline: a real pipeline renders (input generate → drop output), not the empty placeholders.
+ expect(await screen.findByText('generate')).toBeInTheDocument();
+ expect(screen.queryByText(NO_OUTPUT_NOTE_RE)).not.toBeInTheDocument();
+
+ // One bad edit: still valid YAML, but the input/output/pipeline sections are gone. The canvas
+ // must NOT collapse to `input: none` placeholders — it holds the last valid layout and flags it.
+ rerender( );
+ expect(await screen.findByText(STALE_LAYOUT_RE)).toBeInTheDocument();
+ expect(screen.getByText('generate')).toBeInTheDocument();
+ expect(screen.queryByText(NO_OUTPUT_NOTE_RE)).not.toBeInTheDocument();
+ });
+
+ it('does not flag a valid but still-incomplete pipeline as stale while authoring from blank', async () => {
+ const { rerender } = render( );
+ // `input:` is valid YAML, just not yet a pipeline — a blank start has no real layout to fall back
+ // to, so this must render as the empty state, NOT a false "last valid layout" banner.
+ rerender( );
+ expect(await screen.findByText(NO_OUTPUT_NOTE_RE)).toBeInTheDocument();
+ expect(screen.queryByText(STALE_LAYOUT_RE)).not.toBeInTheDocument();
+ });
+});
+
+describe('PipelineFlowCanvas — control-flow render', () => {
+ it('renders the Dagre split → merge DAG without crashing', async () => {
+ render( );
+
+ // A processor inside the branch body renders as a node.
+ expect(await screen.findByText('http')).toBeInTheDocument();
+
+ // Control-flow processors render as compact split markers (not growing containers).
+ expect(screen.getAllByText('switch').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('branch').length).toBeGreaterThan(0);
+ // The construct name can also appear as a faint scope-region label, so allow more than one match.
+ expect(screen.getAllByText('catch').length).toBeGreaterThan(0);
+
+ // Each marker carries a descriptor of what it encloses: the switch counts its 3 cases; catch
+ // labels its error role.
+ expect(screen.getByText('3 cases')).toBeInTheDocument();
+ expect(screen.getByText('on error')).toBeInTheDocument();
+
+ // Branches reconverge at explicit join nodes (branch + switch + try/catch).
+ expect(screen.getAllByTitle(JOIN_TITLE_RE).length).toBeGreaterThan(0);
+ // Edge labels (the case conditions) render via EdgeLabelRenderer, which jsdom doesn't lay out;
+ // asserted in the parser unit test instead.
+ });
+
+ // A switch fanning out to many cases makes its scope region very tall; a single fill div that tall
+ // exceeds the browser's max paintable size and silently stops painting. Fix: stacked, size-capped
+ // pieces so the background always renders.
+ it('caps every scope-region fill piece so a tall area still renders its background', async () => {
+ const cases = Array.from(
+ { length: 60 },
+ (_, i) => ` - check: this.x == ${i}\n processors:\n - mapping: 'root.y = ${i}'`
+ ).join('\n');
+ const yaml = `input:\n generate:\n mapping: 'root = {}'\npipeline:\n processors:\n - switch:\n${cases}\noutput:\n drop: {}`;
+
+ const { container } = render( );
+ // The switch marker also surfaces as a faint scope-region label, so there are several matches.
+ await screen.findAllByText('switch');
+
+ // Fill pieces render into React Flow's viewport portal, tagged data-region-fill (the border is a
+ // single SVG path, excluded here).
+ const portal = container.querySelector('.react-flow__viewport-portal');
+ const pieces = portal ? Array.from(portal.querySelectorAll('[data-region-fill]')) : [];
+ const heights = pieces.map((el) => Number.parseFloat((el as HTMLElement).style.height));
+
+ expect(pieces.length).toBeGreaterThan(0);
+ // No piece exceeds the cap — this is what keeps the fill paintable.
+ for (const h of heights) {
+ expect(h).toBeLessThanOrEqual(MAX_REGION_PIECE_PX);
+ }
+ // Split into several stacked pieces — proof the capping engaged (a single giant div would be one).
+ expect(pieces.length).toBeGreaterThan(3);
+ });
+});
diff --git a/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-canvas.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-canvas.test.tsx
new file mode 100644
index 0000000000..bd93f21e7d
--- /dev/null
+++ b/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-canvas.test.tsx
@@ -0,0 +1,252 @@
+/**
+ * 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 { Edge, Node } from '@xyflow/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+ decorateEdges,
+ injectNodeData,
+ regionGeometry,
+ scopeBounds,
+ selectionTargetForNode,
+} from './pipeline-flow-canvas';
+
+const edges: Edge[] = [
+ { id: 'spine-a-b', source: 'a', target: 'b', type: 'flowGraphEdge', data: { insertIndex: 1 } },
+ { id: 'fanout-c', source: 'b', target: 'c', type: 'flowGraphEdge', data: { tone: 'primary' } },
+ { id: 'ref-a-resource-0', source: 'a', target: 'resource-0', type: 'flowGraphEdge', data: { tone: 'muted' } },
+];
+
+const refData = (decorated: Edge[]) =>
+ decorated.find((e) => e.id.startsWith('ref-'))?.data as { dimmed?: boolean; emphasized?: boolean; faint?: boolean };
+
+const scope = (...ids: string[]) => new Set(ids);
+
+describe('scopeBounds — body bounds plus the entry marker', () => {
+ const node = (id: string, x: number, y: number, type = 'flowCard'): Node => ({
+ id,
+ position: { x, y },
+ initialWidth: 200,
+ initialHeight: 80,
+ data: {},
+ type,
+ });
+ const noMeasure = new Map();
+
+ it('keeps everything but the entry marker in the body (the join stays inside)', () => {
+ // Marker (col 0, the entry) fans to two outputs (col 1) and reconverges at a join (col 2). Only
+ // the marker is the entry; the outputs AND the join are body, so the box covers them all.
+ const marker = node('marker', 0, 300, 'flowSplit');
+ const out1 = node('out1', 500, 0);
+ const out2 = node('out2', 500, 600);
+ const join = node('join', 1000, 300, 'flowMerge');
+ const geom = scopeBounds(
+ [marker, out1, out2, join],
+ scope('marker', 'out1', 'out2', 'join'),
+ noMeasure,
+ (n) => n.id === 'marker'
+ );
+ expect(geom?.body).toEqual({ minX: 500, minY: 0, maxX: 1200, maxY: 680 }); // outputs + join
+ expect(geom?.entry).toEqual({ minX: 0, minY: 300, maxX: 200, maxY: 380 }); // just the entry marker
+ });
+
+ it('has a null entry when nothing is flagged', () => {
+ const a = node('a', 0, 0);
+ const b = node('b', 500, 300);
+ const geom = scopeBounds([a, b], scope('a', 'b'), noMeasure);
+ expect(geom?.body).toEqual({ minX: 0, minY: 0, maxX: 700, maxY: 380 });
+ expect(geom?.entry).toBeNull();
+ });
+
+ it('ignores nodes outside the scope; returns null when the scope is only the entry', () => {
+ const marker = node('marker', 0, 300, 'flowSplit');
+ const outsider = node('outsider', 5000, 5000); // NOT in scope — must not stretch the bounds
+ expect(scopeBounds([marker, outsider], scope('marker'), noMeasure)?.body).toEqual({
+ minX: 0,
+ minY: 300,
+ maxX: 200,
+ maxY: 380,
+ });
+ // Only the entry marker is in scope → no body → null.
+ expect(scopeBounds([marker, outsider], scope('marker'), noMeasure, (n) => n.id === 'marker')).toBeNull();
+ expect(scopeBounds([marker, outsider], scope('nope'), noMeasure)).toBeNull();
+ });
+});
+
+describe('regionGeometry — the arm reaches the entry marker', () => {
+ const body = { minX: 500, minY: 300, maxX: 700, maxY: 480 };
+
+ it('wraps a same-row marker with a thin arm clamped to the body height', () => {
+ const marker = { minX: 0, minY: 340, maxX: 200, maxY: 420 }; // within the body's rows
+ const { rects } = regionGeometry(body, marker, 6, 6);
+ expect(rects).toHaveLength(2); // padded body + one arm
+ const arm = rects[1];
+ expect(arm.maxX).toBe(body.minX - 6); // arm abuts the padded body's left edge
+ expect(arm.minY).toBeGreaterThanOrEqual(body.minY - 6);
+ expect(arm.maxY).toBeLessThanOrEqual(body.maxY + 6);
+ });
+
+ it('stretches the arm up to meet the body when the marker sits clear above it (try/catch)', () => {
+ const marker = { minX: 0, minY: 100, maxX: 200, maxY: 180 }; // fully above the body
+ const { rects } = regionGeometry(body, marker, 6, 6);
+ const arm = rects[1];
+ // The arm covers the marker's row AND reaches down to the padded body top, so they connect.
+ expect(arm.minY).toBe(marker.minY - 6);
+ expect(arm.maxY).toBe(body.minY - 6);
+ });
+});
+
+describe('decorateEdges', () => {
+ it('keeps resource-reference edges faint until an endpoint is selected or hovered', () => {
+ // Reference edges never fully dim: even with an unrelated node selected they stay faint
+ // (a visible hint) rather than vanishing like dimmed flow edges.
+ expect(refData(decorateEdges(edges, {}))).toMatchObject({ faint: true });
+ expect(refData(decorateEdges(edges, { selectedScope: scope('b') }))).toMatchObject({ faint: true });
+
+ // Selecting (or hovering) either endpoint renders the edge full-strength.
+ expect(refData(decorateEdges(edges, { selectedScope: scope('a') }))).toMatchObject({
+ faint: false,
+ emphasized: true,
+ });
+ expect(refData(decorateEdges(edges, { hoveredScope: scope('a') }))).toMatchObject({
+ faint: false,
+ emphasized: true,
+ });
+
+ // Selecting the resource itself reveals who uses it.
+ expect(refData(decorateEdges(edges, { selectedScope: scope('resource-0') }))).toMatchObject({ emphasized: true });
+ });
+
+ it('hover alone does not dim unrelated edges (only selection does)', () => {
+ const hovered = decorateEdges(edges, { hoveredScope: scope('a') });
+ expect((hovered.find((e) => e.id === 'fanout-c')?.data as { dimmed?: boolean }).dimmed).toBeUndefined();
+ });
+
+ it('dims unrelated edges and emphasizes connected ones while a node is selected', () => {
+ const decorated = decorateEdges(edges, { selectedScope: scope('a') });
+ const spine = decorated.find((e) => e.id === 'spine-a-b')?.data as { dimmed?: boolean; emphasized?: boolean };
+ const fanout = decorated.find((e) => e.id === 'fanout-c')?.data as { dimmed?: boolean; emphasized?: boolean };
+ expect(spine.emphasized).toBe(true);
+ expect(spine.dimmed).toBe(false);
+ expect(fanout.dimmed).toBe(true);
+
+ // No selection → no dimming anywhere.
+ const plain = decorateEdges(edges, {});
+ expect((plain.find((e) => e.id === 'fanout-c')?.data as { dimmed?: boolean }).dimmed).toBeUndefined();
+ });
+
+ it("keeps a selected container's internal wiring lit (scope includes descendants)", () => {
+ // Selecting container 'a' (subtree includes 'b','c'): the fan edge between its children must
+ // stay lit, not dim as "unrelated".
+ const decorated = decorateEdges(edges, { selectedScope: scope('a', 'b', 'c') });
+ const fanout = decorated.find((e) => e.id === 'fanout-c')?.data as { dimmed?: boolean; emphasized?: boolean };
+ expect(fanout.emphasized).toBe(true);
+ expect(fanout.dimmed).toBe(false);
+ });
+
+ it('wires the insert handler onto spine edges with their processor index', () => {
+ const onInsert = vi.fn();
+ const decorated = decorateEdges(edges, { onInsert });
+ const spineData = decorated.find((e) => e.type === 'flowGraphEdge')?.data as { onInsert?: () => void };
+ spineData.onInsert?.();
+ expect(onInsert).toHaveBeenCalledWith(1);
+ });
+});
+
+describe('selectionTargetForNode', () => {
+ const switchNode: Node = {
+ id: 'proc-0',
+ position: { x: 0, y: 0 },
+ data: { label: 'switch', editTarget: { kind: 'processor', index: 0 } },
+ };
+ const caseNode: Node = {
+ id: 'proc-0-case-1',
+ parentId: 'proc-0',
+ position: { x: 0, y: 0 },
+ data: { label: 'case 1', isCase: true }, // no editTarget
+ };
+
+ it('selects an editable node directly', () => {
+ expect(selectionTargetForNode(switchNode, [switchNode, caseNode])).toEqual({
+ id: 'proc-0',
+ target: { kind: 'processor', index: 0 },
+ });
+ });
+
+ it('walks up from a structural node (no editTarget) to select its parent', () => {
+ expect(selectionTargetForNode(caseNode, [switchNode, caseNode])).toEqual({
+ id: 'proc-0',
+ target: { kind: 'processor', index: 0 },
+ });
+ });
+
+ it('selects a switch case directly when it carries a switchCase editTarget', () => {
+ const editableCase: Node = {
+ id: 'proc-0-case-1',
+ parentId: 'proc-0',
+ position: { x: 0, y: 0 },
+ data: {
+ label: 'case 1',
+ isCase: true,
+ editTarget: { kind: 'switchCase', path: ['pipeline', 'processors', 0, 'switch', 0] },
+ },
+ };
+ expect(selectionTargetForNode(editableCase, [switchNode, editableCase])).toEqual({
+ id: 'proc-0-case-1',
+ target: { kind: 'switchCase', path: ['pipeline', 'processors', 0, 'switch', 0] },
+ });
+ });
+
+ it('returns null when no ancestor is selectable', () => {
+ const orphan: Node = { id: 'x', position: { x: 0, y: 0 }, data: { label: 'x' } };
+ expect(selectionTargetForNode(orphan, [orphan])).toBeNull();
+ });
+});
+
+describe('injectNodeData — appearance', () => {
+ const node: Node = {
+ id: 'child-1',
+ position: { x: 0, y: 0 },
+ data: { label: 'http' },
+ style: { transition: 'transform 200ms ease' },
+ };
+ const base = { collapsedIds: new Set(), toggleCollapse: () => undefined };
+
+ it('marks a node new this render as appeared and drops its reposition transition', () => {
+ // Empty previousIds → the node is appearing (e.g. revealed by expanding).
+ const injected = injectNodeData(node, { ...base, previousIds: new Set() });
+ expect((injected.data as { appeared?: boolean }).appeared).toBe(true);
+ // No transform transition, so it snaps to its spot instead of flying from origin.
+ expect((injected.style as { transition?: string }).transition).toBeUndefined();
+ });
+
+ it('leaves an existing node untouched (smooth reposition transition kept)', () => {
+ const injected = injectNodeData(node, { ...base, previousIds: new Set(['child-1']) });
+ expect((injected.data as { appeared?: boolean }).appeared).toBeUndefined();
+ expect((injected.style as { transition?: string }).transition).toBe('transform 200ms ease');
+ });
+
+ it('flags a node as unsaved when its id is in unsavedNodeIds', () => {
+ const unsaved = injectNodeData(node, {
+ ...base,
+ previousIds: new Set(['child-1']),
+ unsavedNodeIds: new Set(['child-1']),
+ });
+ expect((unsaved.data as { unsaved?: boolean }).unsaved).toBe(true);
+ const clean = injectNodeData(node, {
+ ...base,
+ previousIds: new Set(['child-1']),
+ unsavedNodeIds: new Set(['other']),
+ });
+ expect((clean.data as { unsaved?: boolean }).unsaved).toBeUndefined();
+ });
+});
diff --git a/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-canvas.tsx b/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-canvas.tsx
new file mode 100644
index 0000000000..b229803081
--- /dev/null
+++ b/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-canvas.tsx
@@ -0,0 +1,1380 @@
+/**
+ * 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 {
+ Background,
+ BackgroundVariant,
+ Controls,
+ type Edge,
+ type Node,
+ ReactFlow,
+ ReactFlowProvider,
+ useReactFlow,
+ useStore,
+ useStoreApi,
+ ViewportPortal,
+} from '@xyflow/react';
+import { useDebouncedValue } from 'hooks/use-debounced-value';
+import {
+ memo,
+ type PointerEvent as ReactPointerEvent,
+ type RefObject,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+
+import { InvalidConfigNotice } from './invalid-config-notice';
+import type { FlowCardData } from './pipeline-flow-canvas-nodes';
+import { flowEdgeTypes, flowNodeTypes, sectionAccent } from './pipeline-flow-canvas-nodes';
+import { PipelineFlowSkeleton } from './pipeline-flow-skeleton';
+import { useResilientParse } from './use-resilient-parse';
+import { computeGraphLayout, type FlowInsertPayload } from '../utils/pipeline-flow-layout';
+import { buildChildrenMap } from '../utils/pipeline-flow-parser';
+import type { EditTarget } from '../utils/yaml';
+
+const PARSE_DEBOUNCE_MS = 300;
+// How far past the diagram the canvas may be panned, so an edge node can be brought to the middle.
+const PAN_PADDING = 240;
+
+// RF's pan/zoom eases are d3-driven (JS), beyond the CSS reduced-motion rule — collapse them to instant.
+const animMs = (ms: number): number =>
+ typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 0 : ms;
+const MIN_ZOOM = 0.5;
+const MAX_ZOOM = 1.25;
+// Floor for graphs too big to fit even at MIN_ZOOM — never below this, so it can't zoom out to a dot.
+const ABSOLUTE_MIN_ZOOM = 0.05;
+
+// Zoom-out floor: MIN_ZOOM normally, down to ABSOLUTE_MIN_ZOOM when the graph is too big to fit.
+function fitMinZoom(graphW: number, graphH: number, paneW: number, paneH: number): number {
+ if (graphW <= 0 || graphH <= 0 || paneW <= 0 || paneH <= 0) {
+ return MIN_ZOOM;
+ }
+ // 0.9 leaves a little margin around the fully-zoomed-out graph.
+ const fit = 0.9 * Math.min(paneW / graphW, paneH / graphH);
+ return Math.min(MIN_ZOOM, Math.max(ABSOLUTE_MIN_ZOOM, fit));
+}
+
+// Bounding box of top-level nodes; measured (not assumed from origin) since resource cards can sit at negative x.
+function contentBounds(nodes: Node[]): { minX: number; minY: number; maxX: number; maxY: number } {
+ let minX = 0;
+ let minY = 0;
+ let maxX = 0;
+ let maxY = 0;
+ let seen = false;
+ for (const node of nodes) {
+ if (node.parentId) {
+ continue;
+ }
+ const w = (node.initialWidth ?? node.width ?? 0) as number;
+ const h = (node.initialHeight ?? node.height ?? 0) as number;
+ const right = node.position.x + w;
+ const bottom = node.position.y + h;
+ if (seen) {
+ minX = Math.min(minX, node.position.x);
+ minY = Math.min(minY, node.position.y);
+ maxX = Math.max(maxX, right);
+ maxY = Math.max(maxY, bottom);
+ } else {
+ minX = node.position.x;
+ minY = node.position.y;
+ maxX = right;
+ maxY = bottom;
+ seen = true;
+ }
+ }
+ return { minX, minY, maxX, maxY };
+}
+// Fixed width; height tracks the diagram's aspect (clamped) so there's no unreachable letterbox space.
+const MINIMAP_WIDTH = 132;
+const MINIMAP_MIN_INNER_H = 32;
+const MINIMAP_MAX_INNER_H = 168;
+// Inset between drawing and frame so the viewport rect's border stays visible at the pan limits.
+const MINIMAP_PAD = 2;
+// The frame's 1px border (border-box) shrinks the svg's drawing area on each side.
+const MINIMAP_BORDER = 1;
+
+const clampValue = (value: number, lo: number, hi: number): number => Math.min(Math.max(value, lo), hi);
+
+// Tint each blip with its node's role accent; structural marks (section labels) drop out.
+function miniMapNodeColor(node: Node): string {
+ return sectionAccent((node.data as FlowCardData | undefined)?.section) ?? 'transparent';
+}
+
+// Overview minimap; draws only the pan-reachable world (see `world`). Click/drag re-centres.
+function PipelineMiniMap({
+ nodes,
+ translateExtent,
+}: {
+ nodes: Node[];
+ translateExtent: [[number, number], [number, number]];
+}) {
+ const transform = useStore((s) => s.transform);
+ const paneWidth = useStore((s) => s.width);
+ const paneHeight = useStore((s) => s.height);
+ const { setCenter } = useReactFlow();
+ const draggingRef = useRef(false);
+
+ // Fill the frame's content box (frame minus its 1px border each side) so nothing is clipped.
+ const mapW = MINIMAP_WIDTH - 2 * MINIMAP_BORDER;
+
+ const [tx, ty, zoom] = transform;
+ const viewLeft = -tx / zoom;
+ const viewTop = -ty / zoom;
+ const vw = paneWidth / zoom;
+ const vh = paneHeight / zoom;
+
+ // The SAME `translateExtent` passed to , so the minimap agrees with the canvas on reach.
+ const ext = useMemo(
+ () => ({
+ minX: translateExtent[0][0],
+ minY: translateExtent[0][1],
+ maxX: translateExtent[1][0],
+ maxY: translateExtent[1][1],
+ }),
+ [translateExtent]
+ );
+
+ // Per axis: viewport smaller than extent → whole extent is reachable, show it; else the axis is
+ // locked, so show the live visible window (RF pins an oversized viewport to an edge).
+ const canPanX = vw < ext.maxX - ext.minX;
+ const canPanY = vh < ext.maxY - ext.minY;
+ const world = {
+ minX: canPanX ? ext.minX : viewLeft,
+ maxX: canPanX ? ext.maxX : viewLeft + vw,
+ minY: canPanY ? ext.minY : viewTop,
+ maxY: canPanY ? ext.maxY : viewTop + vh,
+ };
+ const worldW = Math.max(world.maxX - world.minX, 1);
+ const worldH = Math.max(world.maxY - world.minY, 1);
+
+ // Each axis scales independently so a clamp-forced aspect mismatch can't leave dead bands.
+ const innerW = mapW - 2 * MINIMAP_PAD;
+ const innerH = clampValue(innerW * (worldH / worldW), MINIMAP_MIN_INNER_H, MINIMAP_MAX_INNER_H);
+ const mapH = innerH + 2 * MINIMAP_PAD;
+ const scaleX = innerW / worldW;
+ const scaleY = innerH / worldH;
+ const offsetX = MINIMAP_PAD - world.minX * scaleX;
+ const offsetY = MINIMAP_PAD - world.minY * scaleY;
+
+ // The world always contains the viewport, so the rect needs no clamping; on a locked axis it fills edge-to-edge.
+ const view = { x: viewLeft * scaleX + offsetX, y: viewTop * scaleY + offsetY, w: vw * scaleX, h: vh * scaleY };
+
+ const panToEvent = (e: ReactPointerEvent) => {
+ const rect = e.currentTarget.getBoundingClientRect();
+ const fx = (e.clientX - rect.left - offsetX) / scaleX;
+ const fy = (e.clientY - rect.top - offsetY) / scaleY;
+ // Clamp the target centre to the drag extent; lock to the extent centre on an axis too small to pan.
+ const cx = canPanX ? clampValue(fx, ext.minX + vw / 2, ext.maxX - vw / 2) : (ext.minX + ext.maxX) / 2;
+ const cy = canPanY ? clampValue(fy, ext.minY + vh / 2, ext.maxY - vh / 2) : (ext.minY + ext.maxY) / 2;
+ setCenter(cx, cy, { zoom, duration: 0 });
+ };
+
+ return (
+
+ {
+ // An interrupted drag never fires pointerup — clear the flag so moves stop re-centering.
+ draggingRef.current = false;
+ }}
+ onPointerDown={(e) => {
+ draggingRef.current = true;
+ e.currentTarget.setPointerCapture(e.pointerId);
+ panToEvent(e);
+ }}
+ onPointerMove={(e) => {
+ if (draggingRef.current) {
+ panToEvent(e);
+ }
+ }}
+ onPointerUp={(e) => {
+ draggingRef.current = false;
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ }}
+ role="img"
+ width={mapW}
+ >
+ {nodes.map((node) => {
+ const color = miniMapNodeColor(node);
+ if (node.parentId || color === 'transparent') {
+ return null;
+ }
+ const w = ((node.initialWidth ?? node.width ?? 0) as number) * scaleX;
+ const h = ((node.initialHeight ?? node.height ?? 0) as number) * scaleY;
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
+
+// Reveal margin (px), and how long to wait for the inspector rail's open animation before measuring.
+const SELECTION_REVEAL_MARGIN = 32;
+const RAIL_SETTLE_MS = 240;
+
+// In scope if it's a member by id, or carries the construct's id as `ownerId` (marks outside the parser tree).
+function nodeInScope(node: Node, scope: ReadonlySet): boolean {
+ return scope.has(node.id) || scope.has((node.data as FlowCardData).ownerId ?? ' ');
+}
+
+// Selecting a control-flow construct focuses its branch: nodes outside its scope fade back so its members read clearly.
+function focusDimNodes(
+ rfNodes: Node[],
+ selectedNodeId: string | undefined,
+ scopeOf: (id: string | undefined) => ReadonlySet | undefined
+): Node[] {
+ const selectedNode = selectedNodeId ? rfNodes.find((n) => n.id === selectedNodeId) : undefined;
+ const focus = selectedNode?.type === 'flowSplit' ? scopeOf(selectedNodeId) : undefined;
+ if (!focus) {
+ return rfNodes;
+ }
+ return rfNodes.map((node) =>
+ nodeInScope(node, focus)
+ ? node
+ : {
+ ...node,
+ style: { ...(node.style as Record), opacity: 0.3, transition: 'opacity 200ms ease' },
+ }
+ );
+}
+
+// The graph fades back while it's showing the stale (last-good) layout, cueing that it's not live.
+const staleFlowClass = (stale: boolean): string => `transition-opacity duration-200 ${stale ? 'opacity-60' : ''}`;
+
+const CANVAS_NOTICE_CLASS =
+ 'absolute top-3 left-1/2 z-20 -translate-x-1/2 px-3 py-1.5 text-sm shadow-sm backdrop-blur-sm';
+
+// Banner shown while rendering the last-good graph for invalid YAML (see useResilientParse).
+function StaleParseBanner({ show }: { show: boolean }) {
+ if (!show) {
+ return null;
+ }
+ return (
+
+ Can't visualize the latest YAML — showing the last valid layout.
+
+ );
+}
+
+// The zoom tool's armed state: a magnifier-`+` (zoom in) or `-` (zoom out) cursor, or off.
+type ZoomMode = 'in' | 'out' | null;
+const ZOOM_STEP = 1.5;
+
+function cursorForMode(mode: ZoomMode): string {
+ return mode === 'in' ? 'zoom-in' : mode === 'out' ? 'zoom-out' : '';
+}
+
+// Paint the magnifier cursor while the zoom tool is armed. Set inline on the container AND its pane:
+// RF styles the pane cursor itself, so a Tailwind class loses but an inline style wins.
+function useZoomCursor(mode: ZoomMode, ref: RefObject) {
+ useEffect(() => {
+ const root = ref.current;
+ if (!root) {
+ return;
+ }
+ const cursor = cursorForMode(mode);
+ const panes = Array.from(root.querySelectorAll('.react-flow__pane'));
+ root.style.cursor = cursor;
+ for (const pane of panes) {
+ pane.style.cursor = cursor;
+ }
+ return () => {
+ root.style.cursor = '';
+ for (const pane of panes) {
+ pane.style.cursor = '';
+ }
+ };
+ }, [mode, ref]);
+}
+
+// Free-pan is suppressed while the zoom tool is armed — else a pan-drag ends in a click that zooms.
+const canPanCanvas = (mode: ZoomMode): boolean => mode === null;
+
+// Spring-loaded: held Z → zoom-in, held Z + Option/Alt → zoom-out, nothing held → off.
+function heldZoomMode(zDown: boolean, alt: boolean): ZoomMode {
+ return zDown ? (alt ? 'out' : 'in') : null;
+}
+
+const isTypingTarget = (target: EventTarget | null): boolean =>
+ Boolean((target as HTMLElement | null)?.closest('input, textarea, [contenteditable="true"], .monaco-editor'));
+
+// Figma-style hold-to-activate zoom: while Z is held a click zooms toward the pointer (Option/Alt to
+// zoom out); releasing returns to pan/select. The wheel is left to the page. Ignored while typing.
+function ZoomTool({ mode, setMode, minZoom }: { mode: ZoomMode; setMode: (next: ZoomMode) => void; minZoom: number }) {
+ const { screenToFlowPosition, getZoom, setCenter } = useReactFlow();
+
+ // Track key-hold state via keydown/keyup, plus blur so a release missed while unfocused can't leave the tool stuck on.
+ useEffect(() => {
+ let zDown = false;
+ let altHeld = false;
+ const sync = () => setMode(heldZoomMode(zDown, altHeld));
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Alt') {
+ altHeld = true;
+ sync();
+ return;
+ }
+ // Match on `code` (not `key`) so Option+Z on macOS — which emits a special character — still
+ // registers as Z. Meta/Ctrl are reserved for the app shell, so ignore those combos.
+ if (e.code !== 'KeyZ' || e.metaKey || e.ctrlKey || isTypingTarget(e.target)) {
+ return;
+ }
+ e.preventDefault();
+ zDown = true;
+ altHeld = e.altKey;
+ sync();
+ };
+ const onKeyUp = (e: KeyboardEvent) => {
+ if (e.key === 'Alt') {
+ altHeld = false;
+ } else if (e.code === 'KeyZ') {
+ zDown = false;
+ } else {
+ return;
+ }
+ sync();
+ };
+ const reset = () => {
+ zDown = false;
+ altHeld = false;
+ sync();
+ };
+ window.addEventListener('keydown', onKeyDown);
+ window.addEventListener('keyup', onKeyUp);
+ window.addEventListener('blur', reset);
+ return () => {
+ window.removeEventListener('keydown', onKeyDown);
+ window.removeEventListener('keyup', onKeyUp);
+ window.removeEventListener('blur', reset);
+ };
+ }, [setMode]);
+
+ useEffect(() => {
+ if (!mode) {
+ return;
+ }
+ const onClick = (e: MouseEvent) => {
+ if (!(e.target as HTMLElement | null)?.closest('.react-flow__pane, .react-flow__node')) {
+ return;
+ }
+ // Take the click before React Flow's select/deselect runs, and zoom toward the pointer.
+ e.preventDefault();
+ e.stopPropagation();
+ const point = screenToFlowPosition({ x: e.clientX, y: e.clientY });
+ const next = clampValue(getZoom() * (mode === 'in' ? ZOOM_STEP : 1 / ZOOM_STEP), minZoom, MAX_ZOOM);
+ setCenter(point.x, point.y, { zoom: next, duration: animMs(200) });
+ };
+ window.addEventListener('click', onClick, true);
+ return () => window.removeEventListener('click', onClick, true);
+ }, [mode, minZoom, screenToFlowPosition, getZoom, setCenter]);
+
+ return null;
+}
+
+// Frame the graph on first load: centered and zoomed to fit. Retries each frame until pane size and
+// node bounds are real, so it fires ASAP rather than waiting for a later resize (a visible "snap").
+function FitOnInit() {
+ const nodeCount = useStore((s) => s.nodes.length);
+ const storeApi = useStoreApi();
+ const { getNodes, getNodesBounds, fitView } = useReactFlow();
+ const doneRef = useRef(false);
+ useEffect(() => {
+ if (doneRef.current || nodeCount === 0) {
+ return;
+ }
+ let raf = 0;
+ let tries = 0;
+ const place = () => {
+ const { width, height } = storeApi.getState();
+ const bounds = getNodesBounds(getNodes());
+ if (width > 0 && height > 0 && bounds.width > 0 && bounds.height > 0) {
+ doneRef.current = true;
+ // Big graphs may fit below MIN_ZOOM; small graphs stay capped so a few nodes aren't blown up.
+ fitView({ padding: 0.2, minZoom: fitMinZoom(bounds.width, bounds.height, width, height), maxZoom: 1 });
+ return;
+ }
+ tries += 1;
+ if (tries < 60) {
+ raf = requestAnimationFrame(place);
+ }
+ };
+ raf = requestAnimationFrame(place);
+ return () => cancelAnimationFrame(raf);
+ }, [nodeCount, storeApi, getNodes, getNodesBounds, fitView]);
+ return null;
+}
+
+// Keeps the zoom-out floor in sync with graph size (see fitMinZoom); inside the provider for pane size.
+function MinZoomController({
+ graphWidth,
+ graphHeight,
+ onChange,
+}: {
+ graphWidth: number;
+ graphHeight: number;
+ onChange: (minZoom: number) => void;
+}) {
+ const paneWidth = useStore((s) => s.width);
+ const paneHeight = useStore((s) => s.height);
+ useEffect(() => {
+ if (paneWidth > 0 && paneHeight > 0) {
+ onChange(fitMinZoom(graphWidth, graphHeight, paneWidth, paneHeight));
+ }
+ }, [graphWidth, graphHeight, paneWidth, paneHeight, onChange]);
+ return null;
+}
+
+// Recenters the viewport on a node (command-palette "go to"). Keyed by a token so re-picking the same node pans again.
+function FocusNode({ nodeId, token }: { nodeId?: string; token?: number }) {
+ const { getNodesBounds, setCenter, getZoom } = useReactFlow();
+ // biome-ignore lint/correctness/useExhaustiveDependencies: token is the intentional re-trigger so re-picking the same node pans again.
+ useEffect(() => {
+ if (!nodeId) {
+ return;
+ }
+ const bounds = getNodesBounds([nodeId]);
+ if (!bounds || bounds.width === 0) {
+ return;
+ }
+ // Keep the current zoom (clamped to a readable band) so jumping doesn't lurch the scale.
+ const zoom = Math.min(Math.max(getZoom(), 0.8), 1);
+ // Long ease so the pan reads as a glide, not a jump.
+ setCenter(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2, { duration: animMs(600), zoom });
+ }, [nodeId, token, getNodesBounds, setCenter, getZoom]);
+ return null;
+}
+
+// Keeps the selected node visible when the inspector rail opens (a flex sibling stealing ~384px on
+// the right). Once its open animation settles, nudge the viewport just enough to reveal a clipped node.
+function KeepSelectionInView({ selectedNodeId, focusToken }: { selectedNodeId?: string; focusToken?: number }) {
+ const { getNodesBounds, getViewport, setViewport } = useReactFlow();
+ // Pane-width changes reset the settle timer, so the nudge fires once the rail stops resizing the canvas.
+ const paneWidth = useStore((s) => s.width);
+ // A palette "go to" centers via FocusNode; skip this nudge that cycle so the two don't compete.
+ const lastFocusToken = useRef(focusToken);
+
+ useEffect(() => {
+ if (!selectedNodeId) {
+ return;
+ }
+ if (focusToken !== lastFocusToken.current) {
+ lastFocusToken.current = focusToken;
+ return;
+ }
+ const timer = setTimeout(() => {
+ const bounds = getNodesBounds([selectedNodeId]);
+ if (!bounds || bounds.width === 0) {
+ return;
+ }
+ const { x, y, zoom } = getViewport();
+ const screenLeft = bounds.x * zoom + x;
+ const screenRight = (bounds.x + bounds.width) * zoom + x;
+ let dx = 0;
+ if (screenRight > paneWidth - SELECTION_REVEAL_MARGIN) {
+ // Off the right edge / behind the rail — pan content left to reveal it.
+ dx = paneWidth - SELECTION_REVEAL_MARGIN - screenRight;
+ } else if (screenLeft < SELECTION_REVEAL_MARGIN) {
+ dx = SELECTION_REVEAL_MARGIN - screenLeft;
+ }
+ if (dx !== 0) {
+ setViewport({ x: x + dx, y, zoom }, { duration: animMs(200) });
+ }
+ }, RAIL_SETTLE_MS);
+ return () => clearTimeout(timer);
+ }, [selectedNodeId, paneWidth, focusToken, getNodesBounds, getViewport, setViewport]);
+
+ return null;
+}
+
+// Padding (px) between a construct's members and the region edge; extra per nesting level so outer regions stand off.
+const SCOPE_REGION_PAD = 6;
+const SCOPE_REGION_TOP_PAD = 6;
+const SCOPE_REGION_NEST_STEP = 4;
+
+// The accent for a construct node; error/dead-letter paths (catch) get a distinct tone.
+function constructAccent(node?: Node): string {
+ const d = node?.data as FlowCardData | undefined;
+ return d?.isErrorPath ? 'var(--color-destructive)' : (sectionAccent(d?.section) ?? 'var(--color-primary)');
+}
+
+type ScopeBounds = { minX: number; minY: number; maxX: number; maxY: number };
+type Point = { x: number; y: number };
+type MeasuredById = Map;
+// One rectangle over the `body` members; the `entry` marker sits outside, reached by an arm (see regionGeometry).
+type ScopeGeometry = { body: ScopeBounds; entry: ScopeBounds | null };
+
+function nodeRect(n: Node, measuredById: MeasuredById): ScopeBounds {
+ const m = measuredById.get(n.id)?.measured;
+ const w = (m?.width ?? n.initialWidth ?? n.width ?? 0) as number;
+ const h = (m?.height ?? n.initialHeight ?? n.height ?? 0) as number;
+ return { minX: n.position.x, minY: n.position.y, maxX: n.position.x + w, maxY: n.position.y + h };
+}
+
+const growBounds = (box: ScopeBounds | null, r: ScopeBounds): ScopeBounds =>
+ box
+ ? {
+ minX: Math.min(box.minX, r.minX),
+ minY: Math.min(box.minY, r.minY),
+ maxX: Math.max(box.maxX, r.maxX),
+ maxY: Math.max(box.maxY, r.maxY),
+ }
+ : r;
+
+// Split a construct's scoped members into the body (everything but the entry marker) and the entry
+// marker's own rect. Prefers measured size, falling back to layout size. Null when there's no body.
+export function scopeBounds(
+ nodes: Node[],
+ scope: ReadonlySet,
+ measuredById: MeasuredById,
+ isEntry?: (n: Node) => boolean
+): ScopeGeometry | null {
+ let body: ScopeBounds | null = null;
+ let entry: ScopeBounds | null = null;
+ for (const n of nodes) {
+ if (!nodeInScope(n, scope)) {
+ continue;
+ }
+ const r = nodeRect(n, measuredById);
+ if (isEntry?.(n)) {
+ entry = r;
+ } else {
+ body = growBounds(body, r);
+ }
+ }
+ return body ? { body, entry } : null;
+}
+
+type ScopeRegion = {
+ id: string;
+ scope: ReadonlySet;
+ body: ScopeBounds;
+ entry: ScopeBounds | null;
+ accent: string;
+ label: string;
+ /** Side / top padding — larger for outer regions so nested ones don't touch. */
+ pad: number;
+ topPad: number;
+};
+
+// Browsers silently drop a filled div's background past ~8k–16k device px, so tall region fills are
+// painted as stacked pieces capped at this. Exported so the render test can assert the cap.
+export const MAX_REGION_PIECE_PX = 3000;
+
+type RegionGeometry = {
+ /** Padded body rect + optional arm rect, painted as the fill (sliced by MAX_REGION_PIECE_PX). */
+ rects: ScopeBounds[];
+ /** Dashed outline path in bbox-local coords: the body rectangle with an arm out to the entry. */
+ path: string;
+ bbox: { x: number; y: number; w: number; h: number };
+ /** Body's top-left corner, where the label sits. */
+ anchor: Point;
+};
+
+// A thin arm from the body edge out to the entry marker, at the marker's row.
+type ScopeArm = { side: 'left' | 'right'; outerX: number; top: number; bottom: number };
+
+// Which side of the body the entry marker sits on (undefined if it overlaps the body horizontally).
+function entrySide(entry: ScopeBounds, body: ScopeBounds): 'left' | 'right' | undefined {
+ return entry.maxX <= body.minX ? 'left' : entry.minX >= body.maxX ? 'right' : undefined;
+}
+
+// The arm to the entry marker at its own row: stretched to meet the body when the marker sits clear
+// above/below it; otherwise clamped to the body so it stays thin and can't reach into a sibling.
+function entryArm(entry: ScopeBounds, body: ScopeBounds, pb: ScopeBounds, pad: number): ScopeArm | undefined {
+ const side = entrySide(entry, body);
+ if (!side) {
+ return;
+ }
+ let top = entry.minY - pad;
+ let bottom = entry.maxY + pad;
+ if (bottom < pb.minY) {
+ bottom = pb.minY;
+ } else if (top > pb.maxY) {
+ top = pb.maxY;
+ } else {
+ top = Math.max(top, pb.minY);
+ bottom = Math.min(bottom, pb.maxY);
+ }
+ return bottom > top
+ ? { side, outerX: side === 'left' ? entry.minX - pad : entry.maxX + pad, top, bottom }
+ : undefined;
+}
+
+// The body rectangle traced clockwise, poking the arm out at the entry marker's row.
+function regionPath(pb: ScopeBounds, arm?: ScopeArm): Point[] {
+ const pts: Point[] = [
+ { x: pb.minX, y: pb.minY },
+ { x: pb.maxX, y: pb.minY },
+ ];
+ if (arm?.side === 'right') {
+ pts.push(
+ { x: pb.maxX, y: arm.top },
+ { x: arm.outerX, y: arm.top },
+ { x: arm.outerX, y: arm.bottom },
+ { x: pb.maxX, y: arm.bottom }
+ );
+ }
+ pts.push({ x: pb.maxX, y: pb.maxY }, { x: pb.minX, y: pb.maxY });
+ if (arm?.side === 'left') {
+ pts.push(
+ { x: pb.minX, y: arm.bottom },
+ { x: arm.outerX, y: arm.bottom },
+ { x: arm.outerX, y: arm.top },
+ { x: pb.minX, y: arm.top }
+ );
+ }
+ return pts;
+}
+
+// A padded body rectangle plus a thin arm out to the entry marker, so it reads as part of the area
+// while the box stays clear of whatever shares the marker's column.
+export function regionGeometry(
+ body: ScopeBounds,
+ entry: ScopeBounds | null,
+ pad: number,
+ topPad: number
+): RegionGeometry {
+ const pb = { minX: body.minX - pad, minY: body.minY - topPad, maxX: body.maxX + pad, maxY: body.maxY + pad };
+ const arm = entry ? entryArm(entry, body, pb, pad) : undefined;
+ const rects: ScopeBounds[] = [pb];
+ if (arm) {
+ rects.push(
+ arm.side === 'left'
+ ? { minX: arm.outerX, minY: arm.top, maxX: pb.minX, maxY: arm.bottom }
+ : { minX: pb.maxX, minY: arm.top, maxX: arm.outerX, maxY: arm.bottom }
+ );
+ }
+ let ox = pb.minX;
+ let oy = pb.minY;
+ let ox2 = pb.maxX;
+ let oy2 = pb.maxY;
+ for (const r of rects) {
+ ox = Math.min(ox, r.minX);
+ oy = Math.min(oy, r.minY);
+ ox2 = Math.max(ox2, r.maxX);
+ oy2 = Math.max(oy2, r.maxY);
+ }
+ const path = `M${regionPath(pb, arm)
+ .map((p) => `${p.x - ox} ${p.y - oy}`)
+ .join('L')}Z`;
+ return { rects, path, bbox: { x: ox, y: oy, w: ox2 - ox, h: oy2 - oy }, anchor: { x: pb.minX, y: pb.minY } };
+}
+
+// One construct's enclosure: a faint dashed rect + arm to the entry marker; strengthens when the
+// construct (or anything inside) is active. Non-interactive, behind the nodes, in flow coordinates.
+const RegionBox = memo(({ region, active }: { region: ScopeRegion; active: boolean }) => {
+ const { body, entry, accent, label, pad, topPad } = region;
+ const borderColor = `color-mix(in srgb, ${accent} ${active ? 70 : 32}%, transparent)`;
+ const backgroundColor = `color-mix(in srgb, ${accent} ${active ? 10 : 5}%, transparent)`;
+ // Geometry is `active`-independent, so hover (colour only) doesn't recompute it.
+ const { rects, path, bbox, anchor } = useMemo(
+ () => regionGeometry(body, entry, pad, topPad),
+ [body, entry, pad, topPad]
+ );
+
+ return (
+ <>
+ {/* Fill in height-capped pieces so a tall area never exceeds the browser paint limit (see MAX_REGION_PIECE_PX). */}
+ {rects.flatMap((r) => {
+ const width = r.maxX - r.minX;
+ const height = r.maxY - r.minY;
+ const pieceCount = Math.max(1, Math.ceil(height / MAX_REGION_PIECE_PX));
+ const pieceHeight = height / pieceCount;
+ return Array.from({ length: pieceCount }, (_, p) => {
+ const topY = r.minY + p * pieceHeight;
+ return (
+
+ );
+ });
+ })}
+ {/* Dashed border as one path — no per-piece seams. */}
+
+
+
+
+
+ {label}
+
+
+ >
+ );
+});
+
+type ScopeRegionDraft = Omit;
+type RegionContext = {
+ rfNodes: Node[];
+ scopeOf: (id: string | undefined) => ReadonlySet | undefined;
+ nodeLookup: MeasuredById;
+ /** try marker id → its fused catch marker id, so a try's area also covers its error handling. */
+ tryCatchPairs: ReadonlyMap;
+};
+
+// One construct's region (before nesting padding). Only the entry marker is held outside (reached by
+// an arm); everything else — branches, the merge/join, and a try's fused catch — is in the body.
+function scopeRegionDraft(node: Node, ctx: RegionContext): ScopeRegionDraft | null {
+ const { rfNodes, scopeOf, nodeLookup, tryCatchPairs } = ctx;
+ const own = scopeOf(node.id);
+ if (!own || own.size < 2) {
+ return null;
+ }
+ const catchId = tryCatchPairs.get(node.id);
+ const scope = catchId ? new Set([...own, ...(scopeOf(catchId) ?? [])]) : own;
+ const geom = scopeBounds(rfNodes, scope, nodeLookup, (n) => n.id === node.id);
+ if (!geom) {
+ return null;
+ }
+ return {
+ id: node.id,
+ scope,
+ body: geom.body,
+ entry: geom.entry,
+ accent: constructAccent(node),
+ label: (node.data as FlowCardData).label,
+ };
+}
+
+// Faint enclosure boxes around each construct's sub-graph, emphasized when it (or anything inside) is
+// active. Drawn via `ViewportPortal` (hover stays cheap); a box may span a scattered non-member card.
+function ScopeRegions({
+ hoveredId,
+ selectedId,
+ rfNodes,
+ scopeOf,
+ tryCatchPairs,
+}: {
+ hoveredId?: string;
+ selectedId?: string;
+ rfNodes: Node[];
+ scopeOf: (id: string | undefined) => ReadonlySet | undefined;
+ tryCatchPairs: ReadonlyMap;
+}) {
+ // The store's nodeLookup is stably mutated, so subscribing to it alone won't re-render;
+ // `measuredKey` is a primitive digest that changes when cards measure, so boxes tighten to rendered sizes.
+ const nodeLookup = useStore((s) => s.nodeLookup);
+ // Only scoped cards affect region geometry, so the digest below covers just those — selector stays cheap.
+ const scopedIds = useMemo(() => {
+ const ids = new Set();
+ for (const node of rfNodes) {
+ if (node.type !== 'flowSplit') {
+ continue;
+ }
+ const scope = scopeOf(node.id);
+ if (scope) {
+ for (const id of scope) {
+ ids.add(id);
+ }
+ }
+ }
+ return ids;
+ }, [rfNodes, scopeOf]);
+ const measuredKey = useStore((s) => {
+ // Integer digest of measured sizes (no per-frame string alloc); folds width+height so any reflow retriggers.
+ let h = 0;
+ for (const id of scopedIds) {
+ const m = s.nodeLookup.get(id)?.measured;
+ h = (h * 31 + Math.round(m?.width ?? 0)) % 2_147_483_647;
+ h = (h * 31 + Math.round(m?.height ?? 0)) % 2_147_483_647;
+ }
+ return h;
+ });
+ // Geometry depends only on the layout + measurements, so cache it across hover/selection changes.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: measuredKey re-triggers bounds when cards measure.
+ const regions = useMemo(() => {
+ // A catch fused into a try is drawn as part of the try's area, not its own region.
+ const absorbed = new Set(tryCatchPairs.values());
+ const ctx: RegionContext = { rfNodes, scopeOf, nodeLookup, tryCatchPairs };
+ const drafts = rfNodes
+ .filter((n) => n.type === 'flowSplit' && !absorbed.has(n.id))
+ .map((n) => scopeRegionDraft(n, ctx))
+ .filter((d): d is ScopeRegionDraft => d !== null);
+ // How many OTHER regions enclose each (its construct id is in their scope).
+ const depthOf = (r: ScopeRegionDraft) => drafts.filter((o) => o.id !== r.id && o.scope.has(r.id)).length;
+ const depths = drafts.map(depthOf);
+ // Standoff scales with inner nesting depth (0 if none), so each enclosing box stands one step off the inner ones.
+ const innerDepth = (i: number) =>
+ drafts.reduce((h, o, j) => (j !== i && drafts[i].scope.has(o.id) ? Math.max(h, depths[j] - depths[i]) : h), 0);
+ return drafts.map((r, i) => {
+ const extra = innerDepth(i) * SCOPE_REGION_NEST_STEP;
+ return { ...r, pad: SCOPE_REGION_PAD + extra, topPad: SCOPE_REGION_TOP_PAD + extra };
+ });
+ }, [rfNodes, scopeOf, nodeLookup, tryCatchPairs, measuredKey]);
+
+ if (regions.length === 0) {
+ return null;
+ }
+ // Emphasized when the active node is the construct or anywhere in its sub-graph (merge dots match via owner id).
+ const activeId = hoveredId ?? selectedId;
+ const activeOwner = activeId
+ ? (rfNodes.find((n) => n.id === activeId)?.data as FlowCardData | undefined)?.ownerId
+ : undefined;
+ return (
+
+ {regions.map((region) => {
+ const active =
+ activeId !== undefined &&
+ (region.scope.has(activeId) || (activeOwner ? region.scope.has(activeOwner) : false));
+ return ;
+ })}
+
+ );
+}
+
+type CanvasCallbacks = {
+ onAddConnector?: (section: string) => void;
+ onAddTopic?: (section: string, componentName: string) => void;
+ onAddSasl?: (section: string, componentName: string) => void;
+ onSlotInsert?: (payload: FlowInsertPayload) => void;
+ collapsedIds: ReadonlySet;
+ toggleCollapse: (nodeId: string) => void;
+ selectedNodeId?: string;
+ /** Kind of the selected edit target — `'switchCase'` means the condition, not the component. */
+ selectedTargetKind?: string;
+ lintErrorsByNode?: Map;
+ /** Node ids whose config differs from the last-saved pipeline. */
+ unsavedNodeIds?: ReadonlySet;
+ flashNodeIds?: ReadonlySet;
+ flashToken?: number;
+ /** Node ids present on the previous render — anything new is "appearing". */
+ previousIds: ReadonlySet;
+};
+
+// Edit-mode action callbacks wired onto a node's data.
+function wireNodeActions(data: FlowCardData, node: Node, cb: CanvasCallbacks): void {
+ if (data.label === 'none' && cb.onAddConnector) {
+ data.onAddConnector = cb.onAddConnector;
+ }
+ if (data.missingTopic && cb.onAddTopic) {
+ data.onAddTopic = cb.onAddTopic;
+ }
+ if (data.missingSasl && cb.onAddSasl) {
+ data.onAddSasl = cb.onAddSasl;
+ }
+ if (data.addAction && cb.onSlotInsert) {
+ const payload = data.addAction.payload;
+ data.onAddChild = () => cb.onSlotInsert?.(payload);
+ }
+ if (node.type === 'flowInsert' && cb.onSlotInsert) {
+ (data as { onInsert?: (payload: FlowInsertPayload) => void }).onInsert = cb.onSlotInsert;
+ }
+}
+
+export function injectNodeData(node: Node, cb: CanvasCallbacks): Node {
+ const data = { ...node.data } as FlowCardData;
+ if (data.collapsible) {
+ data.collapsed = cb.collapsedIds.has(node.id);
+ data.onToggle = () => cb.toggleCollapse(node.id);
+ }
+ if (cb.selectedNodeId && node.id === cb.selectedNodeId) {
+ // The target kind picks whether to ring the condition row (case) or the whole card (component).
+ if (data.caseEditTarget && cb.selectedTargetKind === 'switchCase') {
+ data.conditionSelected = true;
+ } else {
+ data.selected = true;
+ }
+ }
+ const lintErrors = cb.lintErrorsByNode?.get(node.id);
+ if (lintErrors?.length) {
+ data.lintErrors = lintErrors;
+ }
+ // A condition edit is attributed to the non-rendered case-wrapper node — also mark its stand-in entry card.
+ if (cb.unsavedNodeIds?.has(node.id) || (data.caseOwnerId && cb.unsavedNodeIds?.has(data.caseOwnerId))) {
+ data.unsaved = true;
+ }
+ if (cb.flashNodeIds?.has(node.id)) {
+ data.flash = true;
+ data.flashToken = cb.flashToken;
+ }
+ wireNodeActions(data, node, cb);
+ // New nodes appear in place: drop the transform transition so they snap, then fade + grow in (`appeared`).
+ if (!cb.previousIds.has(node.id)) {
+ data.appeared = true;
+ return {
+ ...node,
+ data,
+ style: { ...(node.style as Record), transition: undefined },
+ };
+ }
+ return { ...node, data };
+}
+
+type LegendFlags = { condition: boolean; error: boolean; reference: boolean };
+
+// A line swatch matching the edge styles drawn on the canvas.
+function LegendSwatch({ color, dashed }: { color: string; dashed?: boolean }) {
+ return (
+
+ );
+}
+
+// A filled chip swatch for node-borne vocabulary (routing condition), vs. edge line swatches.
+function LegendChipSwatch({ color }: { color: string }) {
+ return (
+
+ );
+}
+
+// Explains the diagram vocabulary; only the kinds present in the current diagram are listed.
+function FlowLegend({ flags }: { flags: LegendFlags }) {
+ if (!(flags.condition || flags.error || flags.reference)) {
+ return null;
+ }
+ return (
+
+
Legend
+
+
+ Data flow
+
+ {flags.condition ? (
+
+
+ Routing condition
+
+ ) : null}
+ {flags.error ? (
+
+
+ Error / dead-letter
+
+ ) : null}
+ {flags.reference ? (
+
+
+ Uses resource
+
+ ) : null}
+
+ );
+}
+
+type DecorateEdgeOptions = {
+ /** The selected node and all of its descendants (a container's whole subtree). */
+ selectedScope?: ReadonlySet;
+ /** The hovered node and its descendants. */
+ hoveredScope?: ReadonlySet;
+ onInsert?: (processorIndex: number) => void;
+ /** Nested insert (into a control-flow body) carried on an edge's `slotPayload`. */
+ onSlotInsert?: (payload: FlowInsertPayload) => void;
+ /** Click a routing-condition label to edit its case (`selectId`/`selectTarget` on the edge). */
+ onSelectNode?: (nodeId: string, target: EditTarget, caseTarget?: EditTarget) => void;
+};
+
+// Reference edges are context lines: never below the "faint" tier, highlighted when an endpoint is active.
+function decorateReferenceEdge(edge: Edge, activeScope: ReadonlySet | undefined): Edge {
+ const touchesActive = activeScope !== undefined && (activeScope.has(edge.source) || activeScope.has(edge.target));
+ return { ...edge, data: { ...edge.data, emphasized: touchesActive, faint: !touchesActive } };
+}
+
+// Per-render edge styling: reference edges stay faint until an endpoint is active; a selection
+// emphasizes its own edges and dims the rest; graph edges get their insert (+) handler.
+export function decorateEdges(
+ edges: Edge[],
+ { selectedScope, hoveredScope, onInsert, onSlotInsert, onSelectNode }: DecorateEdgeOptions
+): Edge[] {
+ const activeScope = selectedScope ?? hoveredScope;
+ return edges.map((edge) => {
+ let next = edge;
+ const edgeData = next.data as
+ | { insertIndex?: number; slotPayload?: FlowInsertPayload; selectId?: string; selectTarget?: EditTarget }
+ | undefined;
+ const insertIndex = edgeData?.insertIndex;
+ const slotPayload = edgeData?.slotPayload;
+ if (onInsert && next.type === 'flowGraphEdge' && insertIndex !== undefined) {
+ next = { ...next, data: { ...next.data, onInsert: () => onInsert(insertIndex) } };
+ } else if (onSlotInsert && next.type === 'flowGraphEdge' && slotPayload) {
+ next = { ...next, data: { ...next.data, onInsert: () => onSlotInsert(slotPayload) } };
+ }
+ // A routing-condition label that selects its case to edit the condition.
+ if (onSelectNode && edgeData?.selectId && edgeData.selectTarget) {
+ const { selectId, selectTarget } = edgeData;
+ next = { ...next, data: { ...next.data, onLabelClick: () => onSelectNode(selectId, selectTarget) } };
+ }
+ if (next.id.startsWith('ref-')) {
+ return decorateReferenceEdge(next, activeScope);
+ }
+ if (selectedScope !== undefined) {
+ const touchesSelection = selectedScope.has(next.source) || selectedScope.has(next.target);
+ return { ...next, data: { ...next.data, dimmed: !touchesSelection, emphasized: touchesSelection } };
+ }
+ return next;
+ });
+}
+
+// What a click selects: the node itself if editable, else the nearest selectable ancestor
+// (structural sub-nodes like switch cases have no `editTarget`).
+export function selectionTargetForNode(
+ node: Node,
+ nodes: Node[]
+): { id: string; target: EditTarget; caseTarget?: EditTarget } | null {
+ let current: Node | undefined = node;
+ while (current && !(current.data as FlowCardData).editTarget) {
+ // Nodes are positioned absolutely (no RF parentId), so walk the logical owner in `data.ownerId`.
+ const ownerId: string | undefined = current.parentId ?? (current.data as FlowCardData).ownerId;
+ current = ownerId ? nodes.find((n) => n.id === ownerId) : undefined;
+ }
+ const target = current && (current.data as FlowCardData).editTarget;
+ if (!(current && target)) {
+ return null;
+ }
+ // A case-entry node also carries its routing-condition target so the inspector can edit the condition too.
+ const caseTarget = (current.data as FlowCardData).caseEditTarget;
+ return caseTarget ? { id: current.id, target, caseTarget } : { id: current.id, target };
+}
+
+type PipelineFlowCanvasProps = {
+ configYaml: string;
+ /** Currently selected node id (highlighted on the canvas). */
+ selectedNodeId?: string;
+ /** Kind of the selected edit target — `'switchCase'` highlights the condition, not the card. */
+ selectedTargetKind?: string;
+ /** Lint messages mapped to node ids — badged in place on the canvas. */
+ lintErrorsByNode?: Map;
+ /** Node ids whose config differs from the last-saved pipeline — flagged with an unsaved dot. */
+ unsavedNodeIds?: ReadonlySet;
+ /** Node ids to briefly pulse (e.g. after undo/redo), with a token to replay it. */
+ flashNodeIds?: ReadonlySet;
+ flashToken?: number;
+ /** Node id to recenter the viewport on (e.g. picked from the command palette); the token
+ re-triggers the pan even when the same node is chosen twice. */
+ focusNodeId?: string;
+ focusToken?: number;
+ /** Select a node by id + its edit target (clicking a node). */
+ onSelectNode?: (nodeId: string, target: EditTarget, caseTarget?: EditTarget) => void;
+ /** Clear the selection (clicking empty canvas). */
+ onClearSelection?: () => void;
+ // Edit-mode callbacks. When omitted the canvas is a read-only viewer.
+ onInsert?: (processorIndex: number) => void;
+ onSlotInsert?: (payload: FlowInsertPayload) => void;
+ onAddConnector?: (section: string) => void;
+ onAddTopic?: (section: string, componentName: string) => void;
+ onAddSasl?: (section: string, componentName: string) => void;
+};
+
+export function PipelineFlowCanvas({
+ configYaml,
+ selectedNodeId,
+ selectedTargetKind,
+ lintErrorsByNode,
+ unsavedNodeIds,
+ flashNodeIds,
+ flashToken,
+ focusNodeId,
+ focusToken,
+ onSelectNode,
+ onClearSelection,
+ onInsert,
+ onSlotInsert,
+ onAddConnector,
+ onAddTopic,
+ onAddSasl,
+}: PipelineFlowCanvasProps) {
+ const [collapsedIds, setCollapsedIds] = useState>(new Set());
+ // Hovering a node lights up its (otherwise faint) resource-reference edges.
+ const [hoveredNodeId, setHoveredNodeId] = useState();
+ // The armed zoom tool (Z / Option+Z) — drives the magnifier cursor and click-to-zoom.
+ const [zoomMode, setZoomMode] = useState(null);
+ // Interactive zoom-out floor — lowered for graphs too big to fit at MIN_ZOOM (MinZoomController).
+ const [minZoom, setMinZoom] = useState(MIN_ZOOM);
+ const wrapperRef = useRef(null);
+ useZoomCursor(zoomMode, wrapperRef);
+ // Node ids committed last render — anything new this render "appears" in place (see injectNodeData).
+ const previousIdsRef = useRef>(new Set());
+ const debouncedYaml = useDebouncedValue(configYaml, PARSE_DEBOUNCE_MS);
+ const { nodes, error, showingStale } = useResilientParse(debouncedYaml);
+
+ // A node's "scope" — itself plus all descendants — so selecting/hovering a container lights its whole branch.
+ const childrenByParent = useMemo(() => buildChildrenMap(nodes), [nodes]);
+ const scopeOf = useCallback(
+ (id: string | undefined): ReadonlySet | undefined => {
+ if (id === undefined) {
+ return;
+ }
+ const scope = new Set([id]);
+ const queue = [id];
+ while (queue.length > 0) {
+ for (const child of childrenByParent.get(queue.pop() as string) ?? []) {
+ scope.add(child.id);
+ queue.push(child.id);
+ }
+ }
+ return scope;
+ },
+ [childrenByParent]
+ );
+
+ const toggleCollapse = useCallback((nodeId: string) => {
+ setCollapsedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(nodeId)) {
+ next.delete(nodeId);
+ } else {
+ next.add(nodeId);
+ }
+ return next;
+ });
+ }, []);
+
+ // Layout keys on this stable boolean, not the callback identity — else an ancestor re-creating
+ // onSlotInsert would re-run the Dagre pass.
+ const editable = Boolean(onSlotInsert);
+
+ // Expensive layout memo: the full Dagre pass plus everything derived from its geometry (pan
+ // extent, legend). Keyed only on parsed nodes + edit mode, so decoration changes never re-run layout.
+ const { layoutNodes, layoutEdges, translateExtent, contentWidth, contentHeight, legend } = useMemo(() => {
+ const layout = computeGraphLayout(nodes, editable);
+
+ const margin = PAN_PADDING;
+ const bounds = contentBounds(layout.rfNodes);
+ const extent: [[number, number], [number, number]] = [
+ [bounds.minX - margin, bounds.minY - margin],
+ [bounds.maxX + margin, bounds.maxY + margin],
+ ];
+
+ // Which vocabularies appear — drives the adaptive legend (trivial pipelines stay legend-free).
+ const legendFlags = {
+ condition: layout.rfNodes.some((n: Node) => Boolean((n.data as FlowCardData | undefined)?.caseEditTarget)),
+ error: layout.rfEdges.some((e: Edge) => (e.data as { tone?: string } | undefined)?.tone === 'error'),
+ reference: layout.rfEdges.some((e: Edge) => e.id.startsWith('ref-')),
+ };
+
+ return {
+ layoutNodes: layout.rfNodes,
+ layoutEdges: layout.rfEdges,
+ translateExtent: extent,
+ contentWidth: bounds.maxX - bounds.minX,
+ contentHeight: bounds.maxY - bounds.minY,
+ legend: legendFlags,
+ };
+ }, [nodes, editable]);
+
+ // try marker → its fused catch marker (from the `error--` edge) so a try's region folds in its catch.
+ const tryCatchPairs = useMemo(() => {
+ const pairs = new Map();
+ for (const edge of layoutEdges) {
+ if (edge.id.startsWith('error-')) {
+ pairs.set(edge.source, edge.target);
+ }
+ }
+ return pairs;
+ }, [layoutEdges]);
+
+ // Cheap decoration memo, keyed on decoration inputs only — a click re-runs this, not the Dagre
+ // layout. Hover is excluded to stay on the cheap edges-only path.
+ const rfNodes = useMemo(() => {
+ const callbacks: CanvasCallbacks = {
+ onAddConnector,
+ onAddTopic,
+ onAddSasl,
+ onSlotInsert,
+ collapsedIds,
+ toggleCollapse,
+ selectedNodeId,
+ selectedTargetKind,
+ lintErrorsByNode,
+ unsavedNodeIds,
+ flashNodeIds,
+ flashToken,
+ previousIds: previousIdsRef.current,
+ };
+ return focusDimNodes(
+ layoutNodes.map((node: Node) => injectNodeData(node, callbacks)),
+ selectedNodeId,
+ scopeOf
+ );
+ }, [
+ layoutNodes,
+ collapsedIds,
+ toggleCollapse,
+ selectedNodeId,
+ selectedTargetKind,
+ lintErrorsByNode,
+ unsavedNodeIds,
+ flashNodeIds,
+ flashToken,
+ onAddConnector,
+ onAddTopic,
+ onAddSasl,
+ onSlotInsert,
+ scopeOf,
+ ]);
+
+ // Record the committed node ids so the next render can tell which nodes are new.
+ useEffect(() => {
+ previousIdsRef.current = new Set(rfNodes.map((node) => node.id));
+ }, [rfNodes]);
+
+ // Hover only restyles edges so node objects stay referentially stable — rebuilding the DOM under
+ // the cursor looped mouseleave/mouseenter, flickering the cursor and eating clicks.
+ const rfEdges = useMemo(
+ () =>
+ decorateEdges(layoutEdges, {
+ selectedScope: scopeOf(selectedNodeId),
+ hoveredScope: scopeOf(hoveredNodeId),
+ onInsert,
+ onSlotInsert,
+ onSelectNode,
+ }),
+ [layoutEdges, scopeOf, selectedNodeId, hoveredNodeId, onInsert, onSlotInsert, onSelectNode]
+ );
+
+ if (rfNodes.length === 0) {
+ return (
+
+ {/* Parse error with no last-good graph: keep the notice up (no dismiss) and point at the fix. */}
+ {error ? (
+
+ Unable to visualize this pipeline — fix the YAML in the YAML tab.
+
+ ) : null}
+ {/* Freeze the skeleton under an error (it's a backdrop, not a loading state). */}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {
+ const selection = selectionTargetForNode(node, rfNodes);
+ if (selection) {
+ onSelectNode?.(selection.id, selection.target, selection.caseTarget);
+ }
+ }}
+ onNodeMouseEnter={(_, node) => setHoveredNodeId(node.id)}
+ // Only clear when leaving the tracked node: crossing into a nested child fires
+ // enter(child) then leave(parent), which must not wipe the child's hover.
+ onNodeMouseLeave={(_, node) => setHoveredNodeId((prev) => (prev === node.id ? undefined : prev))}
+ onPaneClick={() => onClearSelection?.()}
+ panOnDrag={canPanCanvas(zoomMode)}
+ panOnScroll={false}
+ // The wheel scrolls the embedded page; zoom is via Z / Option+Z (ZoomTool), the Controls, and pinch.
+ preventScrolling={false}
+ proOptions={{ hideAttribution: true }}
+ translateExtent={translateExtent}
+ zoomOnDoubleClick
+ zoomOnPinch
+ zoomOnScroll={false}
+ >
+ {/* Faint plus-mark texture so the canvas doesn't read as blank — a background hint, not a grid. */}
+
+
+
+ {/* Renders only for control-flow markers (flowSplit/flowMerge). */}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-diagram-extent.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-diagram-extent.test.tsx
deleted file mode 100644
index f66305a3f8..0000000000
--- a/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-diagram-extent.test.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * Copyright 2025 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 { Node } from '@xyflow/react';
-import { describe, expect, it } from 'vitest';
-
-import { computeTranslateExtent } from './pipeline-flow-diagram';
-
-function makeNode(x: number, y: number, w: number, h: number): Node {
- return { id: `n-${x}-${y}`, position: { x, y }, data: {}, width: w, height: h };
-}
-
-describe('computeTranslateExtent', () => {
- it('clamps max extent to container width when content fits', () => {
- const nodes = [makeNode(0, 0, 100, 36)];
- const extent = computeTranslateExtent(nodes, 300, 600);
-
- expect(extent[1][0]).toBe(300);
- });
-
- it('extends max extent beyond container when content overflows', () => {
- // Node at x=100, width=250 → right edge at 350, +40 padding = 390 > 300
- const nodes = [makeNode(100, 0, 250, 36)];
- const extent = computeTranslateExtent(nodes, 300, 600);
-
- expect(extent[1][0]).toBe(390);
- });
-
- it('accounts for multiple nodes when computing extent', () => {
- const nodes = [makeNode(0, 0, 100, 36), makeNode(200, 50, 150, 36)];
- // Rightmost edge: 200+150=350, +40 padding = 390
- const extent = computeTranslateExtent(nodes, 300, 600);
-
- expect(extent[1][0]).toBe(390);
- });
-
- it('uses measured width when available', () => {
- const node: Node = {
- id: 'measured',
- position: { x: 0, y: 0 },
- data: {},
- width: 100,
- measured: { width: 320, height: 36 },
- };
- // measured width 320 → right edge 320 (overflows 300 container), +40 padding = 360
- const extent = computeTranslateExtent([node], 300, 600);
-
- expect(extent[1][0]).toBe(360);
- });
-
- it('locks extent to container when content fits both axes', () => {
- // Content at (0,0) → (100, 36) fits inside 300x600 container.
- // Without locking, the +40 padding would let the viewport pan by up to 40px
- // even though there's nothing to scroll to.
- const nodes = [makeNode(0, 0, 100, 36)];
- const extent = computeTranslateExtent(nodes, 300, 600);
-
- expect(extent).toEqual([
- [0, 0],
- [300, 600],
- ]);
- });
-});
diff --git a/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-diagram.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-diagram.test.tsx
deleted file mode 100644
index 0a6122bb73..0000000000
--- a/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-diagram.test.tsx
+++ /dev/null
@@ -1,215 +0,0 @@
-/**
- * Copyright 2025 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 userEvent from '@testing-library/user-event';
-import { render, screen } from 'test-utils';
-import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
-
-import { PipelineFlowDiagram } from './pipeline-flow-diagram';
-
-// ── Mocks ───────────────────────────────────────────────────────────
-
-// ResizeObserver doesn't exist in jsdom
-class ResizeObserverMock {
- observe() {}
- disconnect() {}
- unobserve() {}
-}
-
-let originalClientWidth: PropertyDescriptor | undefined;
-let originalClientHeight: PropertyDescriptor | undefined;
-
-beforeAll(() => {
- vi.stubGlobal('ResizeObserver', ResizeObserverMock);
- // Mock container dimensions so useLayoutEffect sets containerSize
- // and ReactFlow actually renders (it guards on containerSize !== null).
- originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth');
- originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight');
- Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 800 });
- Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, value: 600 });
-});
-
-afterAll(() => {
- vi.unstubAllGlobals();
- if (originalClientWidth) {
- Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth);
- }
- if (originalClientHeight) {
- Object.defineProperty(HTMLElement.prototype, 'clientHeight', originalClientHeight);
- }
-});
-
-// ── Fixtures ────────────────────────────────────────────────────────
-
-const KAFKA_INPUT_WITH_TOPIC = [
- 'input:',
- ' kafka_franz:',
- ' seed_brokers:',
- ' - broker:9092',
- ' topics:',
- ' - my-topic',
-].join('\n');
-
-const REDPANDA_INPUT_MISSING_CONFIG = ['input:', ' redpanda:', ' seed_brokers:', ' - broker:9092'].join('\n');
-
-const HTTP_OUTPUT = ['output:', ' http_client:', ' url: http://example.com'].join('\n');
-
-const PROCESSOR_YAML = ['pipeline:', ' processors:', " - mapping: 'root = this'"].join('\n');
-
-const EMPTY_INPUT = ['input:', ' none: {}'].join('\n');
-
-// ── Helpers ─────────────────────────────────────────────────────────
-
-type DiagramCallbacks = {
- onAddConnector?: (type: string) => void;
- onAddTopic?: (section: string, componentName: string) => void;
- onAddSasl?: (section: string, componentName: string) => void;
-};
-
-function renderDiagram(yaml: string, callbacks: DiagramCallbacks = {}) {
- return render(
-
- );
-}
-
-// ── Tests ───────────────────────────────────────────────────────────
-
-describe('PipelineFlowDiagram', () => {
- describe('empty and error states', () => {
- it('renders placeholder nodes for empty YAML', () => {
- renderDiagram('');
- // Empty YAML still produces placeholder sections via the parser
- expect(screen.getByText('Add input')).toBeInTheDocument();
- expect(screen.getByText('Add output')).toBeInTheDocument();
- });
-
- it('shows error banner for invalid YAML', () => {
- renderDiagram('{{{invalid');
- expect(screen.getByText('Unable to visualize pipeline.')).toBeInTheDocument();
- });
- });
-
- describe('section headers and connector names', () => {
- // Section labels from the parser are lowercase; CSS `uppercase` class handles display.
- it('renders input section header and connector name', () => {
- renderDiagram(KAFKA_INPUT_WITH_TOPIC);
- expect(screen.getByText('input')).toBeInTheDocument();
- expect(screen.getByText('kafka_franz')).toBeInTheDocument();
- });
-
- it('renders output section header and connector name', () => {
- renderDiagram(HTTP_OUTPUT);
- expect(screen.getByText('output')).toBeInTheDocument();
- expect(screen.getByText('http_client')).toBeInTheDocument();
- });
-
- it('renders processor section header and processor name', () => {
- renderDiagram(PROCESSOR_YAML);
- expect(screen.getByText('processors')).toBeInTheDocument();
- expect(screen.getByText('mapping')).toBeInTheDocument();
- });
- });
-
- describe('placeholder nodes', () => {
- it('shows "Add input" text for empty input placeholder', () => {
- renderDiagram(EMPTY_INPUT);
- expect(screen.getByText('Add input')).toBeInTheDocument();
- });
-
- it('fires onAddConnector when placeholder add button is clicked', async () => {
- const user = userEvent.setup();
- const onAddConnector = vi.fn();
- const { container } = renderDiagram(EMPTY_INPUT, { onAddConnector });
-
- // Find the absolute-positioned "+" button on the placeholder node via DOM query
- const plusButtons = container.querySelectorAll('button[class*="absolute"]');
- expect(plusButtons.length).toBeGreaterThan(0);
- await user.click(plusButtons[0]);
- expect(onAddConnector).toHaveBeenCalledWith('input');
- });
-
- it('does not show add button when onAddConnector is not provided', () => {
- const { container } = renderDiagram(EMPTY_INPUT);
- const plusButtons = container.querySelectorAll('button[class*="absolute"]');
- expect(plusButtons).toHaveLength(0);
- });
- });
-
- describe('topic badges', () => {
- it('renders topic badge for connectors with topics', () => {
- renderDiagram(KAFKA_INPUT_WITH_TOPIC);
- expect(screen.getByText('topic: my-topic')).toBeInTheDocument();
- });
- });
-
- describe('setup hint buttons', () => {
- it('shows "Topic" button on Redpanda node missing topic config', () => {
- renderDiagram(REDPANDA_INPUT_MISSING_CONFIG, { onAddTopic: vi.fn() });
- expect(screen.getByText('Topic')).toBeInTheDocument();
- });
-
- it('fires onAddTopic when "Topic" button is clicked', async () => {
- const user = userEvent.setup();
- const onAddTopic = vi.fn();
- renderDiagram(REDPANDA_INPUT_MISSING_CONFIG, { onAddTopic });
-
- const topicButton = screen.getByText('Topic').closest('button');
- expect(topicButton).not.toBeNull();
- await user.click(topicButton as HTMLButtonElement);
- expect(onAddTopic).toHaveBeenCalledWith('input', 'redpanda');
- });
-
- it('shows "User" button on Redpanda node missing SASL config', () => {
- renderDiagram(REDPANDA_INPUT_MISSING_CONFIG, { onAddSasl: vi.fn() });
- expect(screen.getByText('User')).toBeInTheDocument();
- });
-
- it('fires onAddSasl when "User" button is clicked', async () => {
- const user = userEvent.setup();
- const onAddSasl = vi.fn();
- renderDiagram(REDPANDA_INPUT_MISSING_CONFIG, { onAddSasl });
-
- const userButton = screen.getByText('User').closest('button');
- expect(userButton).not.toBeNull();
- await user.click(userButton as HTMLButtonElement);
- expect(onAddSasl).toHaveBeenCalledWith('input', 'redpanda');
- });
-
- it('renders static "No topic"/"No user" status pills (not buttons) when callbacks are absent', () => {
- // View-only mode (no edit callbacks): we still surface the missing-config
- // signal, but as a static pill so it doesn't look clickable.
- renderDiagram(REDPANDA_INPUT_MISSING_CONFIG);
- expect(screen.getByText('No topic')).toBeInTheDocument();
- expect(screen.getByText('No user')).toBeInTheDocument();
- expect(screen.queryByText('Topic')).not.toBeInTheDocument();
- expect(screen.queryByText('User')).not.toBeInTheDocument();
- });
- });
-
- describe('docs link', () => {
- it('renders a documentation link on connector leaf nodes', () => {
- renderDiagram(KAFKA_INPUT_WITH_TOPIC);
- const docsLink = screen.getByLabelText('kafka_franz documentation');
- expect(docsLink).toBeInTheDocument();
- expect(docsLink).toHaveAttribute(
- 'href',
- 'https://docs.redpanda.com/redpanda-cloud/develop/connect/components/inputs/kafka_franz/'
- );
- expect(docsLink).toHaveAttribute('target', '_blank');
- });
- });
-});
diff --git a/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-diagram.tsx b/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-diagram.tsx
deleted file mode 100644
index 639f787921..0000000000
--- a/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-diagram.tsx
+++ /dev/null
@@ -1,365 +0,0 @@
-/**
- * Copyright 2025 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 Edge,
- type Node,
- PanOnScrollMode,
- ReactFlow,
- type ReactFlowInstance,
- ReactFlowProvider,
- useReactFlow,
-} from '@xyflow/react';
-import { Button } from 'components/redpanda-ui/components/button';
-import { useDebouncedValue } from 'hooks/use-debounced-value';
-import { ArrowRight, MinusIcon, PlusIcon, Sparkles } from 'lucide-react';
-import { AnimatePresence, motion } from 'motion/react';
-import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
-
-import { PipelineFlowSkeleton, pipelineEdgeTypes, pipelineNodeTypes } from './pipeline-flow-nodes';
-import {
- computeTreeLayout as defaultComputeLayout,
- parsePipelineFlowTree as defaultParseTree,
- type ParsePipelineFlowTreeResult,
- type PipelineFlowNode,
-} from '../utils/pipeline-flow-parser';
-
-const EXTENT_PADDING = 40;
-const PARSE_DEBOUNCE_MS = 300;
-const MIN_ZOOM = 0.5;
-const MAX_ZOOM = 1.5;
-
-function ZoomControls() {
- const { zoomIn, zoomOut } = useReactFlow();
- return (
-
-
zoomIn({ duration: 200 })}
- size="xs"
- variant="outline"
- >
-
-
-
zoomOut({ duration: 200 })}
- size="xs"
- variant="outline"
- >
-
-
-
- );
-}
-
-type PipelineFlowDiagramProps = {
- configYaml: string;
- onAddConnector?: (type: string) => void;
- onAddTopic?: (section: string, componentName: string) => void;
- onAddSasl?: (section: string, componentName: string) => void;
- onBrowseTemplates?: () => void;
- hideZoomControls?: boolean;
- parseTree?: (yaml: string) => ParsePipelineFlowTreeResult;
- computeLayout?: (
- nodes: PipelineFlowNode[],
- collapsedIds: ReadonlySet
- ) => { rfNodes: Node[]; rfEdges: Edge[]; height: number; maxDepth?: number };
-};
-
-export function computeTranslateExtent(
- rfNodes: Node[],
- containerWidth: number,
- containerHeight: number
-): [[number, number], [number, number]] {
- let minX = 0;
- let minY = 0;
- let maxX = 0;
- let maxY = 0;
-
- for (const node of rfNodes) {
- const { x, y } = node.position;
- const w = node.measured?.width ?? node.width ?? 300;
- const h = node.measured?.height ?? node.height ?? 36;
-
- if (x < minX) {
- minX = x;
- }
- if (y < minY) {
- minY = y;
- }
- if (x + w > maxX) {
- maxX = x + w;
- }
- if (y + h > maxY) {
- maxY = y + h;
- }
- }
-
- // Lock each axis where content fits — the EXTENT_PADDING buffer would
- // otherwise let the viewport pan by ~40px with nothing to scroll to.
- const xFits = maxX <= containerWidth;
- const yFits = maxY <= containerHeight;
- const lowX = xFits ? 0 : minX - EXTENT_PADDING;
- const lowY = yFits ? 0 : minY - EXTENT_PADDING;
- const highX = xFits ? containerWidth : maxX + EXTENT_PADDING;
- const highY = yFits ? containerHeight : maxY + EXTENT_PADDING;
- return [
- [lowX, lowY],
- [highX, highY],
- ];
-}
-
-export const PipelineFlowDiagram = ({
- configYaml,
- onAddConnector,
- onAddTopic,
- onAddSasl,
- onBrowseTemplates,
- hideZoomControls,
- parseTree = defaultParseTree,
- computeLayout = defaultComputeLayout,
-}: PipelineFlowDiagramProps) => {
- const instanceId = useId();
- const [collapsedIds, setCollapsedIds] = useState>(new Set());
- const containerRef = useRef(null);
- const rfInstanceRef = useRef | null>(null);
- const [containerSize, setContainerSize] = useState<{ width: number; height: number } | null>(null);
-
- const debouncedYaml = useDebouncedValue(configYaml, PARSE_DEBOUNCE_MS);
-
- const toggleCollapse = useCallback((nodeId: string) => {
- setCollapsedIds((prev) => {
- const next = new Set(prev);
- if (next.has(nodeId)) {
- next.delete(nodeId);
- } else {
- next.add(nodeId);
- }
- return next;
- });
- }, []);
-
- // Measure container synchronously before first paint so ReactFlow
- // initialises with the correct translateExtent (avoids centering flash).
- useLayoutEffect(() => {
- const el = containerRef.current;
- if (!el) {
- return;
- }
- const w = el.clientWidth;
- const h = el.clientHeight;
- if (w > 0 && h > 0) {
- setContainerSize({ width: w, height: h });
- }
- }, []);
-
- // Track subsequent resizes (e.g. ResizablePanel drag).
- useEffect(() => {
- const el = containerRef.current;
- if (!el) {
- return;
- }
- const ro = new ResizeObserver(([entry]) => {
- const w = Math.round(entry.contentRect.width);
- const h = Math.round(entry.contentRect.height);
- if (w === 0 || h === 0) {
- return;
- }
- setContainerSize((prev) => {
- if (prev && prev.width === w && prev.height === h) {
- return prev;
- }
- return { width: w, height: h };
- });
- });
- ro.observe(el);
- return () => ro.disconnect();
- }, []);
-
- const { nodes, error } = useMemo(
- () =>
- parseTree === defaultParseTree
- ? defaultParseTree(debouncedYaml, { idPrefix: instanceId })
- : parseTree(debouncedYaml),
- [debouncedYaml, parseTree, instanceId]
- );
-
- const { rfNodes, rfEdges } = useMemo(() => {
- const layout = computeLayout(nodes, collapsedIds);
-
- // Inject callbacks into group, placeholder, and setup-hint leaf nodes.
- const nodesWithCallbacks = layout.rfNodes.map((node: Node) => {
- if (node.type === 'treeGroup') {
- return {
- ...node,
- data: { ...node.data, onToggle: () => toggleCollapse(node.id) },
- };
- }
- if (node.type === 'treeLeaf') {
- const isPlaceholder = node.data.label === 'none';
- // Placeholder nodes: show "+" connector button
- if (isPlaceholder && onAddConnector && (node.data.section === 'input' || node.data.section === 'output')) {
- return {
- ...node,
- data: { ...node.data, onAddConnector },
- };
- }
- // Non-placeholder redpanda nodes: inject setup hint callbacks
- if (!isPlaceholder && (node.data.missingTopic || node.data.missingSasl)) {
- return {
- ...node,
- data: {
- ...node.data,
- ...(onAddTopic && node.data.missingTopic ? { onAddTopic } : {}),
- ...(onAddSasl && node.data.missingSasl ? { onAddSasl } : {}),
- },
- };
- }
- }
- return node;
- });
-
- return { rfNodes: nodesWithCallbacks, rfEdges: layout.rfEdges };
- }, [nodes, collapsedIds, toggleCollapse, computeLayout, onAddConnector, onAddTopic, onAddSasl]);
-
- const { translateExtent, contentOverflows } = useMemo(() => {
- if (!containerSize) {
- return { translateExtent: undefined, contentOverflows: false };
- }
- const extent = computeTranslateExtent(rfNodes, containerSize.width, containerSize.height);
- const rawMaxX = extent[1][0] - EXTENT_PADDING;
- const rawMaxY = extent[1][1] - EXTENT_PADDING;
- const overflows = rawMaxX > containerSize.width || rawMaxY > containerSize.height;
- return {
- translateExtent: extent,
- contentOverflows: overflows,
- };
- }, [rfNodes, containerSize]);
-
- // React Flow occasionally settles with a vertical offset when the container
- // is much taller than the diagram; defaultViewport alone doesn't override it.
- useLayoutEffect(() => {
- const rf = rfInstanceRef.current;
- if (rf && rfNodes.length > 0) {
- rf.setViewport({ x: 0, y: 0, zoom: 1 });
- }
- }, [rfNodes, translateExtent]);
-
- // At the diagram's top/bottom pan boundary, swallow the wheel event in capture
- // so the page scrolls instead. Mid-range events fall through to React Flow's pan.
- useEffect(() => {
- const el = containerRef.current;
- if (!(el && contentOverflows && translateExtent && containerSize)) {
- return;
- }
- const [[, minBoundY], [, maxBoundY]] = translateExtent;
- const maxVpY = -minBoundY;
- const minVpY = containerSize.height - maxBoundY;
- const handleWheel = (event: WheelEvent) => {
- const rf = rfInstanceRef.current;
- if (!rf) {
- return;
- }
- const vp = rf.getViewport();
- const atTop = vp.y >= maxVpY - 0.5;
- const atBottom = vp.y <= minVpY + 0.5;
- if ((event.deltaY < 0 && atTop) || (event.deltaY > 0 && atBottom)) {
- event.stopPropagation();
- }
- };
- el.addEventListener('wheel', handleWheel, { capture: true, passive: true });
- return () => el.removeEventListener('wheel', handleWheel, { capture: true } as EventListenerOptions);
- }, [contentOverflows, translateExtent, containerSize]);
-
- if (rfNodes.length === 0) {
- return (
-
- );
- }
-
- // Section labels and `none` placeholders don't count as user content.
- const isPipelineEmpty = !nodes.some((n) => n.kind === 'group' || (n.kind === 'leaf' && n.label !== 'none'));
- const showTemplateFab = Boolean(onBrowseTemplates) && isPipelineEmpty;
-
- return (
-
- {containerSize ? (
-
- {
- rfInstanceRef.current = instance;
- instance.setViewport({ x: 0, y: 0, zoom: 1 });
- }}
- panOnDrag={false}
- panOnScroll={contentOverflows}
- panOnScrollMode={PanOnScrollMode.Vertical}
- preventScrolling={contentOverflows}
- proOptions={{ hideAttribution: true }}
- translateExtent={translateExtent}
- zoomOnPinch={false}
- zoomOnScroll={false}
- />
- {hideZoomControls ? null : }
-
- {showTemplateFab && onBrowseTemplates ? (
-
-
-
-
-
-
- Start from a template
- Skip the YAML — fill a short form
-
-
-
-
- ) : null}
-
-
- ) : null}
-
- );
-};
diff --git a/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-nodes.tsx b/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-nodes.tsx
deleted file mode 100644
index 93baa425f2..0000000000
--- a/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-nodes.tsx
+++ /dev/null
@@ -1,289 +0,0 @@
-/**
- * Copyright 2025 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 { BaseEdge, type EdgeProps, Handle, Position } from '@xyflow/react';
-import type { ComponentName } from 'assets/connectors/component-logo-map';
-import { Badge } from 'components/redpanda-ui/components/badge';
-import { BadgeGroup } from 'components/redpanda-ui/components/badge-group';
-import { Banner, BannerClose, BannerContent } from 'components/redpanda-ui/components/banner';
-import { Button } from 'components/redpanda-ui/components/button';
-import { CountDot } from 'components/redpanda-ui/components/count-dot';
-import { Skeleton } from 'components/redpanda-ui/components/skeleton';
-import { Text } from 'components/redpanda-ui/components/typography';
-import { cn } from 'components/redpanda-ui/lib/utils';
-import { BaseNode } from 'components/ui/base-node';
-import { BookOpenIcon, Box, ChevronDown, ChevronUp, PlusIcon } from 'lucide-react';
-
-import { ConnectorLogo } from '../onboarding/connector-logo';
-
-const invisibleHandle = '!w-0 !h-0 !border-0 !bg-transparent !min-w-0 !min-h-0';
-
-const DOCS_BASE = 'https://docs.redpanda.com/redpanda-cloud/develop/connect/components';
-const DOCS_SECTIONS = new Set(['input', 'output', 'processor']);
-
-export function getConnectorDocsUrl(section: string, connectorName: string): string | undefined {
- if (!DOCS_SECTIONS.has(section)) {
- return;
- }
- return `${DOCS_BASE}/${section}s/${connectorName}/`;
-}
-const ARROW_GAP = 8;
-const BRANCH_INDENT = 12;
-const SECTION_EDGE_GAP = 20;
-
-const SKELETON_SECTIONS = [
- { label: 'INPUT', leaves: 1 },
- { label: 'PROCESSORS', leaves: 2 },
- { label: 'OUTPUT', leaves: 1 },
-] as const;
-
-type PipelineFlowSkeletonProps = {
- error?: string;
-};
-
-export function PipelineFlowSkeleton({ error }: PipelineFlowSkeletonProps) {
- return (
-
- {error ? (
-
- Unable to visualize pipeline.
-
-
- ) : null}
-
- {SKELETON_SECTIONS.map((section) => (
-
-
- {Array.from({ length: section.leaves }, (_, leafIndex) => (
-
- ))}
-
- ))}
-
-
- );
-}
-
-type TreeNodeData = {
- label: string;
- labelText?: string;
- topics?: string[];
- section?: string;
- collapsed?: boolean;
- collapsible?: boolean;
- childCount?: number;
- missingTopic?: boolean;
- missingSasl?: boolean;
- onToggle?: () => void;
- onAddConnector?: (type: string) => void;
- onAddTopic?: (section: string, componentName: string) => void;
- onAddSasl?: (section: string, componentName: string) => void;
-};
-
-const TreeSectionNode = ({ data }: { data: TreeNodeData }) => (
-
-
-
- {data.label}
-
-
-
-);
-
-const TreeGroupNode = ({ data }: { data: TreeNodeData }) => (
-
-
-
-
- {data.label}
-
- {data.collapsible ? (
-
- {data.collapsed ? : }
-
- ) : null}
- {data.collapsed && data.childCount ? (
-
- ) : null}
- {!data.collapsed && }
-
-);
-
-// Clickable "+ X" button when `onAdd` is wired, else a static "No X" pill —
-// keeps the missing-config signal visible in view mode without looking clickable.
-type MissingConfigChipProps = {
- addLabel: string;
- missingLabel: string;
- onAdd?: () => void;
-};
-
-const MissingConfigChip = ({ addLabel, missingLabel, onAdd }: MissingConfigChipProps) => {
- if (onAdd) {
- return (
- }
- onClick={onAdd}
- size="xs"
- variant="secondary"
- >
- {addLabel}
-
- );
- }
- return (
-
- {missingLabel}
-
- );
-};
-
-// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: leaf node renders topics, setup hints, doc links, and placeholder add button
-const TreeLeafNode = ({ data }: { data: TreeNodeData }) => {
- const hasTopics = data.topics && data.topics.length > 0;
- const isPlaceholder = data.label === 'none';
- const showAddButton = isPlaceholder && data.onAddConnector && data.section;
- const showSetupHints = !isPlaceholder && (data.missingTopic || data.missingSasl);
- const docsUrl = isPlaceholder ? undefined : getConnectorDocsUrl(data.section ?? '', data.label);
- return (
-
-
-
-
- {isPlaceholder ? null : (
-
- )}
-
- {isPlaceholder ? `Add ${data.section ?? 'connector'}` : data.label}
-
- {docsUrl ? (
-
-
-
- ) : null}
-
- {data.labelText || hasTopics || showSetupHints ? (
-
- {data.labelText ? (
-
-
- {data.labelText}
-
-
- ) : null}
- {hasTopics ? (
-
- {data.topics?.map((t) => (
-
-
- topic: {t}
-
-
- ))}
-
- ) : null}
- {showSetupHints ? (
- <>
- {data.missingTopic ? (
- data.onAddTopic?.(data.section ?? '', data.label) : undefined}
- />
- ) : null}
- {data.missingSasl ? (
- data.onAddSasl?.(data.section ?? '', data.label) : undefined}
- />
- ) : null}
- >
- ) : null}
-
- ) : null}
-
- {showAddButton ? (
- data.onAddConnector?.(data.section ?? '')}
- size="icon-xs"
- variant="secondary"
- >
-
-
- ) : null}
-
- );
-};
-
-export function TreeEdge({ sourceX, sourceY, targetX, targetY, markerEnd }: EdgeProps) {
- const path = `M ${sourceX + BRANCH_INDENT} ${sourceY} V ${targetY} H ${targetX - ARROW_GAP}`;
- return ;
-}
-
-export function SectionEdge({ sourceX, sourceY, targetY, markerEnd }: EdgeProps) {
- const path = `M ${sourceX} ${sourceY} V ${targetY - SECTION_EDGE_GAP}`;
- return (
-
- );
-}
-
-export const pipelineNodeTypes = {
- treeSection: TreeSectionNode,
- treeGroup: TreeGroupNode,
- treeLeaf: TreeLeafNode,
-};
-
-export const pipelineEdgeTypes = {
- treeEdge: TreeEdge,
- sectionEdge: SectionEdge,
-};
diff --git a/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-skeleton.tsx b/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-skeleton.tsx
new file mode 100644
index 0000000000..ba69638dcf
--- /dev/null
+++ b/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-skeleton.tsx
@@ -0,0 +1,41 @@
+/**
+ * Copyright 2025 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 { Skeleton } from 'components/redpanda-ui/components/skeleton';
+
+const SKELETON_SECTIONS = [
+ { label: 'INPUT', leaves: 1 },
+ { label: 'PROCESSORS', leaves: 2 },
+ { label: 'OUTPUT', leaves: 1 },
+] as const;
+
+export function PipelineFlowSkeleton() {
+ return (
+
+
+ {SKELETON_SECTIONS.map((section) => (
+
+
+ {Array.from({ length: section.leaves }, (_, leafIndex) => (
+
+ ))}
+
+ ))}
+
+
+ );
+}
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 81a918fa0e..8fedc70113 100644
--- a/frontend/src/components/pages/rp-connect/pipeline/pipeline-header.tsx
+++ b/frontend/src/components/pages/rp-connect/pipeline/pipeline-header.tsx
@@ -66,6 +66,22 @@ const DetailLine = ({ label, children }: { label: string; children: ReactNode })
);
+// Description gets its own block (not the narrow key/value column): quiet label, prose width, kept line breaks.
+const DescriptionBlock = ({ text, clamp }: { text: string; clamp?: boolean }) => (
+