From 539024b141da82af43468b86aca2b36a1e22405c Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Tue, 7 Jul 2026 09:14:22 -0700 Subject: [PATCH 1/5] UX-1338 - RPCN visual editor --- frontend/.gitignore | 3 + frontend/bun.lock | 5 + frontend/package.json | 1 + frontend/src/components/constants.ts | 1 + .../debug-helper/connect-config-fixtures.ts | 968 ++++++++ .../debug-helper/feature-flag-overrides.ts | 2 +- .../onboarding/add-connector-dialog.tsx | 42 +- .../connect-command-palette.test.tsx | 91 + .../onboarding/connect-command-palette.tsx | 706 ++++++ .../pipeline/editor-tips-bar.test.tsx | 45 + .../rp-connect/pipeline/editor-tips-bar.tsx | 135 ++ .../pages/rp-connect/pipeline/index.test.tsx | 143 +- .../pages/rp-connect/pipeline/index.tsx | 580 +++-- .../pipeline/node-config-form.test.tsx | 291 +++ .../rp-connect/pipeline/node-config-form.tsx | 1028 +++++++++ .../rp-connect/pipeline/node-inspector.tsx | 850 +++++++ .../pipeline-canvas-command-palette.tsx | 173 ++ .../pipeline/pipeline-flow-canvas-nodes.tsx | 986 ++++++++ .../pipeline-flow-canvas.render.test.tsx | 164 ++ .../pipeline/pipeline-flow-canvas.test.tsx | 244 ++ .../pipeline/pipeline-flow-canvas.tsx | 1526 +++++++++++++ .../pipeline-flow-diagram-extent.test.tsx | 71 - .../pipeline/pipeline-flow-diagram.test.tsx | 215 -- .../pipeline/pipeline-flow-diagram.tsx | 367 --- .../pipeline/pipeline-flow-nodes.test.ts | 17 +- .../pipeline/pipeline-flow-nodes.tsx | 230 +- .../rp-connect/pipeline/pipeline-header.tsx | 57 +- .../pipeline/pipeline-problems-panel.test.tsx | 71 + .../pipeline/pipeline-problems-panel.tsx | 249 ++ .../pipeline/pipeline-status-toggle.test.tsx | 54 + .../pipeline/pipeline-status-toggle.tsx | 47 +- .../pipeline/pipeline-structure-tree.test.tsx | 148 ++ .../pipeline/pipeline-structure-tree.tsx | 412 ++++ .../pipeline/pipeline-unsaved-panel.test.tsx | 86 + .../pipeline/pipeline-unsaved-panel.tsx | 87 + .../rp-connect/pipeline/scroll-shadow.tsx | 55 + .../rp-connect/pipeline/template-cta.tsx | 70 + .../pipeline/use-pipeline-editor-store.ts | 83 +- .../pipeline/visual-editor-panel.test.tsx | 417 ++++ .../pipeline/visual-editor-panel.tsx | 1043 +++++++++ .../utils/component-aliases.test.ts | 29 + .../rp-connect/utils/component-aliases.ts | 47 + .../rp-connect/utils/pipeline-diff.test.tsx | 82 + .../pages/rp-connect/utils/pipeline-diff.ts | 110 + .../rp-connect/utils/pipeline-flow-meta.ts | 282 +++ .../utils/pipeline-flow-parser.test.tsx | 1166 ++++++++-- .../rp-connect/utils/pipeline-flow-parser.ts | 2006 ++++++++++++++--- .../rp-connect/utils/pipeline-lint.test.tsx | 137 ++ .../pages/rp-connect/utils/pipeline-lint.ts | 124 + .../pages/rp-connect/utils/yaml.test.tsx | 389 ++++ .../components/pages/rp-connect/utils/yaml.ts | 613 +++-- .../redpanda-ui/components/dialog.tsx | 36 +- .../components/drag-scroll-area.tsx | 168 ++ .../redpanda-ui/components/tabs.tsx | 37 +- .../redpanda-ui/lib/use-scroll-shadow.ts | 100 +- .../components/ui/connect/log-explorer.tsx | 6 +- frontend/src/globals.css | 17 + frontend/src/routes/__root.tsx | 3 +- 58 files changed, 15239 insertions(+), 1876 deletions(-) create mode 100644 frontend/src/components/pages/rp-connect/onboarding/connect-command-palette.test.tsx create mode 100644 frontend/src/components/pages/rp-connect/onboarding/connect-command-palette.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/editor-tips-bar.test.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/editor-tips-bar.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/node-config-form.test.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/node-config-form.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/node-inspector.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/pipeline-canvas-command-palette.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-canvas-nodes.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-canvas.render.test.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-canvas.test.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-canvas.tsx delete mode 100644 frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-diagram-extent.test.tsx delete mode 100644 frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-diagram.test.tsx delete mode 100644 frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-diagram.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/pipeline-problems-panel.test.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/pipeline-problems-panel.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/pipeline-status-toggle.test.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/pipeline-structure-tree.test.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/pipeline-structure-tree.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/pipeline-unsaved-panel.test.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/pipeline-unsaved-panel.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/scroll-shadow.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/template-cta.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/visual-editor-panel.test.tsx create mode 100644 frontend/src/components/pages/rp-connect/pipeline/visual-editor-panel.tsx create mode 100644 frontend/src/components/pages/rp-connect/utils/component-aliases.test.ts create mode 100644 frontend/src/components/pages/rp-connect/utils/component-aliases.ts create mode 100644 frontend/src/components/pages/rp-connect/utils/pipeline-diff.test.tsx create mode 100644 frontend/src/components/pages/rp-connect/utils/pipeline-diff.ts create mode 100644 frontend/src/components/pages/rp-connect/utils/pipeline-flow-meta.ts create mode 100644 frontend/src/components/pages/rp-connect/utils/pipeline-lint.test.tsx create mode 100644 frontend/src/components/pages/rp-connect/utils/pipeline-lint.ts create mode 100644 frontend/src/components/redpanda-ui/components/drag-scroll-area.tsx diff --git a/frontend/.gitignore b/frontend/.gitignore index 391a029afe..fe93b26898 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -105,6 +105,9 @@ web_modules/ .cache .parcel-cache +# TanStack Router generated temp cache +.tanstack/ + # Next.js build output .next out diff --git a/frontend/bun.lock b/frontend/bun.lock index 333d5453e9..0e7d6b35bf 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -25,6 +25,7 @@ "@connectrpc/connect": "^2.1.0", "@connectrpc/connect-query": "^2.2.0", "@connectrpc/connect-web": "^2.1.0", + "@dagrejs/dagre": "^3.0.0", "@emotion/css": "^11.13.5", "@hello-pangea/dnd": "^18.0.1", "@hookform/resolvers": "^5.2.2", @@ -437,6 +438,10 @@ "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + "@dagrejs/dagre": ["@dagrejs/dagre@3.0.0", "", { "dependencies": { "@dagrejs/graphlib": "4.0.1" } }, "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q=="], + + "@dagrejs/graphlib": ["@dagrejs/graphlib@4.0.1", "", {}, "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA=="], + "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], diff --git a/frontend/package.json b/frontend/package.json index 2384e8087c..4b7a572597 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -69,6 +69,7 @@ "@connectrpc/connect": "^2.1.0", "@connectrpc/connect-query": "^2.2.0", "@connectrpc/connect-web": "^2.1.0", + "@dagrejs/dagre": "^3.0.0", "@emotion/css": "^11.13.5", "@hello-pangea/dnd": "^18.0.1", "@hookform/resolvers": "^5.2.2", diff --git a/frontend/src/components/constants.ts b/frontend/src/components/constants.ts index 84fb5590a8..cdc79fc872 100644 --- a/frontend/src/components/constants.ts +++ b/frontend/src/components/constants.ts @@ -10,6 +10,7 @@ export const FEATURE_FLAGS = { enableRemoteMcpInConsole: false, enableRpcnTiles: false, enableRpcnTemplateGallery: false, + enableRpcnVisualEditor: false, enableServerlessOnboardingWizard: false, enableApiKeyConfigurationAgent: false, enableDataplaneObservabilityServerless: false, diff --git a/frontend/src/components/debug-helper/connect-config-fixtures.ts b/frontend/src/components/debug-helper/connect-config-fixtures.ts index 9c29de8e72..b8edddb424 100644 --- a/frontend/src/components/debug-helper/connect-config-fixtures.ts +++ b/frontend/src/components/debug-helper/connect-config-fixtures.ts @@ -564,6 +564,197 @@ tracer: ratio: 0.1 `; +const heavyBranching = `# Heavy branching & routing — stress-tests the visualizer's container/edge model: +# broker fan-in input; nested switch -> branch -> try/catch; for_each + parallel; +# resource references (cache + rate limit); switch output with DLQ + fallback tiers. +input: + broker: + inputs: + - redpanda: + seed_brokers: + - \${REDPANDA_BROKERS} + topics: [orders, payments] + consumer_group: routing-demo + sasl: + - mechanism: SCRAM-SHA-256 + username: \${secrets.KAFKA_USER} + password: \${secrets.KAFKA_PASSWORD} + tls: + enabled: true + - generate: + interval: 5s + mapping: 'root = {"synthetic": true, "region": "us"}' + +pipeline: + threads: 4 + processors: + - cache: + resource: dedupe_cache + operator: add + key: \${! json("order_id") } + value: "1" + - mapping: 'root = if errored() { deleted() } else { this }' + - switch: + - check: this.region == "us" + processors: + - branch: + request_map: 'root = this.customer_id' + processors: + - http: + url: https://us.api/customers + verb: GET + retries: 3 + result_map: 'root.customer = this' + - rate_limit: + resource: us_limiter + - check: this.region == "eu" + processors: + - branch: + request_map: 'root = this.customer_id' + processors: + - try: + - http: + url: https://eu.api/customers + - cache: + resource: customer_cache + operator: set + key: \${! json("id") } + value: \${! content() } + - catch: + - log: + level: WARN + message: 'EU customer lookup failed' + - mapping: 'root.customer = {"fallback": true}' + result_map: 'root.customer = this' + - processors: + - mapping: 'root.region = "unknown"' + - for_each: + - mapping: 'root = this' + - parallel: + cap: 3 + processors: + - http: + url: https://enrich.geo + - http: + url: https://enrich.risk + - branch: + request_map: 'root = this' + processors: + - aws_lambda: + function: fraud-score + region: us-east-1 + result_map: 'root.fraud = this' + +output: + switch: + cases: + - check: errored() + output: + redpanda: + seed_brokers: + - \${REDPANDA_BROKERS} + topic: orders-dlq + sasl: + - mechanism: SCRAM-SHA-256 + username: \${secrets.KAFKA_USER} + password: \${secrets.KAFKA_PASSWORD} + tls: + enabled: true + - check: this.fraud.score > 0.9 + output: + redpanda: + seed_brokers: + - \${REDPANDA_BROKERS} + topic: orders-review + - check: this.region == "us" + output: + aws_s3: + bucket: us-orders-prod + path: orders/\${! timestamp_unix() }-\${! uuid_v4() }.json + - output: + fallback: + - gcp_pubsub: + project: my-project + topic: orders-stream + - redpanda: + seed_brokers: + - \${REDPANDA_BROKERS} + topic: orders-fallback + +cache_resources: + - label: dedupe_cache + redis: + url: redis://cache:6379 + prefix: "dedupe:" + - label: customer_cache + memcached: + addresses: + - memcached:11211 + default_ttl: 5m + +rate_limit_resources: + - label: us_limiter + local: + count: 1000 + interval: 1s +`; + +const resourceIndirection = `# Resource indirection — the input, output, and a processor are declared as named +# *_resources entries and referenced by label with \`resource:\`. The HTTP enrichment +# processor is defined ONCE and reused in two places (the main path and the catch +# handler), so editing it updates both. Valid in Redpanda Cloud. +# See: https://docs.redpanda.com/redpanda-cloud/develop/connect/configuration/resources/ +input: + resource: orders_in + +pipeline: + processors: + - resource: enrich_http + - catch: + - resource: enrich_http + - log: + level: WARN + message: 'enrichment retry failed: \${! error() }' + +output: + resource: orders_out + +input_resources: + - label: orders_in + redpanda: + seed_brokers: + - \${REDPANDA_BROKERS} + topics: [orders] + consumer_group: connect-resource-demo + sasl: + - mechanism: SCRAM-SHA-256 + username: \${secrets.KAFKA_USER} + password: \${secrets.KAFKA_PASSWORD} + tls: + enabled: true + +processor_resources: + - label: enrich_http + http: + url: https://internal.api/enrich + verb: POST + timeout: 2s + retries: 3 + +output_resources: + - label: orders_out + redpanda: + seed_brokers: + - \${REDPANDA_BROKERS} + topic: orders-enriched + sasl: + - mechanism: SCRAM-SHA-256 + username: \${secrets.KAFKA_USER} + password: \${secrets.KAFKA_PASSWORD} + tls: + enabled: true +`; + const malformedYaml = `# Intentionally broken — for testing editor error states. input: redpanda @@ -660,6 +851,759 @@ output: path: events/\${! timestamp_unix() }.json `; +const omniChannelPlatform = `# Omni-channel commerce event platform — a deliberately MASSIVE, maximally-branched pipeline. +# Five sources fan into one broker; a top-level switch routes each event by type (order / +# payment / clickstream / inventory / shipment / heartbeat / audit) into its own deeply nested +# sub-pipeline (switch -> branch -> try/catch -> parallel -> workflow DAG -> for_each -> group_by). +# After type routing, every event runs shared cross-cutting stages: fraud scoring (lambda), +# parallel 360 enrichment, an audited side-effect write with try/catch, and a final routing +# decision. A large switch output then fans results to DLQ, review, per-region S3, pub/sub and +# layered fallbacks. Cache + rate-limit resources are referenced by label throughout. Built to +# exercise every node/edge shape in the visualizer — not all components validate in Cloud. +input: + broker: + inputs: + - redpanda: + seed_brokers: + - \${REDPANDA_BROKERS} + topics: [orders] + consumer_group: omni-orders + sasl: + - mechanism: SCRAM-SHA-256 + username: \${secrets.KAFKA_USER} + password: \${secrets.KAFKA_PASSWORD} + tls: + enabled: true + - redpanda: + seed_brokers: + - \${REDPANDA_BROKERS} + topics: [payments] + consumer_group: omni-payments + sasl: + - mechanism: SCRAM-SHA-256 + username: \${secrets.KAFKA_USER} + password: \${secrets.KAFKA_PASSWORD} + tls: + enabled: true + - redpanda: + seed_brokers: + - \${REDPANDA_BROKERS} + topics: [clickstream] + consumer_group: omni-clicks + sasl: + - mechanism: SCRAM-SHA-256 + username: \${secrets.KAFKA_USER} + password: \${secrets.KAFKA_PASSWORD} + tls: + enabled: true + - http_server: + path: /ingest/webhooks + rate_limit: ingest_limiter + - generate: + interval: 30s + mapping: | + root = { + "event_type": "heartbeat", + "event_id": uuid_v4(), + "ts": now(), + "source": "synthetic" + } + batching: + count: 100 + period: 1s + processors: + - mapping: 'root = this' + +pipeline: + threads: 8 + processors: + # 1. Drop duplicates by idempotency key (add returns an error on a repeat -> deleted below). + - cache: + resource: dedupe_cache + operator: add + key: \${! json("event_id") } + value: "1" + - mapping: 'root = if errored() { deleted() } else { this }' + # 2. Normalize the envelope so every downstream branch can assume the same shape. + - mapping: | + root = this + root.event_id = this.event_id | uuid_v4() + root.event_type = (this.event_type | "unknown").lowercase() + root.region = this.region | "us" + root.received_at = now() + meta ingest_source = this.source | "kafka" + # 3. Redact PII up front — hash the email, keep only the last 4 of the phone. + - mapping: | + root = this + root.customer.email = (this.customer.email | "").hash("sha256").encode("hex") + root.customer.phone = if this.customer.phone != null { + "***-***-" + this.customer.phone.slice(-4) + } + # 4. TOP-LEVEL ROUTING — one deep branch per event type. + - switch: + # ---- ORDER ---- + - check: this.event_type == "order" + processors: + - mapping: 'root.pipeline = "order"' + - log: + level: DEBUG + message: 'routing order \${! json("event_id") }' + - switch: + - check: this.order.channel == "web" + processors: + - branch: + request_map: 'root = {"customer_id": this.customer.id, "cart": this.order.cart}' + processors: + - try: + - http: + url: https://catalog.internal/price + verb: POST + headers: + Content-Type: application/json + Authorization: 'Bearer \${secrets.CATALOG_TOKEN}' + retries: 3 + timeout: 5s + - cache: + resource: price_cache + operator: set + key: \${! json("customer_id") } + value: \${! content() } + - catch: + - log: + level: ERROR + message: 'price lookup failed, falling back to cache' + - cache: + resource: price_cache + operator: get + key: \${! json("customer_id") } + result_map: 'root.order.pricing = this' + - parallel: + cap: 3 + processors: + - http: + url: https://loyalty.internal/points + verb: GET + - http: + url: https://tax.internal/estimate + verb: POST + - http: + url: https://promo.internal/apply + verb: POST + - check: this.order.channel == "mobile" + processors: + - branch: + request_map: 'root = this.order' + processors: + - switch: + - check: this.total > 500 + processors: + - rate_limit: + resource: high_value_limiter + - aws_lambda: + function: high-value-review + region: us-east-1 + - processors: + - mapping: 'root.auto_approved = true' + result_map: 'root.order.review = this' + - check: this.order.channel == "store" + processors: + - for_each: + - mapping: 'root = this' + - cache: + resource: inventory_cache + operator: add + key: \${! json("sku") } + value: \${! json("qty") } + - processors: + - mapping: 'root.order.channel = "unknown"' + - log: + level: WARN + message: 'order with unknown channel' + # ---- PAYMENT ---- + - check: this.event_type == "payment" + processors: + - mapping: 'root.pipeline = "payment"' + - switch: + - check: this.payment.method == "card" + processors: + - try: + - branch: + request_map: 'root = {"pan": this.payment.card.token, "amount": this.payment.amount}' + processors: + - rate_limit: + resource: psp_limiter + - http: + url: https://psp.internal/authorize + verb: POST + retries: 2 + timeout: 8s + result_map: 'root.payment.auth = this' + - mapping: 'root.payment.status = this.payment.auth.status' + - catch: + - mapping: 'root.payment.status = "auth_error"' + - log: + level: ERROR + message: 'card authorization failed' + - check: this.payment.method == "wallet" + processors: + - branch: + request_map: 'root = this.payment.wallet' + processors: + - http: + url: https://wallet.internal/charge + verb: POST + result_map: 'root.payment.wallet_result = this' + - check: this.payment.method == "bank_transfer" + processors: + - workflow: + meta_path: meta.bank_workflow + order: + - [verify_account] + - [check_balance, aml_screen] + - [settle] + branches: + verify_account: + request_map: 'root = this.payment.bank' + processors: + - http: + url: https://bank.internal/verify + result_map: 'root.verified = this.ok' + check_balance: + request_map: 'root = this.payment.bank' + processors: + - http: + url: https://bank.internal/balance + result_map: 'root.balance = this.amount' + aml_screen: + request_map: 'root = {"name": this.customer.name}' + processors: + - aws_lambda: + function: aml-screen + region: us-east-1 + result_map: 'root.aml_flag = this.flag' + settle: + request_map: 'root = this.payment' + processors: + - http: + url: https://bank.internal/settle + verb: POST + result_map: 'root.settlement = this' + - processors: + - mapping: 'root.payment.method = "other"' + # ---- CLICKSTREAM ---- + - check: this.event_type == "clickstream" + processors: + - mapping: 'root.pipeline = "clickstream"' + - group_by_value: + value: \${! json("session_id") } + - for_each: + - mapping: | + root = this + root.dwell_ms = this.exit_ts - this.enter_ts + - switch: + - check: this.dwell_ms > 30000 + processors: + - cache: + resource: engaged_cache + operator: set + key: \${! json("session_id") } + value: "1" + - branch: + request_map: 'root = {"session": this.session_id, "path": this.page_path}' + processors: + - http: + url: https://recs.internal/next-best + verb: POST + result_map: 'root.recommendation = this' + - processors: + - mapping: 'root.engaged = false' + # ---- INVENTORY (workflow DAG) ---- + - check: this.event_type == "inventory" + processors: + - mapping: 'root.pipeline = "inventory"' + - workflow: + meta_path: meta.inventory_workflow + order: + - [reserve] + - [reprice, restock_alert] + - [publish_delta] + branches: + reserve: + request_map: 'root = {"sku": this.sku, "qty": this.qty}' + processors: + - cache: + resource: inventory_cache + operator: add + key: \${! json("sku") } + value: \${! json("qty") } + result_map: 'root.reserved = true' + reprice: + request_map: 'root = {"sku": this.sku}' + processors: + - branch: + request_map: 'root = this' + processors: + - http: + url: https://pricing.internal/reprice + verb: POST + result_map: 'root.new_price = this.price' + result_map: 'root.reprice = this' + restock_alert: + request_map: 'root = {"sku": this.sku, "on_hand": this.on_hand}' + processors: + - switch: + - check: this.on_hand < 10 + processors: + - http: + url: https://alerts.internal/restock + verb: POST + - processors: + - mapping: 'root = deleted()' + result_map: 'root.alerted = this != null' + publish_delta: + request_map: 'root = this' + processors: + - mapping: 'root.delta_published = true' + result_map: 'root.published = this' + # ---- SHIPMENT ---- + - check: this.event_type == "shipment" + processors: + - mapping: 'root.pipeline = "shipment"' + - branch: + request_map: 'root = this.shipment' + processors: + - switch: + - check: this.carrier == "express" + processors: + - try: + - http: + url: https://express.carrier/track + - catch: + - log: + level: WARN + message: 'express tracking unavailable' + - mapping: 'root.tracking = "unavailable"' + - processors: + - http: + url: https://standard.carrier/track + result_map: 'root.shipment.tracking = this' + # ---- REFUND ---- + - check: this.event_type == "refund" + processors: + - mapping: 'root.pipeline = "refund"' + - switch: + - check: this.refund.reason == "fraud" + processors: + - branch: + request_map: 'root = {"order_id": this.refund.order_id}' + processors: + - aws_lambda: + function: fraud-clawback + region: us-east-1 + result_map: 'root.refund.clawback = this' + - log: + level: WARN + message: 'fraud refund \${! json("refund.order_id") }' + - check: this.refund.amount > 1000 + processors: + - workflow: + meta_path: meta.refund_approval + order: + - [manager_approve] + - [finance_approve] + - [reverse_charge] + branches: + manager_approve: + request_map: 'root = this.refund' + processors: + - http: + url: https://approvals.internal/manager + verb: POST + retries: 2 + result_map: 'root.manager_ok = this.approved' + finance_approve: + request_map: 'root = this.refund' + processors: + - http: + url: https://approvals.internal/finance + verb: POST + result_map: 'root.finance_ok = this.approved' + reverse_charge: + request_map: 'root = {"charge_id": this.refund.charge_id}' + processors: + - try: + - http: + url: https://psp.internal/reverse + verb: POST + - catch: + - log: + level: ERROR + message: 'charge reversal failed' + - mapping: 'root.reversal_status = "manual_review"' + result_map: 'root.reversal = this' + - processors: + - rate_limit: + resource: refund_limiter + - branch: + request_map: 'root = {"charge_id": this.refund.charge_id}' + processors: + - http: + url: https://psp.internal/refund + verb: POST + result_map: 'root.refund.result = this' + # ---- SUBSCRIPTION ---- + - check: this.event_type == "subscription" + processors: + - mapping: 'root.pipeline = "subscription"' + - switch: + - check: this.subscription.action == "create" + processors: + - parallel: + cap: 2 + processors: + - branch: + request_map: 'root = this.customer' + processors: + - http: + url: https://billing.internal/subscribe + verb: POST + result_map: 'root.billing = this' + - http: + url: https://email.internal/welcome + verb: POST + - check: this.subscription.action == "renew" + processors: + - try: + - branch: + request_map: 'root = {"sub_id": this.subscription.id}' + processors: + - rate_limit: + resource: psp_limiter + - http: + url: https://billing.internal/charge + verb: POST + retries: 3 + result_map: 'root.subscription.charge = this' + - mapping: 'root.subscription.status = "active"' + - catch: + - mapping: 'root.subscription.status = "past_due"' + - branch: + request_map: 'root = this.customer' + processors: + - http: + url: https://email.internal/dunning + verb: POST + result_map: 'root.dunning = this' + - check: this.subscription.action == "cancel" + processors: + - branch: + request_map: 'root = {"sub_id": this.subscription.id}' + processors: + - http: + url: https://billing.internal/cancel + verb: POST + result_map: 'root.subscription.cancelled = this.ok' + - switch: + - check: this.subscription.reason == "too_expensive" + processors: + - branch: + request_map: 'root = this.customer' + processors: + - http: + url: https://retention.internal/offer + verb: POST + result_map: 'root.win_back = this' + - processors: + - mapping: 'root.win_back = null' + - processors: + - mapping: 'root.subscription.action = "noop"' + # ---- REVIEW ---- + - check: this.event_type == "review" + processors: + - mapping: 'root.pipeline = "review"' + - branch: + request_map: 'root = {"text": this.review.body}' + processors: + - aws_lambda: + function: sentiment-score + region: us-east-1 + result_map: 'root.review.sentiment = this.score' + - switch: + - check: this.review.sentiment < 0.2 + processors: + - cache: + resource: audit_cache + operator: set + key: \${! json("review.id") } + value: "negative" + - branch: + request_map: 'root = this.review' + processors: + - http: + url: https://support.internal/escalate + verb: POST + result_map: 'root.escalation = this' + - check: this.review.rating >= 4 + processors: + - mapping: 'root.review.featured = true' + - processors: + - mapping: 'root.review.featured = false' + # ---- FULFILLMENT (workflow DAG) ---- + - check: this.event_type == "fulfillment" + processors: + - mapping: 'root.pipeline = "fulfillment"' + - workflow: + meta_path: meta.fulfillment + order: + - [allocate] + - [pick, pack] + - [label, notify] + branches: + allocate: + request_map: 'root = {"order_id": this.fulfillment.order_id}' + processors: + - cache: + resource: inventory_cache + operator: get + key: \${! json("order_id") } + result_map: 'root.warehouse = this.warehouse' + pick: + request_map: 'root = this.fulfillment' + processors: + - for_each: + - mapping: 'root = this' + result_map: 'root.picked = true' + pack: + request_map: 'root = this.fulfillment' + processors: + - http: + url: https://wms.internal/pack + verb: POST + result_map: 'root.packed = this.ok' + label: + request_map: 'root = {"carrier": this.shipment.carrier}' + processors: + - try: + - http: + url: https://carrier.internal/label + verb: POST + - catch: + - log: + level: ERROR + message: 'label generation failed' + result_map: 'root.label = this' + notify: + request_map: 'root = this.customer' + processors: + - http: + url: https://email.internal/shipped + verb: POST + result_map: 'root.notified = this.ok' + # ---- HEARTBEAT ---- + - check: this.event_type == "heartbeat" + processors: + - log: + level: INFO + message: 'heartbeat from \${! meta("ingest_source") }' + - mapping: 'root = deleted()' + # ---- DEFAULT / AUDIT ---- + - processors: + - log: + level: WARN + message: 'unclassified event \${! json("event_type") }' + - mapping: 'root.pipeline = "audit"' + # 5. CROSS-CUTTING — runs for every surviving event regardless of type. + # 5a. Fraud scoring via Lambda. + - branch: + request_map: | + root.amount = this.order.total | this.payment.amount | 0 + root.region = this.region + root.customer_id = this.customer.id + processors: + - aws_lambda: + function: fraud-score + region: us-east-1 + result_map: 'root.fraud = this' + # 5b. Parallel 360-degree enrichment. + - parallel: + cap: 4 + processors: + - http: + url: https://enrich.internal/geo + - http: + url: https://enrich.internal/device + - http: + url: https://enrich.internal/customer360 + - branch: + request_map: 'root = this.customer.id' + processors: + - cache: + resource: customer_cache + operator: get + key: \${! content() } + result_map: 'root.customer.profile = this.catch(this)' + # 5c. Per-line-item normalization. + - for_each: + - mapping: 'root = this' + # 5d. Audited side-effect write (best-effort). + - try: + - mapping: 'root.enriched_at = now()' + - cache: + resource: audit_cache + operator: set + key: \${! json("event_id") } + value: \${! content() } + - catch: + - log: + level: ERROR + message: 'audit write failed for \${! json("event_id") }' + # 5e. Final routing decision consumed by the output switch. + - mapping: | + root = this + root.route = match { + this.fraud.score > 0.9 => "block", + this.region == "eu" => "eu", + this.region == "apac" => "apac", + _ => "us" + } + +output: + switch: + cases: + - check: errored() || this.route == "block" + output: + redpanda: + seed_brokers: + - \${REDPANDA_BROKERS} + topic: events-dlq + sasl: + - mechanism: SCRAM-SHA-256 + username: \${secrets.KAFKA_USER} + password: \${secrets.KAFKA_PASSWORD} + tls: + enabled: true + - check: this.pipeline == "payment" && this.payment.status == "auth_error" + output: + redpanda: + seed_brokers: + - \${REDPANDA_BROKERS} + topic: payments-review + - check: this.route == "eu" + output: + aws_s3: + bucket: eu-events-prod + region: eu-west-1 + path: events/\${! timestamp_unix() }-\${! uuid_v4() }.json + - check: this.route == "apac" + output: + broker: + pattern: fan_out + outputs: + - gcp_pubsub: + project: apac-project + topic: events-stream + - aws_s3: + bucket: apac-events-prod + region: ap-southeast-1 + path: events/\${! timestamp_unix() }.json + - check: this.pipeline == "clickstream" + output: + redpanda: + seed_brokers: + - \${REDPANDA_BROKERS} + topic: clickstream-enriched + - check: this.pipeline == "refund" + output: + broker: + pattern: fan_out + outputs: + - redpanda: + seed_brokers: + - \${REDPANDA_BROKERS} + topic: refunds-ledger + - aws_s3: + bucket: finance-audit + region: us-east-1 + path: refunds/\${! timestamp_unix() }-\${! uuid_v4() }.json + - check: this.pipeline == "subscription" + output: + redpanda: + seed_brokers: + - \${REDPANDA_BROKERS} + topic: subscription-events + - check: this.pipeline == "review" && this.review.featured + output: + gcp_pubsub: + project: cx-project + topic: featured-reviews + - check: this.pipeline == "fulfillment" + output: + redpanda: + seed_brokers: + - \${REDPANDA_BROKERS} + topic: fulfillment-updates + - output: + fallback: + - redpanda: + seed_brokers: + - \${REDPANDA_BROKERS} + topic: events-main + sasl: + - mechanism: SCRAM-SHA-256 + username: \${secrets.KAFKA_USER} + password: \${secrets.KAFKA_PASSWORD} + tls: + enabled: true + - aws_s3: + bucket: events-cold-storage + region: us-east-1 + path: events/\${! timestamp_unix() }-\${! uuid_v4() }.json + - drop: {} + +cache_resources: + - label: dedupe_cache + redis: + url: redis://cache:6379 + prefix: "dedupe:" + - label: price_cache + memcached: + addresses: + - memcached:11211 + default_ttl: 10m + - label: inventory_cache + redis: + url: redis://inventory:6379 + prefix: "inv:" + - label: customer_cache + redis: + url: redis://customer:6379 + prefix: "cust:" + - label: engaged_cache + memory: + default_ttl: 1h + - label: audit_cache + memory: + default_ttl: 5m + +rate_limit_resources: + - label: ingest_limiter + local: + count: 5000 + interval: 1s + - label: psp_limiter + local: + count: 200 + interval: 1s + - label: high_value_limiter + local: + count: 50 + interval: 1s + - label: refund_limiter + local: + count: 100 + interval: 1s +`; + export const CONNECT_CONFIG_FIXTURES: ConnectConfigFixture[] = [ { id: 'simple-generate-to-drop', @@ -731,6 +1675,30 @@ export const CONNECT_CONFIG_FIXTURES: ConnectConfigFixture[] = [ yaml: allComponentsKitchenSink, tags: ['complex'], }, + { + id: 'complex-heavy-branching', + name: 'Complex — heavy branching & routing', + description: + 'Broker fan-in, nested switch → branch → try/catch, for_each, parallel, cache/rate-limit refs, switch+fallback DLQ output. Built to exercise the visualizer.', + yaml: heavyBranching, + tags: ['complex'], + }, + { + id: 'complex-omni-channel-platform', + name: 'Complex — omni-channel platform (mega)', + description: + '~800-line, maximally-branched commerce platform: broker fan-in of 5 sources, top-level switch routing per event type into nested switch/branch/try-catch/parallel/workflow/for_each/group_by, shared fraud + enrichment stages, and a large switch output with DLQ, per-region S3, pub/sub and fallbacks. Exercises every visualizer node/edge shape.', + yaml: omniChannelPlatform, + tags: ['complex'], + }, + { + id: 'edge-resource-indirection', + name: 'Edge — resource indirection (*_resources)', + description: + 'input/output/processor declared as named *_resources and referenced via resource:. Exercises the visualizer’s reference linking and a reused processor resource.', + yaml: resourceIndirection, + tags: ['edge-case'], + }, { id: 'edge-secrets-heavy', name: 'Edge — many secret references', diff --git a/frontend/src/components/debug-helper/feature-flag-overrides.ts b/frontend/src/components/debug-helper/feature-flag-overrides.ts index b0d6225528..bf3616350a 100644 --- a/frontend/src/components/debug-helper/feature-flag-overrides.ts +++ b/frontend/src/components/debug-helper/feature-flag-overrides.ts @@ -72,7 +72,7 @@ export function getEffectiveFlags(): Record { } export function getAllFlagKeys(): FeatureFlagKey[] { - return Object.keys(FEATURE_FLAGS) as FeatureFlagKey[]; + return (Object.keys(FEATURE_FLAGS) as FeatureFlagKey[]).sort((a, b) => a.localeCompare(b)); } let baseFlags: Record = { ...FEATURE_FLAGS }; diff --git a/frontend/src/components/pages/rp-connect/onboarding/add-connector-dialog.tsx b/frontend/src/components/pages/rp-connect/onboarding/add-connector-dialog.tsx index a6696e23f3..656eaea632 100644 --- a/frontend/src/components/pages/rp-connect/onboarding/add-connector-dialog.tsx +++ b/frontend/src/components/pages/rp-connect/onboarding/add-connector-dialog.tsx @@ -1,25 +1,15 @@ import { Dialog, - DialogBody, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from 'components/redpanda-ui/components/dialog'; -import { Link } from 'components/redpanda-ui/components/typography'; import type { ComponentList } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; -import { ConnectTiles } from './connect-tiles'; +import { ConnectCommandPalette } from './connect-command-palette'; import type { ConnectComponentType } from '../types/schema'; -function getDocsUrl(connectorType?: ConnectComponentType | ConnectComponentType[]): string | null { - const type = Array.isArray(connectorType) ? connectorType[0] : connectorType; - if (!type) { - return null; - } - return `https://docs.redpanda.com/redpanda-cloud/develop/connect/components/${type}s/about/`; -} - export const AddConnectorDialog = ({ isOpen, onCloseAddConnector, @@ -44,34 +34,22 @@ export const AddConnectorDialog = ({ typeFilter = [connectorType]; } - const docsUrl = getDocsUrl(connectorType); - return ( - + {title ?? 'Add a connector'} - Configure your pipeline.{' '} - {docsUrl ? ( - - Learn more - - ) : null} + Search the component catalog, then select a component to add it to your pipeline. - - - + onAddConnector?.(name, type)} + searchPlaceholder={searchPlaceholder} + /> ); diff --git a/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette.test.tsx b/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette.test.tsx new file mode 100644 index 0000000000..282ef3876a --- /dev/null +++ b/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette.test.tsx @@ -0,0 +1,91 @@ +import { ComponentStatus } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; +import { describe, expect, it } from 'vitest'; + +import { asciidocToMarkdown, buildEmptyMessage, byProminence } from './connect-command-palette'; +import type { ConnectComponentSpec } from '../types/schema'; + +const spec = (name: string, status: ComponentStatus = ComponentStatus.STABLE): ConnectComponentSpec => + ({ name, type: 'processor', status }) as ConnectComponentSpec; + +const sorted = (specs: ConnectComponentSpec[]) => [...specs].sort(byProminence).map((s) => s.name); + +describe('byProminence', () => { + it('sorts common components ahead of the long tail', () => { + // `mapping` is a common processor; `avro` is not. + expect(sorted([spec('avro'), spec('mapping')])).toEqual(['mapping', 'avro']); + }); + + it('demotes deprecated and experimental below stable, even when common', () => { + const result = sorted([ + spec('mapping', ComponentStatus.DEPRECATED), + spec('avro', ComponentStatus.STABLE), + spec('parquet', ComponentStatus.EXPERIMENTAL), + ]); + // Stable (even uncommon) sorts above demoted ones; deprecated/experimental sink. + expect(result[0]).toBe('avro'); + expect(result.slice(1).sort()).toEqual(['mapping', 'parquet']); + }); + + it('falls back to alphabetical within the same prominence bucket', () => { + expect(sorted([spec('zzz'), spec('aaa')])).toEqual(['aaa', 'zzz']); + }); +}); + +describe('asciidocToMarkdown', () => { + it('turns AsciiDoc section titles into Markdown headings instead of leaking "=="', () => { + const out = asciidocToMarkdown('== Performance\nThis output benefits from batching.'); + expect(out).toBe('#### Performance\nThis output benefits from batching.'); + expect(out).not.toContain('== '); + }); + + it('handles multiple heading levels and keeps paragraphs separated', () => { + const out = asciidocToMarkdown('Intro paragraph.\n\n=== Delivery Guarantees\nAt least once.'); + expect(out).toBe('Intro paragraph.\n\n#### Delivery Guarantees\nAt least once.'); + }); + + it('converts link/xref macros to label text and bare URL macros to Markdown links', () => { + expect(asciidocToMarkdown('See xref:guides:about.adoc[the guide] for details.')).toBe('See the guide for details.'); + expect(asciidocToMarkdown('Uses https://github.com/twmb/franz-go[franz-go] under the hood.')).toBe( + 'Uses [franz-go](https://github.com/twmb/franz-go) under the hood.' + ); + }); + + it('converts AsciiDoc bullets to Markdown list items', () => { + expect(asciidocToMarkdown('* first\n* second')).toBe('- first\n- second'); + }); +}); + +describe('buildEmptyMessage', () => { + it('reports an empty catalog when there is no query', () => { + expect(buildEmptyMessage('')).toBe('No components available.'); + expect(buildEmptyMessage('', ['processor'])).toBe('No components available.'); + }); + + it('reports a plain miss without type locking', () => { + expect(buildEmptyMessage('flurb')).toBe('No components match “flurb”.'); + }); + + it('names the locked type when the slot restricts the catalog', () => { + expect(buildEmptyMessage('flurb', ['processor'])).toBe('No processors match “flurb”.'); + expect(buildEmptyMessage('flurb', ['rate_limit'])).toBe('No rate limits match “flurb”.'); + }); + + it('joins multiple locked types', () => { + expect(buildEmptyMessage('flurb', ['cache', 'rate_limit'])).toBe('No caches or rate limits match “flurb”.'); + }); + + it('points at a single out-of-scope type the query exists as', () => { + expect(buildEmptyMessage('generate', ['processor'], ['input'])).toBe( + 'No processors match “generate” — it exists as an input.' + ); + }); + + it('lists multiple out-of-scope types with articles and "or"', () => { + expect(buildEmptyMessage('kafka_franz', ['processor'], ['input', 'output'])).toBe( + 'No processors match “kafka_franz” — it exists as an input or an output.' + ); + expect(buildEmptyMessage('redis', ['input'], ['cache', 'processor', 'rate_limit'])).toBe( + 'No inputs match “redis” — it exists as a cache, a processor or a rate limit.' + ); + }); +}); diff --git a/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette.tsx b/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette.tsx new file mode 100644 index 0000000000..feeaded50e --- /dev/null +++ b/frontend/src/components/pages/rp-connect/onboarding/connect-command-palette.tsx @@ -0,0 +1,706 @@ +import { type ComponentName, componentLogoMap } from 'assets/connectors/component-logo-map'; +import { Badge } from 'components/redpanda-ui/components/badge'; +import { Button } from 'components/redpanda-ui/components/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from 'components/redpanda-ui/components/command'; +import { DialogFooter } from 'components/redpanda-ui/components/dialog'; +import { ScrollableTabsList, Tabs, TabsTrigger } from 'components/redpanda-ui/components/tabs'; +import { Link, Text } from 'components/redpanda-ui/components/typography'; +import { ExternalLink, Waypoints } from 'lucide-react'; +import type { ComponentList } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; +import { ComponentStatus } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; +import { useMemo, useState } from 'react'; +import ReactMarkdown, { type Components } from 'react-markdown'; + +import { ConnectorLogo } from './connector-logo'; +import { getConnectorDocsUrl } from '../pipeline/pipeline-flow-nodes'; +import type { ConnectComponentSpec, ConnectComponentType, ExtendedConnectComponentSpec } from '../types/schema'; +import { getCategoryDisplayName } from '../utils/categories'; +import { aliasTermsForName } from '../utils/component-aliases'; +import { componentStatusToString, parseSchema } from '../utils/schema'; + +const RECENTS_KEY = 'rpcn-recent-components'; +const MAX_RECENTS = 6; + +// Curated "likely next" defaults per slot kind, shown before the user types. Names absent from the catalog are dropped. +const SUGGESTED_BY_TYPE: Partial> = { + input: ['kafka_franz', 'redpanda', 'generate', 'http_client', 'file'], + output: ['kafka_franz', 'redpanda', 'http_client', 'drop', 'stdout'], + processor: ['mapping', 'bloblang', 'cache', 'http', 'rate_limit', 'log'], + cache: ['memory', 'redis', 'memcached'], + rate_limit: ['local'], +}; + +// Everyday components sorted ahead of the long tail when browsing. +const COMMON_COMPONENTS = new Set([ + ...Object.values(SUGGESTED_BY_TYPE).flat(), + 'kafka', + 'redpanda_migrator', + 'sql_insert', + 'sql_select', + 'aws_s3', + 'gcp_cloud_storage', + 'postgres_cdc', + 'mysql_cdc', + 'switch', + 'branch', + 'workflow', + 'json_schema', + 'unarchive', + 'archive', +]); + +// Deprecated/experimental components are de-emphasised: sorted below stable ones. +function isDemoted(status: ComponentStatus): boolean { + return status === ComponentStatus.DEPRECATED || status === ComponentStatus.EXPERIMENTAL; +} + +// Tiebreaker order (after relevance, primary when browsing): stable before demoted, common before long tail, then name. +export function byProminence(a: ConnectComponentSpec, b: ConnectComponentSpec): number { + const demoted = (isDemoted(a.status) ? 1 : 0) - (isDemoted(b.status) ? 1 : 0); + if (demoted !== 0) { + return demoted; + } + const common = (COMMON_COMPONENTS.has(a.name) ? 0 : 1) - (COMMON_COMPONENTS.has(b.name) ? 0 : 1); + if (common !== 0) { + return common; + } + return a.name.localeCompare(b.name); +} + +type RecentEntry = { name: string; type: ConnectComponentType }; + +function readRecents(): RecentEntry[] { + if (typeof window === 'undefined') { + return []; + } + try { + const raw = window.localStorage.getItem(RECENTS_KEY); + const parsed = raw ? (JSON.parse(raw) as RecentEntry[]) : []; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function pushRecent(entry: RecentEntry): void { + if (typeof window === 'undefined') { + return; + } + try { + const next = [entry, ...readRecents().filter((r) => !(r.name === entry.name && r.type === entry.type))].slice( + 0, + MAX_RECENTS + ); + window.localStorage.setItem(RECENTS_KEY, JSON.stringify(next)); + } catch { + // Best-effort; ignore storage failures. + } +} + +// Lowercased searchable text (name + aliases + summary + description + categories) for substring matching. +function searchableText(component: ConnectComponentSpec): string { + return [ + component.name, + ...aliasTermsForName(component.name), + component.summary ?? '', + component.description ?? '', + ...(component.categories ?? []), + ] + .join(' ') + .toLowerCase(); +} + +// Rank a match: exact name > prefix > substring > other text. Lower is better; -1 = no match. +function matchRank(component: ConnectComponentSpec, query: string, text: string): number { + const name = component.name.toLowerCase(); + if (name === query) { + return 0; + } + if (name.startsWith(query)) { + return 1; + } + if (name.includes(query)) { + return 2; + } + return text.includes(query) ? 3 : -1; +} + +function ComponentIcon({ component, className = 'size-5' }: { component: ConnectComponentSpec; className?: string }) { + if (component.logoUrl) { + return ; + } + if (componentLogoMap[component.name as ComponentName]) { + return ; + } + return ; +} + +// Reduce one-line AsciiDoc summaries (link macros, code spans) to plain label text on a single line. +function cleanText(text: string): string { + return text + .replace(/(?:xref|link):[^\s[]*\[([^\]]*)\]/g, '$1') + .replace(/https?:\/\/[^\s[]+\[([^\]]*)\]/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\s+/g, ' ') + .trim(); +} + +// Convert the AsciiDoc constructs Connect uses (titles, link macros, bullets) to Markdown for react-markdown. +// Unlike cleanText, newlines are preserved so titles/paragraphs stay distinct. +export function asciidocToMarkdown(raw: string): string { + return ( + raw + .replace(/\r\n/g, '\n') + // Link macros → label text. + .replace(/(?:xref|link):[^\s[\]]*\[([^\]]*)\]/g, '$1') + // Bare URL macro → Markdown link. + .replace(/(https?:\/\/[^\s[\]]+)\[([^\]]*)\]/g, '[$2]($1)') + // Section titles (`==`/`===`/… Title) → small heading. + .replace(/^=+\s+(.{1,60})$/gm, '#### $1') + // Strip leftover markers from over-long titles. + .replace(/^=+\s+/gm, '') + // List markers → bullets. + .replace(/^\*\s+/gm, '- ') + .replace(/\n{3,}/g, '\n\n') + .trim() + ); +} + +// Compact section title matching the inspector's small-label style. +const MarkdownHeading = ({ children }: { children?: React.ReactNode }) => ( + {children} +); + +// DS-styled element map matching the inspector's type scale. All heading levels collapse to the same +// compact label — these are short section titles, not a hierarchy. +const MARKDOWN_COMPONENTS: Components = { + h1: MarkdownHeading, + h2: MarkdownHeading, + h3: MarkdownHeading, + h4: MarkdownHeading, + h5: MarkdownHeading, + h6: MarkdownHeading, + p: ({ children }) => {children}, + a: ({ href, children }) => ( + + {children} + + ), + code: ({ children }) => {children}, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , +}; + +function FormattedDescription({ markdown }: { markdown: string }) { + return ( +
    + {markdown} +
    + ); +} + +// Long descriptions are clamped behind a "Show more" toggle to keep the preview scannable. +// Keyed by component name at the call site so the toggle resets per row. +function DescriptionBlock({ markdown, collapsible }: { markdown: string; collapsible: boolean }) { + const [expanded, setExpanded] = useState(false); + if (!collapsible) { + return ; + } + return ( +
    +
    + + {expanded ? null : ( +
    + )} +
    + +
    + ); +} + +function statusBadge(status: ComponentStatus, name: string) { + if ( + name !== 'redpanda' && + (status === ComponentStatus.BETA || + status === ComponentStatus.EXPERIMENTAL || + status === ComponentStatus.DEPRECATED) + ) { + return ( + + {componentStatusToString(status)} + + ); + } + return null; +} + +// Bold the matched span of the name so users see why a result surfaced. +function HighlightedName({ name, query }: { name: string; query: string }) { + const idx = query ? name.toLowerCase().indexOf(query) : -1; + if (idx < 0) { + return {name}; + } + return ( + + {name.slice(0, idx)} + {name.slice(idx, idx + query.length)} + {name.slice(idx + query.length)} + + ); +} + +// "processors", "caches or rate limits" — plural label for the slot's allowed types. +function pluralTypeLabel(types?: ConnectComponentType[]): string { + if (!types || types.length === 0) { + return 'components'; + } + return types.map((type) => `${type.replace(/_/g, ' ')}s`).join(' or '); +} + +const STARTS_WITH_VOWEL = /^[aeiou]/; + +// "an input", "an input or a processor" — out-of-scope types with indefinite articles. +function withArticles(types: ConnectComponentType[]): string { + const labeled = types.map((type) => { + const label = type.replace(/_/g, ' '); + return `${STARTS_WITH_VOWEL.test(label) ? 'an' : 'a'} ${label}`; + }); + if (labeled.length <= 1) { + return labeled[0] ?? ''; + } + return `${labeled.slice(0, -1).join(', ')} or ${labeled.at(-1)}`; +} + +// Empty-state copy. A type-locked miss that matches other component types is explained +// ("it exists as an input") so it doesn't read as a missing integration. +export function buildEmptyMessage( + query: string, + allowedTypes?: ConnectComponentType[], + outOfScopeTypes: ConnectComponentType[] = [] +): string { + if (!query) { + return 'No components available.'; + } + if (outOfScopeTypes.length === 0) { + return `No ${pluralTypeLabel(allowedTypes)} match “${query}”.`; + } + return `No ${pluralTypeLabel(allowedTypes)} match “${query}” — it exists as ${withArticles(outOfScopeTypes)}.`; +} + +function Row({ + component, + query, + onPreview, + onCommit, +}: { + component: ConnectComponentSpec; + query: string; + onPreview: (component: ConnectComponentSpec) => void; + onCommit: (component: ConnectComponentSpec) => void; +}) { + const category = component.categories?.[0]; + // Fall back to the humanized type (cache / rate_limit / …) so same-named components of different + // kinds — e.g. the redis cache vs the redis rate-limit — are distinguishable in the list. + const badgeLabel = category ? getCategoryDisplayName(category) : component.type.replace(/_/g, ' '); + return ( + onCommit(component)} + onSelect={() => onPreview(component)} + value={`${component.type}:${component.name}`} + > + + + + {statusBadge(component.status, component.name)} + + {badgeLabel ? ( + + {badgeLabel} + + ) : null} + + ); +} + +// Facet tabs (Recent / Suggested / All / per-category) that narrow the browse list. +type Tab = { id: string; label: string }; + +// Right-hand preview of the highlighted row: full metadata (description, categories, version, docs). +// Insertion is deferred to the footer Add action. +function DetailPane({ component }: { component?: ConnectComponentSpec }) { + if (!component) { + return ( +
    + + Select a component to see its description, categories, and documentation, then add it to your pipeline. + +
    + ); + } + + const categories = (component.categories ?? []).map(getCategoryDisplayName).filter(Boolean); + const summary = cleanText(component.summary ?? ''); + const descriptionMd = component.description ? asciidocToMarkdown(component.description) : ''; + // Skip the description when it just repeats the summary. + const showDescription = descriptionMd !== '' && cleanText(component.description ?? '') !== summary; + const docsUrl = getConnectorDocsUrl(component.type, component.name); + + return ( +
    +
    + +
    + + {component.name} + {statusBadge(component.status, component.name)} + + + {component.type.replace(/_/g, ' ')} + {component.version ? ` · v${component.version}` : ''} + +
    +
    + + {summary ? {summary} : null} + + {showDescription ? ( + 700} + key={`${component.type}:${component.name}`} + markdown={descriptionMd} + /> + ) : null} + + {categories.length > 0 ? ( +
    + Categories +
    + {categories.map((category) => ( + + {category} + + ))} +
    +
    + ) : null} + + {docsUrl ? ( + + View documentation + + + ) : null} +
    + ); +} + +type ConnectCommandPaletteProps = { + components?: ComponentList; + additionalComponents?: ExtendedConnectComponentSpec[]; + /** Component types valid in the slot being filled — the palette only lists components of these types. */ + allowedTypes?: ConnectComponentType[]; + onSelect: (connectionName: string, connectionType: ConnectComponentType) => void; + /** Dismisses the picker without adding anything (wired to the footer Cancel button). */ + onCancel?: () => void; + searchPlaceholder?: string; +}; + +/** + * Search-first add-node picker as a master/detail panel: tab-filtered list, metadata preview, and a footer + * that defers insertion until commit. Matches across name, aliases, summary, description and categories; + * locks to the slot's valid types; leads with recents and suggestions; keyboard-first. + */ +export const ConnectCommandPalette = ({ + components, + additionalComponents, + allowedTypes, + onSelect, + onCancel, + searchPlaceholder, +}: ConnectCommandPaletteProps) => { + const [query, setQuery] = useState(''); + const [activeValue, setActiveValue] = useState(''); + // null until the user picks a tab, so the derived default can react to the catalog. + const [tab, setTab] = useState(null); + const recents = useMemo(() => readRecents(), []); + + const allComponents = useMemo(() => { + const builtIn = components ? parseSchema(components) : []; + return [...builtIn, ...(additionalComponents ?? [])]; + }, [components, additionalComponents]); + + const typeAllowed = useMemo(() => { + const set = allowedTypes && allowedTypes.length > 0 ? new Set(allowedTypes) : null; + return (type: ConnectComponentType) => !set || set.has(type); + }, [allowedTypes]); + + const inScope = useMemo(() => allComponents.filter((c) => typeAllowed(c.type)), [allComponents, typeAllowed]); + const byName = useMemo(() => new Map(inScope.map((c) => [c.name, c])), [inScope]); + // Keyed by CommandItem `value` (`type:name`) so a highlighted row resolves to its spec + // even when two types share a name (e.g. kafka in/out). + const byKey = useMemo(() => new Map(inScope.map((c) => [`${c.type}:${c.name}`, c])), [inScope]); + const activeComponent = byKey.get(activeValue); + + const handleCommit = (component: ConnectComponentSpec) => { + pushRecent({ name: component.name, type: component.type }); + onSelect(component.name, component.type); + }; + + // Clicking a row only previews it; cmdk also drives this via hover/arrow nav through `onValueChange`. + const handlePreview = (component: ConnectComponentSpec) => setActiveValue(`${component.type}:${component.name}`); + + const q = query.trim().toLowerCase(); + + // Searching: a single ranked, flat list across the in-scope catalog. + const results = useMemo(() => { + if (!q) { + return []; + } + return ( + inScope + .map((component) => ({ component, rank: matchRank(component, q, searchableText(component)) })) + .filter((r) => r.rank >= 0) + // Relevance first; ties broken by prominence. + .sort((a, b) => a.rank - b.rank || byProminence(a.component, b.component)) + .map((r) => r.component) + ); + }, [inScope, q]); + + // When a type-locked search comes up empty, the component types (outside the slot's scope) + // that DO match the query — surfaced in the empty state so the miss isn't read as a gap. + const outOfScopeTypes = useMemo(() => { + if (!q || results.length > 0 || !allowedTypes || allowedTypes.length === 0) { + return []; + } + const types = new Set(); + for (const component of allComponents) { + if (!typeAllowed(component.type) && matchRank(component, q, searchableText(component)) >= 0) { + types.add(component.type); + } + } + return [...types].sort(); + }, [q, results, allowedTypes, allComponents, typeAllowed]); + + // Browse facets (no query): recents + suggested + everything grouped by category. + const recentComponents = useMemo( + () => recents.map((r) => byName.get(r.name)).filter((c): c is ConnectComponentSpec => Boolean(c)), + [recents, byName] + ); + + const suggested = useMemo(() => { + const names = new Set((allowedTypes ?? []).flatMap((t) => SUGGESTED_BY_TYPE[t] ?? [])); + const recentNames = new Set(recentComponents.map((c) => c.name)); + return [...names] + .map((n) => byName.get(n)) + .filter((c): c is ConnectComponentSpec => Boolean(c) && !recentNames.has((c as ConnectComponentSpec).name)); + }, [allowedTypes, byName, recentComponents]); + + // In-scope components grouped by category, powering the "All" and per-category tabs. A component + // appears under EVERY category it lists (not just the first), so it's found under each relevant tab. + const grouped = useMemo(() => { + const groups = new Map(); + for (const component of inScope) { + const cats = component.categories?.length ? component.categories : ['other']; + for (const key of cats) { + const list = groups.get(key); + if (list) { + list.push(component); + } else { + groups.set(key, [component]); + } + } + } + return [...groups.entries()] + .map(([id, list]) => ({ id, name: getCategoryDisplayName(id), components: list.sort(byProminence) })) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [inScope]); + + // The "All" tab lists every component once, deduped across the category groups it may appear in. + const allGroups = useMemo(() => { + const seen = new Set(); + return grouped + .map((group) => ({ + ...group, + components: group.components.filter((c) => { + const key = `${c.type}:${c.name}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }), + })) + .filter((group) => group.components.length > 0); + }, [grouped]); + + const tabs = useMemo(() => { + const list: Tab[] = []; + if (recentComponents.length > 0) { + list.push({ id: 'recent', label: 'Recent' }); + } + if (suggested.length > 0) { + list.push({ id: 'suggested', label: 'Suggested' }); + } + list.push({ id: 'all', label: 'All' }); + for (const group of grouped) { + list.push({ id: `cat:${group.id}`, label: group.name }); + } + return list; + }, [recentComponents, suggested, grouped]); + + const defaultTab = suggested.length > 0 ? 'suggested' : 'all'; + const currentTab = tab && tabs.some((t) => t.id === tab) ? tab : defaultTab; + + // Resolve the active tab to the rows it should render. + const browseItems = useMemo(() => { + if (currentTab === 'recent') { + return recentComponents; + } + if (currentTab === 'suggested') { + return suggested; + } + if (currentTab.startsWith('cat:')) { + const id = currentTab.slice(4); + return grouped.find((g) => g.id === id)?.components ?? []; + } + return null; // "all" renders the grouped view, not a flat list. + }, [currentTab, recentComponents, suggested, grouped]); + + // Enter adds the highlighted component (fast keyboard path); a single click only previews, + // keeping insertion explicit, while double-click commits directly. + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && !event.nativeEvent.isComposing && activeComponent) { + event.preventDefault(); + handleCommit(activeComponent); + } + }; + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: keyboard handling augments the inner cmdk listbox. +
    + + + {q ? ( +
    + + {results.length} result{results.length === 1 ? '' : 's'} + +
    + ) : ( +
    + + + {tabs.map((t) => ( + + {t.label} + + ))} + + +
    + )} + +
    + + {buildEmptyMessage(query.trim(), allowedTypes, outOfScopeTypes)} + + {q ? ( + + {results.map((component) => ( + + ))} + + ) : browseItems ? ( + + {browseItems.map((component) => ( + + ))} + + ) : ( + allGroups.map((group) => ( + + {group.components.map((component) => ( + + ))} + + )) + )} + + + +
    +
    + + +
    + {activeComponent ? ( + <> + Selected + + {activeComponent.name} + + ) : ( + Select a component to add + )} +
    +
    + {onCancel ? ( + + ) : null} + +
    +
    +
    + ); +}; diff --git a/frontend/src/components/pages/rp-connect/pipeline/editor-tips-bar.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/editor-tips-bar.test.tsx new file mode 100644 index 0000000000..5ae1252720 --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/editor-tips-bar.test.tsx @@ -0,0 +1,45 @@ +/** + * 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 { beforeEach, describe, expect, it, vi } from 'vitest'; + +// The tips bar derives its key-chip text from the platform at module load, so each test picks the +// platform first, then imports a fresh copy of the module. +const platform = vi.hoisted(() => ({ mac: false })); +vi.mock('utils/platform', () => ({ isMacOS: () => platform.mac })); + +async function renderVisualTips(mac: boolean) { + platform.mac = mac; + vi.resetModules(); + const { EditorTipsBar } = await import('./editor-tips-bar'); + render(); +} + +describe('EditorTipsBar — shortcut chip formatting', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('joins keys with "+" and spells out modifiers off macOS (Alt+Z, Ctrl+Z, Ctrl+Shift+Z)', async () => { + await renderVisualTips(false); + expect(screen.getByText('Alt+Z')).toBeInTheDocument(); + expect(screen.getByText('Ctrl+Z')).toBeInTheDocument(); + expect(screen.getByText('Ctrl+Shift+Z')).toBeInTheDocument(); + }); + + it('runs glyphs together on macOS (⌥Z, ⌘Z, ⌘⇧Z)', async () => { + await renderVisualTips(true); + expect(screen.getByText('⌥Z')).toBeInTheDocument(); + expect(screen.getByText('⌘Z')).toBeInTheDocument(); + expect(screen.getByText('⌘⇧Z')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/pages/rp-connect/pipeline/editor-tips-bar.tsx b/frontend/src/components/pages/rp-connect/pipeline/editor-tips-bar.tsx new file mode 100644 index 0000000000..1841d1309d --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/editor-tips-bar.tsx @@ -0,0 +1,135 @@ +/** + * 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 { Kbd } from 'components/redpanda-ui/components/kbd'; +import { Lightbulb } from 'lucide-react'; +import { Fragment, type ReactNode } from 'react'; +import { isMacOS } from 'utils/platform'; + +const MAC = isMacOS(); +const MOD = MAC ? '⌘' : 'Ctrl'; +// The zoom-out modifier (Figma-style): Option on macOS, Alt elsewhere. +const ALT = MAC ? '⌥' : 'Alt'; +const SHIFT = MAC ? '⇧' : 'Shift'; +// Chip text for a key combo: glyphs run together on macOS (⌘⇧Z); words join with "+" elsewhere +// (Ctrl+Shift+Z), matching each platform's convention. +const combo = (...keys: string[]): string => keys.join(MAC ? '' : '+'); + +/** Which editor surface the tips relate to — drives a different tip set. */ +export type TipContext = 'yaml' | 'visual'; + +type Tip = { id: string; content: ReactNode }; + +// Small monospace key chip sized to sit inside a line of tip text. +const Key = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +function tipsFor( + context: TipContext, + { slashMenuEnabled, readOnly }: { slashMenuEnabled: boolean; readOnly: boolean } +): Tip[] { + if (context === 'visual') { + const tips: Tip[] = [ + { id: 'select', content: <>Click a node to {readOnly ? 'inspect' : 'edit'} it }, + { + id: 'zoom', + content: ( + <> + Drag to pan, hold Z / {combo(ALT, 'Z')} and click to zoom + + ), + }, + { + id: 'palette', + content: ( + <> + / to search nodes and actions + + ), + }, + ]; + if (!readOnly) { + tips.push({ + id: 'history', + content: ( + <> + {combo(MOD, 'Z')} to undo, {combo(MOD, SHIFT, 'Z')} to redo + + ), + }); + } + return tips; + } + + const tips: Tip[] = []; + if (slashMenuEnabled && !readOnly) { + tips.push({ + id: 'slash', + content: ( + <> + Press / to insert variables, secrets, and topics + + ), + }); + } + if (!readOnly) { + tips.push({ + id: 'save', + content: ( + <> + Save with {combo(MOD, 'S')} + + ), + }); + } + return tips; +} + +/** + * Full-width strip of contextual usage tips beneath the editor (different sets for YAML vs. visual). + * Static by design: all tips visible at once, dot-separated, no moving parts. + */ +export function EditorTipsBar({ + context, + slashMenuEnabled = false, + readOnly = false, +}: { + context: TipContext; + slashMenuEnabled?: boolean; + readOnly?: boolean; +}) { + const tips = tipsFor(context, { slashMenuEnabled, readOnly }); + if (tips.length === 0) { + return null; + } + + return ( + + ); +} diff --git a/frontend/src/components/pages/rp-connect/pipeline/index.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/index.test.tsx index f5a4aeaca9..c7ad7fe39c 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/index.test.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/index.test.tsx @@ -99,6 +99,8 @@ const mockEditorInstance = { contentChangeListeners.push(cb); return { dispose: vi.fn() }; }), + // Cursor → structure-tree highlight sync subscribes to this. + onDidChangeCursorPosition: vi.fn(() => ({ dispose: vi.fn() })), executeEdits: vi.fn(), focus: vi.fn(), // Scroll API used by the read-only viewer's vertical overflow shadows. @@ -133,11 +135,12 @@ vi.mock('components/ui/yaml/yaml-editor', async () => { }); // 5. Mock complex sub-components that are irrelevant to our tests -vi.mock('./pipeline-flow-diagram', async () => { +// The expanded Visual lane renders the canvas; stub it to a marker carrying the YAML. +vi.mock('./pipeline-flow-canvas', async () => { const React = await import('react'); return { - PipelineFlowDiagram: (props: { configYaml: string }) => - React.createElement('div', { 'data-testid': 'flow-diagram', 'data-configyaml': props.configYaml }), + PipelineFlowCanvas: (props: { configYaml: string }) => + React.createElement('div', { 'data-testid': 'flow-canvas', 'data-configyaml': props.configYaml }), }; }); vi.mock('./pipeline-throughput-card', () => ({ PipelineThroughputCard: () => null })); @@ -371,6 +374,28 @@ describe('PipelinePage', () => { // ── Creating a pipeline ───────────────────────────────────────────── + it('Cmd/Ctrl+S saves the pipeline instead of opening the browser save dialog', async () => { + const user = userEvent.setup(); + const createPipelineMock = vi.fn().mockReturnValue( + create(ConsoleCreatePipelineResponseSchema, { + response: create(CreatePipelineResponseSchema, { + pipeline: create(PipelineSchema, { id: 'new-pipeline' }), + }), + }) + ); + + render(, { transport: createTransport({ createPipelineMock }) }); + + await setPipelineNameViaDialog(user, 'my-pipeline'); + fireEvent.change(screen.getByTestId('yaml-editor'), { target: { value: 'input:\n generate: {}' } }); + + fireEvent.keyDown(window, { key: 's', metaKey: true }); + + await waitFor(() => { + expect(createPipelineMock).toHaveBeenCalled(); + }); + }); + it('saving a new pipeline sends the name and YAML config to the backend', async () => { const user = userEvent.setup(); const createPipelineMock = vi.fn().mockReturnValue( @@ -576,35 +601,123 @@ describe('PipelinePage', () => { expect(screen.queryByRole('heading', { name: 'Pipeline view' })).not.toBeInTheDocument(); }); - it('hydrates the flow diagram with pipeline configYaml in view mode', async () => { + it('hydrates the sidebar structure tree from the pipeline config in view mode', async () => { + mockUsePipelineMode.mockReturnValue({ mode: 'view', pipelineId: 'test-pipeline' }); + mockIsFeatureFlagEnabled.mockImplementation( + (flag: string) => flag === 'enablePipelineDiagrams' || flag === 'enableRpcnVisualEditor' + ); + mockIsEmbedded.mockReturnValue(true); + + render(, { transport: createTransport() }); + + // The sidebar is the structure-tree outline (the old mini flow diagram was removed), hydrated + // from the config — its input/output components appear as tree rows once the pipeline loads. + await waitFor(() => expect(screen.getByText('stdin')).toBeInTheDocument()); + expect(screen.getByText('stdout')).toBeInTheDocument(); + // One labelled tree per non-empty section. + expect(screen.getAllByRole('tree').length).toBeGreaterThan(0); + }); + + it('shows the structure-tree side-lane even when the visual editor flag is off', async () => { + // Diagrams on, visual-editor lane off → the sidebar still uses the structure outline + // (the old mini flow diagram was removed), and the full Visual canvas stays hidden. mockUsePipelineMode.mockReturnValue({ mode: 'view', pipelineId: 'test-pipeline' }); mockIsFeatureFlagEnabled.mockImplementation((flag: string) => flag === 'enablePipelineDiagrams'); mockIsEmbedded.mockReturnValue(true); render(, { transport: createTransport() }); - await waitFor(() => { - const diagram = screen.getByTestId('flow-diagram'); - expect(diagram.getAttribute('data-configyaml')).toBe('input:\n stdin: {}\noutput:\n stdout: {}'); + await waitFor(() => expect(screen.getAllByRole('tree').length).toBeGreaterThan(0)); + expect(screen.queryByTestId('flow-canvas')).not.toBeInTheDocument(); + }); + + it('offers a "Start from a template" entry in the sidebar visualizer while the pipeline is empty', async () => { + const user = userEvent.setup(); + mockUsePipelineMode.mockReturnValue({ mode: 'create' }); + mockIsFeatureFlagEnabled.mockImplementation( + (flag: string) => + flag === 'enablePipelineDiagrams' || flag === 'enableRpcnVisualEditor' || flag === 'enableRpcnTemplateGallery' + ); + mockIsEmbedded.mockReturnValue(true); + + render(, { transport: createTransport() }); + + // The visual editor defaults to the Visual lane; the sidebar (with its template + // CTA) lives alongside the YAML lane, so switch there first. + await user.click(await screen.findByRole('tab', { name: 'YAML' })); + + // Empty pipeline → the template gallery entry is offered. + expect(await screen.findByTestId('browse-templates-cta')).toBeInTheDocument(); + + // Once the pipeline has real content, the entry animates away. + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'input:\n generate:\n mapping: root = {}' }, }); + await waitFor(() => expect(screen.queryByTestId('browse-templates-cta')).not.toBeInTheDocument()); }); - it('view page exposes Monitor and Configuration lanes; Configuration shows the YAML read-only', async () => { + it('view page exposes Monitor and YAML lanes; YAML shows the config read-only', async () => { const user = userEvent.setup(); mockUsePipelineMode.mockReturnValue({ mode: 'view', pipelineId: 'test-pipeline' }); render(, { transport: createTransport() }); // Monitor is the default lane — no YAML editor shown yet. - expect(await screen.findByText('Configuration')).toBeInTheDocument(); + expect(await screen.findByRole('tab', { name: 'YAML' })).toBeInTheDocument(); expect(screen.queryByTestId('yaml-editor')).not.toBeInTheDocument(); - // Switching to Configuration shows the pipeline YAML. - await user.click(screen.getByText('Configuration')); + // Switching to the YAML lane shows the pipeline config read-only. + await user.click(screen.getByRole('tab', { name: 'YAML' })); const yaml = (await screen.findByTestId('yaml-editor')) as HTMLTextAreaElement; expect(yaml.value).toBe('input:\n stdin: {}\noutput:\n stdout: {}'); }); + it('hides the view-mode Visual lane unless the visual editor flag is enabled', async () => { + mockUsePipelineMode.mockReturnValue({ mode: 'view', pipelineId: 'test-pipeline' }); + + render(, { transport: createTransport() }); + + expect(await screen.findByRole('tab', { name: 'YAML' })).toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: 'Visual' })).not.toBeInTheDocument(); + }); + + it('view page Visual lane renders the full pipeline diagram from the pipeline config', async () => { + const user = userEvent.setup(); + mockUsePipelineMode.mockReturnValue({ mode: 'view', pipelineId: 'test-pipeline' }); + // The visual editor builds on the diagrams flag, so both are required. + mockIsFeatureFlagEnabled.mockImplementation( + (flag: string) => flag === 'enableRpcnVisualEditor' || flag === 'enablePipelineDiagrams' + ); + mockIsEmbedded.mockReturnValue(true); + + render(, { transport: createTransport() }); + + await user.click(await screen.findByRole('tab', { name: 'Visual' })); + + const canvas = await screen.findByTestId('flow-canvas'); + expect(canvas.getAttribute('data-configyaml')).toBe('input:\n stdin: {}\noutput:\n stdout: {}'); + }); + + it('opens editing on the Visual lane when the visual editor is enabled, and YAML swaps in the editor', async () => { + const user = userEvent.setup(); + mockUsePipelineMode.mockReturnValue({ mode: 'edit', pipelineId: 'test-pipeline' }); + // The visual editor builds on the diagrams flag, so both are required. + mockIsFeatureFlagEnabled.mockImplementation( + (flag: string) => flag === 'enableRpcnVisualEditor' || flag === 'enablePipelineDiagrams' + ); + mockIsEmbedded.mockReturnValue(true); + + render(, { transport: createTransport() }); + + // Visual is the default edit lane when the flag is on — the editor is not shown. + expect(await screen.findByTestId('flow-canvas')).toBeInTheDocument(); + expect(screen.queryByTestId('yaml-editor')).not.toBeInTheDocument(); + + // Switching to YAML swaps in the editor. + await user.click(await screen.findByRole('tab', { name: 'YAML' })); + expect(await screen.findByTestId('yaml-editor')).toBeInTheDocument(); + }); + it('confirms before stopping a running pipeline', async () => { const user = userEvent.setup(); mockUsePipelineMode.mockReturnValue({ mode: 'view', pipelineId: 'test-pipeline' }); @@ -685,18 +798,18 @@ describe('PipelinePage', () => { // ── Feature flags and mode routing ────────────────────────────────── describe('feature flags and mode routing', () => { - it('shows a slash-command tip banner when the feature is enabled', async () => { + it('shows the slash-command tip in the editor tips bar when the feature is enabled', async () => { mockIsFeatureFlagEnabled.mockImplementation((flag: string) => flag === 'enableConnectSlashMenu'); render(, { transport: createTransport() }); + // The tips bar leads with the slash tip (rotation starts at index 0). await waitFor(() => { - expect(screen.getByText(/Tip: Use/)).toBeInTheDocument(); expect(screen.getByText(/to insert variables/)).toBeInTheDocument(); }); }); - it('hides the slash-command tip banner when the feature is disabled', async () => { + it('omits the slash-command tip when the feature is disabled', async () => { // Default: all flags return false render(, { transport: createTransport() }); @@ -704,7 +817,7 @@ describe('PipelinePage', () => { expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); }); - expect(screen.queryByText(/Tip: Use/)).not.toBeInTheDocument(); + expect(screen.queryByText(/to insert variables/)).not.toBeInTheDocument(); }); it('uses the new log explorer when the feature flag is enabled', async () => { diff --git a/frontend/src/components/pages/rp-connect/pipeline/index.tsx b/frontend/src/components/pages/rp-connect/pipeline/index.tsx index 113c311799..fdfa54bd7d 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/index.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/index.tsx @@ -17,7 +17,6 @@ import { useBlocker, useNavigate, useRouter, useSearch } from '@tanstack/react-r import { getUserTagEntries, isSystemTag } from 'components/constants'; import { ArrowLeftIcon } from 'components/icons'; import { Alert, AlertDescription, AlertTitle } from 'components/redpanda-ui/components/alert'; -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 { @@ -29,7 +28,6 @@ import { DialogHeader, DialogTitle, } from 'components/redpanda-ui/components/dialog'; -import { Kbd } from 'components/redpanda-ui/components/kbd'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'components/redpanda-ui/components/resizable'; import { Separator } from 'components/redpanda-ui/components/separator'; import { Skeleton } from 'components/redpanda-ui/components/skeleton'; @@ -62,7 +60,7 @@ import { PipelineUpdateSchema, UpdatePipelineRequestSchema as UpdatePipelineRequestSchemaDataPlane, } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'; import { type Resolver, type UseFormReturn, useForm } from 'react-hook-form'; import { useGetPipelineServiceConfigSchemaQuery, @@ -83,12 +81,16 @@ import { z } from 'zod'; import { ConfigDialog } from './config-dialog'; import { DetailsDialog } from './details-dialog'; +import { EditorTipsBar, type TipContext } from './editor-tips-bar'; import { PipelineCommandMenu } from './pipeline-command-menu'; -import { PipelineFlowDiagram } from './pipeline-flow-diagram'; import { PipelineEditHeader, PipelineViewHeader } from './pipeline-header'; +import { PipelineStructureTree } from './pipeline-structure-tree'; import { PipelineThroughputCard } from './pipeline-throughput-card'; +import { ScrollShadow } from './scroll-shadow'; +import { TemplateGalleryCta } from './template-cta'; import { PipelineEditorProvider, usePipelineEditorStore, usePipelineEditorStoreApi } from './use-pipeline-editor-store'; import { useSlashCommand } from './use-slash-command'; +import { VisualEditorPanel } from './visual-editor-panel'; import { extractLintHintsFromError } from '../errors'; import { AddConnectorDialog } from '../onboarding/add-connector-dialog'; import { AddConnectorsCard } from '../onboarding/add-connectors-card'; @@ -106,6 +108,9 @@ import type { UserStepRef, } from '../types/wizard'; import { navigateToConnectClusters } from '../utils/navigation'; +import { changedNodeIds } from '../utils/pipeline-diff'; +import { isPipelineEmpty, parsePipelineFlowTree } from '../utils/pipeline-flow-parser'; +import { enclosingNodeId, mapLintHintsToNodes, mergeLintHints, nodeLineRanges } from '../utils/pipeline-lint'; import { parseSchema } from '../utils/schema'; import { useCreateModeInitialYaml } from '../utils/use-create-mode-initial-yaml'; import { usePipelineMode } from '../utils/use-pipeline-mode'; @@ -131,6 +136,20 @@ function getConnectorDialogPlaceholder(type: ConnectComponentType | 'resource' | return; } +// Tips to show beneath the editor for the active lane; read-only YAML and Monitor get none. +function tipsContextForLane(isView: boolean, viewLane: string, editLane: string): TipContext | null { + if (isView) { + return viewLane === 'visual' ? 'visual' : null; + } + return editLane === 'visual' ? 'visual' : 'yaml'; +} + +// Stable empty set for the "nothing unsaved" / view-mode case so highlights don't churn renders. +const EMPTY_NODE_IDS: ReadonlySet = new Set(); + +// How many effect re-runs (editor mount, ranges catch-up) a reveal request survives unresolved. +const MAX_REVEAL_ATTEMPTS = 5; + const pipelineFormSchema = z.object({ name: z .string() @@ -228,25 +247,18 @@ function usePipelineLint(yamlContent: string, errorLintHints: Record { - const merged: Record = {}; - for (const [key, hint] of Object.entries(errorLintHints)) { - merged[`error_${key}`] = hint; - } - if (lintResponse) { - for (const [idx, hint] of Object.entries(lintResponse.lintHints || [])) { - merged[`lint_hint_${idx}`] = hint; - } - } - return merged; - }, [errorLintHints, lintResponse]); + // Dedupe: after a failed save the same problem arrives from the error details and the re-lint. + const lintHints = useMemo( + () => mergeLintHints(errorLintHints, lintResponse?.lintHints ?? []), + [errorLintHints, lintResponse] + ); return { lintHints, isLintPending }; } function usePipelineSave({ form, - yamlContent, + editorStore, mode, pipelineId, pipeline, @@ -254,7 +266,8 @@ function usePipelineSave({ onBeforeSaveNavigate, }: { form: UseFormReturn; - yamlContent: string; + /** The editor store — read fresh at save time (after flushing pending visual edits). */ + editorStore: ReturnType; mode: string; pipelineId: string | undefined; pipeline: Pipeline | undefined; @@ -287,6 +300,11 @@ function usePipelineSave({ return; } + // Flush the Visual lane's in-progress edit (auto-applies on leave, but the user may save + // mid-edit), then read the freshest YAML from the store. + editorStore.getState().pendingEditCommit?.(); + const yamlContent = editorStore.getState().yamlContent; + const { name, description, computeUnits, tags: formTags } = form.getValues(); const userTags = buildUserTags(formTags); @@ -341,7 +359,7 @@ function usePipelineSave({ } }, [ form, - yamlContent, + editorStore, mode, pipelineId, createMutation, @@ -479,34 +497,41 @@ function YamlViewPanel({ configYaml: string; schema: ReturnType; }) { - // Top/bottom shadows from Monaco's scroll position (useScrollShadow needs a native - // scroll container; Monaco virtualizes, so onDidScrollChange is the only signal). + // Top/bottom shadows from Monaco's scroll position (it virtualizes, so onDidScrollChange is the only signal). const [overflow, setOverflow] = useState({ top: false, bottom: false }); - // Listener disposables from the editor mount, torn down on unmount (effect below). Without - // this the sync closures (which capture the editor) keep the editor + listener graph alive - // per mount of the view page. + // Mount-time listener disposables, torn down on unmount so the editor + listener graph can be GC'd. const scrollSyncSubscriptions = useRef[]>([]); - const handleMount = useCallback((instance: editor.IStandaloneCodeEditor) => { - const sync = () => { - const scrollTop = instance.getScrollTop(); - const maxY = instance.getScrollHeight() - instance.getLayoutInfo().height; - setOverflow({ top: scrollTop > 1, bottom: scrollTop < maxY - 1 }); - }; - scrollSyncSubscriptions.current = [ - instance.onDidScrollChange(sync), - instance.onDidContentSizeChange(sync), - instance.onDidLayoutChange(sync), - ]; - sync(); - }, []); - useEffect(function disposeScrollSyncListeners() { - return () => { - for (const subscription of scrollSyncSubscriptions.current) { - subscription.dispose(); - } - scrollSyncSubscriptions.current = []; - }; - }, []); + // Register the read-only viewer as the active editor so sidebar/Visual selection can reveal lines here too. + const setEditorInstance = usePipelineEditorStore((s) => s.setEditorInstance); + const handleMount = useCallback( + (instance: editor.IStandaloneCodeEditor) => { + const sync = () => { + const scrollTop = instance.getScrollTop(); + const maxY = instance.getScrollHeight() - instance.getLayoutInfo().height; + setOverflow({ top: scrollTop > 1, bottom: scrollTop < maxY - 1 }); + }; + scrollSyncSubscriptions.current = [ + instance.onDidScrollChange(sync), + instance.onDidContentSizeChange(sync), + instance.onDidLayoutChange(sync), + ]; + sync(); + setEditorInstance(instance); + }, + [setEditorInstance] + ); + useEffect( + function disposeScrollSyncListeners() { + return () => { + for (const subscription of scrollSyncSubscriptions.current) { + subscription.dispose(); + } + scrollSyncSubscriptions.current = []; + setEditorInstance(null); + }; + }, + [setEditorInstance] + ); const edge = 'pointer-events-none absolute inset-x-0 h-4 from-black/10 to-transparent transition-opacity duration-150 dark:from-black/40'; @@ -557,7 +582,7 @@ function ViewModePanel({ pipeline }: { pipeline: Pipeline | undefined }) { ) : null} -
    +
    {isFeatureFlagEnabled('enableNewPipelineLogs') ? ( // Title renders inline in the explorer's control row to line up with the table. void; yamlContent: string; onYamlChange: (val: string) => void; onEditorMount: (editorRef: editor.IStandaloneCodeEditor) => void; @@ -605,33 +626,16 @@ function EditorPanel({ {isServerlessInitializing ? ( ) : ( - <> - {slashTipVisible ? ( -
    - - - Tip: Use{' '} - - / - {' '} - to insert variables - - - -
    - ) : null} - {/* Out of flow so Monaco can't feed its width up the layout and latch the page wide. */} -
    - onYamlChange(val || '')} - onEditorMount={onEditorMount} - options={slashTipVisible ? { padding: { top: 32 } } : undefined} - schema={yamlEditorSchema} - transparentBackground - value={yamlContent} - /> -
    - + // Out of flow so Monaco can't feed its width up the layout and latch the page wide. +
    + onYamlChange(val || '')} + onEditorMount={onEditorMount} + schema={yamlEditorSchema} + transparentBackground + value={yamlContent} + /> +
    )}
    @@ -653,41 +657,139 @@ function EditorPanel({ ); } +function useIsPipelineEmpty(yamlContent: string): boolean { + return useMemo(() => isPipelineEmpty(parsePipelineFlowTree(yamlContent).nodes), [yamlContent]); +} + function SidebarPanel({ mode, yamlContent, isPipelineDiagramsEnabled, + errorNodeIds, + unsavedNodeIds, onAddConnector, - onAddTopic, - onAddSasl, - onOpenCommandMenu, onBrowseTemplates, + onOpenCommandMenu, }: { mode: string; yamlContent: string; isPipelineDiagramsEnabled: boolean; + errorNodeIds?: ReadonlySet; + unsavedNodeIds?: ReadonlySet; onAddConnector: (type: ConnectComponentType | 'resource') => void; - onAddTopic: (section: string, componentName: string) => void; - onAddSasl: (section: string, componentName: string) => void; - onOpenCommandMenu: (filter?: 'all' | 'variables' | 'secrets' | 'topics' | 'users') => void; onBrowseTemplates?: () => void; + onOpenCommandMenu: (filter?: 'all' | 'variables' | 'secrets' | 'topics' | 'users') => void; }) { - // View mode is read-only; only wire editing handlers otherwise. - const editHandlers = - mode === 'view' - ? {} - : { - onAddConnector: (type: string) => onAddConnector(type as ConnectComponentType), - onAddSasl, - onAddTopic, - onBrowseTemplates, - }; + // View mode is read-only; only wire add handlers otherwise. + const canEdit = mode !== 'view'; + // The tree/decoration consumers below tolerate slightly-stale YAML, so defer it to keep their + // full-document parses off the per-keystroke critical path (the Monaco editor stays live). + const deferredYaml = useDeferredValue(yamlContent); + const isEmpty = useIsPipelineEmpty(deferredYaml); + // The structure outline is the single sidebar visualizer, shown whenever diagrams are enabled. + const showStructureTree = isPipelineDiagramsEnabled; + + // Two-way sync: clicking a node reveals/selects its lines; moving the cursor highlights the node. + const editorInstance = usePipelineEditorStore((s) => s.editorInstance); + const [activeNodeId, setActiveNodeId] = useState(); + // Node → YAML line ranges, recomputed as the document changes. + const nodeRanges = useMemo(() => { + try { + return nodeLineRanges(deferredYaml); + } catch { + return []; + } + }, [deferredYaml]); + // Latest ranges for the long-lived cursor listener, without re-subscribing per keystroke. + const nodeRangesRef = useRef(nodeRanges); + nodeRangesRef.current = nodeRanges; + + const revealNodeInEditor = useCallback( + (nodeId?: string) => { + const ed = editorInstance; + const range = nodeId ? nodeRanges.find((r) => r.id === nodeId) : undefined; + const model = ed?.getModel(); + if (!(ed && range && model)) { + return; + } + const endLine = Math.min(range.end, model.getLineCount()); + ed.setSelection({ + startLineNumber: range.start, + startColumn: 1, + endLineNumber: endLine, + endColumn: model.getLineMaxColumn(endLine), + }); + ed.revealLineInCenterIfOutsideViewport(range.start); + ed.focus(); + }, + [editorInstance, nodeRanges] + ); + + const handleSelectNode = useCallback( + (highlightId: string, editableId?: string) => { + setActiveNodeId(highlightId); + revealNodeInEditor(editableId); + }, + [revealNodeInEditor] + ); + + // Editor cursor → highlight the most specific node enclosing the caret line. + useEffect(() => { + if (!editorInstance) { + return; + } + const sub = editorInstance.onDidChangeCursorPosition((e) => { + setActiveNodeId(enclosingNodeId(e.position.lineNumber, nodeRangesRef.current)); + }); + return () => sub.dispose(); + }, [editorInstance]); + + // Pending reveal request from the Visual lane: honour once editor + ranges mount, then clear (fires once). + const revealNodeId = usePipelineEditorStore((s) => s.revealNodeId); + const requestRevealNode = usePipelineEditorStore((s) => s.requestRevealNode); + const revealAttemptRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 }); + useEffect(() => { + if (!revealNodeId) { + revealAttemptRef.current = { id: null, count: 0 }; + return; + } + if (revealAttemptRef.current.id !== revealNodeId) { + revealAttemptRef.current = { id: revealNodeId, count: 0 }; + } + const range = nodeRanges.find((r) => r.id === revealNodeId); + if (editorInstance?.getModel() && range) { + setActiveNodeId(revealNodeId); + revealNodeInEditor(revealNodeId); + requestRevealNode(null); + return; + } + // Bounded retry: allow a few re-runs (editor mount, ranges catch-up), then drop the request + // so an id that never resolves can't fire as a surprise jump much later. + revealAttemptRef.current.count += 1; + if (revealAttemptRef.current.count > MAX_REVEAL_ATTEMPTS) { + requestRevealNode(null); + } + }, [revealNodeId, editorInstance, nodeRanges, revealNodeInEditor, requestRevealNode]); + const showTemplateCta = showStructureTree && canEdit && Boolean(onBrowseTemplates) && isEmpty; return (
    -
    - {isPipelineDiagramsEnabled ? ( - + {/* Relative so the template entry point can float pinned at the bottom with an enter/exit animation. */} +
    + + {showStructureTree ? ( + onAddConnector(section as ConnectComponentType) : undefined} + onSelectNode={handleSelectNode} + selectedNodeId={activeNodeId} + unsavedNodeIds={unsavedNodeIds} + /> + ) : null} + + {showStructureTree && onBrowseTemplates ? ( + ) : null}
    {mode !== 'view' && ( @@ -756,11 +858,14 @@ function SidebarPanel({ } export default function PipelinePage() { - const { mode, pipelineId } = usePipelineMode(); - const isSlashMenuEnabled = isFeatureFlagEnabled('enableConnectSlashMenu'); + const { pipelineId } = usePipelineMode(); + // With the visual editor enabled, open editing on the Visual lane by default. The visual editor + // builds on the diagram parsing/outline, so it also requires the diagrams flag. + const isVisualEditorEnabled = + isFeatureFlagEnabled('enableRpcnVisualEditor') && isFeatureFlagEnabled('enablePipelineDiagrams') && isEmbedded(); // Keyed by pipeline id so each pipeline gets a fresh editor store. return ( - + ); @@ -775,6 +880,8 @@ function PipelinePageContent() { const isSlashMenuEnabled = isFeatureFlagEnabled('enableConnectSlashMenu'); const isServerlessMode = search.serverless === 'true'; const isPipelineDiagramsEnabled = isFeatureFlagEnabled('enablePipelineDiagrams') && isEmbedded(); + // The visual editor builds on the diagram parsing/outline, so it also requires the diagrams flag. + const isVisualEditorEnabled = isFeatureFlagEnabled('enableRpcnVisualEditor') && isPipelineDiagramsEnabled; const isTemplateGalleryEnabled = isFeatureFlagEnabled('enableRpcnTemplateGallery'); // Actions are stable, so read them once via getState; values use selectors. @@ -787,9 +894,10 @@ function PipelinePageContent() { resolveInitialYaml, setAllowNavigation, setActiveViewLane, + setActiveEditLane, + requestRevealNode, setCommandMenuFilter, setAddConnectorType, - setSlashTipVisible, setIsConfigDialogOpen, setIsViewConfigDialogOpen, setIsDeleteAlertOpen, @@ -801,13 +909,15 @@ function PipelinePageContent() { const editorInstance = usePipelineEditorStore((s) => s.editorInstance); const hydratedPipelineId = usePipelineEditorStore((s) => s.hydratedPipelineId); const activeViewLane = usePipelineEditorStore((s) => s.activeViewLane); + const activeEditLane = usePipelineEditorStore((s) => s.activeEditLane); + const selectedNodeId = usePipelineEditorStore((s) => s.selectedNodeId); const commandMenuFilter = usePipelineEditorStore((s) => s.commandMenuFilter); const addConnectorType = usePipelineEditorStore((s) => s.addConnectorType); - const slashTipVisible = usePipelineEditorStore((s) => s.slashTipVisible); const isConfigDialogOpen = usePipelineEditorStore((s) => s.isConfigDialogOpen); const isViewConfigDialogOpen = usePipelineEditorStore((s) => s.isViewConfigDialogOpen); const isDeleteAlertOpen = usePipelineEditorStore((s) => s.isDeleteAlertOpen); const isTemplateDialogOpen = usePipelineEditorStore((s) => s.isTemplateDialogOpen); + const tipsContext = tipsContextForLane(mode === 'view', activeViewLane, activeEditLane); const form = useForm({ resolver: zodResolver(pipelineFormSchema) as Resolver, @@ -847,7 +957,7 @@ function PipelinePageContent() { const { handleSave, handleDelete, clearWizardStore, errorLintHints, clearErrorLintHints, isSaving, isDeleting } = usePipelineSave({ form, - yamlContent, + editorStore, mode, pipelineId, pipeline, @@ -859,9 +969,36 @@ function PipelinePageContent() { // Guard against losing unsaved edits when navigating away from the editor (edit or create). const yamlDirty = initialYaml !== null && yamlContent !== initialYaml; const hasUnsavedChanges = mode !== 'view' && (form.formState.isDirty || yamlDirty); + + // Guard-time dirty check: flush any in-progress inspector draft into the store first (the + // rendered `hasUnsavedChanges` above can't see a pending draft), then re-read fresh state. + const hasUnsavedChangesFresh = useCallback(() => { + if (mode === 'view') { + return false; + } + editorStore.getState().pendingEditCommit?.(); + const { yamlContent: yaml, initialYaml: baseline } = editorStore.getState(); + return form.formState.isDirty || (baseline !== null && yaml !== baseline); + }, [mode, editorStore, form]); + + // Node-level highlights for the structure tree: nodes with lint errors, and nodes with unsaved + // edits (config changed vs the saved/loaded baseline). Deferred YAML keeps these decorations' + // full-document parses/diffs off the per-keystroke critical path. + const deferredYamlContent = useDeferredValue(yamlContent); + const errorNodeIds = useMemo( + () => new Set(mapLintHintsToNodes(deferredYamlContent, Object.values(lintHints)).keys()), + [deferredYamlContent, lintHints] + ); + const unsavedNodeIds = useMemo( + () => + mode !== 'view' && initialYaml !== null + ? new Set(changedNodeIds(initialYaml, deferredYamlContent)) + : EMPTY_NODE_IDS, + [mode, initialYaml, deferredYamlContent] + ); const blocker = useBlocker({ - shouldBlockFn: () => hasUnsavedChanges && !editorStore.getState().allowNavigation, - enableBeforeUnload: () => hasUnsavedChanges, + shouldBlockFn: () => hasUnsavedChangesFresh() && !editorStore.getState().allowNavigation, + enableBeforeUnload: () => hasUnsavedChangesFresh(), withResolver: true, }); // Re-arm the guard whenever the mode changes (e.g. after the post-save nav to view). @@ -869,6 +1006,31 @@ function PipelinePageContent() { setAllowNavigation(false); }, [mode, setAllowNavigation]); + // ⌘S / Ctrl+S saves (overriding the browser save-page dialog), from both YAML and Visual lanes. + useEffect(() => { + if (mode === 'view') { + return; + } + const onKeyDown = (e: KeyboardEvent) => { + // Plain ⌘S/Ctrl+S only — ⌘⇧S (save-as) keeps its browser behaviour. + if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.key.toLowerCase() !== 's') { + return; + } + // Skip while a MODAL dialog captures input (settings, discard-changes, …) — before + // preventDefault, so an unhandled press keeps browser behaviour. Scoped to aria-modal so a + // host-shell element or non-modal popover (role="dialog") can't disable it. + if (document.querySelector('[role="dialog"][aria-modal="true"], [role="alertdialog"]')) { + return; + } + e.preventDefault(); + if (!isSaving) { + handleSave(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [mode, isSaving, handleSave]); + // On any document change: clear stale lint and mirror the create-mode draft to the wizard store. useEffect( () => @@ -929,15 +1091,26 @@ function PipelinePageContent() { } }, [pipeline, mode, hydratedPipelineId, hydrateFromServer]); + // Populate the form from the loaded pipeline. The query polls while starting/stopping, so a + // DIRTY form is never reset (would clobber in-progress edits) — but a clean form re-syncs when + // the server payload changes (stale-cache refetch, or a concurrent rename must not be reverted). + const formResetSnapshotRef = useRef(null); useEffect(() => { - if (pipeline && mode === 'edit') { - form.reset({ - name: pipeline.displayName, - description: pipeline.description || '', - computeUnits: cpuToTasks(pipeline.resources?.cpuShares) || MIN_TASKS, - tags: getUserTagEntries(pipeline.tags), - }); + if (!(pipeline && mode === 'edit')) { + return; + } + const values = { + name: pipeline.displayName, + description: pipeline.description || '', + computeUnits: cpuToTasks(pipeline.resources?.cpuShares) || MIN_TASKS, + tags: getUserTagEntries(pipeline.tags), + }; + const snapshot = `${pipeline.id}\n${JSON.stringify(values)}`; + if (snapshot === formResetSnapshotRef.current || form.formState.isDirty) { + return; } + formResetSnapshotRef.current = snapshot; + form.reset(values); }, [pipeline, mode, form]); const handleInitialYamlResolved = useCallback((yaml: string) => resolveInitialYaml(yaml), [resolveInitialYaml]); @@ -950,6 +1123,16 @@ function PipelinePageContent() { onResolved: handleInitialYamlResolved, }); + // Non-serverless create + visual editor: useCreateModeInitialYaml bails when diagrams are on, so + // no baseline resolves — seed from the starting content, else `initialYaml` stays null, the guard + // never arms, and canvas-only edits are lost on navigate-away. Serverless is excluded (it resolves + // its own baseline once components load; seeding '' first would leave a stale baseline → false-dirty). + useEffect(() => { + if (mode === 'create' && isPipelineDiagramsEnabled && !isServerlessMode && initialYaml === null) { + resolveInitialYaml(yamlContent); + } + }, [mode, isPipelineDiagramsEnabled, isServerlessMode, initialYaml, yamlContent, resolveInitialYaml]); + const handleCancel = useCallback(() => { if (mode === 'create') { clearWizardStore(); @@ -970,9 +1153,36 @@ function PipelinePageContent() { } }, [mode, clearWizardStore, navigate, pipelineId, router]); + // Visual lanes take the full canvas, so the YAML/diagram sidebar is hidden. + const isViewVisualLane = mode === 'view' && activeViewLane === 'visual'; + const isEditVisualLane = mode !== 'view' && activeEditLane === 'visual'; + const showSidebar = !(isViewVisualLane || isEditVisualLane); + + // Open the YAML lane and reveal a node: explicit id, else the selected node. Routes per mode. + const goToYamlNode = useCallback( + (nodeId?: string) => { + const target = nodeId ?? selectedNodeId; + if (target) { + requestRevealNode(target); + } + if (mode === 'view') { + setActiveViewLane('configuration'); + } else { + // Commit the selected node's in-progress edit before unmounting the Visual lane, + // otherwise the lane switch discards it (no commit-on-unmount). + editorStore.getState().pendingEditCommit?.(); + setActiveEditLane('yaml'); + } + }, + [mode, selectedNodeId, requestRevealNode, setActiveViewLane, setActiveEditLane, editorStore] + ); + return ( - // overflow-x-clip guards against stray horizontal overflow (clip, not hidden, to keep overflow-y visible). -
    + // Bounded to the viewport (definite height, not just a min) so a tall lane scrolls WITHIN the + // framed panel instead of stretching the page. Reserve only the chrome ABOVE (app header + pt-8), + // NOT the footer — the editor fills the screen and the footer sits below the fold rather than + // eating height. overflow-x-clip (not hidden) blocks stray horizontal overflow but keeps overflow-y. +
    {mode === 'view' && pipeline ? ( ) : null} - {/* View-mode lanes: Monitor (throughput/logs) vs Configuration (read-only YAML). */} - {mode === 'view' && pipeline ? ( - - - setActiveViewLane('monitor')} value="monitor" variant="underline"> - Monitor - - setActiveViewLane('configuration')} value="configuration" variant="underline"> - Configuration - - - - ) : null} - {/* min-w-0 + overflow-hidden keep the editor region from propagating width upward. */} -
    - setAddConnectorType(type)} - onAddSasl={handleAddSasl} - onAddTopic={handleAddTopic} - onBrowseTemplates={ - isTemplateGalleryEnabled && mode !== 'view' ? () => setIsTemplateDialogOpen(true) : undefined - } - onOpenCommandMenu={handleCommandMenuOpen} - yamlContent={yamlContent} - /> -
    - {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 full-width underline is the + internal divider) with the content below, all inside one rounded border. */} +
    + {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 ? ( + + {/* Full-width list (so the underline divider spans) with content-width triggers so tabs pack left. */} + + 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/node-config-form.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/node-config-form.test.tsx new file mode 100644 index 0000000000..1cc08d943a --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/node-config-form.test.tsx @@ -0,0 +1,291 @@ +/** + * 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 as it changes +// (null when clean), and the inspector auto-commits it on leave / save. Tests assert the latest +// reported config rather than clicking Apply. +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]; +} + +const NESTED_COMPONENT_HINT = /processors is a nested component/i; + +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'); + expect(screen.getAllByRole('button', { name: 'Show value' }).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; surfaces a hint 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 is a control; the nested component is NOT — it's a hint instead. + expect(screen.getByText('request_map')).toBeInTheDocument(); + expect(screen.getByText(NESTED_COMPONENT_HINT)).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..3d786b2a85 --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/node-config-form.tsx @@ -0,0 +1,1028 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { Button } from 'components/redpanda-ui/components/button'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from 'components/redpanda-ui/components/collapsible'; +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 { AlertCircle, ChevronDown, ChevronRight, Eye, EyeOff, Plus } from 'lucide-react'; +import { createContext, useContext, useEffect, useId, useState } 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 type { ConnectComponentSpec, RawFieldSpec } from '../types/schema'; +import { checkRequired } from '../utils/schema'; +import type { EditTarget, ResourceKind } from '../utils/yaml'; + +// Re-exported for node-inspector, which imports ResourceKind from here. +export type { ResourceKind } from '../utils/yaml'; + +// A direct child (case / step) of a control-flow component, shown in the inspector as a +// clickable row so you can jump from the high-level construct to the actual node's 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; +}; + +// Routing-condition text color: red for error paths, muted for default, the warning accent otherwise. +const childItemCondColor = (item: InspectorChildItem): string => { + if (item.isErrorPath) { + return 'text-destructive'; + } + if (item.isDefault) { + return 'text-muted-foreground'; + } + return 'text-warning'; +}; + +// One clickable child row: its routing condition over the component name, a lint count if +// any, and a chevron. Selecting it navigates the inspector to that node. +const ChildItemRow = ({ + item, + onSelect, +}: { + item: InspectorChildItem; + onSelect: (item: InspectorChildItem) => void; +}) => { + const condText = childItemConditionText(item); + return ( + + ); +}; + +export const ChildItemsList = ({ + items, + onSelect, + label, +}: { + items: InspectorChildItem[]; + onSelect: (item: InspectorChildItem) => void; + label: string; +}) => ( +
    + +
    + {items.map((item) => ( + + ))} +
    + + Select one to open its full configuration. + +
    +); + +// Resource-link context: the existing labels to offer and a create-and-link action. +// Provided by the inspector (which owns the full YAML) and consumed by resource-ref +// fields, so the form itself stays a pure config editor. +type ResourceFieldContextValue = { + labels: Record; + onCreateResource?: (kind: ResourceKind) => void; + // The resource kind of the component being edited (cache/rate_limit processor), so a + // plainly-typed `resource:` string field is still recognised as a link. + componentResourceKind?: ResourceKind; +}; +const ResourceFieldContext = createContext({ labels: { cache: [], rate_limit: [] } }); + +const CREATE_RESOURCE_VALUE = '__create_resource__'; + +// Resolve which resource kind a field links to: by its field type, or — for a field +// literally named `resource` — the kind of the cache/rate_limit component it sits in +// (some schemas type the reference 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; +} + +// A typed dropdown for a `resource:` link: pick an existing label or create-and-link a +// new resource. Never free text, so the reference can't be mistyped or dangle. A value +// that isn't among the known labels (e.g. a stale link) 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 current = value ?? ''; + const isMissing = current !== '' && !options.includes(current); + return ( + + ); +}; + +const SCALAR_TYPES = new Set(['string', 'int', 'float', 'bool']); + +function hasOptions(spec: RawFieldSpec): boolean { + return (spec.annotatedOptions?.length ?? 0) > 0; +} + +// A reference to a cache/rate_limit resource: a scalar string whose field type names +// the resource kind (not an inline component). Rendered as a label dropdown so the +// link can't be mistyped, and stored/assembled like any other scalar string. +function isResourceRefField(spec: RawFieldSpec): boolean { + return ( + Boolean(spec.name) && + spec.kind === 'scalar' && + (spec.type === 'cache' || spec.type === 'rate_limit') && + !(spec.children?.length ?? 0) + ); +} + +// A single editable value: a primitive (string/int/float/bool), an enum select, or a +// resource reference (stored as a string). +function isScalarField(spec: RawFieldSpec): boolean { + return ( + Boolean(spec.name) && + spec.kind === 'scalar' && + (SCALAR_TYPES.has(spec.type) || hasOptions(spec) || isResourceRefField(spec)) + ); +} + +// A list of primitives (e.g. `topics: [a, b]`), edited as one-per-line text. +function isScalarArray(spec: RawFieldSpec): boolean { + return Boolean(spec.name) && spec.kind === 'array' && SCALAR_TYPES.has(spec.type) && !hasOptions(spec); +} + +// A nested object with its own fields (e.g. `tls`, `batching`) — rendered as a +// collapsible sub-section whose children recurse through the same renderer. +function isObjectGroup(spec: RawFieldSpec): boolean { + return Boolean(spec.name) && spec.kind === 'scalar' && (spec.children?.length ?? 0) > 0; +} + +// A field whose value is itself a nested component (a processor sub-pipeline, an +// input/output, …). These are their own nodes in the graph and are edited by +// selecting them on the canvas — never inline here. They're preserved untouched. +const COMPONENT_FIELD_TYPES = new Set([ + 'input', + 'output', + 'processor', + 'cache', + 'rate_limit', + 'buffer', + 'metrics', + 'tracer', + 'scanner', +]); +function isComponentField(spec: RawFieldSpec): boolean { + // A resource reference is a string link, not an inline nested component. + return Boolean(spec.name) && COMPONENT_FIELD_TYPES.has(spec.type) && !isResourceRefField(spec); +} + +// Fields rendered as form controls. Nested components are excluded (edited on the +// canvas); other complex fields (object arrays, maps, 2d arrays, unknown keys) fall +// back to the raw-YAML section. +function isFormField(spec: RawFieldSpec): boolean { + return !isComponentField(spec) && (isScalarField(spec) || isScalarArray(spec) || isObjectGroup(spec)); +} + +// ---- plain-object path helpers (the config we mutate is plain JSON) ----------- + +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]; + } + } + } +} + +// ---- value coercion ----------------------------------------------------------- + +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 in any field, incl. numeric ones — keep + // them verbatim rather than coercing to a number (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; +} + +// ---- flattening the schema into addressable leaves ---------------------------- + +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 (isScalarArray(f)) { + arrays.push(leaf); + } else if (isObjectGroup(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. Returns the mapping, `{}` when empty (an intentional +// clear), or `null` when the text is invalid / not a mapping — so callers preserve the existing +// keys instead of silently 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 on the field and NOT committed — coercing it would + // silently truncate (`10x` → 10) or drop the value; the saved value is kept until it's 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 every `resource: