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/src/components/ai-elements/prompt-input.tsx b/frontend/src/components/ai-elements/prompt-input.tsx index 5177b72206..d8d5c626e1 100644 --- a/frontend/src/components/ai-elements/prompt-input.tsx +++ b/frontend/src/components/ai-elements/prompt-input.tsx @@ -414,8 +414,8 @@ export const PromptInputActionAddAttachments = ({ return ( { - e.preventDefault(); + closeOnClick={false} + onClick={() => { attachments.openFileDialog(); }} > 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/pipeline/pipeline-flow-diagram.tsx b/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-diagram.tsx index a93580a4f2..639f787921 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-diagram.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/pipeline-flow-diagram.tsx @@ -28,7 +28,6 @@ import { PipelineFlowSkeleton, pipelineEdgeTypes, pipelineNodeTypes } from './pi import { computeTreeLayout as defaultComputeLayout, parsePipelineFlowTree as defaultParseTree, - MAX_NESTING_DEPTH, type ParsePipelineFlowTreeResult, type PipelineFlowNode, } from '../utils/pipeline-flow-parser'; @@ -196,7 +195,7 @@ export const PipelineFlowDiagram = ({ [debouncedYaml, parseTree, instanceId] ); - const { rfNodes, rfEdges, maxDepth } = useMemo(() => { + const { rfNodes, rfEdges } = useMemo(() => { const layout = computeLayout(nodes, collapsedIds); // Inject callbacks into group, placeholder, and setup-hint leaf nodes. @@ -231,12 +230,12 @@ export const PipelineFlowDiagram = ({ return node; }); - return { rfNodes: nodesWithCallbacks, rfEdges: layout.rfEdges, maxDepth: layout.maxDepth ?? 0 }; + return { rfNodes: nodesWithCallbacks, rfEdges: layout.rfEdges }; }, [nodes, collapsedIds, toggleCollapse, computeLayout, onAddConnector, onAddTopic, onAddSasl]); - const { translateExtent, panOnScrollMode, contentOverflows } = useMemo(() => { + const { translateExtent, contentOverflows } = useMemo(() => { if (!containerSize) { - return { translateExtent: undefined, panOnScrollMode: PanOnScrollMode.Vertical, contentOverflows: false }; + return { translateExtent: undefined, contentOverflows: false }; } const extent = computeTranslateExtent(rfNodes, containerSize.width, containerSize.height); const rawMaxX = extent[1][0] - EXTENT_PADDING; @@ -244,10 +243,9 @@ export const PipelineFlowDiagram = ({ const overflows = rawMaxX > containerSize.width || rawMaxY > containerSize.height; return { translateExtent: extent, - panOnScrollMode: maxDepth > MAX_NESTING_DEPTH ? PanOnScrollMode.Free : PanOnScrollMode.Vertical, contentOverflows: overflows, }; - }, [rfNodes, containerSize, maxDepth]); + }, [rfNodes, containerSize]); // React Flow occasionally settles with a vertical offset when the container // is much taller than the diagram; defaultViewport alone doesn't override it. @@ -321,7 +319,7 @@ export const PipelineFlowDiagram = ({ }} panOnDrag={false} panOnScroll={contentOverflows} - panOnScrollMode={panOnScrollMode} + panOnScrollMode={PanOnScrollMode.Vertical} preventScrolling={contentOverflows} proOptions={{ hideAttribution: true }} translateExtent={translateExtent} diff --git a/frontend/src/components/pages/rp-connect/pipeline/pipeline-status-toggle.test.tsx b/frontend/src/components/pages/rp-connect/pipeline/pipeline-status-toggle.test.tsx new file mode 100644 index 0000000000..0ff9605c3e --- /dev/null +++ b/frontend/src/components/pages/rp-connect/pipeline/pipeline-status-toggle.test.tsx @@ -0,0 +1,54 @@ +/** + * 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 { Pipeline_State } from 'protogen/redpanda/api/dataplane/v1/pipeline_pb'; +import { render, screen } from 'test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { PipelineStatusToggle } from './pipeline-status-toggle'; + +const { startMutate, stopMutate } = vi.hoisted(() => ({ startMutate: vi.fn(), stopMutate: vi.fn() })); + +vi.mock('react-query/api/pipeline', () => ({ + useStartPipelineMutation: () => ({ mutate: startMutate, isPending: false }), + useStopPipelineMutation: () => ({ mutate: stopMutate, isPending: false }), +})); + +describe('PipelineStatusToggle', () => { + beforeEach(() => { + startMutate.mockClear(); + stopMutate.mockClear(); + }); + + test('a STARTING pipeline stays interactive and can be cancelled back to Stopped', async () => { + const user = userEvent.setup(); + render(); + + // The toggle is NOT locked mid-start — that's what rescues a stuck pipeline. + const toggle = screen.getByTestId('pipeline-run-toggle'); + expect(toggle).not.toBeDisabled(); + + // Turning it off asks to cancel the start (not the generic "Stop pipeline?"). + await user.click(toggle); + expect(await screen.findByText('Cancel pipeline start?')).toBeInTheDocument(); + + // Confirming issues the stop request (which returns the pipeline to Stopped). + await user.click(screen.getByRole('button', { name: 'Cancel start' })); + expect(stopMutate).toHaveBeenCalledTimes(1); + expect(startMutate).not.toHaveBeenCalled(); + }); + + test('a STOPPING pipeline stays locked (already settling toward Stopped)', () => { + render(); + expect(screen.getByTestId('pipeline-run-toggle')).toHaveAttribute('data-disabled'); + }); +}); diff --git a/frontend/src/components/pages/rp-connect/pipeline/pipeline-status-toggle.tsx b/frontend/src/components/pages/rp-connect/pipeline/pipeline-status-toggle.tsx index ef8e62e6c9..d073700a0c 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/pipeline-status-toggle.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/pipeline-status-toggle.tsx @@ -39,8 +39,8 @@ import { formatToastErrorMessageGRPC } from 'utils/toast.utils'; type Tone = 'success' | 'error' | 'muted'; -// Pill chrome per state: running is a filled green pill, error is red text, -// everything else (stopped/completed/transitioning) reads as plain muted text. +// Pill chrome per state: running is a filled green pill, error is red text, everything else +// (stopped/completed/transitioning) reads as plain muted text. const statusPill = cva( 'inline-flex h-9 items-center gap-2 rounded-full border px-3 font-medium text-sm transition-colors', { @@ -65,10 +65,8 @@ function getTone(state?: Pipeline_State): Tone { return 'muted'; } -// Combined status + run control: a switch whose on/off mirrors the pipeline's -// running state, paired with the state label. Turning it on starts the -// pipeline; turning it off opens a stop confirmation. Replaces the separate -// status badge + start/stop button. +// Combined status + run control: a switch mirroring the running state, paired with the state +// label. On starts the pipeline; off opens a stop confirmation. export function PipelineStatusToggle({ pipelineId, pipelineState, @@ -80,6 +78,9 @@ export function PipelineStatusToggle({ const { mutate: stopMutation, isPending: isStopPending } = useStopPipelineMutation(); const [isStopConfirmOpen, setIsStopConfirmOpen] = useState(false); + const isStarting = pipelineState === PipelineState.STARTING; + const isStopping = pipelineState === PipelineState.STOPPING; + const handleStart = useCallback(() => { if (!pipelineId) { return; @@ -99,7 +100,7 @@ export function PipelineStatusToggle({ } stopMutation(create(StopPipelineRequestSchema, { request: { id: pipelineId } }), { onSuccess: () => { - toast.success('Pipeline stopped'); + toast.success(isStarting ? 'Canceling pipeline start' : 'Pipeline stopped'); setIsStopConfirmOpen(false); }, onError: (err) => { @@ -107,15 +108,17 @@ export function PipelineStatusToggle({ setIsStopConfirmOpen(false); }, }); - }, [pipelineId, stopMutation]); + }, [pipelineId, stopMutation, isStarting]); - // Switch reflects (and drives) the running state. Starting/stopping are - // in-flight, so the control is locked until the backend settles. - const checked = pipelineState === PipelineState.RUNNING || pipelineState === PipelineState.STARTING; - const isTransitioning = pipelineState === PipelineState.STARTING || pipelineState === PipelineState.STOPPING; - const isDisabled = !pipelineId || isTransitioning || isStartPending || isStopPending; + // Switch reflects and drives the running state. STARTING stays interactive so it can be cancelled + // back to Stopped (matching STOPPABLE_STATES), rescuing one stuck mid-start. STOPPING is already + // settling toward Stopped, so it's locked until the backend confirms. + const checked = pipelineState === PipelineState.RUNNING || isStarting; + const isDisabled = !pipelineId || isStopping || isStartPending || isStopPending; const label = (pipelineState !== undefined && PIPELINE_STATE_LABELS[pipelineState]) || 'Unknown'; const tone = getTone(pipelineState); + const runningAriaLabel = checked ? 'Stop pipeline' : 'Start pipeline'; + const switchAriaLabel = isStarting ? 'Cancel pipeline start' : runningAriaLabel; const handleCheckedChange = useCallback( (next: boolean) => { @@ -125,7 +128,7 @@ export function PipelineStatusToggle({ if (next) { handleStart(); } else { - // Stopping halts processing, so gate it behind a confirmation. + // Stopping halts processing (or cancels an in-progress start), so gate it behind a confirmation. setIsStopConfirmOpen(true); } }, @@ -136,7 +139,7 @@ export function PipelineStatusToggle({ <>
- {isTransitioning ? : null} + {isStarting || isStopping ? : null} @@ -152,9 +155,13 @@ export function PipelineStatusToggle({ - Stop pipeline? + {isStarting ? 'Cancel pipeline start?' : 'Stop pipeline?'} - Stopping the pipeline halts all data processing until you start it again. + + {isStarting + ? 'This stops the pipeline before it finishes starting and returns it to Stopped.' + : 'Stopping the pipeline halts all data processing until you start it again.'} + diff --git a/frontend/src/components/redpanda-ui/components/dialog.tsx b/frontend/src/components/redpanda-ui/components/dialog.tsx index fd4a35ae6e..337b44e1f7 100644 --- a/frontend/src/components/redpanda-ui/components/dialog.tsx +++ b/frontend/src/components/redpanda-ui/components/dialog.tsx @@ -262,13 +262,17 @@ const dialogBodyContentVariants = cva('', { }, }); +const dialogScrollShadow = 'pointer-events-none sticky z-10 h-0 transition-opacity duration-150'; +const dialogScrollShadowState = (visible: boolean) => (visible ? 'opacity-100' : 'opacity-0'); + interface DialogBodyProps extends React.ComponentProps<'div'>, VariantProps { /** Show fading top/bottom shadows when the body overflows. Defaults to `true`. */ scrollShadow?: boolean; } function DialogBody({ className, padding, spacing, scrollShadow = true, children, style, ...props }: DialogBodyProps) { - const { containerRef, topRef, bottomRef, edges } = useScrollShadow(scrollShadow); + const containerRef = React.useRef(null); + const edges = useScrollShadow(containerRef, scrollShadow); return (
{scrollShadow ? ( - <> -
-
-
-
- +
+
+
) : null}
{children}
{scrollShadow ? ( - <> -
-
-
-
- +
+
+
) : null}
); diff --git a/frontend/src/components/redpanda-ui/components/drag-scroll-area.tsx b/frontend/src/components/redpanda-ui/components/drag-scroll-area.tsx new file mode 100644 index 0000000000..df2fd2474f --- /dev/null +++ b/frontend/src/components/redpanda-ui/components/drag-scroll-area.tsx @@ -0,0 +1,168 @@ +'use client'; + +import React from 'react'; + +import { useScrollShadow } from '../lib/use-scroll-shadow'; +import { cn, type SharedProps } from '../lib/utils'; + +// Drag past this many px counts as a scroll, not a click. +const DRAG_THRESHOLD = 4; + +// Default width of the edge fade, also the inset a focused child is scrolled clear of. +const DEFAULT_FADE_SIZE = 32; + +// Manual horizontal scroll (not `scrollIntoView`) so the page's vertical scroll isn't disturbed. +function scrollChildIntoView(scroller: HTMLDivElement, child: HTMLElement, fadeSize: number) { + const view = scroller.getBoundingClientRect(); + const rect = child.getBoundingClientRect(); + // Inset by the fade so the child clears the gradient, not just the frame edge. + if (rect.left < view.left + fadeSize) { + scroller.scrollLeft -= view.left + fadeSize - rect.left; + } else if (rect.right > view.right - fadeSize) { + scroller.scrollLeft += rect.right - (view.right - fadeSize); + } +} + +// Scroll any focused descendant into view via `focusin` — agnostic to the children, so roving +// keyboard focus, toolbars, and chip rows all work. +function useFocusScroll(ref: React.RefObject, fadeSize: number) { + React.useEffect(() => { + const el = ref.current; + if (!el) { + return; + } + const onFocusIn = (e: FocusEvent) => { + const target = e.target as HTMLElement | null; + if (target && el.contains(target)) { + scrollChildIntoView(el, target, fadeSize); + } + }; + el.addEventListener('focusin', onFocusIn); + return () => el.removeEventListener('focusin', onFocusIn); + }, [ref, fadeSize]); +} + +// Click-and-drag horizontal scrolling; a real drag swallows the trailing click. +function useDragScroll(ref: React.RefObject) { + const drag = React.useRef<{ x: number; left: number } | null>(null); + const dragged = React.useRef(false); + + const onPointerDown = (e: React.PointerEvent) => { + const el = ref.current; + if (!el || e.button !== 0) { + return; + } + drag.current = { x: e.clientX, left: el.scrollLeft }; + dragged.current = false; + }; + const onPointerMove = (e: React.PointerEvent) => { + const el = ref.current; + const start = drag.current; + if (!(el && start)) { + return; + } + const dx = e.clientX - start.x; + // Stay a click until the pointer crosses the threshold; only then capture and scroll, so a + // sub-threshold wobble never nudges the strip. + if (!dragged.current && Math.abs(dx) <= DRAG_THRESHOLD) { + return; + } + dragged.current = true; + if (typeof el.setPointerCapture === 'function' && !el.hasPointerCapture(e.pointerId)) { + el.setPointerCapture(e.pointerId); + } + el.scrollLeft = start.left - dx; + }; + const onPointerEnd = () => { + drag.current = null; + }; + const onClickCapture = (e: React.MouseEvent) => { + // Swallow only the pointer-driven click that ends a real drag. Keyboard activation (Enter/Space) + // produces a `detail === 0` click, so it always passes — even if a prior drag left `dragged` set. + if (dragged.current && e.detail > 0) { + e.preventDefault(); + e.stopPropagation(); + } + dragged.current = false; + }; + return { onPointerDown, onPointerMove, onPointerEnd, onClickCapture }; +} + +type MaskStyle = Pick; + +// Alpha mask fading the content to transparent on the overflowing side(s) — no track-color +// knowledge needed, so it blends on any background. Snaps when an edge flips (reduced-motion-safe). +// `preserveBottomEdge` keeps the bottom N px opaque (e.g. an underline baseline) by unioning a +// second layer with `mask-composite: add` (legacy WebKit spells `add` as `source-over`). +function edgeMask(start: boolean, end: boolean, fadeSize: number, preserveBottomEdge: number): MaskStyle { + if (!(start || end)) { + return {}; + } + const left = start ? `transparent 0, #000 ${fadeSize}px` : '#000 0'; + const right = end ? `#000 calc(100% - ${fadeSize}px), transparent 100%` : '#000 100%'; + const horizontal = `linear-gradient(to right, ${left}, ${right})`; + + if (!preserveBottomEdge) { + return { maskImage: horizontal, WebkitMaskImage: horizontal }; + } + const bottom = `linear-gradient(to top, #000 ${preserveBottomEdge}px, transparent ${preserveBottomEdge}px)`; + const image = `${horizontal}, ${bottom}`; + return { maskImage: image, WebkitMaskImage: image, maskComposite: 'add', WebkitMaskComposite: 'source-over' }; +} + +type DragScrollAreaProps = React.ComponentProps<'div'> & + SharedProps & { + // Width of the edge fade in px, and the inset a focused child is scrolled clear of. + fadeSize?: number; + // Keep the bottom N px fully opaque, exempt from the edge fade (e.g. an underline baseline). + preserveBottomEdge?: number; + }; + +/** + * Makes an overflowing horizontal strip drag-scrollable, with alpha edge fades and + * keyboard-focus-into-view. Wrap any single-row content that can overflow its container + * (tab strips, toolbars, chip rows). Horizontal only by design. + */ +function DragScrollArea({ + className, + children, + fadeSize = DEFAULT_FADE_SIZE, + preserveBottomEdge = 0, + testId, + style, + ...props +}: DragScrollAreaProps) { + const scrollerRef = React.useRef(null); + const edges = useScrollShadow(scrollerRef, true, 'horizontal'); + const drag = useDragScroll(scrollerRef); + useFocusScroll(scrollerRef, fadeSize); + + const mask = edgeMask(edges.start, edges.end, fadeSize, preserveBottomEdge); + const overflowing = edges.start || edges.end; + + return ( + // `{...props}` first so the drag handlers, ref, and mask can't be clobbered by a consumer. +
+ {children} +
+ ); +} + +export { DragScrollArea, type DragScrollAreaProps }; diff --git a/frontend/src/components/redpanda-ui/components/dropdown-menu.tsx b/frontend/src/components/redpanda-ui/components/dropdown-menu.tsx index ab457ed87f..ef94d1350c 100644 --- a/frontend/src/components/redpanda-ui/components/dropdown-menu.tsx +++ b/frontend/src/components/redpanda-ui/components/dropdown-menu.tsx @@ -236,6 +236,8 @@ function DropdownMenuContent({ type DropdownMenuItemProps = React.ComponentProps & { inset?: boolean; variant?: 'default' | 'destructive'; + /** @deprecated Base UI menu items have no `onSelect`. Use `onClick` (add `closeOnClick={false}` to keep the menu open). */ + onSelect?: never; }; function DropdownMenuItem({ @@ -276,6 +278,8 @@ function DropdownMenuItem({ type DropdownMenuCheckboxItemProps = React.ComponentProps & { inset?: boolean; + /** @deprecated Base UI menu items have no `onSelect`. Use `onClick` (add `closeOnClick={false}` to keep the menu open). */ + onSelect?: never; }; function DropdownMenuCheckboxItem({ @@ -313,6 +317,8 @@ function DropdownMenuCheckboxItem({ type DropdownMenuRadioItemProps = React.ComponentProps & { inset?: boolean; + /** @deprecated Base UI menu items have no `onSelect`. Use `onClick` (add `closeOnClick={false}` to keep the menu open). */ + onSelect?: never; }; function DropdownMenuRadioItem({ className, children, disabled, inset, ...props }: DropdownMenuRadioItemProps) { diff --git a/frontend/src/components/redpanda-ui/components/tabs.tsx b/frontend/src/components/redpanda-ui/components/tabs.tsx index b0fcb354fc..874e890933 100644 --- a/frontend/src/components/redpanda-ui/components/tabs.tsx +++ b/frontend/src/components/redpanda-ui/components/tabs.tsx @@ -2,8 +2,9 @@ import { Tabs as TabsPrimitive } from '@base-ui/react/tabs'; import { cva, type VariantProps } from 'class-variance-authority'; -import type React from 'react'; +import React from 'react'; +import { DragScrollArea } from './drag-scroll-area'; import { cn, type SharedProps } from '../lib/utils'; const tabsVariants = cva('flex data-[orientation=horizontal]:flex-col', { @@ -100,6 +101,8 @@ function TabsList({ style, ...props }: TabsListProps) { + // To make an overflowing strip drag-scrollable, wrap in and add + // `w-max min-w-full` to size the list to its content. See the scrollable tabs demos. return ( + + + ); +} + const tabsTriggerVariants = cva( // Base UI uses `aria-disabled`/`data-disabled` (not native `disabled`) and `data-active`, so target those. 'z-[1] inline-flex size-full cursor-pointer items-center justify-center whitespace-nowrap rounded-sm font-medium text-sm ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[orientation=vertical]:w-full data-[orientation=vertical]:justify-start data-[active]:text-foreground', @@ -195,11 +228,13 @@ function TabsContents({ children, className, ...props }: TabsContentsProps) { export { Tabs, TabsList, + ScrollableTabsList, TabsTrigger, TabsContent, TabsContents, type TabsProps, type TabsListProps, + type ScrollableTabsListProps, type TabsTriggerProps, type TabsContentProps, type TabsContentsProps, diff --git a/frontend/src/components/redpanda-ui/lib/use-scroll-shadow.ts b/frontend/src/components/redpanda-ui/lib/use-scroll-shadow.ts index 3d655817b2..788e2fd42f 100644 --- a/frontend/src/components/redpanda-ui/lib/use-scroll-shadow.ts +++ b/frontend/src/components/redpanda-ui/lib/use-scroll-shadow.ts @@ -1,57 +1,69 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { type RefObject, useCallback, useEffect, useState } from 'react'; -type ScrollEdges = { top: boolean; bottom: boolean }; +type Orientation = 'vertical' | 'horizontal'; +type ScrollEdges = { start: boolean; end: boolean }; /** - * Tracks whether a scroll container can be scrolled up or down. + * Tracks whether a scroll container has hidden content past its start/end + * edges, so a consumer can fade in shadows on the overflowing sides only. * - * Uses 1px sentinel elements at the top and bottom of the scrollable content - * with `IntersectionObserver` rooted on the container. A sentinel that is - * intersecting means that edge is currently in view (nothing further to - * scroll in that direction); not intersecting means there is more content - * past that edge. + * `start`/`end` follow the axis: for `vertical` they mean top/bottom; for + * `horizontal`, left/right. Reads scroll geometry on scroll and whenever the + * container or its content resizes, re-rendering only when an edge flips. */ -export function useScrollShadow(enabled: boolean) { - const [container, setContainer] = useState(null); - const [topSentinel, setTopSentinel] = useState(null); - const [bottomSentinel, setBottomSentinel] = useState(null); - const [edges, setEdges] = useState({ top: false, bottom: false }); +export function useScrollShadow( + ref: RefObject, + enabled = true, + orientation: Orientation = 'vertical' +): ScrollEdges { + const [edges, setEdges] = useState({ start: false, end: false }); - useEffect(() => { - if (!(enabled && container && topSentinel && bottomSentinel)) { + const update = useCallback(() => { + const el = ref.current; + if (!el) { return; } + const isVertical = orientation === 'vertical'; + // Horizontal assumes LTR (scrollLeft grows from 0 at the start edge); RTL is not handled. + const scroll = isVertical ? el.scrollTop : el.scrollLeft; + const max = isVertical ? el.scrollHeight - el.clientHeight : el.scrollWidth - el.clientWidth; + const start = scroll > 1; + const end = scroll < max - 1; + // Re-render only when an edge actually flips, not on every scroll frame. + setEdges((prev) => (prev.start === start && prev.end === end ? prev : { start, end })); + }, [ref, orientation]); - const observer = new IntersectionObserver( - (entries) => { - setEdges((prev) => { - let next = prev; - for (const entry of entries) { - const hasMore = !entry.isIntersecting; - if (entry.target === topSentinel && next.top !== hasMore) { - next = { ...next, top: hasMore }; - } else if (entry.target === bottomSentinel && next.bottom !== hasMore) { - next = { ...next, bottom: hasMore }; - } - } - return next; - }); - }, - { root: container, threshold: 0 } - ); - - observer.observe(topSentinel); - observer.observe(bottomSentinel); - - return () => observer.disconnect(); - }, [enabled, container, topSentinel, bottomSentinel]); + useEffect(() => { + const el = ref.current; + if (!(el && enabled)) { + return; + } + update(); + el.addEventListener('scroll', update, { passive: true }); + // Observe the container and its content so edges re-evaluate as either grows. Re-observing an + // existing child is a no-op, so syncing on every mutation just picks up newly added children. + const resize = new ResizeObserver(update); + resize.observe(el); + const observeChildren = () => { + for (const child of Array.from(el.children)) { + resize.observe(child); + } + }; + observeChildren(); + // Children swapped/added after mount aren't covered by the snapshot above; re-sync on mutation. + const mutation = new MutationObserver(() => { + observeChildren(); + update(); + }); + mutation.observe(el, { childList: true }); + return () => { + el.removeEventListener('scroll', update); + resize.disconnect(); + mutation.disconnect(); + }; + }, [ref, enabled, update]); - return { - containerRef: setContainer, - topRef: setTopSentinel, - bottomRef: setBottomSentinel, - edges, - }; + return edges; } diff --git a/frontend/src/components/ui/connect/log-explorer.tsx b/frontend/src/components/ui/connect/log-explorer.tsx index a41f7a4122..d794ffd77b 100644 --- a/frontend/src/components/ui/connect/log-explorer.tsx +++ b/frontend/src/components/ui/connect/log-explorer.tsx @@ -384,7 +384,7 @@ export function LogExplorer({ pipeline, serverless, enableLiveView = false, titl }, [filteredRowCount, pageSize, pageIndex]); return ( -
+
{title ? ( @@ -428,8 +428,7 @@ export function LogExplorer({ pipeline, serverless, enableLiveView = false, titl
-
-
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -556,7 +555,6 @@ export function LogExplorer({ pipeline, serverless, enableLiveView = false, titl })()}
-
{/* Hide DataTablePagination's "X of N row(s) selected." (no row selection) while keeping its layout slot. */} {filteredRowCount > 0 && ( diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index 66369b52db..94c011a3e9 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -32,6 +32,7 @@ import { RouterSync } from '../components/misc/router-sync'; import { SidebarInset } from '../components/redpanda-ui/components/sidebar'; import RequireAuth from '../components/require-auth'; import { useIsDarkMode } from '../hooks/use-is-dark-mode'; +import { IsDev } from '../utils/env'; import { isFullscreenPath } from '../utils/fullscreen-routes'; import { ModalContainer } from '../utils/modal-container'; @@ -53,10 +54,10 @@ function RootLayout() { {isEmbedded() ? : } - {process.env.NODE_ENV === 'development' && } + {IsDev && } - {process.env.NODE_ENV === 'development' && } + {IsDev && } ); }