From 4d33c797edf3e8bf94154ad7656151a8e6b2f1c4 Mon Sep 17 00:00:00 2001 From: Julin <142230457+c-julin@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:51:59 +0100 Subject: [PATCH 01/26] feat(sql): add query workspace with iceberg bridge [UX-1259] --- .../src/components/pages/sql/catalog-tree.css | 378 +++++++++++++ .../src/components/pages/sql/catalog-tree.tsx | 476 +++++++++++++++++ .../src/components/pages/sql/sql-editor.css | 230 ++++++++ .../src/components/pages/sql/sql-editor.tsx | 365 +++++++++++++ .../src/components/pages/sql/sql-results.css | 501 ++++++++++++++++++ .../src/components/pages/sql/sql-results.tsx | 393 ++++++++++++++ .../src/components/pages/sql/sql-types.ts | 152 ++++++ .../src/components/pages/sql/sql-wizard.css | 375 +++++++++++++ .../src/components/pages/sql/sql-wizard.tsx | 231 ++++++++ .../components/pages/sql/sql-workspace.tsx | 408 ++++++++++++++ frontend/src/components/pages/sql/sql.css | 190 +++++++ frontend/src/components/pages/sql/sql.tsx | 272 ++++++++++ frontend/src/react-query/api/sql.tsx | 74 +++ frontend/src/routes/sql.tsx | 35 ++ 14 files changed, 4080 insertions(+) create mode 100644 frontend/src/components/pages/sql/catalog-tree.css create mode 100644 frontend/src/components/pages/sql/catalog-tree.tsx create mode 100644 frontend/src/components/pages/sql/sql-editor.css create mode 100644 frontend/src/components/pages/sql/sql-editor.tsx create mode 100644 frontend/src/components/pages/sql/sql-results.css create mode 100644 frontend/src/components/pages/sql/sql-results.tsx create mode 100644 frontend/src/components/pages/sql/sql-types.ts create mode 100644 frontend/src/components/pages/sql/sql-wizard.css create mode 100644 frontend/src/components/pages/sql/sql-wizard.tsx create mode 100644 frontend/src/components/pages/sql/sql-workspace.tsx create mode 100644 frontend/src/components/pages/sql/sql.css create mode 100644 frontend/src/components/pages/sql/sql.tsx create mode 100644 frontend/src/react-query/api/sql.tsx create mode 100644 frontend/src/routes/sql.tsx diff --git a/frontend/src/components/pages/sql/catalog-tree.css b/frontend/src/components/pages/sql/catalog-tree.css new file mode 100644 index 0000000000..b0f5504687 --- /dev/null +++ b/frontend/src/components/pages/sql/catalog-tree.css @@ -0,0 +1,378 @@ +/** + * 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 + */ + +/* Catalog tree: Catalog -> Namespace -> Table -> Columns. Ported from the + design prototype's .cat-* classes. */ + +.cat { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.cat-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 14px 8px; +} +.cat-head-title { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-muted-foreground); +} +.cat-head-hint { + font-size: 10px; + color: var(--color-disabled); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.cat-search { + padding: 0 12px 10px; +} + +.cat-tree { + flex: 1; + overflow-y: auto; + padding: 0 8px 8px; +} + +.cat-row { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + text-align: left; + background: transparent; + border: 0; + cursor: pointer; + padding: 6px 8px; + border-radius: var(--radius-sm); + color: var(--color-strong); + font-size: 13px; +} +.cat-row:hover { + background: var(--color-selected-hover); +} +.cat-chev { + color: var(--color-disabled); + flex-shrink: 0; +} +.cat-label { + flex: 1; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.cat-row, +.cat-table-main, +.cat-cat-main, +.cat-row-ns, +.cat-row-catalog { + text-align: left; +} +.cat-row-catalog { + padding: 0; + gap: 0; +} +.cat-cat-main { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + background: transparent; + border: 0; + cursor: pointer; + padding: 6px 8px; + color: var(--color-strong); + font-size: 13px; + font-weight: 600; + font-family: inherit; +} +.cat-cat-add { + opacity: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + margin-right: 4px; + border: 0; + background: transparent; + color: var(--color-action-primary); + cursor: pointer; + border-radius: var(--radius-sm); + flex-shrink: 0; +} +.cat-cat-add:hover { + background: var(--color-indigo-100); +} +.cat-row-catalog:hover .cat-cat-add { + opacity: 1; +} +.cat-engine { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: var(--radius-sm); + flex-shrink: 0; +} +.cat-engine-rp { + background: var(--color-indigo-alpha-100); + color: var(--color-indigo-700); +} +.cat-engine-ice { + background: var(--color-blue-alpha-100); + color: var(--color-blue-700); +} +.cat-ns { + margin-left: 10px; +} +.cat-row-ns { + font-weight: 500; + color: var(--color-foreground); +} +.cat-ns-ico { + color: var(--color-muted-foreground); +} +.cat-count { + font-size: 11px; + color: var(--color-muted-foreground); + background: var(--color-muted); + padding: 1px 7px; + border-radius: 999px; +} +.cat-tables { + margin-left: 10px; +} +.cat-row-table { + padding: 0; + gap: 0; +} +.cat-table-main { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + background: transparent; + border: 0; + cursor: pointer; + padding: 6px 8px; + color: var(--color-strong); + font-size: 13px; + font-family: inherit; +} +.cat-table-main:disabled { + cursor: default; + color: var(--color-disabled); +} +.cat-table-ico { + color: var(--color-action-primary); + flex-shrink: 0; +} +.cat-row-table[data-locked] .cat-table-ico { + color: var(--color-disabled); +} +.cat-row-table[data-active] { + background: var(--color-selected); +} +.cat-table-run { + opacity: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + margin-right: 4px; + border: 0; + background: transparent; + color: var(--color-action-primary); + cursor: pointer; + border-radius: var(--radius-sm); + flex-shrink: 0; +} +.cat-table-run:hover { + background: var(--color-indigo-100); +} +.cat-row-table:hover .cat-table-run { + opacity: 1; +} +.cat-lock { + color: var(--color-disabled); + margin-left: 2px; +} +.cat-table-ico-ice { + color: var(--color-blue-700); +} +.cat-ice { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-blue-700); + background: var(--color-blue-alpha-100); + padding: 1px 5px 1px 4px; + border-radius: 3px; + flex-shrink: 0; +} +.cat-ice svg { + color: currentColor; +} +html.dark .cat-ice { + color: var(--color-blue-300); +} +html.dark .cat-table-ico-ice { + color: var(--color-blue-400); +} +.cat-cols { + margin-left: 26px; + border-left: 1px solid var(--color-border-subtle); + padding-left: 8px; + margin-bottom: 2px; +} +.cat-col { + display: flex; + align-items: center; + gap: 7px; + padding: 3px 8px; + font-size: 12px; + color: var(--color-foreground); +} +.cat-col-ico { + color: var(--color-muted-foreground); + flex-shrink: 0; +} +.cat-col-name { + font-family: var(--font-mono); + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.cat-col-type { + font-size: 10px; + color: var(--color-muted-foreground); + font-family: var(--font-mono); + letter-spacing: 0.02em; +} +.cat-ns-empty { + font-size: 12px; + color: var(--color-disabled); + padding: 6px 16px; +} +.cat-add-row { + padding: 6px 8px; + color: var(--color-action-primary); + font-weight: 500; + font-family: inherit; + font-size: 13px; + width: 100%; +} +.cat-add-row .cat-label { + color: var(--color-action-primary); +} +.cat-add-row .cat-add-row-ico { + color: var(--color-action-primary); + flex-shrink: 0; + margin-left: 19px; +} +.cat-add-row:hover { + background: var(--color-indigo-100); +} +.cat-more { + display: flex; + align-items: center; + gap: 7px; + width: 100%; + margin-top: 2px; + padding: 7px 8px; + background: transparent; + border: 0; + cursor: pointer; + border-radius: var(--radius-sm); + font-size: 12px; + color: var(--color-action-primary); + font-weight: 500; + text-align: left; + font-family: inherit; +} +.cat-more:hover { + background: var(--color-indigo-100); +} +.cat-more-ico { + color: var(--color-action-primary); + flex-shrink: 0; +} + +/* Loading / spinner */ +.cat-loading { + display: flex; + align-items: center; + gap: 7px; + padding: 6px 16px; + font-size: 12px; + color: var(--color-muted-foreground); +} +.cat-spinner { + display: inline-block; + width: 13px; + height: 13px; + border: 2px solid var(--color-muted); + border-top-color: var(--color-action-primary); + border-radius: 999px; + animation: cat-spin 0.7s linear infinite; + flex-shrink: 0; +} +@keyframes cat-spin { + to { + transform: rotate(360deg); + } +} + +/* Bridge query (Iceberg-tiered Redpanda table) markers */ +.cat-row-table[data-tiered] .cat-table-ico { + color: var(--color-blue-700); +} +html.dark .cat-row-table[data-tiered] .cat-table-ico { + color: var(--color-blue-400); +} +.cat-bridge { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-blue-700); + background: var(--color-blue-alpha-100); + padding: 1px 5px 1px 4px; + border-radius: 3px; + flex-shrink: 0; +} +.cat-bridge svg { + color: currentColor; +} +html.dark .cat-bridge { + color: var(--color-blue-300); +} diff --git a/frontend/src/components/pages/sql/catalog-tree.tsx b/frontend/src/components/pages/sql/catalog-tree.tsx new file mode 100644 index 0000000000..1cdbe10233 --- /dev/null +++ b/frontend/src/components/pages/sql/catalog-tree.tsx @@ -0,0 +1,476 @@ +/** + * 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 { + Box, + Calendar, + ChevronDown, + ChevronRight, + GitBranch, + GitMerge, + Hash, + Layers, + Lock, + Play, + Plus, + Search, + Table as TableIcon, + ToggleLeft, + Type, +} from 'lucide-react'; +import { useDescribeTableQuery, useListTablesQuery, useTopicIcebergQuery } from 'react-query/api/sql'; +import { useState } from 'react'; + +import './catalog-tree.css'; +import { + type Catalog, + type CatalogEngine, + type ColumnDef, + type ColumnKind, + columnKindForPgType, + type SqlRole, + shortPgType, + type TableRef, +} from './sql-types'; + +export type CatalogTreeProps = { + /** Catalogs to render. Empty array while loading. */ + catalogs: Catalog[]; + /** Effective role of the caller. Drives admin-only affordances (Add a topic). */ + role: SqlRole; + /** True while the initial ListCatalogs fetch is in flight. */ + isLoading?: boolean; + /** id of the table whose query tab is currently active, if any. */ + activeTableId?: string | null; + /** Open `SELECT * FROM . LIMIT 100;` in a new editor tab. */ + onQueryTable: (catalog: Catalog, table: TableRef) => void; + /** Admin entry point for the add-topic wizard (scoped to the Redpanda catalog). */ + onAddTable?: () => void; +}; + +// Promote search past this many tables in a namespace. +const CAT_LIMIT = 20; + +const COL_KIND_ICON: Record = { + num: Hash, + str: Type, + bool: ToggleLeft, + time: Calendar, +}; + +function engineMark(engine: CatalogEngine) { + if (engine === 'redpanda') { + return ( + + + + ); + } + return ( + + + + ); +} + +function Spinner() { + return
+ + + + {cols.map((c) => { + const ds = sort.col === c.name ? (sort.dir ?? 'none') : 'none'; + return ( + + ); + })} + + + + {visible.map((r, i) => ( + + + {cols.map((c) => { + const v = r[c.name]; + return ( + + ); + })} + + ))} + +
# + +
{i + 1} + {c.kind === 'bool' && typeof v === 'boolean' ? ( + {String(v)} + ) : v === null || v === undefined ? ( + NULL + ) : ( + String(v) + )} +
+ + +
+ + Showing {fmtNum(visible.length)} of {fmtNum(run.totalRows)} rows + + {shown < sorted.length && ( + + )} +
+ + ); +} + +export function SqlResults({ run, role, onAddTable }: SqlResultsProps) { + if (run.state === 'idle') { + return ( +
+
+
+ +
+
Run a query to see results
+
+ Write a SELECT against a table in the catalog, then press{' '} + + or hit Run. +
+
+
+ ); + } + + if (run.state === 'running') { + return ( +
+
+
+ +
+
Running query…
+
+
+ ); + } + + if (run.state === 'error') { + return ( +
+
+ +
+
{run.title}
+
{run.message}
+
+
+ {run.hint && ( +
+ {run.hint} + {run.hintAction && role === 'admin' && ( + + )} +
+ )} +
+ ); + } + + return ; +} diff --git a/frontend/src/components/pages/sql/sql-types.ts b/frontend/src/components/pages/sql/sql-types.ts new file mode 100644 index 0000000000..9ea22cae16 --- /dev/null +++ b/frontend/src/components/pages/sql/sql-types.ts @@ -0,0 +1,152 @@ +/** + * 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 + */ + +// Shared types for the SQL workspace. UI-facing view models are derived from the +// generated proto messages where possible so the leaf components and the data +// layer agree on shape. + +import type { + Catalog as ProtoCatalog, + Column as ProtoColumn, + Table as ProtoTable, +} from 'protogen/redpanda/api/dataplane/v1alpha3/sql_pb'; + +// Re-export the proto messages under the names the children import. +export type { ProtoCatalog, ProtoTable, ProtoColumn }; + +// A catalog as displayed in the tree. `displayLabel` is the human label +// (e.g. "Redpanda Catalog") while `name` is the SQL identifier used in queries +// (e.g. "default_redpanda_catalog"). +export type Catalog = { + /** SQL identifier, e.g. `default_redpanda_catalog`. */ + name: string; + /** Human-friendly label shown in the tree. */ + displayLabel: string; + /** Backing engine — drives the glyph/color in the tree. */ + engine: CatalogEngine; + namespaces: Namespace[]; +}; + +export type CatalogEngine = 'redpanda' | 'iceberg'; + +// A namespace groups tables within a catalog. +export type Namespace = { + name: string; + /** Stable id used for expand/collapse + pagination state. */ + id: string; + tables: TableRef[]; +}; + +// A table reference as displayed in the tree. +export type TableRef = { + /** Stable id, typically `..`. */ + id: string; + name: string; + namespaceName: string; + catalogName: string; + /** Backing Kafka topic, when the table is topic-backed. */ + topicName?: string; + /** True when this Redpanda-catalog table is also Iceberg-tiered (bridge query). */ + tiered?: boolean; + /** True when the catalog engine is Iceberg (dedicated Iceberg table). */ + iceberg?: boolean; + /** False when the caller lacks a SELECT grant — rendered locked/disabled. */ + allowed?: boolean; + /** Columns from DescribeTable; undefined until the table is expanded/fetched. */ + columns?: ColumnDef[]; +}; + +// Logical kind derived from the Postgres type name, used for icons, alignment, +// sorting and cell rendering. +export type ColumnKind = 'num' | 'str' | 'bool' | 'time'; + +export type ColumnDef = { + name: string; + /** Raw Postgres type name as reported by the driver (e.g. "INT8", "TEXT"). */ + type: string; + /** Derived display kind. */ + kind: ColumnKind; + /** Short label shown under the column name (e.g. "int", "text"). */ + short: string; +}; + +// A single result cell. `null` is SQL NULL; everything else is the raw string +// (or coerced boolean) for display. +export type CellValue = string | boolean | null; + +// A result row keyed by column name (matches the prototype's grid model). +export type ResultRow = Record; + +// Iceberg-lag snapshot for a bridge query. Offset-based, captured at query time. +export type BridgeInfo = { + topic: string; + translationLag: number; + commitLag: number; + totalLag: number; +}; + +// Discriminated union describing the lifecycle of a single query run. +export type QueryRunIdle = { state: 'idle' }; +export type QueryRunRunning = { state: 'running'; token: number }; +export type QueryRunError = { + state: 'error'; + token: number; + title: string; + message: string; + /** Optional follow-up hint line (e.g. for CREATE → wizard). */ + hint?: string; + /** When true and the caller is an admin, render the "Add a topic" CTA. */ + hintAction?: boolean; +}; +export type QueryRunSuccess = { + state: 'success'; + token: number; + columns: ColumnDef[]; + rows: ResultRow[]; + totalRows: number; + elapsedMs: number; + /** True when the server row cap fired. */ + truncated: boolean; + /** Present only for bridge (Iceberg-tiered) queries. */ + bridge?: BridgeInfo; +}; + +export type QueryRun = QueryRunIdle | QueryRunRunning | QueryRunError | QueryRunSuccess; + +// The caller's effective role in the workspace. Drives admin-only affordances. +export type SqlRole = 'admin' | 'viewer'; + +// Autocomplete identifier surfaced to the editor. +export type SqlIdentifier = { + label: string; + kind: 'catalog' | 'table' | 'column' | 'keyword'; +}; + +// Maps a Postgres type name to a display kind. Conservative defaults: anything +// unrecognized is treated as a string. +export function columnKindForPgType(pgType: string): ColumnKind { + const t = pgType.toUpperCase(); + if (/(INT|FLOAT|NUMERIC|DECIMAL|DOUBLE|REAL|SERIAL|MONEY)/.test(t)) { + return 'num'; + } + if (/BOOL/.test(t)) { + return 'bool'; + } + if (/(TIMESTAMP|DATE|TIME|INTERVAL)/.test(t)) { + return 'time'; + } + return 'str'; +} + +// Short, lower-case label for a Postgres type name (best-effort). +export function shortPgType(pgType: string): string { + return pgType.toLowerCase(); +} diff --git a/frontend/src/components/pages/sql/sql-wizard.css b/frontend/src/components/pages/sql/sql-wizard.css new file mode 100644 index 0000000000..f4e32c7817 --- /dev/null +++ b/frontend/src/components/pages/sql/sql-wizard.css @@ -0,0 +1,375 @@ +/** + * 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 + */ + +/* Ported from the design prototype's `.wz-*` inline-variant blocks, scoped + * under `.sqlws` so they only apply inside the SQL workspace. */ + +.sqlws .wz-inline { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + width: 100%; + background: var(--color-card); +} + +.sqlws .wz-inline .wz-content, +.sqlws .wz-inline .wz-progress, +.sqlws .wz-inline .wz-foot { + max-width: 720px; + width: 100%; + margin-left: auto; + margin-right: auto; +} + +.sqlws .wz-inline-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 18px; + border-bottom: 1px solid var(--color-border-subtle); + font-weight: 600; + color: var(--color-strong); + font-size: 14px; +} + +.sqlws .wz-inline-head span { + display: inline-flex; + align-items: center; + gap: 8px; + white-space: nowrap; + flex-shrink: 0; +} + +.sqlws .wz-inline-head svg { + color: var(--color-action-primary); +} + +.sqlws .wz-head-close { + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + background: transparent; + color: var(--color-muted-foreground); + cursor: pointer; + padding: 4px; + border-radius: var(--radius-md); +} + +.sqlws .wz-head-close:hover { + background: var(--color-muted); + color: var(--color-strong); +} + +.sqlws .wz-body { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; + padding: 0; +} + +.sqlws .wz-progress { + padding: 16px 22px 4px; +} + +.sqlws .wz-step-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-action-primary); + font-weight: 600; +} + +.sqlws .wz-step-name { + display: block; + font-family: var(--font-display); + font-size: 16px; + font-weight: 600; + color: var(--color-strong); + margin: 4px 0 8px; +} + +.sqlws .wz-progress-bar { + height: 4px; + background: var(--color-muted); + border-radius: 999px; + overflow: hidden; +} + +.sqlws .wz-progress-bar span { + display: block; + height: 100%; + background: var(--color-action-primary); + border-radius: 999px; + transition: width 220ms ease-out; +} + +.sqlws .wz-content { + flex: 1; + overflow-y: auto; + padding: 16px 22px; + min-height: 0; +} + +.sqlws .wz-help { + font-size: 13px; + color: var(--color-muted-foreground); + line-height: 1.5; + margin: 0 0 14px; +} + +.sqlws .wz-help code { + font-family: var(--font-mono); +} + +.sqlws .wz-search { + margin-bottom: 12px; +} + +.sqlws .wz-topics { + display: flex; + flex-direction: column; + gap: 8px; +} + +.sqlws .wz-topic { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + text-align: left; + padding: 12px 14px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-card); + cursor: pointer; + font-family: inherit; +} + +.sqlws .wz-topic:hover { + border-color: var(--color-border-strong); + background: var(--color-muted); +} + +.sqlws .wz-topic[data-selected] { + border-color: var(--color-secondary); + border-width: 2px; + padding: 11px 13px; +} + +.sqlws .wz-topic-radio { + width: 18px; + height: 18px; + border-radius: 999px; + border: 1.5px solid var(--color-input); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.sqlws .wz-topic[data-selected] .wz-topic-radio { + border-color: var(--color-secondary); +} + +.sqlws .wz-topic-radio span { + width: 9px; + height: 9px; + border-radius: 999px; + background: var(--color-secondary); +} + +.sqlws .wz-topic-ico { + color: var(--color-action-primary); + flex-shrink: 0; +} + +.sqlws .wz-topic-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.sqlws .wz-topic-name { + font-family: var(--font-mono); + font-size: 13.5px; + font-weight: 600; + color: var(--color-strong); +} + +.sqlws .wz-topic-meta { + font-size: 11.5px; + color: var(--color-muted-foreground); + margin-top: 1px; +} + +.sqlws .wz-field { + margin-bottom: 16px; +} + +.sqlws .wz-field-label { + display: block; + font-size: 13px; + font-weight: 600; + color: var(--color-strong); + margin-bottom: 6px; +} + +.sqlws .wz-readonly { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 12px; + background: var(--color-muted); + border-radius: var(--radius-md); + font-family: var(--font-mono); + font-size: 13px; + color: var(--color-strong); +} + +.sqlws .wz-readonly svg { + color: var(--color-muted-foreground); +} + +.sqlws .wz-readonly-tag { + margin-left: auto; + font-family: var(--font-sans); + font-size: 11px; + color: var(--color-muted-foreground); +} + +.sqlws .wz-field-help { + display: block; + font-size: 12px; + color: var(--color-muted-foreground); + margin-top: 6px; +} + +.sqlws .wz-field-err { + display: block; + font-size: 12px; + color: var(--color-destructive); + margin-top: 6px; +} + +.sqlws .wz-sql { + background: var(--color-background-inverse-base); + border-radius: var(--radius-md); + padding: 14px 16px; + overflow-x: auto; +} + +.sqlws .wz-sql pre { + margin: 0; + font-family: var(--font-mono); + font-size: 12.5px; + line-height: 1.6; +} + +.sqlws .wz-sql .sql-kw { + color: var(--color-purple-300); +} + +.sqlws .wz-sql .sql-str { + color: var(--color-green-300); +} + +.sqlws .wz-sql .sql-id, +.sqlws .wz-sql .sql-pn { + color: var(--color-grey-200); +} + +.sqlws .wz-sql .sql-num { + color: var(--color-orange-300); +} + +.sqlws .wz-foot { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 22px; + border-top: 1px solid var(--color-border-subtle); + flex-shrink: 0; +} + +.sqlws .wz-foot-right { + display: flex; + gap: 8px; +} + +/* Bridge-query (Iceberg) accents. */ +.sqlws .wz-topic-ice { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 10px; + font-weight: 600; + color: var(--color-blue-700); + background: var(--color-blue-alpha-100); + padding: 2px 7px; + border-radius: 999px; + flex-shrink: 0; +} + +.sqlws .wz-topic-ice svg { + color: currentColor; +} + +html.dark .sqlws .wz-topic-ice { + color: var(--color-blue-300); +} + +.sqlws .wz-readonly-ice { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--color-blue-700); +} + +html.dark .sqlws .wz-readonly-ice { + color: var(--color-blue-300); +} + +.sqlws .wz-bridge-note { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 11px 13px; + margin-bottom: 16px; + background: var(--color-blue-alpha-100); + border: 1px solid var(--color-blue-200); + border-radius: var(--radius-md); + font-size: 12.5px; + line-height: 1.45; + color: var(--color-foreground); +} + +.sqlws .wz-bridge-note svg { + color: var(--color-blue-600); + flex-shrink: 0; + margin-top: 1px; +} + +.sqlws .wz-bridge-note code { + font-family: var(--font-mono); +} + +html.dark .sqlws .wz-bridge-note { + background: color-mix(in srgb, var(--color-blue-500) 12%, transparent); + border-color: var(--color-blue-700); +} + +html.dark .sqlws .wz-bridge-note svg { + color: var(--color-blue-400); +} diff --git a/frontend/src/components/pages/sql/sql-wizard.tsx b/frontend/src/components/pages/sql/sql-wizard.tsx new file mode 100644 index 0000000000..5524b8df75 --- /dev/null +++ b/frontend/src/components/pages/sql/sql-wizard.tsx @@ -0,0 +1,231 @@ +/** + * 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 { Input } from 'components/redpanda-ui/components/input'; +import { GitBranch, GitMerge, Layers, Plus, X } from 'lucide-react'; +import { useState } from 'react'; + +import './sql-wizard.css'; +import { highlightSQL } from './sql'; + +export type WizardTopic = { + name: string; + partitions?: number; + format?: string; + iceberg?: boolean; +}; + +export type SqlWizardProps = { + topics: WizardTopic[]; + onClose: () => void; + onCreate: (args: { topic: string; tableName: string }) => void; + isCreating?: boolean; + error?: string; +}; + +const TABLE_NAME_RE = /^[a-z_][a-z0-9_]*$/; +const STEPS = ['Choose a topic', 'Name the table'] as const; + +function createSQL(tableName: string, topic: string): string { + return `CREATE TABLE default_redpanda_catalog=>${tableName || 'my_table'}\n WITH (topic='${topic || 'topic_name'}');`; +} + +export function SqlWizard({ topics, onClose, onCreate, isCreating, error }: SqlWizardProps) { + const [step, setStep] = useState(0); + const [topic, setTopic] = useState(null); + const [name, setName] = useState(''); + const [touched, setTouched] = useState(false); + const [search, setSearch] = useState(''); + + const chosen = topics.find((t) => t.name === topic); + const tableName = name || topic || ''; + const nameError = touched && step === 1 && !TABLE_NAME_RE.test(tableName); + + const q = search.trim().toLowerCase(); + const visibleTopics = q ? topics.filter((t) => t.name.toLowerCase().includes(q)) : topics; + + const pickTopic = (t: WizardTopic) => { + setTopic(t.name); + if (!name) { + setName(t.name); + } + }; + + const next = () => { + if (step === 0 && !topic) { + return; + } + setStep(1); + }; + + const finish = () => { + setTouched(true); + if (!(topic && TABLE_NAME_RE.test(tableName))) { + return; + } + onCreate({ topic, tableName }); + }; + + return ( +
+
+ + Add a topic to SQL + + +
+ +
+
+ + Step {step + 1} of {STEPS.length} + + {STEPS[step]} +
+ +
+
+ +
+ {step === 0 && ( +
+

+ Pick a Redpanda topic to expose as a SQL table. Tables are created in{' '} + default_redpanda_catalog — the catalog for Redpanda topics. +

+ setSearch(e.target.value)} + placeholder="Search topics" + value={search} + /> +
+ {visibleTopics.map((t) => ( + + ))} + {visibleTopics.length === 0 &&
No topics found.
} +
+
+ )} + + {step === 1 && ( +
+
+ Catalog +
+ default_redpanda_catalog{' '} + fixed for Redpanda topics +
+
+
+ Source topic +
+ {topic} + {chosen?.iceberg && ( + + Iceberg-tiered + + )} +
+
+ {chosen?.iceberg && ( +
+ + + This topic is Iceberg-tiered. Queries are bridged automatically — Redpanda meshes + the live topic with its Iceberg table so results stay realtime despite the flush lag. + +
+ )} +
+ + setTouched(true)} + onChange={(e) => setName(e.target.value)} + placeholder="cars" + style={nameError ? { borderColor: 'var(--color-destructive)' } : undefined} + value={name} + /> + {nameError ? ( + + Use lowercase letters, numbers and underscores; must start with a letter or underscore. + + ) : ( + How the table appears in the catalog and your queries. + )} +
+
+ This will run +
+
+                
+
+ {error &&
{error}
} +
+ )} +
+ +
+ +
+ {step > 0 && ( + + )} + {step === 0 ? ( + + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/pages/sql/sql-workspace.tsx b/frontend/src/components/pages/sql/sql-workspace.tsx new file mode 100644 index 0000000000..413fb17f64 --- /dev/null +++ b/frontend/src/components/pages/sql/sql-workspace.tsx @@ -0,0 +1,408 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { create } from '@bufbuild/protobuf'; +import { timestampFromDate } from '@bufbuild/protobuf/wkt'; +import { Badge } from 'components/redpanda-ui/components/badge'; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'components/redpanda-ui/components/resizable'; +import { Database } from 'lucide-react'; +import { CatalogType } from 'protogen/redpanda/api/dataplane/v1alpha3/sql_pb'; +import { ExecuteQueryRequestSchema } from 'protogen/redpanda/api/dataplane/v1alpha3/sql_pb'; +import { useExecuteInstantQuery } from 'react-query/api/observability'; +import { + useExecuteQueryMutation, + useInvalidateSqlCatalog, + useListCatalogsQuery, + useListTablesQuery, +} from 'react-query/api/sql'; +import { useLegacyListTopicsQuery } from 'react-query/api/topic'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { toast } from 'sonner'; + +import './sql.css'; +import { CatalogTree } from './catalog-tree'; +import { SqlEditor, type SqlEditorHandle } from './sql-editor'; +import { SqlResults } from './sql-results'; +import { SqlWizard, type WizardTopic } from './sql-wizard'; +import { + type BridgeInfo, + type Catalog, + type CellValue, + type ColumnDef, + columnKindForPgType, + type QueryRun, + type ResultRow, + type SqlIdentifier, + type SqlRole, + shortPgType, + type TableRef, +} from './sql-types'; +import { firstKeyword, SQL_KEYWORDS } from './sql'; + +const INITIAL_QUERY = 'SELECT name, type\nFROM system.catalogs\nORDER BY name;'; + +// Build autocomplete identifiers from the loaded catalog set + SQL keywords. +function buildIdentifiers(catalogs: Catalog[]): SqlIdentifier[] { + const out: SqlIdentifier[] = []; + const seenCols = new Set(); + for (const c of catalogs) { + out.push({ label: c.name, kind: 'catalog' }); + for (const ns of c.namespaces) { + for (const t of ns.tables) { + out.push({ label: t.name, kind: 'table' }); + for (const col of t.columns ?? []) { + if (!seenCols.has(col.name)) { + seenCols.add(col.name); + out.push({ label: col.name, kind: 'column' }); + } + } + } + } + } + for (const k of SQL_KEYWORDS) { + out.push({ label: k, kind: 'keyword' }); + } + return out; +} + +let RUN_TOKEN = 0; + +// Oxla addresses catalog tables as `catalog=>table`. These match the table +// ref(s) in a statement for the bridge-query indicator. +const BRIDGE_REF_RE = /=>\s*"?[a-zA-Z0-9._-]+/g; +const BRIDGE_TABLE_RE = /=>\s*"?([a-zA-Z0-9._-]+)/; + +// Best-effort: a single-table SELECT against a Redpanda-catalog topic resolves +// to that topic, used to drive the bridge-query indicator. Returns null for +// joins/multi-table/non-matching shapes; the lag query then self-gates +// (non-Iceberg topics have no pending-lag series). +const queriedBridgeTopic = (sql: string): string | null => { + const refs = sql.match(BRIDGE_REF_RE); + if (!refs || refs.length !== 1) { + return null; + } + return sql.match(BRIDGE_TABLE_RE)?.[1] ?? null; +}; + +export type SqlWorkspaceProps = { + /** Effective role of the caller. Defaults to viewer. */ + role?: SqlRole; +}; + +export function SqlWorkspace({ role = 'viewer' }: SqlWorkspaceProps) { + const [run, setRun] = useState({ state: 'idle' }); + // Topic whose Iceberg lag drives the bridge-query indicator. Set when a table + // is queried from the catalog tree; the lag itself comes from the + // ObservabilityService (below), so ExecuteQuery stays untouched. + const [bridgeTopic, setBridgeTopic] = useState(null); + // Timestamp of the run that set bridgeTopic — stamped into the lag query so + // re-running the same query refetches (new key) instead of serving a cached + // snapshot, and reflects the lag at that query's time. + const [bridgeRunAt, setBridgeRunAt] = useState(null); + const editorRef = useRef(null); + const rootRef = useRef(null); + + // Render the workspace as a fixed overlay filling the area right of the + // cluster sidebar, below the page header. This gives a true full-width, + // full-height editor WITHOUT mutating any shared cloud-ui layout nodes — + // so it never leaves residue on other pages (e.g. Overview) when you + // navigate away. Works in both standalone console and embedded cloud-ui. + useEffect(() => { + const el = rootRef.current; + if (!el) { + return; + } + // Natural (in-flow) top sits just below the page header. Measured once + // while still in flow; horizontal resizes don't change it. + const naturalTop = el.getBoundingClientRect().top; + + const findRegionLeft = () => { + // The content region is the INNERMOST ancestor that spans to the + // viewport's right edge — i.e. the main column right of the sidebar. + // (Outer ancestors like the sidebar wrapper also reach the right edge but + // start at x=0 and would put the editor under the sidebar.) + let node = el.parentElement; + while (node && node !== document.body) { + const r = node.getBoundingClientRect(); + if (Math.abs(r.right - window.innerWidth) <= 2 && r.width > 200) { + return r.left; + } + node = node.parentElement; + } + return el.getBoundingClientRect().left; + }; + + const layout = () => { + const left = findRegionLeft(); + el.style.position = 'fixed'; + el.style.top = `${naturalTop}px`; + el.style.left = `${left}px`; + el.style.right = '0px'; + el.style.bottom = '0px'; + el.style.height = 'auto'; + el.style.borderTop = '1px solid var(--sql-border)'; + }; + + layout(); + window.addEventListener('resize', layout); + return () => window.removeEventListener('resize', layout); + }, []); + + const { data: catalogsData, isLoading } = useListCatalogsQuery(); + const executeQuery = useExecuteQueryMutation(); + + // Map proto catalogs to the tree view model. Tables/columns are filled in by + // the catalog-tree agent via ListTables/DescribeTable. + const catalogs = useMemo(() => { + const list = catalogsData?.catalogs ?? []; + return list.map((c) => ({ + name: c.name, + displayLabel: c.type === CatalogType.REDPANDA ? 'Redpanda Catalog' : c.name, + engine: c.type === CatalogType.REDPANDA ? 'redpanda' : 'iceberg', + namespaces: c.namespaceName + ? [{ id: `${c.name}.${c.namespaceName}`, name: c.namespaceName, tables: [] }] + : [], + })); + }, [catalogsData]); + + const identifiers = useMemo(() => buildIdentifiers(catalogs), [catalogs]); + + // Bridge-query lag for the queried topic, read from the ObservabilityService + // (per-topic named queries) — decoupled from ExecuteQuery. A non-Iceberg topic + // has no pending-lag series, so `bridge` resolves to undefined and nothing shows. + const bridgeTxLag = useExecuteInstantQuery( + { + queryName: 'iceberg_topic_translation_lag', + params: { + filters: { topic: bridgeTopic ?? '' }, + time: bridgeRunAt ? timestampFromDate(new Date(bridgeRunAt)) : undefined, + }, + }, + { enabled: Boolean(bridgeTopic) } + ); + const bridgeCommitLag = useExecuteInstantQuery( + { + queryName: 'iceberg_topic_commit_lag', + params: { + filters: { topic: bridgeTopic ?? '' }, + time: bridgeRunAt ? timestampFromDate(new Date(bridgeRunAt)) : undefined, + }, + }, + { enabled: Boolean(bridgeTopic) } + ); + const bridge = useMemo(() => { + if (!bridgeTopic) { + return; + } + const tx = bridgeTxLag.data?.results?.[0]?.value?.value; + const commit = bridgeCommitLag.data?.results?.[0]?.value?.value; + if (tx === undefined && commit === undefined) { + return; + } + const translationLag = tx ?? 0; + const commitLag = commit ?? 0; + return { topic: bridgeTopic, translationLag, commitLag, totalLag: translationLag + commitLag }; + }, [bridgeTopic, bridgeTxLag.data, bridgeCommitLag.data]); + + const doRun = useCallback( + (sql: string) => { + const token = ++RUN_TOKEN; + const kw = firstKeyword(sql); + // Drive the bridge indicator off the executed query (single tiered topic), + // not the catalog click — so it only shows for the topic actually queried. + const nextBridgeTopic = kw === 'SELECT' ? queriedBridgeTopic(sql) : null; + setBridgeTopic(nextBridgeTopic); + setBridgeRunAt(nextBridgeTopic ? Date.now() : null); + + if (kw !== 'SELECT') { + let title = 'Statement not allowed'; + let message = `Only SELECT statements are supported in this release. Found "${kw || 'empty statement'}".`; + let hint: string | undefined; + let hintAction = false; + if (kw === 'CREATE') { + title = 'Use the wizard to create tables'; + message = "CREATE TABLE isn't run from the editor in this release."; + hint = 'Creating a table from a topic?'; + hintAction = true; + } else if (kw === 'GRANT' || kw === 'REVOKE') { + title = 'Manage access in Security'; + message = 'Grants are managed in Security in this release.'; + } + setRun({ state: 'error', token, title, message, hint, hintAction }); + return; + } + + setRun({ state: 'running', token }); + const start = performance.now(); + executeQuery.mutate(create(ExecuteQueryRequestSchema, { statement: sql }), { + onSuccess: (res) => { + if (RUN_TOKEN !== token) { + return; + } + const columns: ColumnDef[] = res.columns.map((c) => ({ + name: c.name, + type: c.type, + kind: columnKindForPgType(c.type), + short: shortPgType(c.type), + })); + const rows: ResultRow[] = res.rows.map((r) => { + const row: ResultRow = {}; + r.values.forEach((v, i) => { + const col = columns[i]; + if (!col) { + return; + } + let cell: CellValue = v.nullValue ? null : (v.value ?? null); + if (cell !== null && col.kind === 'bool') { + cell = cell === 'true' || cell === 't'; + } + row[col.name] = cell; + }); + return row; + }); + setRun({ + state: 'success', + token, + columns, + rows, + totalRows: rows.length, + elapsedMs: Math.round(performance.now() - start), + truncated: res.truncated, + }); + }, + onError: (error) => { + if (RUN_TOKEN !== token) { + return; + } + setRun({ state: 'error', token, title: 'Query failed', message: error.message }); + }, + }); + }, + [executeQuery] + ); + + const onQueryTable = useCallback((catalog: Catalog, table: TableRef) => { + // Redpanda SQL (Oxla) addresses catalog-qualified tables with the `=>` + // operator, e.g. `default_redpanda_catalog=>cars` — not `catalog.table`. + const ref = `${catalog.name}=>${table.name}`; + const sql = `SELECT *\nFROM ${ref}\nLIMIT 100;`; + editorRef.current?.setQuery(sql, table.name); + }, []); + + // ---- Add-topic wizard ---- + const [wizardOpen, setWizardOpen] = useState(false); + const [wizardError, setWizardError] = useState(undefined); + const { data: topicsData } = useLegacyListTopicsQuery(undefined, { hideInternalTopics: true }); + const invalidateSqlCatalog = useInvalidateSqlCatalog(); + + // Topics already exposed as tables in the Redpanda catalog — excluded from + // the wizard's topic picker so you can't create a duplicate. + const redpandaCatalogName = useMemo(() => catalogs.find((c) => c.engine === 'redpanda')?.name ?? '', [catalogs]); + const { data: redpandaTablesData } = useListTablesQuery({ catalog: redpandaCatalogName }); + const takenTopics = useMemo(() => { + const taken = new Set(); + for (const t of redpandaTablesData?.tables ?? []) { + if (t.topicName) { + taken.add(t.topicName); + } + taken.add(t.name); + } + return taken; + }, [redpandaTablesData]); + + const wizardTopics = useMemo( + () => + (topicsData?.topics ?? []) + .filter((t) => !takenTopics.has(t.topicName)) + .map((t) => ({ name: t.topicName, partitions: t.partitionCount })), + [topicsData, takenTopics] + ); + + const openWizard = useCallback(() => { + setWizardError(undefined); + setWizardOpen(true); + }, []); + + const closeWizard = useCallback(() => { + setWizardOpen(false); + setWizardError(undefined); + }, []); + + const onCreateTable = useCallback( + ({ topic, tableName }: { topic: string; tableName: string }) => { + setWizardError(undefined); + const statement = `CREATE TABLE default_redpanda_catalog=>${tableName}\n WITH (topic='${topic}');`; + executeQuery.mutate(create(ExecuteQueryRequestSchema, { statement }), { + onSuccess: async () => { + await invalidateSqlCatalog(); + toast.success(`Table ${tableName} created`); + closeWizard(); + }, + onError: (error) => setWizardError(error.message), + }); + }, + [executeQuery, invalidateSqlCatalog, closeWizard] + ); + + return ( +
+
+
+ SQL · Studio +
+
+ + {role === 'admin' ? 'Admin' : 'Viewer · read-only'} + +
+
+ +
+
+ +
+
+ {wizardOpen ? ( + + ) : ( + + + doRun(sql)} + ref={editorRef} + role={role} + /> + + + + + + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/pages/sql/sql.css b/frontend/src/components/pages/sql/sql.css new file mode 100644 index 0000000000..330ae1da61 --- /dev/null +++ b/frontend/src/components/pages/sql/sql.css @@ -0,0 +1,190 @@ +/** + * 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 + */ + +/* + * SQL workspace styles, ported from the design prototype. Scoped under `.sqlws` + * with its own local CSS variables so the indigo/purple look is self-contained + * and does not depend on global theme tokens. Class names match the prototype + * so the leaf components (CatalogTree, SqlEditor, SqlResults) can use them. + */ + +.sqlws { + /* color scale */ + --sql-indigo-100: #e0eaff; + --sql-indigo-300: #a4bcfd; + --sql-indigo-600: #444ce7; + --sql-indigo-700: #3538cd; + --sql-indigo-alpha-100: rgba(99, 102, 241, 0.04); + --sql-indigo-alpha-200: rgba(99, 102, 241, 0.08); + --sql-purple-700: #6941c6; + --sql-green-100: #d7eed5; + --sql-green-700: #276749; + --sql-orange-700: #f77923; + --sql-blue-700: #1c9be3; + --sql-blue-alpha-100: rgba(69, 173, 232, 0.08); + + /* semantic */ + --sql-bg: #fff; + --sql-card: #fff; + --sql-popover: #fff; + --sql-foreground: #4b5563; + --sql-strong: #111827; + --sql-muted-foreground: #6b7280; + --sql-subtle: #79797d; + --sql-disabled: #9ca3af; + --sql-muted: #f3f4f6; + --sql-border: #c3c4c6; + --sql-border-subtle: #eef0f2; + --sql-border-strong: #d1d5db; + --sql-selected: var(--sql-indigo-100); + --sql-selected-hover: rgba(44, 48, 160, 0.06); + --sql-grey-50: #f5f5f5; + --sql-grey-200: #c3c4c6; + --sql-action-primary: var(--sql-indigo-600); + + --sql-radius-sm: 0.25rem; + --sql-radius-md: 0.375rem; + --sql-font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + + display: flex; + flex-direction: column; + height: 100%; + background: var(--sql-bg); + color: var(--sql-strong); +} + +/* ============ TOP BAR ============ */ +.sqlws .ws-bar { + display: flex; + align-items: center; + gap: 12px; + padding: 0 24px; + height: 52px; + flex-shrink: 0; + background: var(--sql-bg); + border-bottom: 1px solid var(--sql-border); +} +.sqlws .ws-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: var(--sql-strong); + letter-spacing: -0.01em; +} +.sqlws .ws-title svg { + color: var(--sql-action-primary); +} +.sqlws .ws-title-dim { + color: var(--sql-muted-foreground); + font-weight: 500; +} +.sqlws .ws-bar-right { + margin-left: auto; + display: flex; + align-items: center; + gap: 8px; +} + +/* ============ BODY / PANES ============ */ +.sqlws .ws-body { + flex: 1; + display: flex; + min-height: 0; +} +.sqlws .ws-tree { + width: 320px; + flex-shrink: 0; + background: var(--sql-bg); + border-right: 1px solid var(--sql-border); + display: flex; + flex-direction: column; + min-height: 0; +} +.sqlws .ws-work { + flex: 1; + min-width: 0; + /* min-height: 0 lets the height chain constrain to the available space + instead of content — required so the nested resizable PanelGroup gets a + definite height and the editor/results divider is actually draggable. */ + min-height: 0; + display: flex; +} +.sqlws .ws-split { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} +/* ws-editor / ws-results are ResizablePanels — the panel group sets their + sizes (defaultSize/minSize), so no fixed height/flex here. The + ResizableHandle between them is the divider, so no border-top on results. */ +.sqlws .ws-editor { + display: flex; + min-height: 0; + background: var(--sql-bg); +} +.sqlws .ws-results { + min-height: 0; + display: flex; + background: var(--sql-bg); +} +.sqlws .ws-editor > * { + flex: 1; + min-width: 0; +} +.sqlws .ws-results > * { + flex: 1; + min-width: 0; +} + +/* ============ SHARED: syntax tokens (used by the editor overlay) ============ */ +.sqlws .sql-kw { + color: var(--sql-purple-700); + font-weight: 600; +} +.sqlws .sql-fn { + color: var(--sql-action-primary); +} +.sqlws .sql-str { + color: var(--sql-green-700); +} +.sqlws .sql-num { + color: var(--sql-orange-700); +} +.sqlws .sql-cm { + color: var(--sql-muted-foreground); + font-style: italic; +} +.sqlws .sql-pn { + color: var(--sql-subtle); +} +.sqlws .sql-id { + color: var(--sql-strong); +} + +/* ============ STUB PLACEHOLDER (removed as children are implemented) ============ */ +.sqlws .sql-stub { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + font-size: 13px; + color: var(--sql-muted-foreground); + text-align: center; +} +.sqlws .sql-stub-tree { + align-items: flex-start; + justify-content: flex-start; + padding: 16px 14px; +} diff --git a/frontend/src/components/pages/sql/sql.tsx b/frontend/src/components/pages/sql/sql.tsx new file mode 100644 index 0000000000..900ad807c8 --- /dev/null +++ b/frontend/src/components/pages/sql/sql.tsx @@ -0,0 +1,272 @@ +/** + * 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 + */ + +// Typed port of the design prototype `sql.jsx`: a tiny SQL tokenizer used for +// syntax highlighting, a pretty-formatter, and a first-keyword detector. + +export const SQL_KEYWORDS = [ + 'SELECT', + 'FROM', + 'WHERE', + 'AND', + 'OR', + 'NOT', + 'NULL', + 'AS', + 'ON', + 'JOIN', + 'LEFT', + 'RIGHT', + 'INNER', + 'OUTER', + 'FULL', + 'GROUP', + 'BY', + 'ORDER', + 'HAVING', + 'LIMIT', + 'OFFSET', + 'DISTINCT', + 'COUNT', + 'SUM', + 'AVG', + 'MIN', + 'MAX', + 'CASE', + 'WHEN', + 'THEN', + 'ELSE', + 'END', + 'IN', + 'LIKE', + 'BETWEEN', + 'IS', + 'ASC', + 'DESC', + 'UNION', + 'ALL', + 'WITH', + 'CREATE', + 'TABLE', + 'INSERT', + 'INTO', + 'VALUES', + 'UPDATE', + 'SET', + 'DELETE', + 'GRANT', + 'REVOKE', + 'TO', + 'DROP', + 'ALTER', + 'INTERVAL', + 'EXTRACT', + 'DATE', + 'TIMESTAMP', + 'CAST', + 'OVER', + 'PARTITION', + 'DESCRIBE', + 'SHOW', +] as const; + +export const SQL_FUNCS = [ + 'count', + 'sum', + 'avg', + 'min', + 'max', + 'now', + 'date_trunc', + 'lower', + 'upper', + 'coalesce', + 'round', + 'cast', + 'extract', +] as const; + +const KW_SET = new Set(SQL_KEYWORDS.map((k) => k.toUpperCase())); +const FN_SET = new Set(SQL_FUNCS); + +export type SqlTokenType = 'kw' | 'fn' | 'id' | 'str' | 'num' | 'cm' | 'ws' | 'pn'; + +export type SqlToken = { + type: SqlTokenType; + value: string; +}; + +const WS = /\s/; +const DIGIT = /[0-9]/; +const NUM_BODY = /[0-9._]/; +const WORD_START = /[A-Za-z_]/; +const WORD_BODY = /[A-Za-z0-9_]/; + +// Tokenize into tokens preserving all whitespace so the source can be rebuilt verbatim. +export function tokenizeSQL(src: string): SqlToken[] { + const tokens: SqlToken[] = []; + let i = 0; + const n = src.length; + const push = (type: SqlTokenType, value: string) => tokens.push({ type, value }); + + while (i < n) { + const c = src[i]; + + // line comment + if (c === '-' && src[i + 1] === '-') { + let j = i; + while (j < n && src[j] !== '\n') { + j++; + } + push('cm', src.slice(i, j)); + i = j; + continue; + } + + // block comment + if (c === '/' && src[i + 1] === '*') { + let j = i + 2; + while (j < n && !(src[j] === '*' && src[j + 1] === '/')) { + j++; + } + j = Math.min(n, j + 2); + push('cm', src.slice(i, j)); + i = j; + continue; + } + + // string + if (c === "'" || c === '"') { + let j = i + 1; + while (j < n && src[j] !== c) { + if (src[j] === '\\') { + j++; + } + j++; + } + j = Math.min(n, j + 1); + push('str', src.slice(i, j)); + i = j; + continue; + } + + // whitespace + if (WS.test(c)) { + let j = i; + while (j < n && WS.test(src[j])) { + j++; + } + push('ws', src.slice(i, j)); + i = j; + continue; + } + + // number + if (DIGIT.test(c)) { + let j = i; + while (j < n && NUM_BODY.test(src[j])) { + j++; + } + push('num', src.slice(i, j)); + i = j; + continue; + } + + // word + if (WORD_START.test(c)) { + let j = i; + while (j < n && WORD_BODY.test(src[j])) { + j++; + } + const word = src.slice(i, j); + if (KW_SET.has(word.toUpperCase())) { + push('kw', word); + } else if (FN_SET.has(word.toLowerCase()) && src[j] === '(') { + push('fn', word); + } else { + push('id', word); + } + i = j; + continue; + } + + // punctuation / operators + push('pn', c); + i++; + } + + return tokens; +} + +// HTML-escape for the highlight overlay. +export function escHTML(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +// Build highlighted HTML (used inside the overlay
). Token classes match
+// the shared CSS: sql-kw, sql-fn, sql-str, sql-num, sql-cm, sql-id, sql-pn.
+export function highlightSQL(src: string): string {
+  return tokenizeSQL(src)
+    .map((t) => {
+      if (t.type === 'ws') {
+        return escHTML(t.value);
+      }
+      return `${escHTML(t.value)}`;
+    })
+    .join('');
+}
+
+const FORMAT_CLAUSES = [
+  'FROM',
+  'WHERE',
+  'GROUP BY',
+  'ORDER BY',
+  'HAVING',
+  'LIMIT',
+  'LEFT JOIN',
+  'RIGHT JOIN',
+  'INNER JOIN',
+  'JOIN',
+  'UNION',
+];
+
+// Small pretty-formatter: newline before major clauses, keywords upper-cased.
+export function formatSQL(src: string): string {
+  const formatted = src
+    .split(';')
+    .map((stmt) => {
+      let s = stmt.replace(/\s+/g, ' ').trim();
+      if (!s) {
+        return '';
+      }
+      // upper-case standalone keywords
+      s = s.replace(/\b([A-Za-z_]+)\b/g, (m) => (KW_SET.has(m.toUpperCase()) ? m.toUpperCase() : m));
+      // line breaks before clauses
+      for (const cl of FORMAT_CLAUSES) {
+        const re = new RegExp(`\\s+${cl.replace(' ', '\\s+')}\\b`, 'g');
+        s = s.replace(re, `\n${cl}`);
+      }
+      // indent column lists lightly
+      s = s.replace(/,\s*/g, ',\n  ');
+      return s;
+    })
+    .filter(Boolean)
+    .join(';\n\n');
+
+  return formatted + (src.trim().endsWith(';') ? ';' : '');
+}
+
+// First meaningful keyword of a statement (used to detect SELECT vs. others).
+export function firstKeyword(stmt: string): string {
+  const toks = tokenizeSQL(stmt);
+  const t = toks.find((x) => x.type === 'kw' || x.type === 'id');
+  return t ? t.value.toUpperCase() : '';
+}
diff --git a/frontend/src/react-query/api/sql.tsx b/frontend/src/react-query/api/sql.tsx
new file mode 100644
index 0000000000..fc3d2d9e9b
--- /dev/null
+++ b/frontend/src/react-query/api/sql.tsx
@@ -0,0 +1,74 @@
+/**
+ * Copyright 2026 Redpanda Data, Inc.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
+ *
+ * As of the Change Date specified in that file, in accordance with
+ * the Business Source License, use of this software will be governed
+ * by the Apache License, Version 2.0
+ */
+
+import { create } from '@bufbuild/protobuf';
+import { useMutation, useQuery } from '@connectrpc/connect-query';
+import {
+  type DescribeTableRequest,
+  DescribeTableRequestSchema,
+  type ListCatalogsRequest,
+  ListCatalogsRequestSchema,
+  type ListTablesRequest,
+  ListTablesRequestSchema,
+} from 'protogen/redpanda/api/dataplane/v1alpha3/sql_pb';
+import {
+  describeTable,
+  executeQuery,
+  listCatalogs,
+  listTables,
+} from 'protogen/redpanda/api/dataplane/v1alpha3/sql-SQLService_connectquery';
+import { MAX_PAGE_SIZE, type MessageInit } from 'react-query/react-query.utils';
+import { toast } from 'sonner';
+import { formatToastErrorMessageGRPC } from 'utils/toast.utils';
+
+type SqlQueryOptions = {
+  enabled?: boolean;
+};
+
+export const useListCatalogsQuery = (input?: MessageInit, options?: SqlQueryOptions) => {
+  const request = create(ListCatalogsRequestSchema, {
+    pageSize: input?.pageSize ?? MAX_PAGE_SIZE,
+    pageToken: input?.pageToken ?? '',
+  });
+
+  return useQuery(listCatalogs, request, {
+    enabled: options?.enabled !== false,
+  });
+};
+
+export const useListTablesQuery = (input?: MessageInit, options?: SqlQueryOptions) => {
+  const request = create(ListTablesRequestSchema, {
+    catalog: input?.catalog ?? '',
+    pageSize: input?.pageSize ?? MAX_PAGE_SIZE,
+    pageToken: input?.pageToken ?? '',
+    filter: input?.filter,
+  });
+
+  return useQuery(listTables, request, {
+    enabled: options?.enabled !== false && Boolean(input?.catalog),
+  });
+};
+
+export const useDescribeTableQuery = (input?: MessageInit, options?: SqlQueryOptions) => {
+  const request = create(DescribeTableRequestSchema, {
+    catalog: input?.catalog ?? '',
+    name: input?.name ?? '',
+  });
+
+  return useQuery(describeTable, request, {
+    enabled: options?.enabled !== false && Boolean(input?.catalog) && Boolean(input?.name),
+  });
+};
+
+export const useExecuteQueryMutation = () =>
+  useMutation(executeQuery, {
+    onError: (error) => toast.error(formatToastErrorMessageGRPC({ error, action: 'execute', entity: 'SQL query' })),
+  });
diff --git a/frontend/src/routes/sql.tsx b/frontend/src/routes/sql.tsx
new file mode 100644
index 0000000000..93cd1bdbb2
--- /dev/null
+++ b/frontend/src/routes/sql.tsx
@@ -0,0 +1,35 @@
+/**
+ * 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 { createFileRoute } from '@tanstack/react-router';
+import { SqlWorkspace } from 'components/pages/sql/sql-workspace';
+import { Database } from 'lucide-react';
+import { useLayoutEffect } from 'react';
+
+import { uiState } from '../state/ui-state';
+
+export const Route = createFileRoute('/sql')({
+  staticData: {
+    title: 'SQL',
+    icon: Database,
+    fullscreen: true,
+  },
+  component: SqlRouteWrapper,
+});
+
+function SqlRouteWrapper() {
+  useLayoutEffect(() => {
+    uiState.pageBreadcrumbs = [{ title: 'SQL', linkTo: '' }];
+    uiState.pageTitle = 'SQL';
+  }, []);
+
+  return ;
+}

From c6c04a25891e4590704e1c229a9aa011a7b54923 Mon Sep 17 00:00:00 2001
From: Julin <142230457+c-julin@users.noreply.github.com>
Date: Tue, 9 Jun 2026 15:48:57 +0100
Subject: [PATCH 02/26] feat(sql): query workspace UI updates [UX-1259]

SQL editor/workspace refactor (catalog-tree, sql-editor, sql-results,
sql-wizard, sql-workspace, sql, sql API), plus vendored redpanda-ui
registry component updates and supporting route/config changes.

[UX-1259]
---
 .gitignore                                    |  12 +-
 frontend/bun.lock                             |  17 +-
 frontend/package.json                         |   8 +-
 frontend/rsbuild.config.ts                    |   2 +-
 frontend/src/components/constants.ts          |   1 +
 .../components/debug-helper/debug-dialog.tsx  | 316 ++++++++++-
 .../src/components/pages/sql/catalog-tree.css | 378 -------------
 .../src/components/pages/sql/catalog-tree.tsx | 166 +++---
 .../src/components/pages/sql/sql-editor.css   | 230 --------
 .../src/components/pages/sql/sql-editor.tsx   | 202 ++++++-
 .../src/components/pages/sql/sql-results.css  | 501 ------------------
 .../src/components/pages/sql/sql-results.tsx  | 199 +++++--
 .../src/components/pages/sql/sql-wizard.css   | 375 -------------
 .../src/components/pages/sql/sql-wizard.tsx   | 117 ++--
 .../components/pages/sql/sql-workspace.tsx    |  91 +++-
 frontend/src/components/pages/sql/sql.css     | 190 -------
 frontend/src/components/pages/sql/sql.tsx     |  18 +-
 frontend/src/globals.css                      |   9 +
 frontend/src/react-query/api/sql.tsx          |  28 +-
 frontend/src/routeTree.gen.ts                 |  21 +
 frontend/src/routes/__root.tsx                |  28 +-
 frontend/src/utils/route-utils.tsx            |   8 +
 22 files changed, 997 insertions(+), 1920 deletions(-)
 delete mode 100644 frontend/src/components/pages/sql/catalog-tree.css
 delete mode 100644 frontend/src/components/pages/sql/sql-editor.css
 delete mode 100644 frontend/src/components/pages/sql/sql-results.css
 delete mode 100644 frontend/src/components/pages/sql/sql-wizard.css
 delete mode 100644 frontend/src/components/pages/sql/sql.css

diff --git a/.gitignore b/.gitignore
index 5067d0ca48..cc493785ff 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@ fixname.sh
 # IDEs
 **/.vscode
 **/.idea
+**/.cursor
 **/*.code-workspace
 
 # Helper Scripts
@@ -17,7 +18,14 @@ requests.txt
 .prettierrc
 
 # Local build tools installed via Taskfiles
-build
+/build
+/configs
 
-.cursor
+# Go workspace (local dev only)
+go.work
+go.work.sum
+
+# Claude Code local state
+.claude/worktrees/
+.claude/settings.local.json
 .claude/worktrees/
diff --git a/frontend/bun.lock b/frontend/bun.lock
index 61b46281a5..63c438da9d 100644
--- a/frontend/bun.lock
+++ b/frontend/bun.lock
@@ -1,6 +1,5 @@
 {
   "lockfileVersion": 1,
-  "configVersion": 0,
   "workspaces": {
     "": {
       "dependencies": {
@@ -11,7 +10,7 @@
         "@buf/redpandadata_cloud.connectrpc_query-es": "^2.2.0-20251128173054-b9f9fc6e5a70.1",
         "@buf/redpandadata_common.bufbuild_es": "^2.11.0-20260316210807-5d899910f714.1",
         "@bufbuild/cel": "^0.4.0",
-        "@bufbuild/protobuf": "^2.11.0",
+        "@bufbuild/protobuf": "^2.12.0",
         "@bufbuild/protoc-gen-es": "^2.10.0",
         "@builder.io/sdk-react": "^4.2.4",
         "@chakra-ui/object-utils": "^2.1",
@@ -49,7 +48,7 @@
         "class-variance-authority": "^0.7.1",
         "clsx": "^2.1.1",
         "cmdk": "^1.1.1",
-        "date-fns": "^4.1.0",
+        "date-fns": "^4.3.0",
         "dexie": "^4.2.1",
         "dotenv": "^17.2.3",
         "es-cookie": "^1.5.0",
@@ -78,7 +77,7 @@
         "react-draggable": "^4.5.0",
         "react-dropzone": "^15.0.0",
         "react-highlight-words": "^0.21.0",
-        "react-hook-form": "^7.72.0",
+        "react-hook-form": "^7.76.1",
         "react-markdown": "^10.1.0",
         "react-resizable-panels": "^3.0.6",
         "react-simple-code-editor": "^0.14.1",
@@ -96,7 +95,7 @@
         "use-stick-to-bottom": "^1.1.1",
         "vaul": "^1.1.2",
         "yaml": "^2.8.3",
-        "zod": "^4.3.6",
+        "zod": "^4.4.3",
         "zustand": "^5.0.8",
       },
       "devDependencies": {
@@ -1884,7 +1883,7 @@
 
     "data-urls": ["data-urls@2.0.0", "", { "dependencies": { "abab": "^2.0.3", "whatwg-mimetype": "^2.3.0", "whatwg-url": "^8.0.0" } }, "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ=="],
 
-    "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
+    "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="],
 
     "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="],
 
@@ -2972,7 +2971,7 @@
 
     "react-highlight-words": ["react-highlight-words@0.21.0", "", { "dependencies": { "highlight-words-core": "^1.2.0", "memoize-one": "^4.0.0" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.0 || ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-SdWEeU9fIINArEPO1rO5OxPyuhdEKZQhHzZZP1ie6UeXQf+CjycT1kWaB+9bwGcVbR0NowuHK3RqgqNg6bgBDQ=="],
 
-    "react-hook-form": ["react-hook-form@7.73.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-VAfVYOPcx3piiEVQy95vyFmBwbVUsP/AUIN+mpFG8h11yshDd444nn0VyfaGWSRnhOLVgiDu7HIuBtAIzxn9dA=="],
+    "react-hook-form": ["react-hook-form@7.78.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-EEZqc+N23moyzTlz61Pj+JvcXo76ICkpfOZo8JZw+sM4+wLQGh6nI2Ms+PdMOYNluFu0ghlM7B8mCzhRYtJCnA=="],
 
     "react-icons": ["react-icons@4.12.0", "", { "peerDependencies": { "react": "*" } }, "sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw=="],
 
@@ -3566,7 +3565,7 @@
 
     "zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="],
 
-    "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
+    "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
 
     "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
 
@@ -4082,6 +4081,8 @@
 
     "react-datepicker/date-fns": ["date-fns@2.30.0", "", { "dependencies": { "@babel/runtime": "^7.21.0" } }, "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw=="],
 
+    "react-day-picker/date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
+
     "react-highlight-words/memoize-one": ["memoize-one@4.1.0", "", {}, "sha512-2GApq0yI/b22J2j9rhbrAlsHb0Qcz+7yWxeLG8h+95sl1XPUgeLimQSOdur4Vw7cUhrBHwaUZxWFZueojqNRzA=="],
 
     "react-redux/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
diff --git a/frontend/package.json b/frontend/package.json
index c156043be0..0d256b8ef3 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -55,7 +55,7 @@
     "@buf/redpandadata_cloud.connectrpc_query-es": "^2.2.0-20251128173054-b9f9fc6e5a70.1",
     "@buf/redpandadata_common.bufbuild_es": "^2.11.0-20260316210807-5d899910f714.1",
     "@bufbuild/cel": "^0.4.0",
-    "@bufbuild/protobuf": "^2.11.0",
+    "@bufbuild/protobuf": "^2.12.0",
     "@bufbuild/protoc-gen-es": "^2.10.0",
     "@builder.io/sdk-react": "^4.2.4",
     "@chakra-ui/object-utils": "^2.1",
@@ -93,7 +93,7 @@
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
     "cmdk": "^1.1.1",
-    "date-fns": "^4.1.0",
+    "date-fns": "^4.3.0",
     "dexie": "^4.2.1",
     "dotenv": "^17.2.3",
     "es-cookie": "^1.5.0",
@@ -122,7 +122,7 @@
     "react-draggable": "^4.5.0",
     "react-dropzone": "^15.0.0",
     "react-highlight-words": "^0.21.0",
-    "react-hook-form": "^7.72.0",
+    "react-hook-form": "^7.76.1",
     "react-markdown": "^10.1.0",
     "react-resizable-panels": "^3.0.6",
     "react-simple-code-editor": "^0.14.1",
@@ -140,7 +140,7 @@
     "use-stick-to-bottom": "^1.1.1",
     "vaul": "^1.1.2",
     "yaml": "^2.8.3",
-    "zod": "^4.3.6",
+    "zod": "^4.4.3",
     "zustand": "^5.0.8"
   },
   "devDependencies": {
diff --git a/frontend/rsbuild.config.ts b/frontend/rsbuild.config.ts
index 325af3727f..447f53b58c 100644
--- a/frontend/rsbuild.config.ts
+++ b/frontend/rsbuild.config.ts
@@ -186,7 +186,7 @@ export default defineConfig({
           semicolons: true,
         }),
         new MonacoWebpackPlugin({
-          languages: ['yaml', 'json', 'typescript', 'javascript', 'protobuf'],
+          languages: ['yaml', 'json', 'typescript', 'javascript', 'protobuf', 'sql'],
           customLanguages: [
             {
               label: 'yaml',
diff --git a/frontend/src/components/constants.ts b/frontend/src/components/constants.ts
index 84fb982dd7..8f54b5e870 100644
--- a/frontend/src/components/constants.ts
+++ b/frontend/src/components/constants.ts
@@ -20,6 +20,7 @@ export const FEATURE_FLAGS = {
   enableNewSecurityPage: true,
   enableTeamsBridge: false,
   enableNewTopicPage: true,
+  enableSqlInConsole: true,
 };
 
 // Cloud-managed tag keys for service account integration
diff --git a/frontend/src/components/debug-helper/debug-dialog.tsx b/frontend/src/components/debug-helper/debug-dialog.tsx
index c12a5a8b51..8f819bc154 100644
--- a/frontend/src/components/debug-helper/debug-dialog.tsx
+++ b/frontend/src/components/debug-helper/debug-dialog.tsx
@@ -49,11 +49,13 @@ import {
   FileCode,
   Flag,
   Info,
+  LayoutDashboard,
   RotateCw,
   Trash2,
   Wrench,
   Zap,
 } from 'lucide-react';
+import { useQueryState } from 'nuqs';
 import { useCallback, useMemo, useState } from 'react';
 import { toast } from 'sonner';
 
@@ -761,7 +763,311 @@ function ConnectTab() {
   );
 }
 
-type Tab = 'general' | 'flags' | 'connect';
+type OverlayOption = readonly [label: string, value: string | null];
+
+const OVERLAY_ON: readonly OverlayOption[] = [
+  ['off', null],
+  ['on', 'on'],
+];
+const OVERLAY_HEALTH: readonly OverlayOption[] = [
+  ['off', null],
+  ['degraded', 'degraded'],
+  ['down', 'down'],
+];
+const OVERLAY_DATAPLANE: readonly OverlayOption[] = [
+  ['off', null],
+  ['failed', 'failed'],
+];
+const OVERLAY_CUSTOMER_MANAGED: readonly OverlayOption[] = [
+  ['off', null],
+  ['gcp', 'gcp'],
+  ['aws', 'aws'],
+  ['azure', 'azure'],
+];
+const OVERLAY_SQL_METRICS: readonly OverlayOption[] = [
+  ['off', null],
+  ['silent', 'silent'],
+];
+const OVERLAY_PEERING_BANNER: readonly OverlayOption[] = [
+  ['off', null],
+  ['aws', 'aws'],
+  ['gcp', 'gcp'],
+];
+const OVERLAY_LIFECYCLE: readonly OverlayOption[] = [
+  ['creating', 'creating'],
+  ['upgrading', 'upgrading'],
+  ['deleting', 'deleting'],
+  ['failed', 'failed'],
+  ['suspended', 'suspended'],
+];
+const OVERLAY_PRIVATE: readonly OverlayOption[] = [
+  ['aws-privatelink', 'aws-privatelink'],
+  ['azure-privatelink', 'azure-privatelink'],
+  ['gcp-psc', 'gcp-psc'],
+  ['aws-peering', 'aws-peering'],
+  ['azure-peering', 'azure-peering'],
+  ['gcp-peering', 'gcp-peering'],
+  ['aws-privatelink-unconfigured', 'aws-privatelink-unconfigured'],
+  ['aws-privatelink-provisioning', 'aws-privatelink-provisioning'],
+  ['aws-privatelink-pending', 'aws-privatelink-pending'],
+  ['aws-privatelink-failed', 'aws-privatelink-failed'],
+  ['aws-privatelink-deleting', 'aws-privatelink-deleting'],
+  ['aws-privatelink-deleted', 'aws-privatelink-deleted'],
+  ['azure-privatelink-unconfigured', 'azure-privatelink-unconfigured'],
+  ['gcp-psc-unconfigured', 'gcp-psc-unconfigured'],
+];
+
+function parseOverlay(raw: string): Record {
+  const map: Record = {};
+  for (const pair of raw.split(',')) {
+    const i = pair.indexOf(':');
+    const key = (i === -1 ? pair : pair.slice(0, i)).trim();
+    if (key) {
+      map[key] = i === -1 ? 'on' : pair.slice(i + 1).trim();
+    }
+  }
+  return map;
+}
+
+function buildOverlayString(map: Record): string {
+  return Object.entries(map)
+    .filter(([k, v]) => k && v)
+    .map(([k, v]) => `${k}:${v}`)
+    .join(',');
+}
+
+function OverlaySeg({
+  label,
+  urlKey,
+  current,
+  options,
+  onSet,
+}: {
+  label: string;
+  urlKey: string;
+  current: string | null;
+  options: readonly OverlayOption[];
+  onSet: (key: string, value: string | null) => void;
+}) {
+  return (
+    
+ {label} +
+ {options.map(([optLabel, value]) => { + const active = current === value || (value === null && !current); + return ( + + ); + })} +
+
+ ); +} + +function OverlaySelect({ + label, + urlKey, + current, + options, + onSet, +}: { + label: string; + urlKey: string; + current: string | null; + options: readonly OverlayOption[]; + onSet: (key: string, value: string | null) => void; +}) { + return ( +
+ {label} + +
+ ); +} + +function OverviewPageTab() { + const [raw, setRaw] = useQueryState('devOverlay'); + const map = parseOverlay(raw ?? ''); + const cur = (key: string): string | null => map[key] ?? null; + const activeCount = Object.keys(map).filter((k) => map[k]).length; + + const onSet = (key: string, value: string | null) => { + const next = { ...map }; + if (value === null) { + delete next[key]; + } else { + next[key] = value; + } + setRaw(buildOverlayString(next) || null); + }; + + return ( +
+ + Each control updates this page's ?devOverlay= URL param. The Tampermonkey + response-rewrite userscript reads it and fakes the matching API responses; hit Reload to apply. + Nothing here ships in the app. + + +
+ 0 ? 'warning-inverted' : 'simple-outline'}> + {activeCount > 0 ? `${activeCount} active` : 'no overlays'} + + + +
+ + +
+ + + + + + +
+
+ + +
+ + + +
+
+ + +
+ + +
+
+ + +
+ + + + +
+
+
+ ); +} + +type Tab = 'general' | 'flags' | 'connect' | 'overview-page'; export function DebugDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { const [tab, setTab] = useState('general'); @@ -791,6 +1097,9 @@ export function DebugDialog({ open, onOpenChange }: { open: boolean; onOpenChang Connect + + Overview page + @@ -805,6 +1114,11 @@ export function DebugDialog({ open, onOpenChange }: { open: boolean; onOpenChang + +
+ +
+
diff --git a/frontend/src/components/pages/sql/catalog-tree.css b/frontend/src/components/pages/sql/catalog-tree.css deleted file mode 100644 index b0f5504687..0000000000 --- a/frontend/src/components/pages/sql/catalog-tree.css +++ /dev/null @@ -1,378 +0,0 @@ -/** - * Copyright 2026 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -/* Catalog tree: Catalog -> Namespace -> Table -> Columns. Ported from the - design prototype's .cat-* classes. */ - -.cat { - display: flex; - flex-direction: column; - height: 100%; - min-height: 0; -} - -.cat-head { - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 14px 8px; -} -.cat-head-title { - font-size: 12px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--color-muted-foreground); -} -.cat-head-hint { - font-size: 10px; - color: var(--color-disabled); - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.cat-search { - padding: 0 12px 10px; -} - -.cat-tree { - flex: 1; - overflow-y: auto; - padding: 0 8px 8px; -} - -.cat-row { - display: flex; - align-items: center; - gap: 6px; - width: 100%; - text-align: left; - background: transparent; - border: 0; - cursor: pointer; - padding: 6px 8px; - border-radius: var(--radius-sm); - color: var(--color-strong); - font-size: 13px; -} -.cat-row:hover { - background: var(--color-selected-hover); -} -.cat-chev { - color: var(--color-disabled); - flex-shrink: 0; -} -.cat-label { - flex: 1; - text-align: left; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.cat-row, -.cat-table-main, -.cat-cat-main, -.cat-row-ns, -.cat-row-catalog { - text-align: left; -} -.cat-row-catalog { - padding: 0; - gap: 0; -} -.cat-cat-main { - display: flex; - align-items: center; - gap: 6px; - flex: 1; - min-width: 0; - background: transparent; - border: 0; - cursor: pointer; - padding: 6px 8px; - color: var(--color-strong); - font-size: 13px; - font-weight: 600; - font-family: inherit; -} -.cat-cat-add { - opacity: 0; - display: inline-flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - margin-right: 4px; - border: 0; - background: transparent; - color: var(--color-action-primary); - cursor: pointer; - border-radius: var(--radius-sm); - flex-shrink: 0; -} -.cat-cat-add:hover { - background: var(--color-indigo-100); -} -.cat-row-catalog:hover .cat-cat-add { - opacity: 1; -} -.cat-engine { - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: var(--radius-sm); - flex-shrink: 0; -} -.cat-engine-rp { - background: var(--color-indigo-alpha-100); - color: var(--color-indigo-700); -} -.cat-engine-ice { - background: var(--color-blue-alpha-100); - color: var(--color-blue-700); -} -.cat-ns { - margin-left: 10px; -} -.cat-row-ns { - font-weight: 500; - color: var(--color-foreground); -} -.cat-ns-ico { - color: var(--color-muted-foreground); -} -.cat-count { - font-size: 11px; - color: var(--color-muted-foreground); - background: var(--color-muted); - padding: 1px 7px; - border-radius: 999px; -} -.cat-tables { - margin-left: 10px; -} -.cat-row-table { - padding: 0; - gap: 0; -} -.cat-table-main { - display: flex; - align-items: center; - gap: 6px; - flex: 1; - min-width: 0; - background: transparent; - border: 0; - cursor: pointer; - padding: 6px 8px; - color: var(--color-strong); - font-size: 13px; - font-family: inherit; -} -.cat-table-main:disabled { - cursor: default; - color: var(--color-disabled); -} -.cat-table-ico { - color: var(--color-action-primary); - flex-shrink: 0; -} -.cat-row-table[data-locked] .cat-table-ico { - color: var(--color-disabled); -} -.cat-row-table[data-active] { - background: var(--color-selected); -} -.cat-table-run { - opacity: 0; - display: inline-flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - margin-right: 4px; - border: 0; - background: transparent; - color: var(--color-action-primary); - cursor: pointer; - border-radius: var(--radius-sm); - flex-shrink: 0; -} -.cat-table-run:hover { - background: var(--color-indigo-100); -} -.cat-row-table:hover .cat-table-run { - opacity: 1; -} -.cat-lock { - color: var(--color-disabled); - margin-left: 2px; -} -.cat-table-ico-ice { - color: var(--color-blue-700); -} -.cat-ice { - display: inline-flex; - align-items: center; - gap: 3px; - font-size: 9px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.03em; - color: var(--color-blue-700); - background: var(--color-blue-alpha-100); - padding: 1px 5px 1px 4px; - border-radius: 3px; - flex-shrink: 0; -} -.cat-ice svg { - color: currentColor; -} -html.dark .cat-ice { - color: var(--color-blue-300); -} -html.dark .cat-table-ico-ice { - color: var(--color-blue-400); -} -.cat-cols { - margin-left: 26px; - border-left: 1px solid var(--color-border-subtle); - padding-left: 8px; - margin-bottom: 2px; -} -.cat-col { - display: flex; - align-items: center; - gap: 7px; - padding: 3px 8px; - font-size: 12px; - color: var(--color-foreground); -} -.cat-col-ico { - color: var(--color-muted-foreground); - flex-shrink: 0; -} -.cat-col-name { - font-family: var(--font-mono); - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.cat-col-type { - font-size: 10px; - color: var(--color-muted-foreground); - font-family: var(--font-mono); - letter-spacing: 0.02em; -} -.cat-ns-empty { - font-size: 12px; - color: var(--color-disabled); - padding: 6px 16px; -} -.cat-add-row { - padding: 6px 8px; - color: var(--color-action-primary); - font-weight: 500; - font-family: inherit; - font-size: 13px; - width: 100%; -} -.cat-add-row .cat-label { - color: var(--color-action-primary); -} -.cat-add-row .cat-add-row-ico { - color: var(--color-action-primary); - flex-shrink: 0; - margin-left: 19px; -} -.cat-add-row:hover { - background: var(--color-indigo-100); -} -.cat-more { - display: flex; - align-items: center; - gap: 7px; - width: 100%; - margin-top: 2px; - padding: 7px 8px; - background: transparent; - border: 0; - cursor: pointer; - border-radius: var(--radius-sm); - font-size: 12px; - color: var(--color-action-primary); - font-weight: 500; - text-align: left; - font-family: inherit; -} -.cat-more:hover { - background: var(--color-indigo-100); -} -.cat-more-ico { - color: var(--color-action-primary); - flex-shrink: 0; -} - -/* Loading / spinner */ -.cat-loading { - display: flex; - align-items: center; - gap: 7px; - padding: 6px 16px; - font-size: 12px; - color: var(--color-muted-foreground); -} -.cat-spinner { - display: inline-block; - width: 13px; - height: 13px; - border: 2px solid var(--color-muted); - border-top-color: var(--color-action-primary); - border-radius: 999px; - animation: cat-spin 0.7s linear infinite; - flex-shrink: 0; -} -@keyframes cat-spin { - to { - transform: rotate(360deg); - } -} - -/* Bridge query (Iceberg-tiered Redpanda table) markers */ -.cat-row-table[data-tiered] .cat-table-ico { - color: var(--color-blue-700); -} -html.dark .cat-row-table[data-tiered] .cat-table-ico { - color: var(--color-blue-400); -} -.cat-bridge { - display: inline-flex; - align-items: center; - gap: 3px; - font-size: 9px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.03em; - color: var(--color-blue-700); - background: var(--color-blue-alpha-100); - padding: 1px 5px 1px 4px; - border-radius: 3px; - flex-shrink: 0; -} -.cat-bridge svg { - color: currentColor; -} -html.dark .cat-bridge { - color: var(--color-blue-300); -} diff --git a/frontend/src/components/pages/sql/catalog-tree.tsx b/frontend/src/components/pages/sql/catalog-tree.tsx index 1cdbe10233..f3702ea754 100644 --- a/frontend/src/components/pages/sql/catalog-tree.tsx +++ b/frontend/src/components/pages/sql/catalog-tree.tsx @@ -9,6 +9,7 @@ * by the Apache License, Version 2.0 */ +import { cn } from 'components/redpanda-ui/lib/utils'; import { Box, Calendar, @@ -26,10 +27,9 @@ import { ToggleLeft, Type, } from 'lucide-react'; -import { useDescribeTableQuery, useListTablesQuery, useTopicIcebergQuery } from 'react-query/api/sql'; import { useState } from 'react'; +import { useDescribeTableQuery, useListTablesQuery, useTopicIcebergQuery } from 'react-query/api/sql'; -import './catalog-tree.css'; import { type Catalog, type CatalogEngine, @@ -66,23 +66,36 @@ const COL_KIND_ICON: Record = { time: Calendar, }; +// Shared row layout: flex, gap, full-width, left-aligned, padded, rounded, with a +// subtle hover background. Used by namespace rows and the "Add a topic" row. +const ROW_BASE = + 'flex w-full cursor-pointer items-center gap-[6px] rounded border-0 bg-transparent px-[8px] py-[6px] text-left text-[13px] text-strong hover:bg-accent-subtle'; + +// Truncating label that fills the remaining row width. +const LABEL = 'flex-1 overflow-hidden text-left text-ellipsis whitespace-nowrap'; + function engineMark(engine: CatalogEngine) { if (engine === 'redpanda') { return ( - + ); } return ( - + ); } function Spinner() { - return