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..972605b6c1 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", @@ -19,12 +18,18 @@ "@chakra-ui/portal": "^2.1", "@chakra-ui/react-use-disclosure": "^2.1", "@chakra-ui/system": "^2.1", + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/language": "^6.12.3", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.1", "@connectrpc/connect": "^2.1.0", "@connectrpc/connect-query": "^2.2.0", "@connectrpc/connect-web": "^2.1.0", "@emotion/css": "^11.13.5", "@hookform/resolvers": "^5.2.2", "@icons-pack/react-simple-icons": "^13.8.0", + "@lezer/highlight": "^1.2.3", "@milkdown/kit": "^7.18.0", "@milkdown/react": "^7.18.0", "@modelcontextprotocol/sdk": "^1.29.0", @@ -42,6 +47,7 @@ "@tanstack/react-virtual": "^3.13.12", "@tanstack/zod-adapter": "^1.167.0", "@types/prismjs": "^1.26.5", + "@uiw/react-codemirror": "^4.25.10", "@xyflow/react": "^12.9.2", "ai": "^6.0.168", "array-move": "^4.0.0", @@ -49,7 +55,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", @@ -73,12 +79,13 @@ "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", "react-compiler-runtime": "^1.0.0", + "react-data-grid": "7.0.0-beta.47", "react-day-picker": "^9.14.0", "react-dom": "^18.3.1", "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", @@ -88,6 +95,7 @@ "remark-gfm": "^4.0.1", "shiki": "^3.23.0", "sonner": "^2.0.7", + "sql-formatter": "^15.8.1", "stacktrace-js": "^2.0.2", "streamdown": "^2.5.0", "tailwind-merge": "^3.5.0", @@ -159,6 +167,13 @@ "@tanstack/react-router@1.170.15": "patches/@tanstack%2Freact-router@1.170.15.patch", }, "overrides": { + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.10.3", + "@codemirror/language": "^6.12.3", + "@codemirror/lint": "^6.9.7", + "@codemirror/search": "^6.7.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.1", "baseline-browser-mapping": "2.10.33", "dompurify": "^3.4.0", "prismjs": "^1.30.0", @@ -366,9 +381,9 @@ "@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], - "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg=="], + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="], - "@codemirror/commands": ["@codemirror/commands@6.10.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q=="], + "@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="], "@codemirror/lang-angular": ["@codemirror/lang-angular@0.1.4", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.3" } }, "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g=="], @@ -412,21 +427,21 @@ "@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw=="], - "@codemirror/language": ["@codemirror/language@6.12.1", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ=="], + "@codemirror/language": ["@codemirror/language@6.12.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="], "@codemirror/language-data": ["@codemirror/language-data@6.5.2", "", { "dependencies": { "@codemirror/lang-angular": "^0.1.0", "@codemirror/lang-cpp": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-go": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-java": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/lang-jinja": "^6.0.0", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-less": "^6.0.0", "@codemirror/lang-liquid": "^6.0.0", "@codemirror/lang-markdown": "^6.0.0", "@codemirror/lang-php": "^6.0.0", "@codemirror/lang-python": "^6.0.0", "@codemirror/lang-rust": "^6.0.0", "@codemirror/lang-sass": "^6.0.0", "@codemirror/lang-sql": "^6.0.0", "@codemirror/lang-vue": "^0.1.1", "@codemirror/lang-wast": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-yaml": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/legacy-modes": "^6.4.0" } }, "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg=="], "@codemirror/legacy-modes": ["@codemirror/legacy-modes@6.5.2", "", { "dependencies": { "@codemirror/language": "^6.0.0" } }, "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q=="], - "@codemirror/lint": ["@codemirror/lint@6.9.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-y3YkYhdnhjDBAe0VIA0c4wVoFOvnp8CnAvfLqi0TqotIv92wIlAAP7HELOpLBsKwjAX6W92rSflA6an/2zBvXw=="], + "@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="], - "@codemirror/search": ["@codemirror/search@6.6.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw=="], + "@codemirror/search": ["@codemirror/search@6.7.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg=="], - "@codemirror/state": ["@codemirror/state@6.5.4", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw=="], + "@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="], "@codemirror/theme-one-dark": ["@codemirror/theme-one-dark@6.1.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA=="], - "@codemirror/view": ["@codemirror/view@6.39.12", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-f+/VsHVn/kOA9lltk/GFzuYwVVAKmOnNjxbrhkk3tPHntFqjWeI2TbIXx006YkBkqC10wZ4NsnWXCQiFPeAISQ=="], + "@codemirror/view": ["@codemirror/view@6.43.1", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw=="], "@connectrpc/connect": ["@connectrpc/connect@2.1.0", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0" } }, "sha512-xhiwnYlJNHzmFsRw+iSPIwXR/xweTvTw8x5HiwWp10sbVtd4OpOXbRgE7V58xs1EC17fzusF1f5uOAy24OkBuA=="], @@ -1406,6 +1421,10 @@ "@typescript/vfs": ["@typescript/vfs@1.6.2", "", { "dependencies": { "debug": "^4.1.1" }, "peerDependencies": { "typescript": "*" } }, "sha512-hoBwJwcbKHmvd2QVebiytN1aELvpk9B74B4L1mFm/XT1Q/VOYAWl2vQ9AWRFtQq8zmz6enTpfTV8WRc4ATjW/g=="], + "@uiw/codemirror-extensions-basic-setup": ["@uiw/codemirror-extensions-basic-setup@4.25.10", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-P3vytLlpE62KYSWrMUnwDCv2lvaQDuDZzyj03mHntuHo5bSl34fRZpjTY3kQTPGuXHxkGSYpoPFFj+hMTqaaMQ=="], + + "@uiw/react-codemirror": ["@uiw/react-codemirror@4.25.10", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", "@uiw/codemirror-extensions-basic-setup": "4.25.10", "codemirror": "^6.0.0" }, "peerDependencies": { "@codemirror/view": ">=6.0.0", "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-DzgSMwM5qzB7v1FIb4gEeriYt67iiay756/HIOM9mAbeOVK0MO7rqefHf0O5c0269pJKMW7AH9FjclExD23V9w=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], @@ -1884,7 +1903,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=="], @@ -1936,6 +1955,8 @@ "diffie-hellman": ["diffie-hellman@5.0.3", "", { "dependencies": { "bn.js": "^4.1.0", "miller-rabin": "^4.0.0", "randombytes": "^2.0.0" } }, "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg=="], + "discontinuous-range": ["discontinuous-range@1.0.0", "", {}, "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ=="], + "docker-compose": ["docker-compose@1.4.2", "", { "dependencies": { "yaml": "^2.2.2" } }, "sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww=="], "docker-modem": ["docker-modem@5.0.7", "", { "dependencies": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", "ssh2": "^1.15.0" } }, "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA=="], @@ -2692,6 +2713,8 @@ "monaco-yaml": ["monaco-yaml@5.5.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.0.0", "monaco-languageserver-types": "^0.4.0", "monaco-marker-data-provider": "^1.0.0", "monaco-types": "^0.1.0", "monaco-worker-manager": "^2.0.0", "path-browserify": "^1.0.0", "prettier": "^3.0.0", "vscode-languageserver-textdocument": "^1.0.0", "vscode-languageserver-types": "^3.0.0", "vscode-uri": "^3.0.0", "yaml": "^2.0.0" }, "peerDependencies": { "monaco-editor": ">=0.36" } }, "sha512-FEJezTYwzL3VFCWnA98mPp0AmPERoiQNN54ycllF0LorUeTXPN2YzJzu9jfzwVn2CxX1qajEornAr4CrZffuMQ=="], + "moo": ["moo@0.5.3", "", {}, "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA=="], + "motion": ["motion@12.40.0", "", { "dependencies": { "framer-motion": "^12.40.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA=="], "motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="], @@ -2712,6 +2735,8 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "nearley": ["nearley@2.20.1", "", { "dependencies": { "commander": "^2.19.0", "moo": "^0.5.0", "railroad-diagrams": "^1.0.0", "randexp": "0.4.6" }, "bin": { "nearleyc": "bin/nearleyc.js", "nearley-test": "bin/nearley-test.js", "nearley-unparse": "bin/nearley-unparse.js", "nearley-railroad": "bin/nearley-railroad.js" } }, "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], @@ -2936,6 +2961,10 @@ "raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="], + "railroad-diagrams": ["railroad-diagrams@1.0.0", "", {}, "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A=="], + + "randexp": ["randexp@0.4.6", "", { "dependencies": { "discontinuous-range": "1.0.0", "ret": "~0.1.10" } }, "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ=="], + "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], "randomfill": ["randomfill@1.0.4", "", { "dependencies": { "randombytes": "^2.0.5", "safe-buffer": "^5.1.0" } }, "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw=="], @@ -2954,6 +2983,8 @@ "react-compiler-runtime": ["react-compiler-runtime@1.0.0", "", { "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental" } }, "sha512-rRfjYv66HlG8896yPUDONgKzG5BxZD1nV9U6rkm+7VCuvQc903C4MjcoZR4zPw53IKSOX9wMQVpA1IAbRtzQ7w=="], + "react-data-grid": ["react-data-grid@7.0.0-beta.47", "", { "dependencies": { "clsx": "^2.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0", "react-dom": "^18.0 || ^19.0" } }, "sha512-28kjsmwQGD/9RXYC50zn5Zv/SQMhBBoSvG5seq0fM8XXi9TZ0zr9Z5T3YJqLwcEtoNzTOq3y0njkmdujGkIwQQ=="], + "react-datepicker": ["react-datepicker@4.25.0", "", { "dependencies": { "@popperjs/core": "^2.11.8", "classnames": "^2.2.6", "date-fns": "^2.30.0", "prop-types": "^15.7.2", "react-onclickoutside": "^6.13.0", "react-popper": "^2.3.0" }, "peerDependencies": { "react": "^16.9.0 || ^17 || ^18", "react-dom": "^16.9.0 || ^17 || ^18" } }, "sha512-zB7CSi44SJ0sqo8hUQ3BF1saE/knn7u25qEMTO1CQGofY1VAKahO8k9drZtp0cfW1DMfoYLR3uSY1/uMvbEzbg=="], "react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], @@ -2972,7 +3003,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=="], @@ -3078,6 +3109,8 @@ "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "ret": ["ret@0.1.15", "", {}, "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg=="], + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], @@ -3230,6 +3263,8 @@ "split-ca": ["split-ca@1.0.1", "", {}, "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="], + "sql-formatter": ["sql-formatter@15.8.1", "", { "dependencies": { "argparse": "^2.0.1", "nearley": "^2.20.1" }, "bin": { "sql-formatter": "bin/sql-formatter-cli.cjs" } }, "sha512-nT2r90kTEYBuse9fe4r1Rp78v1mOBD35KsGc07Vo9eQSVa1TcTSnCS0zouf6BCmdzvmqBsBW+cYuBoYkHO/OWg=="], + "ssh-remote-port-forward": ["ssh-remote-port-forward@1.0.4", "", { "dependencies": { "@types/ssh2": "^0.5.48", "ssh2": "^1.4.0" } }, "sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ=="], "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="], @@ -3884,6 +3919,8 @@ "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@uiw/react-codemirror/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@vitejs/plugin-react/@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="], "@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -4058,6 +4095,8 @@ "motion/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "nearley/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "no-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "node-abi/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -4082,6 +4121,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..da55fb0de5 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", @@ -63,12 +63,18 @@ "@chakra-ui/portal": "^2.1", "@chakra-ui/react-use-disclosure": "^2.1", "@chakra-ui/system": "^2.1", + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/language": "^6.12.3", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.1", "@connectrpc/connect": "^2.1.0", "@connectrpc/connect-query": "^2.2.0", "@connectrpc/connect-web": "^2.1.0", "@emotion/css": "^11.13.5", "@hookform/resolvers": "^5.2.2", "@icons-pack/react-simple-icons": "^13.8.0", + "@lezer/highlight": "^1.2.3", "@milkdown/kit": "^7.18.0", "@milkdown/react": "^7.18.0", "@modelcontextprotocol/sdk": "^1.29.0", @@ -86,6 +92,7 @@ "@tanstack/react-virtual": "^3.13.12", "@tanstack/zod-adapter": "^1.167.0", "@types/prismjs": "^1.26.5", + "@uiw/react-codemirror": "^4.25.10", "@xyflow/react": "^12.9.2", "ai": "^6.0.168", "array-move": "^4.0.0", @@ -93,7 +100,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", @@ -117,12 +124,13 @@ "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", "react-compiler-runtime": "^1.0.0", + "react-data-grid": "7.0.0-beta.47", "react-day-picker": "^9.14.0", "react-dom": "^18.3.1", "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", @@ -132,6 +140,7 @@ "remark-gfm": "^4.0.1", "shiki": "^3.23.0", "sonner": "^2.0.7", + "sql-formatter": "^15.8.1", "stacktrace-js": "^2.0.2", "streamdown": "^2.5.0", "tailwind-merge": "^3.5.0", @@ -198,6 +207,13 @@ "vitest-browser-react": "^2.2.0" }, "overrides": { + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.10.3", + "@codemirror/language": "^6.12.3", + "@codemirror/lint": "^6.9.7", + "@codemirror/search": "^6.7.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.1", "dompurify": "^3.4.0", "prismjs": "^1.30.0", "baseline-browser-mapping": "2.10.33" 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/pages/sql/catalog-tree.test.tsx b/frontend/src/components/pages/sql/catalog-tree.test.tsx new file mode 100644 index 0000000000..fa67072d20 --- /dev/null +++ b/frontend/src/components/pages/sql/catalog-tree.test.tsx @@ -0,0 +1,160 @@ +/** + * 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 { useDescribeTableQuery, useListTablesQuery, useTopicIcebergQuery } from 'react-query/api/sql'; +import { render, screen } from 'test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { CatalogTree } from './catalog-tree'; +import type { Catalog, TableRef } from './sql-types'; + +vi.mock('react-query/api/sql', () => ({ + useListTablesQuery: vi.fn(), + useDescribeTableQuery: vi.fn(), + useTopicIcebergQuery: vi.fn(), +})); + +const tableRef = (name: string, overrides: Partial = {}): TableRef => ({ + id: `rp.public.${name}`, + name, + namespaceName: 'public', + catalogName: 'rp', + ...overrides, +}); + +const catalog = (overrides: Partial = {}): Catalog => ({ + name: 'rp', + displayLabel: 'Redpanda Catalog', + engine: 'redpanda', + namespaces: [{ id: 'rp.public', name: 'public', tables: [tableRef('orders'), tableRef('users')] }], + ...overrides, +}); + +const noTables = { data: undefined, isLoading: false }; + +beforeEach(() => { + vi.mocked(useListTablesQuery).mockReturnValue(noTables as never); + vi.mocked(useDescribeTableQuery).mockReturnValue({ data: undefined, isLoading: false } as never); + vi.mocked(useTopicIcebergQuery).mockReturnValue({ isIceberg: false } as never); +}); + +describe('CatalogTree', () => { + test('renders an ARIA tree with catalogs, namespaces and tables expanded by default', () => { + render(); + + expect(screen.getByRole('tree', { name: 'Catalogs' })).toBeInTheDocument(); + const items = screen.getAllByRole('treeitem'); + expect(items.map((i) => i.getAttribute('aria-level'))).toEqual(['1', '2', '3', '3']); + expect(screen.getByRole('treeitem', { name: /Redpanda Catalog/ })).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByRole('treeitem', { name: /orders/ })).toBeInTheDocument(); + }); + + test('collapsing a catalog hides its namespaces and tables', async () => { + render(); + + await userEvent.click(screen.getByRole('treeitem', { name: /Redpanda Catalog/ })); + + expect(screen.getByRole('treeitem', { name: /Redpanda Catalog/ })).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('treeitem', { name: /public/ })).toBeNull(); + expect(screen.queryByRole('treeitem', { name: /orders/ })).toBeNull(); + }); + + test('search filters tables and shows the matched count', async () => { + render(); + + await userEvent.type(screen.getByPlaceholderText('Search tables'), 'ord'); + + expect(screen.getByRole('treeitem', { name: /orders/ })).toBeInTheDocument(); + expect(screen.queryByRole('treeitem', { name: /users/ })).toBeNull(); + expect(screen.getByText('1/2')).toBeInTheDocument(); + }); + + test('clicking the query action calls onQueryTable with the catalog and table', async () => { + const onQueryTable = vi.fn(); + render(); + + await userEvent.click(screen.getAllByRole('button', { name: 'Query this table' })[0]); + + expect(onQueryTable).toHaveBeenCalledWith( + expect.objectContaining({ name: 'rp' }), + expect.objectContaining({ name: 'orders' }) + ); + }); + + test('expanding a table lists its columns with type labels', async () => { + vi.mocked(useDescribeTableQuery).mockReturnValue({ + data: { + columns: [ + { name: 'id', type: 'INT8' }, + { name: 'payload', type: 'JSONB' }, + { name: 'tags', type: 'TEXT[]' }, + ], + }, + isLoading: false, + } as never); + render(); + + await userEvent.click(screen.getByRole('treeitem', { name: /orders/ })); + + expect(screen.getByText('payload')).toBeInTheDocument(); + expect(screen.getByText('jsonb')).toBeInTheDocument(); + expect(screen.getByText('text[]')).toBeInTheDocument(); + }); + + test('locked tables are disabled and show no query action', () => { + const locked = catalog({ + namespaces: [{ id: 'rp.public', name: 'public', tables: [tableRef('secret', { allowed: false })] }], + }); + render(); + + expect(screen.getByRole('treeitem', { name: /secret/ })).toBeDisabled(); + expect(screen.queryByRole('button', { name: 'Query this table' })).toBeNull(); + }); + + test('admin sees the catalog-level add action; viewer does not', () => { + const onAddTable = vi.fn(); + const { rerender } = render( + + ); + expect(screen.getByRole('button', { name: 'Add a topic to this catalog' })).toBeInTheDocument(); + + rerender(); + expect(screen.queryByRole('button', { name: 'Add a topic to this catalog' })).toBeNull(); + }); + + test('namespaces past the page limit paginate with a load-more row', async () => { + const tables = Array.from({ length: 25 }, (_, i) => tableRef(`t${String(i).padStart(2, '0')}`)); + const big = catalog({ namespaces: [{ id: 'rp.public', name: 'public', tables }] }); + render(); + + expect(screen.getAllByRole('treeitem', { name: /t\d\d/ })).toHaveLength(20); + await userEvent.click(screen.getByRole('button', { name: /Load more · 5 remaining/ })); + expect(screen.getAllByRole('treeitem', { name: /t\d\d/ })).toHaveLength(25); + }); + + test('arrow keys move focus through visible rows; left collapses', async () => { + render(); + const catalogRow = screen.getByRole('treeitem', { name: /Redpanda Catalog/ }); + const namespaceRow = screen.getByRole('treeitem', { name: /public/ }); + + catalogRow.focus(); + await userEvent.keyboard('{ArrowDown}'); + expect(namespaceRow).toHaveFocus(); + + await userEvent.keyboard('{ArrowUp}'); + expect(catalogRow).toHaveFocus(); + + await userEvent.keyboard('{ArrowLeft}'); + expect(catalogRow).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('treeitem', { name: /public/ })).toBeNull(); + }); +}); 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..11e36343fc --- /dev/null +++ b/frontend/src/components/pages/sql/catalog-tree.tsx @@ -0,0 +1,554 @@ +/** + * 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 { Badge } from 'components/redpanda-ui/components/badge'; +import { Button } from 'components/redpanda-ui/components/button'; +import { Input, InputStart } from 'components/redpanda-ui/components/input'; +import { Spinner } from 'components/redpanda-ui/components/spinner'; +import { Text } from 'components/redpanda-ui/components/typography'; +import { cn } from 'components/redpanda-ui/lib/utils'; +import { + Box, + Braces, + Brackets, + Calendar, + ChevronDown, + ChevronRight, + GitBranch, + Hash, + Layers, + Lock, + Table as LucideTable, + Play, + Plus, + Search, + ToggleLeft, + Type, +} from 'lucide-react'; +import { createContext, type KeyboardEvent, type ReactNode, useContext, useState } from 'react'; +import { useDescribeTableQuery, useListTablesQuery, useTopicIcebergQuery } from 'react-query/api/sql'; + +import { + type Catalog, + type CatalogEngine, + type ColumnDef, + type ColumnKind, + columnKindForPgType, + isArrayPgType, + type Namespace, + type SqlRole, + 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, + json: Braces, +}; + +// 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-1.5 rounded border-0 bg-transparent px-2 py-1.5 text-left text-sm 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'; + +// Tree-wide state and callbacks, provided once by CatalogTree so the node +// components stay lean instead of threading a dozen props per level. +type CatalogTreeContextValue = { + role: SqlRole; + query: string; + activeTableId?: string | null; + /** Expand state per node id. Undefined => default open (`!== false`). */ + open: Record; + /** Expand state per table id. Undefined => closed. */ + openTables: Record; + /** Pagination window per namespace id. */ + shown: Record; + toggle: (id: string) => void; + toggleTable: (id: string) => void; + loadMore: (namespaceId: string) => void; + onQueryTable: (catalog: Catalog, table: TableRef) => void; + onAddTable?: () => void; +}; + +const CatalogTreeContext = createContext(null); + +function useCatalogTree(): CatalogTreeContextValue { + const ctx = useContext(CatalogTreeContext); + if (!ctx) { + throw new Error('useCatalogTree must be used within CatalogTree'); + } + return ctx; +} + +function engineMark(engine: CatalogEngine) { + if (engine === 'redpanda') { + return ( + + + + ); + } + return ( + + + + ); +} + +function LoadingRow({ label }: { label: string }) { + return ( +
+ + + {label} + +
+ ); +} + +function EmptyNote({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +// Merge tables seeded on the namespace with tables fetched from ListTables for +// the catalog. Fetched tables win; seeded names fill in before the fetch lands. +function tablesForNamespace(namespace: Namespace, fetched: TableRef[]): TableRef[] { + const byId = new Map(); + for (const t of namespace.tables) { + byId.set(t.id, t); + } + for (const t of fetched) { + if (t.namespaceName === namespace.name) { + byId.set(t.id, t); + } + } + return [...byId.values()]; +} + +// Roving keyboard navigation for the tree, per the WAI-ARIA tree pattern: +// Up/Down move between visible rows, Home/End jump, Right expands (or moves +// into) a node, Left collapses. Operates on the rendered treeitem buttons so +// it always matches what's visible. +function handleTreeKeyDown(e: KeyboardEvent) { + const handled = ['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'Home', 'End']; + if (!handled.includes(e.key)) { + return; + } + const current = (e.target as HTMLElement).closest('[role="treeitem"]'); + if (!current) { + return; + } + const rows = Array.from(e.currentTarget.querySelectorAll('[role="treeitem"]:not(:disabled)')); + const idx = rows.indexOf(current); + if (idx === -1) { + return; + } + e.preventDefault(); + + const focusRow = (i: number) => rows[i]?.focus(); + const expanded = current.getAttribute('aria-expanded'); + + switch (e.key) { + case 'ArrowDown': + focusRow(idx + 1); + break; + case 'ArrowUp': + focusRow(idx - 1); + break; + case 'Home': + focusRow(0); + break; + case 'End': + focusRow(rows.length - 1); + break; + case 'ArrowRight': + if (expanded === 'false') { + current.click(); + } else { + focusRow(idx + 1); + } + break; + case 'ArrowLeft': + if (expanded === 'true') { + current.click(); + } + break; + default: + break; + } +} + +type ColumnListProps = { + catalogName: string; + tableName: string; +}; + +// Fetches columns for a single expanded table via DescribeTable. +function ColumnList({ catalogName, tableName }: ColumnListProps) { + const { data, isLoading } = useDescribeTableQuery({ catalog: catalogName, name: tableName }); + + const columns: ColumnDef[] = (data?.columns ?? []).map((c) => ({ + name: c.name, + type: c.type, + kind: columnKindForPgType(c.type), + short: c.type.toLowerCase(), + isArray: isArrayPgType(c.type), + })); + + let content: ReactNode; + if (isLoading) { + content = ; + } else if (columns.length === 0) { + content = No columns; + } else { + content = columns.map((col) => { + const KindIcon = COL_KIND_ICON[col.kind]; + return ( +
+ + {col.isArray && } + + + {col.name} + {col.short} +
+ ); + }); + } + + return ( +
+ {content} +
+ ); +} + +type TableRowProps = { + catalog: Catalog; + table: TableRef; +}; + +function TableRow({ catalog, table }: TableRowProps) { + const { activeTableId, openTables, toggleTable, onQueryTable } = useCatalogTree(); + const allowed = table.allowed !== false; + const isOpen = Boolean(openTables[table.id]); + const isActive = activeTableId === table.id; + const isIceberg = catalog.engine === 'iceberg' || table.iceberg === true; + // A Redpanda-catalog table is Iceberg-tiered when its backing topic has + // `redpanda.iceberg.mode` enabled (read from the Kafka topic config). + const { isIceberg: topicTiered } = useTopicIcebergQuery(table.topicName ?? '', { + enabled: catalog.engine === 'redpanda' && Boolean(table.topicName), + }); + const tiered = catalog.engine === 'redpanda' && topicTiered; + const Chevron = isOpen ? ChevronDown : ChevronRight; + + // The table icon picks up the Iceberg blue when the table is Iceberg-backed or + // tiered, the disabled grey when locked, else the action-primary accent. + const tableIcoClass = cn('shrink-0 text-action-primary', { + 'text-info': (isIceberg || tiered) && allowed, + 'text-disabled': !allowed, + }); + + return ( +
+
+ + {allowed && ( + + )} +
+ {isOpen && allowed && } +
+ ); +} + +type NamespaceNodeProps = { + catalog: Catalog; + namespace: Namespace; + fetchedTables: TableRef[]; + isLoading: boolean; +}; + +function NamespaceNode({ catalog, namespace, fetchedTables, isLoading }: NamespaceNodeProps) { + const { query, open, shown, toggle, loadMore, onAddTable } = useCatalogTree(); + const isOpen = open[namespace.id] !== false; + const shownCount = shown[namespace.id] ?? CAT_LIMIT; + + const allTables = tablesForNamespace(namespace, fetchedTables); + const q = query.trim().toLowerCase(); + const matched = q ? allTables.filter((t) => t.name.toLowerCase().includes(q)) : allTables; + + // When searching, hide namespaces whose name and tables both miss. + if (q && matched.length === 0 && !namespace.name.toLowerCase().includes(q)) { + return null; + } + + const paginate = !q && matched.length > CAT_LIMIT; + const visible = paginate ? matched.slice(0, shownCount) : matched; + const remaining = paginate ? Math.max(0, matched.length - visible.length) : 0; + + // Shown whenever a handler is wired (Redpanda catalog, not searching). Real + // admin-gating is a follow-up once the session role is plumbed through. + const showAddTopic = catalog.engine === 'redpanda' && !q && onAddTable; + + const countLabel = + q && matched.length !== allTables.length ? `${matched.length}/${allTables.length}` : allTables.length; + const NsChevron = isOpen ? ChevronDown : ChevronRight; + + return ( +
+ + {isOpen && ( +
+ {isLoading && allTables.length === 0 && } + {visible.map((t) => ( + + ))} + {!isLoading && matched.length === 0 && No tables} + {paginate && remaining > 0 && ( + + )} + {showAddTopic && ( + + )} +
+ )} +
+ ); +} + +// One catalog subtree. Owns the per-catalog ListTables fetch, gated on the +// catalog (or an active search) being expanded. +function CatalogNode({ catalog }: { catalog: Catalog }) { + const { role, query, open, toggle, onAddTable } = useCatalogTree(); + const isCatalogOpen = open[catalog.name] !== false; + const enabled = isCatalogOpen || query.trim().length > 0; + const { data, isLoading } = useListTablesQuery({ catalog: catalog.name }, { enabled }); + + const fetchedTables: TableRef[] = (data?.tables ?? []).map((t) => ({ + id: `${catalog.name}.${t.namespaceName}.${t.name}`, + name: t.name, + namespaceName: t.namespaceName, + catalogName: catalog.name, + topicName: t.topicName, + })); + + const CatChevron = isCatalogOpen ? ChevronDown : ChevronRight; + const showAdd = role === 'admin' && catalog.engine === 'redpanda' && onAddTable; + + return ( +
+
+ + {showAdd && ( + + )} +
+ {isCatalogOpen && ( +
+ {catalog.namespaces.map((ns) => ( + + ))} +
+ )} +
+ ); +} + +export function CatalogTree({ catalogs, role, isLoading, activeTableId, onQueryTable, onAddTable }: CatalogTreeProps) { + // Expand/collapse state per node id. Undefined => default open for catalogs + // and namespaces (see `!== false` checks in the nodes). + const [open, setOpen] = useState>({}); + const [openTables, setOpenTables] = useState>({}); + const [shown, setShown] = useState>({}); + const [query, setQuery] = useState(''); + + const context: CatalogTreeContextValue = { + role, + query, + activeTableId, + open, + openTables, + shown, + toggle: (id) => setOpen((s) => ({ ...s, [id]: !(s[id] ?? true) })), + toggleTable: (id) => setOpenTables((s) => ({ ...s, [id]: !s[id] })), + loadMore: (namespaceId) => setShown((s) => ({ ...s, [namespaceId]: (s[namespaceId] ?? CAT_LIMIT) + CAT_LIMIT })), + onQueryTable, + onAddTable, + }; + + return ( +
+
+ + Catalogs + + {role === 'admin' && ( + + Redpanda only + + )} +
+
+ setQuery(e.target.value)} placeholder="Search tables" size="sm" value={query}> + + + + +
+ + +
+ {isLoading && catalogs.length === 0 && } + {!isLoading && catalogs.length === 0 && No catalogs} + {catalogs.map((catalog) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/components/pages/sql/sql-editor.test.tsx b/frontend/src/components/pages/sql/sql-editor.test.tsx new file mode 100644 index 0000000000..dfe2a62aac --- /dev/null +++ b/frontend/src/components/pages/sql/sql-editor.test.tsx @@ -0,0 +1,86 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { SqlEditor } from './sql-editor'; +import type { SqlRole } from './sql-types'; + +// CodeMirror's layout/measure loop doesn't run in jsdom; the editor surface is +// exercised manually/e2e. +vi.mock('@uiw/react-codemirror', () => ({ + default: ({ value }: { value: string }) =>
{value}
, +})); + +const ADMIN: SqlRole = 'admin'; +const QUERY_1_TAB = /Query 1/; +const QUERY_2_TAB = /Query 2/; +// Matches the Run button's accessible name including its platform Kbd hint. +const RUN_BUTTON = /Run (Ctrl|⌘)/; + +const renderEditor = (onRun = vi.fn()) => { + render(); + return onRun; +}; + +describe('SqlEditor', () => { + beforeEach(() => { + localStorage.clear(); + }); + + test('renders the first query tab as the active tab', () => { + renderEditor(); + expect(screen.getByRole('tab', { name: QUERY_1_TAB })).toHaveAttribute('data-state', 'active'); + expect(screen.getByTestId('editor')).toHaveTextContent('SELECT 1;'); + }); + + test('adds a tab and switches back to the first', () => { + renderEditor(); + fireEvent.click(screen.getByRole('button', { name: 'New query' })); + expect(screen.getByRole('tab', { name: QUERY_2_TAB })).toHaveAttribute('data-state', 'active'); + expect(screen.getByTestId('editor')).toHaveTextContent(''); + + fireEvent.click(screen.getByRole('tab', { name: QUERY_1_TAB })); + expect(screen.getByTestId('editor')).toHaveTextContent('SELECT 1;'); + }); + + test('closing a tab keeps the editor on a remaining tab', () => { + renderEditor(); + fireEvent.click(screen.getByRole('button', { name: 'New query' })); + fireEvent.click(screen.getByRole('button', { name: 'Close Query 2' })); + expect(screen.queryByRole('tab', { name: QUERY_2_TAB })).not.toBeInTheDocument(); + expect(screen.getByRole('tab', { name: QUERY_1_TAB })).toHaveAttribute('data-state', 'active'); + }); + + test('clicking the close button does not activate the closed tab', () => { + renderEditor(); + fireEvent.click(screen.getByRole('button', { name: 'New query' })); + fireEvent.click(screen.getByRole('tab', { name: QUERY_1_TAB })); + fireEvent.click(screen.getByRole('button', { name: 'Close Query 2' })); + expect(screen.getByRole('tab', { name: QUERY_1_TAB })).toHaveAttribute('data-state', 'active'); + }); + + test('run sends the active tab SQL and records history', async () => { + const onRun = renderEditor(); + fireEvent.click(screen.getByRole('button', { name: RUN_BUTTON })); + expect(onRun).toHaveBeenCalledWith('SELECT 1;', 'all'); + + fireEvent.click(screen.getByRole('button', { name: 'History' })); + expect(await screen.findByText('SELECT 1;', { selector: 'span' })).toBeInTheDocument(); + }); + + test('history popover shows an empty state before any run', async () => { + renderEditor(); + fireEvent.click(screen.getByRole('button', { name: 'History' })); + expect(await screen.findByText('No queries yet')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/pages/sql/sql-editor.tsx b/frontend/src/components/pages/sql/sql-editor.tsx new file mode 100644 index 0000000000..5e9522c73d --- /dev/null +++ b/frontend/src/components/pages/sql/sql-editor.tsx @@ -0,0 +1,545 @@ +/** + * 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 { + acceptCompletion, + type CompletionContext, + type CompletionResult, + startCompletion, +} from '@codemirror/autocomplete'; +import { PostgreSQL, type SQLNamespace, sql } from '@codemirror/lang-sql'; +import { HighlightStyle, indentUnit, syntaxHighlighting, syntaxTree } from '@codemirror/language'; +import { EditorState, type Extension, Prec } from '@codemirror/state'; +import { EditorView, keymap } from '@codemirror/view'; +import { tags } from '@lezer/highlight'; +import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror'; +import { Button } from 'components/redpanda-ui/components/button'; +import { Kbd, KbdGroup } from 'components/redpanda-ui/components/kbd'; +import { Popover, PopoverContent, PopoverTrigger } from 'components/redpanda-ui/components/popover'; +import { Tabs, TabsList, TabsTrigger } from 'components/redpanda-ui/components/tabs'; +import { Text } from 'components/redpanda-ui/components/typography'; +import { FileText, History, Play, Plus, Terminal, Wand2, X } from 'lucide-react'; +import { + forwardRef, + type MouseEvent, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react'; +import { isMacOS } from 'utils/platform'; +import { z } from 'zod'; + +import type { Catalog, SqlRole, TableRef } from './sql-types'; + +// Imperative handle exposed to the workspace so the catalog tree can open a +// query in a new editor tab (mirrors the prototype's editorRef). +export type SqlEditorHandle = { + /** Open `sql` in a new tab named `name` (or "Query N") and focus it. */ + setQuery: (sql: string, name?: string) => void; +}; + +export type RunMode = 'all' | 'selection'; + +export type SqlEditorProps = { + /** Run a statement. `mode` distinguishes whole-tab vs. selection runs. */ + onRun: (sql: string, mode: RunMode) => void; + /** Loaded catalog tree; drives schema-aware autocomplete. */ + catalogs: Catalog[]; + /** Effective role; gates admin-only affordances. */ + role: SqlRole; + /** SQL to seed the first tab with. */ + initialQuery?: string; +}; + +const HISTORY_KEY = 'rp_sql_history_v1'; + +const HistoryEntrySchema = z.object({ sql: z.string(), at: z.number() }); + +type HistoryEntry = z.infer; + +function loadHistory(): HistoryEntry[] { + try { + const raw: unknown = JSON.parse(localStorage.getItem(HISTORY_KEY) ?? '[]'); + if (!Array.isArray(raw)) { + return []; + } + return raw.flatMap((entry) => { + const parsed = HistoryEntrySchema.safeParse(entry); + return parsed.success ? [parsed.data] : []; + }); + } catch { + return []; + } +} + +function saveHistory(list: HistoryEntry[]): void { + try { + localStorage.setItem(HISTORY_KEY, JSON.stringify(list.slice(0, 40))); + } catch { + // best-effort; ignore quota/serialization failures + } +} + +type Tab = { id: number; name: string; sql: string }; + +const DEFAULT_QUERY = + 'SELECT vin, make, model, year, price_usd\nFROM default_redpanda_catalog=>cars\nWHERE in_stock = true\nORDER BY price_usd DESC\nLIMIT 100;'; + +// Tracks the registry `.dark` class on the document root so the editor (whose +// highlight palette is built from theme-invariant color scales, not Tailwind +// classes) switches theme in lockstep with the rest of the surface. Uses +// useSyncExternalStore — no effect — per project style. +function subscribeToColorMode(onStoreChange: () => void): () => void { + const observer = new MutationObserver(onStoreChange); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); + return () => observer.disconnect(); +} + +function getIsDarkSnapshot(): boolean { + return document.documentElement.classList.contains('dark'); +} + +function useIsDarkMode(): boolean { + return useSyncExternalStore(subscribeToColorMode, getIsDarkSnapshot, () => false); +} + +// Editor chrome tuned to match the SQL Studio surface: transparent editor and +// gutter so the surrounding `bg-background` container shows through, with +// muted gutter line numbers. CodeMirror themes are plain CSS, so registry +// custom properties can be referenced directly and stay live. +function editorChrome(mode: 'light' | 'dark'): Extension { + const gutter = mode === 'dark' ? 'var(--color-grey-600)' : 'var(--color-grey-400)'; + const gutterActive = mode === 'dark' ? 'var(--color-grey-400)' : 'var(--color-grey-600)'; + return EditorView.theme( + { + '&': { backgroundColor: 'transparent', height: '100%', fontSize: '13px' }, + '&.cm-focused': { outline: 'none' }, + '.cm-scroller': { + fontFamily: "'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace", + lineHeight: '21px', + }, + '.cm-content': { padding: '12px 0' }, + '.cm-gutters': { backgroundColor: 'transparent', border: 'none', color: gutter }, + '.cm-activeLineGutter': { backgroundColor: 'transparent', color: gutterActive }, + '.cm-activeLine': { + backgroundColor: mode === 'dark' ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.03)', + }, + }, + { dark: mode === 'dark' } + ); +} + +// SQL syntax palette, mapped from the design's `.sql-*` token classes onto the +// Lezer highlight tags the SQL grammar emits (keywords, built-ins, strings, +// numbers, comments, operators/punctuation and identifiers). +function sqlHighlight(mode: 'light' | 'dark'): Extension { + const c = + mode === 'dark' + ? { + keyword: 'var(--color-purple-300)', + fn: 'var(--color-indigo-300)', + str: 'var(--color-green-300)', + num: 'var(--color-orange-300)', + comment: 'var(--color-grey-400)', + punct: 'var(--color-grey-500)', + id: 'var(--color-grey-100)', + } + : { + keyword: 'var(--color-purple-700)', + fn: 'var(--color-indigo-600)', + str: 'var(--color-green-700)', + num: 'var(--color-orange-700)', + comment: 'var(--color-grey-600)', + punct: 'var(--color-grey-500)', + id: 'var(--color-grey-900)', + }; + return syntaxHighlighting( + HighlightStyle.define([ + { tag: tags.keyword, color: c.keyword, fontWeight: 'bold' }, + { tag: [tags.standard(tags.name), tags.function(tags.variableName), tags.typeName], color: c.fn }, + { tag: [tags.string, tags.special(tags.string)], color: c.str }, + { tag: tags.number, color: c.num }, + { tag: tags.comment, color: c.comment, fontStyle: 'italic' }, + { + tag: [tags.operator, tags.punctuation, tags.separator, tags.paren, tags.brace, tags.squareBracket], + color: c.punct, + }, + { tag: tags.name, color: c.id }, + ]) + ); +} + +const LIGHT_THEME: Extension = [editorChrome('light'), sqlHighlight('light')]; +const DARK_THEME: Extension = [editorChrome('dark'), sqlHighlight('dark')]; + +function tableNamespace(table: TableRef): SQLNamespace { + return { + self: { label: table.name, type: 'class' }, + children: (table.columns ?? []).map((col) => ({ label: col.name, type: 'property', detail: col.short })), + }; +} + +// Builds the lang-sql completion schema from the loaded catalog tree: bare +// table names → columns. Tables are deliberately NOT nested under their +// catalog — Redpanda SQL (Oxla) addresses catalog tables with arrow notation +// (`catalog=>table`), which catalogArrowSource below handles; dot-style +// nesting would advertise syntax the server rejects. Bare entries still give +// alias/column resolution (`FROM default_redpanda_catalog=>cars c` → `c.`). +function buildSchema(catalogs: Catalog[]): SQLNamespace { + const root: Record = {}; + for (const catalog of catalogs) { + for (const ns of catalog.namespaces) { + for (const table of ns.tables) { + if (!(table.name in root)) { + root[table.name] = tableNamespace(table); + } + } + } + } + return root; +} + +// Matches an identifier followed by `=>` or `.` and a partial table name, +// anchored at the cursor: [, name, gap1, separator, gap2, quote, partial]. +const CATALOG_REF_RE = /([A-Za-z_][\w$]*)(\s*)(=>|\.)(\s*)("?)([\w$]*)$/; + +// Completion source for Redpanda SQL's catalog arrow notation. The generic +// schema completion can't model `catalog=>table`, so this source: +// - offers catalog names (boosted right after FROM/JOIN); applying one +// inserts `catalog=>` and immediately reopens completion for its tables +// - offers the catalog's tables after `catalog=>` — and after a typed +// `catalog.`, rewriting the dot to `=>` so users land on valid syntax +function catalogArrowSource(catalogs: Catalog[]): (context: CompletionContext) => CompletionResult | null { + return (context) => { + const nodeName = syntaxTree(context.state).resolveInner(context.pos, -1).name; + if (/Comment|String/.test(nodeName)) { + return null; + } + const line = context.state.doc.lineAt(context.pos); + const before = line.text.slice(0, context.pos - line.from); + + const ref = CATALOG_REF_RE.exec(before); + const cleanStart = ref ? !/[\w$".]/.test(before[ref.index - 1] ?? '') : false; + const catalog = ref && cleanStart ? catalogs.find((c) => c.name === ref[1]) : undefined; + if (ref && catalog) { + const [, , gap1, separator, gap2, quote, partial] = ref; + const separatorFrom = context.pos - partial.length - quote.length - gap2.length - separator.length; + return { + from: context.pos - partial.length, + options: catalog.namespaces + .flatMap((ns) => ns.tables) + .map((table) => ({ + label: table.name, + type: 'class', + boost: 50, + // Replace from the separator so a typed `.` (and any stray + // whitespace around it) is rewritten to `=>`. + apply: (view: EditorView, _completion: unknown, _from: number, to: number) => { + view.dispatch({ + changes: { from: separatorFrom - gap1.length, to, insert: `=>${table.name}` }, + }); + }, + })), + validFor: /^[\w$]*$/, + }; + } + + const word = context.matchBefore(/[\w$]+/); + if (!(word || context.explicit)) { + return null; + } + // Skip when completing a dotted member (schema completion's territory). + const wordFrom = word ? word.from : context.pos; + if (before[wordFrom - line.from - 1] === '.') { + return null; + } + const afterFromClause = /\b(?:from|join)\s+["\w$]*$/i.test(before); + return { + from: wordFrom, + options: catalogs.map((c) => ({ + label: c.name, + detail: '=>', + type: 'namespace', + boost: afterFromClause ? 60 : 0, + // Insert the arrow with the name and chain straight into the + // table list. + apply: (view: EditorView, _completion: unknown, from: number, to: number) => { + view.dispatch({ + changes: { from, to, insert: `${c.name}=>` }, + selection: { anchor: from + c.name.length + 2 }, + }); + startCompletion(view); + }, + })), + validFor: /^[\w$]*$/, + }; + }; +} + +// Reformats the whole document through sql-formatter (dynamically imported to +// keep it out of the initial bundle; postgresql is the closest dialect to +// Oxla) as a single transaction, so undo restores the pre-format text. +async function formatDocument(view: EditorView): Promise { + const { format } = await import('sql-formatter'); + const current = view.state.doc.toString(); + let next: string; + try { + next = format(current, { language: 'postgresql', keywordCase: 'upper' }); + } catch { + // Unparseable SQL (mid-edit) — leave the text untouched. + return; + } + if (next !== current) { + view.dispatch({ + changes: { from: 0, to: current.length, insert: next }, + selection: { anchor: Math.min(view.state.selection.main.head, next.length) }, + }); + } +} + +export const SqlEditor = forwardRef(function SqlEditor( + { onRun, catalogs, initialQuery }, + ref +) { + const [tabs, setTabs] = useState([{ id: 1, name: 'Query 1', sql: initialQuery ?? DEFAULT_QUERY }]); + const [activeId, setActiveId] = useState(1); + const nextId = useRef(2); + const [history, setHistory] = useState(loadHistory); + const [histOpen, setHistOpen] = useState(false); + const [hasSel, setHasSel] = useState(false); + const isDark = useIsDarkMode(); + + const editorRef = useRef(null); + // Latest run callback, bound into the Cmd/Ctrl+Enter keymap (built once per + // catalog/theme change, not per render). + const runRef = useRef<() => void>(() => undefined); + + const active = tabs.find((t) => t.id === activeId) ?? tabs[0]; + + useImperativeHandle( + ref, + () => ({ + setQuery: (sql: string, name?: string) => { + const id = nextId.current++; + setTabs((prev) => [...prev, { id, name: name ?? `Query ${id}`, sql }]); + setActiveId(id); + requestAnimationFrame(() => editorRef.current?.view?.focus()); + }, + }), + [] + ); + + const updateSql = (sql: string) => { + setTabs((prev) => prev.map((t) => (t.id === activeId ? { ...t, sql } : t))); + }; + + const runText = (text: string, mode: RunMode) => { + const trimmed = text.trim(); + if (!trimmed) { + return; + } + const entry: HistoryEntry = { sql: trimmed, at: Date.now() }; + const nh = [entry, ...history.filter((h) => h.sql !== entry.sql)].slice(0, 40); + setHistory(nh); + saveHistory(nh); + onRun(trimmed, mode); + }; + + // Run the current selection if any, else the whole tab. + const doRun = () => { + const state = editorRef.current?.view?.state; + const sel = state?.selection.main; + if (state && sel && !sel.empty) { + runText(state.sliceDoc(sel.from, sel.to), 'selection'); + return; + } + runText(active.sql, 'all'); + }; + + // The Cmd/Ctrl+Enter keymap is part of the extensions array (rebuilt only on + // catalog/theme changes), so it reads fresh state through this render-synced + // ref (the useLatest pattern — see react-best-practices rules). + useLayoutEffect(() => { + runRef.current = doRun; + }); + + const runSelection = () => { + const state = editorRef.current?.view?.state; + const sel = state?.selection.main; + if (state && sel && !sel.empty) { + runText(state.sliceDoc(sel.from, sel.to), 'selection'); + } + }; + + const extensions = useMemo(() => { + const sqlSupport = sql({ dialect: PostgreSQL, schema: buildSchema(catalogs), upperCaseKeywords: true }); + return [ + // Prec.highest so Mod-Enter beats the default keymap's insertBlankLine. + Prec.highest( + keymap.of([ + { + key: 'Mod-Enter', + run: () => { + runRef.current(); + return true; + }, + }, + // Tab accepts an open completion (Monaco muscle memory); falls + // through to the default Tab behavior when no popup is open. + { key: 'Tab', run: acceptCompletion }, + { + key: 'Shift-Alt-f', + run: (view) => { + void formatDocument(view); + return true; + }, + }, + ]) + ), + sqlSupport, + sqlSupport.language.data.of({ autocomplete: catalogArrowSource(catalogs) }), + isDark ? DARK_THEME : LIGHT_THEME, + EditorView.updateListener.of((update) => { + if (update.selectionSet) { + setHasSel(!update.state.selection.main.empty); + } + }), + indentUnit.of(' '), + EditorState.tabSize.of(2), + ]; + }, [catalogs, isDark]); + + const addTab = () => { + const id = nextId.current++; + setTabs((prev) => [...prev, { id, name: `Query ${id}`, sql: '' }]); + setActiveId(id); + }; + + const closeTab = (id: number, e: MouseEvent) => { + e.stopPropagation(); + setTabs((prev) => { + const idx = prev.findIndex((t) => t.id === id); + const nextTabs = prev.filter((t) => t.id !== id); + if (nextTabs.length === 0) { + const nid = nextId.current++; + setActiveId(nid); + return [{ id: nid, name: `Query ${nid}`, sql: '' }]; + } + if (id === activeId) { + setActiveId(nextTabs[Math.max(0, idx - 1)].id); + } + return nextTabs; + }); + }; + + return ( +
+
+ setActiveId(Number(v))} value={String(active.id)}> + + {tabs.map((t) => ( + } + value={String(t.id)} + variant="underline" + > + + {t.name} + + + ))} + + + +
+ + + + + + + Recent queries · this browser + + {history.length === 0 ? No queries yet : null} + {history.map((h, i) => ( + + ))} + + + + + +
+
+ +
+ +
+
+ ); +}); diff --git a/frontend/src/components/pages/sql/sql-results.css b/frontend/src/components/pages/sql/sql-results.css new file mode 100644 index 0000000000..4af55dd638 --- /dev/null +++ b/frontend/src/components/pages/sql/sql-results.css @@ -0,0 +1,26 @@ +/* react-data-grid theming for the SQL results grid. + rdg's stylesheet is unlayered, so Tailwind's layered utilities cannot + override the custom properties rdg declares on .rdg — these rules must + be unlayered too, and more specific than rdg's own selectors. */ +.rdg.sql-results-grid { + --rdg-font-size: 12px; + --rdg-color: var(--color-foreground); + --rdg-border-color: var(--color-border-subtle); + --rdg-background-color: var(--color-card); + --rdg-header-background-color: var(--color-muted); + --rdg-row-hover-background-color: var(--color-accent-subtle); + --rdg-selection-color: var(--color-action-primary); + border: none; + block-size: auto; + flex: 1; + min-block-size: 0; +} + +.sql-results-grid .rdg-cell { + padding-inline: 16px; +} + +/* Zebra stripe, applied per row via rowClass. */ +.sql-results-grid .sql-results-row-alt { + --rdg-background-color: var(--color-background-subtle); +} diff --git a/frontend/src/components/pages/sql/sql-results.test.tsx b/frontend/src/components/pages/sql/sql-results.test.tsx new file mode 100644 index 0000000000..122d0460ac --- /dev/null +++ b/frontend/src/components/pages/sql/sql-results.test.tsx @@ -0,0 +1,95 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import userEvent from '@testing-library/user-event'; +import { render, screen } from 'test-utils'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; + +import { SqlResults } from './sql-results'; +import type { QueryRunSuccess, SqlRole } from './sql-types'; + +// react-data-grid virtualizes rows and columns against the grid root's +// measured size, which happy-dom reports as 0 — culling every column. +// Give the .rdg root a real viewport so all columns and rows render; +// other elements keep zero size so 'max-content' column measuring falls +// back to minWidth instead of exploding past the viewport. +const GRID_RECT = { width: 1920, height: 600 }; + +beforeAll(() => { + const proto = HTMLDivElement.prototype; + const original = proto.getBoundingClientRect; + const isGridRoot = (el: Element) => el.classList.contains('rdg'); + + proto.getBoundingClientRect = function (this: HTMLDivElement) { + if (!isGridRoot(this)) { + return original.call(this); + } + return { ...original.call(this), width: GRID_RECT.width, height: GRID_RECT.height }; + }; + for (const [prop, value] of [ + ['clientWidth', GRID_RECT.width], + ['clientHeight', GRID_RECT.height], + ['offsetWidth', GRID_RECT.width], + ['offsetHeight', GRID_RECT.height], + ] as const) { + Object.defineProperty(proto, prop, { + configurable: true, + get(this: HTMLDivElement) { + return isGridRoot(this) ? value : 0; + }, + }); + } + Reflect.set(proto, '__rdgRectRestore', original); +}); + +afterAll(() => { + const proto = HTMLDivElement.prototype; + const original = Reflect.get(proto, '__rdgRectRestore') as typeof proto.getBoundingClientRect; + proto.getBoundingClientRect = original; + for (const prop of ['clientWidth', 'clientHeight', 'offsetWidth', 'offsetHeight']) { + Reflect.deleteProperty(proto, prop); + } + Reflect.deleteProperty(proto, '__rdgRectRestore'); +}); + +const LONG_VALUE = `{"payload":"${'x'.repeat(120)}"}`; +const viewer: SqlRole = 'viewer'; + +const run: QueryRunSuccess = { + state: 'success', + token: 1, + columns: [ + { name: 'id', type: 'TEXT', kind: 'str', short: 'text' }, + { name: 'doc', type: 'TEXT', kind: 'str', short: 'text' }, + ], + rows: [{ id: 'row-1', doc: LONG_VALUE }], + totalRows: 1, + elapsedMs: 3, + truncated: false, +}; + +describe('SqlResults cell clamping', () => { + test('short values render as plain text without a click target', () => { + render(); + const short = screen.getByText('row-1'); + expect(short.closest('button')).toBeNull(); + }); + + test('long values truncate and open the full value in a popover on click', async () => { + render(); + const trigger = screen.getByRole('button', { name: LONG_VALUE }); + expect(trigger.className).toContain('truncate'); + + await userEvent.click(trigger); + const occurrences = await screen.findAllByText(LONG_VALUE); + expect(occurrences.length).toBeGreaterThan(1); + }); +}); diff --git a/frontend/src/components/pages/sql/sql-results.tsx b/frontend/src/components/pages/sql/sql-results.tsx new file mode 100644 index 0000000000..d63589cc4b --- /dev/null +++ b/frontend/src/components/pages/sql/sql-results.tsx @@ -0,0 +1,430 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { Alert, AlertDescription, AlertTitle } from 'components/redpanda-ui/components/alert'; +import { Badge } from 'components/redpanda-ui/components/badge'; +import { Button } from 'components/redpanda-ui/components/button'; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from 'components/redpanda-ui/components/empty'; +import { Kbd, KbdGroup } from 'components/redpanda-ui/components/kbd'; +import { Popover, PopoverContent, PopoverTrigger } from 'components/redpanda-ui/components/popover'; +import { Spinner } from 'components/redpanda-ui/components/spinner'; +import { StatusDot } from 'components/redpanda-ui/components/status-dot'; +import { InlineCode, Text } from 'components/redpanda-ui/components/typography'; +import { cn } from 'components/redpanda-ui/lib/utils'; +import { + Activity, + Box, + Braces, + Brackets, + Calendar, + CircleX, + Clock, + Download, + GitMerge, + Hash, + Plus, + Rows3, + Terminal, + ToggleLeft, + Type, + Waves, +} from 'lucide-react'; +import type { ReactNode } from 'react'; +import DataGrid, { type Column } from 'react-data-grid'; + +import 'react-data-grid/lib/styles.css'; +import './sql-results.css'; + +import type { + BridgeInfo, + CellValue, + ColumnDef, + ColumnKind, + QueryRun, + QueryRunSuccess, + ResultRow, + SqlRole, +} from './sql-types'; + +export type SqlResultsProps = { + /** Current run state: idle | running | error | success. */ + run: QueryRun; + /** Effective role; gates the admin "Add a topic" CTA on CREATE errors. */ + role: SqlRole; + /** Admin entry point for the add-topic wizard. */ + onAddTable?: () => void; +}; + +const fmtNum = (n: number) => n.toLocaleString('en-US'); +const offStr = (n: number) => `${fmtNum(n)} offset${n === 1 ? '' : 's'}`; + +// Shared inline-stat layout used across the summary bar. +const RES_STAT = + 'inline-flex items-center gap-1.5 text-xs text-foreground [font-variant-numeric:tabular-nums] [&_svg]:text-muted-foreground'; + +function TypeIcon({ kind, isArray, size = 11 }: { kind: ColumnKind; isArray?: boolean; size?: number }) { + let icon: ReactNode; + switch (kind) { + case 'num': + icon = ; + break; + case 'bool': + icon = ; + break; + case 'time': + icon = ; + break; + case 'json': + icon = ; + break; + default: + icon = ; + } + if (!isArray) { + return icon; + } + return ( + + + {icon} + + ); +} + +// Inline chip + Iceberg-lag snapshot shown in the summary bar for bridge queries. +function BridgeBar({ bridge }: { bridge: BridgeInfo }) { + return ( + <> + + Bridge query + + {bridge.totalLag > 0 && ( + + Iceberg {offStr(bridge.totalLag)} behind{' '} + + at query time + + + )} + + ); +} + +function PendingStat({ count, label }: { count: number; label: string }) { + return ( + + {fmtNum(count)} {label} + + ); +} + +// Horizontal timeline: Iceberg history meshed with the live topic tail at the +// watermark. Segment widths are illustrative; the offset counts are the real +// metric snapshot captured at query time. +function BridgeTimeline({ bridge }: { bridge: BridgeInfo }) { + // The bar always renders; only the lag-specific labels/caption (and the + // "in sync" wording, which we don't surface) are conditional. + const caught = bridge.totalLag === 0; + return ( +
+
+ + Iceberg history + + {!caught && ( + + Live topic tail + + )} +
+
+ {/* Iceberg segment. When caught up it closes its right edge (full + radius); otherwise it abuts the live segment. */} +
+ {!caught && ( + <> +
+ + watermark · {offStr(bridge.totalLag)} + +
+ {/* Live-tail segment with the 45deg hatch overlay. */} +
+ + )} +
+ {!caught && ( +
+ Live tail covers {offStr(bridge.totalLag)} not yet in Iceberg at query time —{' '} + +{' '} + . Bridging serves them from the topic so + results stay realtime. +
+ )} +
+ ); +} + +function cellText(v: CellValue): string { + return v === null || v === undefined ? '' : String(v); +} + +// Cells are clamped to this width; values long enough to truncate at it +// (~45 mono-xs chars) open the full value in a popover on click. +const CELL_MAX_W = 'max-w-80'; +const CELL_CLAMP_CHARS = 45; + +function CellContent({ v, kind }: { v: CellValue; kind: ColumnKind }) { + if (kind === 'bool' && typeof v === 'boolean') { + return {String(v)}; + } + if (v === null || v === undefined) { + return NULL; + } + const s = String(v); + if (s.length <= CELL_CLAMP_CHARS) { + return {s}; + } + return ( + + + + + + {s} + + + ); +} + +function exportData(fmt: 'csv' | 'json', cols: ColumnDef[], rows: ResultRow[]) { + let blob: Blob; + if (fmt === 'json') { + blob = new Blob([JSON.stringify(rows, null, 2)], { type: 'application/json' }); + } else { + const head = cols.map((c) => c.name).join(','); + const body = rows + .map((r) => + cols + .map((c) => { + const s = cellText(r[c.name]); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }) + .join(',') + ) + .join('\n'); + blob = new Blob([`${head}\n${body}`], { type: 'text/csv' }); + } + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `query_result.${fmt}`; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +// Stable grid keys: rows are stable object references from the run, so a +// WeakMap gives each a consistent id for rowKeyGetter without an index key. +function buildRowKeys(rows: ResultRow[]): WeakMap { + const map = new WeakMap(); + rows.forEach((r, i) => map.set(r, i)); + return map; +} + +// Key for the synthetic row-number column; double underscores avoid +// colliding with a real result column of the same name. +const ROWNUM_KEY = '__rownum__'; + +function buildColumns(cols: ColumnDef[]): Column[] { + const rowNum: Column = { + key: ROWNUM_KEY, + name: '', + frozen: true, + resizable: false, + width: 'max-content', + renderHeaderCell: () => #, + renderCell: ({ rowIdx }) => rowIdx + 1, + cellClass: 'text-right font-mono text-disabled text-xs [user-select:none]', + }; + const dataCols = cols.map((c): Column => { + const alignRight = c.kind === 'num'; + return { + key: c.name, + name: c.name, + // At least content-sized; spare panel width is shared between columns + // so the grid always fills horizontally. + width: 'minmax(max-content, 1fr)', + minWidth: 96, + renderHeaderCell: () => ( + + {c.name} + + {c.short} + + + ), + renderCell: ({ row }) => , + cellClass: cn('font-mono text-xs', alignRight && 'text-right'), + headerCellClass: alignRight ? 'text-right' : undefined, + }; + }); + return [rowNum, ...dataCols]; +} + +// Keyed by run.token from SqlResults, so a new run resets the grid's internal +// state (scroll position, resized column widths) by remounting. Rows render +// in server order; ordering is the query's job (ORDER BY), not the grid's. +function SuccessGrid({ run }: { run: QueryRunSuccess }) { + const cols = run.columns; + const bridge = run.bridge; + + const columns = buildColumns(cols); + const rowKeys = buildRowKeys(run.rows); + + return ( +
+
+
+ {bridge ? ( + + ) : ( + + Success + + )} + + {fmtNum(run.totalRows)} rows + + + {run.elapsedMs} ms + + {run.truncated && ( + + truncated + + )} +
+
+ + +
+
+ + {bridge && } + + {/* Virtualized grid: rdg renders only visible rows, so the full result + set is handed over with no client-side pagination. */} + (i % 2 === 1 ? 'sql-results-row-alt' : undefined)} + rowHeight={30} + rowKeyGetter={(r) => rowKeys.get(r) ?? -1} + rows={run.rows} + /> +
+ ); +} + +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 ( +
+ } variant="destructive"> + {run.title} + {run.message} + + {run.hint && ( +
+ {run.hint} + {run.hintAction && role === 'admin' && ( + + )} +
+ )} +
+ ); + } + + return ; +} diff --git a/frontend/src/components/pages/sql/sql-types.test.ts b/frontend/src/components/pages/sql/sql-types.test.ts new file mode 100644 index 0000000000..d4f3270275 --- /dev/null +++ b/frontend/src/components/pages/sql/sql-types.test.ts @@ -0,0 +1,56 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { describe, expect, test } from 'vitest'; + +import { arrayElementPgType, columnKindForPgType, isArrayPgType } from './sql-types'; + +describe('columnKindForPgType', () => { + test.each([ + ['INT8', 'num'], + ['NUMERIC(10,2)', 'num'], + ['BOOL', 'bool'], + ['TIMESTAMPTZ', 'time'], + ['JSON', 'json'], + ['JSONB', 'json'], + ['TEXT', 'str'], + ['UNKNOWN_TYPE', 'str'], + ] as const)('%s → %s', (pgType, kind) => { + expect(columnKindForPgType(pgType)).toBe(kind); + }); + + test.each([ + ['TEXT[]', 'str'], + ['_INT4', 'num'], + ['JSONB[]', 'json'], + ['ARRAY', 'str'], + ['LIST', 'time'], + ] as const)('array %s maps to element kind %s', (pgType, kind) => { + expect(columnKindForPgType(pgType)).toBe(kind); + }); +}); + +describe('arrayElementPgType', () => { + test.each([ + ['TEXT[]', 'TEXT'], + ['_INT4', 'INT4'], + ['ARRAY', 'STRING'], + ['list', 'double'], + ] as const)('%s unwraps to %s', (pgType, element) => { + expect(arrayElementPgType(pgType)).toBe(element); + }); + + test('non-array types return null', () => { + expect(arrayElementPgType('TEXT')).toBeNull(); + expect(isArrayPgType('TEXT')).toBe(false); + expect(isArrayPgType('TEXT[]')).toBe(true); + }); +}); 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..8dc60821c3 --- /dev/null +++ b/frontend/src/components/pages/sql/sql-types.ts @@ -0,0 +1,154 @@ +/** + * 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, so the leaf components (catalog tree, +// editor, results) and the data layer in sql-workspace agree on shape without +// importing from each other. + +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'; + +export type Namespace = { + name: string; + /** Stable id used for expand/collapse + pagination state. */ + id: string; + tables: TableRef[]; +}; + +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' | 'json'; + +export type ColumnDef = { + name: string; + /** Raw Postgres type name as reported by the driver (e.g. "INT8", "TEXT"). */ + type: string; + /** Derived display kind. For arrays this is the element kind. */ + kind: ColumnKind; + /** Short label shown under the column name — the type name lower-cased. */ + short: string; + /** True for array types (e.g. "TEXT[]", "_INT4", "ARRAY"). */ + isArray?: boolean; +}; + +// 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. +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; +}; + +type QueryRunIdle = { state: 'idle' }; +type QueryRunRunning = { state: 'running'; token: number }; +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; + +// Drives admin-only affordances (e.g. the "Add a topic" CTA). +export type SqlRole = 'admin' | 'viewer'; + +// Unwraps one level of array syntax — "TEXT[]", "_TEXT" (pg wire naming), or +// "ARRAY"/"LIST" (Iceberg) — returning the element type, or null +// when the type is not an array. +export function arrayElementPgType(pgType: string): string | null { + const t = pgType.trim(); + if (t.endsWith('[]')) { + return t.slice(0, -2); + } + if (t.startsWith('_')) { + return t.slice(1); + } + const wrapped = /^(?:ARRAY|LIST)\s*<(.+)>$/i.exec(t); + return wrapped ? wrapped[1] : null; +} + +export function isArrayPgType(pgType: string): boolean { + return arrayElementPgType(pgType) !== null; +} + +// Maps a Postgres type name to a display kind. Arrays map to their element +// kind. Conservative defaults: anything unrecognized is treated as a string. +export function columnKindForPgType(pgType: string): ColumnKind { + const element = arrayElementPgType(pgType); + if (element !== null) { + return columnKindForPgType(element); + } + 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'; + } + if (t.includes('JSON')) { + return 'json'; + } + return 'str'; +} diff --git a/frontend/src/components/pages/sql/sql-wizard.test.tsx b/frontend/src/components/pages/sql/sql-wizard.test.tsx new file mode 100644 index 0000000000..2385c95e02 --- /dev/null +++ b/frontend/src/components/pages/sql/sql-wizard.test.tsx @@ -0,0 +1,135 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import userEvent from '@testing-library/user-event'; +import { render, screen } from 'test-utils'; +import { describe, expect, test, vi } from 'vitest'; + +import { SqlWizard, type SqlWizardProps, type WizardTopic } from './sql-wizard'; + +vi.mock('components/redpanda-ui/components/code-block-dynamic', () => ({ + SyncCodeBlock: ({ code }: { code: string }) =>
{code}
, +})); + +const TOPICS: WizardTopic[] = [ + { name: 'orders', partitions: 12, format: 'AVRO' }, + { name: 'cars-telemetry.v1', partitions: 3, iceberg: true }, +]; + +const renderWizard = (overrides: Partial = {}) => { + const props: SqlWizardProps = { + topics: TOPICS, + onClose: vi.fn(), + onCreate: vi.fn(), + ...overrides, + }; + render(); + return props; +}; + +const pickTopicAndContinue = async (topicName: string) => { + await userEvent.click(screen.getByRole('radio', { name: new RegExp(topicName) })); + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); +}; + +describe('SqlWizard', () => { + test('lists topics with details and filters them by search', async () => { + renderWizard(); + + expect(screen.getByRole('radio', { name: /orders/ })).toBeInTheDocument(); + expect(screen.getByText('12 partitions · AVRO')).toBeInTheDocument(); + + await userEvent.type(screen.getByPlaceholderText('Search topics'), 'cars'); + + expect(screen.queryByRole('radio', { name: /orders/ })).toBeNull(); + expect(screen.getByRole('radio', { name: /cars-telemetry/ })).toBeInTheDocument(); + }); + + test('shows an empty message when no topic matches the search', async () => { + renderWizard(); + + await userEvent.type(screen.getByPlaceholderText('Search topics'), 'nope'); + + expect(screen.getByText('No topics found.')).toBeInTheDocument(); + expect(screen.queryByRole('radio')).toBeNull(); + }); + + test('continue is disabled until a topic is selected', async () => { + renderWizard(); + + expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); + + await userEvent.click(screen.getByRole('radio', { name: /orders/ })); + + expect(screen.getByRole('button', { name: 'Continue' })).toBeEnabled(); + }); + + test('prefills a sanitized table name from the topic and previews the SQL', async () => { + renderWizard(); + + await pickTopicAndContinue('cars-telemetry'); + + expect(screen.getByLabelText('Table name')).toHaveValue('cars_telemetry_v1'); + expect(screen.getByTestId('sql-preview')).toHaveTextContent( + "CREATE TABLE default_redpanda_catalog=>cars_telemetry_v1 WITH (topic='cars-telemetry.v1');" + ); + }); + + test('rejects an invalid table name and does not create', async () => { + const { onCreate } = renderWizard(); + + await pickTopicAndContinue('orders'); + await userEvent.clear(screen.getByLabelText('Table name')); + await userEvent.type(screen.getByLabelText('Table name'), 'Bad Name'); + await userEvent.click(screen.getByRole('button', { name: /Create table/ })); + + expect(screen.getByText(/Use lowercase letters, numbers and underscores/)).toBeInTheDocument(); + expect(onCreate).not.toHaveBeenCalled(); + }); + + test('creates the table with the chosen topic and edited name', async () => { + const { onCreate } = renderWizard(); + + await pickTopicAndContinue('orders'); + await userEvent.clear(screen.getByLabelText('Table name')); + await userEvent.type(screen.getByLabelText('Table name'), 'orders_table'); + await userEvent.click(screen.getByRole('button', { name: /Create table/ })); + + expect(onCreate).toHaveBeenCalledWith({ topic: 'orders', tableName: 'orders_table' }); + }); + + test('shows the iceberg badge and bridged-query notice for iceberg topics', async () => { + renderWizard(); + + expect(screen.getByTitle('Iceberg tiering enabled')).toBeInTheDocument(); + + await pickTopicAndContinue('cars-telemetry'); + + expect(screen.getByText(/Queries are/)).toBeInTheDocument(); + expect(screen.getByText(/bridged/)).toBeInTheDocument(); + }); + + test('renders the creation error from the parent', async () => { + renderWizard({ error: 'table already exists' }); + + await pickTopicAndContinue('orders'); + + expect(screen.getByRole('alert')).toHaveTextContent('table already exists'); + }); + + test('close button calls onClose', async () => { + const { onClose } = renderWizard(); + + await userEvent.click(screen.getByRole('button', { name: 'Close' })); + + expect(onClose).toHaveBeenCalled(); + }); +}); 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..4d74de2fa1 --- /dev/null +++ b/frontend/src/components/pages/sql/sql-wizard.tsx @@ -0,0 +1,293 @@ +/** + * 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 { zodResolver } from '@hookform/resolvers/zod'; +import { Alert, AlertDescription } from 'components/redpanda-ui/components/alert'; +import { Badge } from 'components/redpanda-ui/components/badge'; +import { Button } from 'components/redpanda-ui/components/button'; +import { + Choicebox, + ChoiceboxItem, + ChoiceboxItemContent, + ChoiceboxItemHeader, + ChoiceboxItemIndicator, + ChoiceboxItemSubtitle, + ChoiceboxItemTitle, +} from 'components/redpanda-ui/components/choicebox'; +import { SyncCodeBlock } from 'components/redpanda-ui/components/code-block-dynamic'; +import { Field, FieldDescription, FieldError, FieldLabel } from 'components/redpanda-ui/components/field'; +import { Input } from 'components/redpanda-ui/components/input'; +import { Label } from 'components/redpanda-ui/components/label'; +import { Progress } from 'components/redpanda-ui/components/progress'; +import { InlineCode, Text } from 'components/redpanda-ui/components/typography'; +import { GitBranch, GitMerge, Layers, Plus, X } from 'lucide-react'; +import { type ReactNode, useState } from 'react'; +import { Controller, type UseFormReturn, useForm, useWatch } from 'react-hook-form'; +import { z } from 'zod'; + +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 CATALOG_NAME = 'default_redpanda_catalog'; +const STEPS = ['Choose a topic', 'Name the table'] as const; +const TABLE_NAME_RE = /^[a-z_][a-z0-9_]*$/; + +const formSchema = z.object({ + tableName: z + .string() + .min(1, 'Table name is required.') + .regex(TABLE_NAME_RE, 'Use lowercase letters, numbers and underscores; must start with a letter or underscore.'), +}); + +type FormValues = z.infer; + +/** Turn a topic name into a valid default table name (topic names may contain dots or dashes). */ +function suggestTableName(topicName: string): string { + const slug = topicName.toLowerCase().replaceAll(/[^a-z0-9_]/g, '_'); + return TABLE_NAME_RE.test(slug) ? slug : `_${slug}`; +} + +function createTableSql(tableName: string, topic: string): string { + return `CREATE TABLE ${CATALOG_NAME}=>${tableName || 'my_table'}\n WITH (topic='${topic}');`; +} + +function describeTopic(topic: WizardTopic): string { + const partitions = typeof topic.partitions === 'number' ? `${topic.partitions} partitions` : 'topic'; + return topic.format ? `${partitions} · ${topic.format}` : partitions; +} + +export function SqlWizard({ topics, onClose, onCreate, isCreating, error }: SqlWizardProps) { + const [step, setStep] = useState(0); + const [selectedTopic, setSelectedTopic] = useState(null); + + const form = useForm({ + resolver: zodResolver(formSchema), + mode: 'onTouched', + defaultValues: { tableName: '' }, + }); + + const selectTopic = (topicName: string) => { + const topic = topics.find((t) => t.name === topicName); + if (!topic) { + return; + } + // Prefill the table name unless the user already typed their own. + const currentName = form.getValues('tableName'); + if (!currentName || (selectedTopic && currentName === suggestTableName(selectedTopic.name))) { + form.setValue('tableName', suggestTableName(topic.name)); + } + setSelectedTopic(topic); + }; + + const submit = form.handleSubmit(({ tableName }) => { + if (selectedTopic) { + onCreate({ topic: selectedTopic.name, tableName }); + } + }); + + return ( +
+
+ + Add a topic to SQL + + +
+ +
+
+ + Step {step + 1} of {STEPS.length} + + {STEPS[step]} + +
+ +
+ {step === 0 || !selectedTopic ? ( + + ) : ( + + )} +
+ +
+ +
+ {step > 0 && ( + + )} + {step === 0 ? ( + + ) : ( + + )} +
+
+
+
+ ); +} + +type TopicStepProps = { + topics: WizardTopic[]; + selectedTopicName: string | undefined; + onSelect: (topicName: string) => void; +}; + +function TopicStep({ topics, selectedTopicName, onSelect }: TopicStepProps) { + const [search, setSearch] = useState(''); + + const query = search.trim().toLowerCase(); + const visibleTopics = query ? topics.filter((t) => t.name.toLowerCase().includes(query)) : topics; + + return ( +
+ + Pick a Redpanda topic to expose as a SQL table. Tables are created in {CATALOG_NAME} — + the catalog for Redpanda topics. + + setSearch(e.target.value)} placeholder="Search topics" value={search} /> + {visibleTopics.length === 0 ? ( + No topics found. + ) : ( + onSelect(value as string)} + value={selectedTopicName ?? ''} + > + {visibleTopics.map((topic) => ( + + + + + + + + {topic.name} + + {describeTopic(topic)} + + {topic.iceberg && } + + ))} + + )} +
+ ); +} + +type TableNameStepProps = { + topic: WizardTopic; + form: UseFormReturn; + error?: string; +}; + +function TableNameStep({ topic, form, error }: TableNameStepProps) { + const tableName = useWatch({ control: form.control, name: 'tableName' }); + + return ( +
+ + {CATALOG_NAME} + fixed for Redpanda topics + + + + {topic.name} + {topic.iceberg && ( + + + + )} + + + {topic.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. + + + )} + + ( + + Table name + + How the table appears in the catalog and your queries. + {Boolean(fieldState.invalid) && } + + )} + /> + +
+ + +
+ + {error && ( + + {error} + + )} +
+ ); +} + +function SummaryRow({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ +
+ {children} +
+
+ ); +} + +function IcebergBadge() { + return ( + + + Iceberg + + ); +} 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..d9145d6946 --- /dev/null +++ b/frontend/src/components/pages/sql/sql-workspace.tsx @@ -0,0 +1,494 @@ +/** + * 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, ExecuteQueryRequestSchema } from 'protogen/redpanda/api/dataplane/v1alpha3/sql_pb'; +import { useCallback, useMemo, useRef, useState } from 'react'; +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 { toast } from 'sonner'; + +import { CatalogTree } from './catalog-tree'; +import { firstKeyword } from './sql'; +import { SqlEditor, type SqlEditorHandle } from './sql-editor'; +import { SqlResults } from './sql-results'; +import { + type BridgeInfo, + type Catalog, + type CellValue, + type ColumnDef, + columnKindForPgType, + isArrayPgType, + type QueryRun, + type ResultRow, + type SqlRole, + type TableRef, +} from './sql-types'; +import { SqlWizard, type WizardTopic } from './sql-wizard'; + +const INITIAL_QUERY = 'SELECT name, type\nFROM system.catalogs\nORDER BY name;'; + +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; +}; + +// Renders the workspace as a fixed overlay filling the area right of the +// cluster sidebar, below the page header. This gives a true full-width, +// full-height editor WITHOUT mutating any shared cloud-ui layout nodes — so it +// never leaves residue on other pages (e.g. Overview) when you navigate away. +// Works in both standalone console and embedded cloud-ui. Returns teardown. +function setupOverlayLayout(el: HTMLDivElement): () => void { + // Natural (in-flow) top sits just below the page header. Measured once + // while still in flow; horizontal resizes don't change it. + const naturalTop = el.getBoundingClientRect().top; + + const findRegionLeft = () => { + // The content region is the INNERMOST ancestor that spans to the + // viewport's right edge — i.e. the main column right of the sidebar. + // (Outer ancestors like the sidebar wrapper also reach the right edge but + // start at x=0 and would put the editor under the sidebar.) + let node = el.parentElement; + while (node && node !== document.body) { + const r = node.getBoundingClientRect(); + if (Math.abs(r.right - window.innerWidth) <= 2 && r.width > 200) { + return r.left; + } + node = node.parentElement; + } + return el.getBoundingClientRect().left; + }; + + const layout = () => { + const 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(--color-border)'; + }; + + // The overlay is fixed, but the host page (cloud-ui chrome when embedded) + // still scrolls behind it — dragging the host page header up/down/sideways + // while the pinned editor stays put. Lock every scrollable ancestor (plus + // the document scroller) so nothing behind the overlay can scroll, keeping + // the host header static. No-op in standalone console, where nothing scrolls. + const locked: Array<{ node: HTMLElement; overflow: string }> = []; + const lock = (node: HTMLElement) => { + locked.push({ node, overflow: node.style.overflow }); + node.style.overflow = 'hidden'; + }; + const lockAll = () => { + let node: HTMLElement | null = el.parentElement; + while (node && node !== document.body) { + const c = getComputedStyle(node); + const scrollable = /(auto|scroll)/.test(c.overflowY + c.overflowX); + if (scrollable && (node.scrollHeight > node.clientHeight || node.scrollWidth > node.clientWidth)) { + lock(node); + } + node = node.parentElement; + } + const scroller = (document.scrollingElement ?? document.documentElement) as HTMLElement; + lock(scroller); + if (document.body) { + lock(document.body); + } + }; + const unlockAll = () => { + for (const { node, overflow } of locked) { + node.style.overflow = overflow; + } + locked.length = 0; + }; + + // When embedded, the host keeps Console MOUNTED but display:none while on + // its own routes (e.g. /overview) — unmount cleanup never runs there, which + // would strand the scroll locks on a page that needs to scroll. Hold the + // locks only while actually on screen: display:none collapses the overlay + // to 0x0, which fires the ResizeObserver, and we release until shown again. + const isVisible = () => el.getClientRects().length > 0; + let active = false; + const sync = () => { + if (isVisible() && !active) { + layout(); + lockAll(); + active = true; + } else if (!isVisible() && active) { + unlockAll(); + active = false; + } + }; + sync(); + const visibilityObserver = new ResizeObserver(sync); + visibilityObserver.observe(el); + + const onWindowResize = () => { + if (active) { + layout(); + } + }; + window.addEventListener('resize', onWindowResize); + + return () => { + visibilityObserver.disconnect(); + window.removeEventListener('resize', onWindowResize); + if (active) { + unlockAll(); + } + }; +} + +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 overlayCleanup = useRef<(() => void) | null>(null); + // Callback ref (no effect): React calls it with the node on mount and null + // on unmount, which maps 1:1 onto the overlay's setup/teardown. Must be + // identity-stable, or React would detach/reattach the overlay every render. + const attachOverlay = useCallback((el: HTMLDivElement | null) => { + overlayCleanup.current?.(); + overlayCleanup.current = el ? setupOverlayLayout(el) : null; + }, []); + + 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(() => { + // MVP surfaces only the Redpanda catalog; Iceberg catalog support lands later. + const list = (catalogsData?.catalogs ?? []).filter((c) => c.type === CatalogType.REDPANDA); + 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]); + + // 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: c.type.toLowerCase(), + isArray: isArrayPgType(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); + // Arrays keep their raw string form — only scalar bools coerce. + if (cell !== null && col.kind === 'bool' && !col.isArray) { + 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] + ); + + // Catalogs enriched with the Redpanda-catalog tables already fetched for the + // wizard above, so editor autocomplete can resolve table references — the + // bare catalog list seeds namespaces with empty `tables`. + const completionCatalogs = useMemo( + () => + catalogs.map((catalog) => { + if (catalog.name !== redpandaCatalogName) { + return catalog; + } + const tablesByNamespace = new Map(); + for (const t of redpandaTablesData?.tables ?? []) { + const list = tablesByNamespace.get(t.namespaceName) ?? []; + list.push({ + id: `${catalog.name}.${t.namespaceName}.${t.name}`, + name: t.name, + namespaceName: t.namespaceName, + catalogName: catalog.name, + topicName: t.topicName, + }); + tablesByNamespace.set(t.namespaceName, list); + } + const namespaces = catalog.namespaces.map((ns) => ({ + ...ns, + tables: tablesByNamespace.get(ns.name) ?? ns.tables, + })); + for (const [name, tables] of tablesByNamespace) { + if (!namespaces.some((ns) => ns.name === name)) { + namespaces.push({ id: `${catalog.name}.${name}`, name, tables }); + } + } + return { ...catalog, namespaces }; + }), + [catalogs, redpandaCatalogName, redpandaTablesData] + ); + + 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 ( +
+
+
+ Redpanda SQL · Studio +
+
+ + {role === 'admin' ? 'Admin' : 'Viewer · read-only'} + +
+
+ +
+
+ +
+
+ {wizardOpen ? ( + + ) : ( + + + doRun(sql)} + ref={editorRef} + role={role} + /> + + + + + + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/pages/sql/sql.test.tsx b/frontend/src/components/pages/sql/sql.test.tsx new file mode 100644 index 0000000000..4e1c54543e --- /dev/null +++ b/frontend/src/components/pages/sql/sql.test.tsx @@ -0,0 +1,25 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { describe, expect, test } from 'vitest'; + +import { firstKeyword } from './sql'; + +describe('sql helpers', () => { + test('firstKeyword skips comments and uppercases', () => { + expect(firstKeyword('select * from t')).toBe('SELECT'); + expect(firstKeyword('-- a comment\nselect * from t')).toBe('SELECT'); + expect(firstKeyword('/* block */ INSERT INTO t VALUES (1)')).toBe('INSERT'); + expect(firstKeyword(' \n grant all on t to u')).toBe('GRANT'); + expect(firstKeyword('-- only a comment')).toBe(''); + expect(firstKeyword('')).toBe(''); + }); +}); diff --git a/frontend/src/components/pages/sql/sql.tsx b/frontend/src/components/pages/sql/sql.tsx new file mode 100644 index 0000000000..c661182d89 --- /dev/null +++ b/frontend/src/components/pages/sql/sql.tsx @@ -0,0 +1,23 @@ +/** + * 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 helpers for the query workspace. Highlighting (editor: CodeMirror's +// Lezer SQL grammar, wizard preview: shiki via DynamicCodeBlock), keyword +// completion (lang-sql's PostgreSQL dialect) and formatting (sql-formatter) +// are handled by the libraries directly. + +const LEADING_COMMENTS = /^(?:\s+|--[^\n]*\n?|\/\*[\s\S]*?\*\/)*/; + +// First meaningful keyword of a statement (used to gate to SELECT-only). +export function firstKeyword(stmt: string): string { + const word = stmt.replace(LEADING_COMMENTS, '').match(/^[A-Za-z_][A-Za-z0-9_]*/); + return word ? word[0].toUpperCase() : ''; +} diff --git a/frontend/src/components/redpanda-ui/components/choicebox.tsx b/frontend/src/components/redpanda-ui/components/choicebox.tsx index d9b20d1bca..96427851e7 100644 --- a/frontend/src/components/redpanda-ui/components/choicebox.tsx +++ b/frontend/src/components/redpanda-ui/components/choicebox.tsx @@ -99,6 +99,8 @@ export const ChoiceboxItemIndicator = ({ , 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), + }); +}; + +// RP SQL's SHOW TABLES has no per-table Iceberg flag; the authoritative signal +// is the backing Kafka topic's `redpanda.iceberg.mode` config. Returns whether +// the topic is Iceberg-tiered so the catalog tree can show the label. +export const useTopicIcebergQuery = (topicName: string, options?: SqlQueryOptions) => { + const request = create(GetTopicConfigurationsRequestSchema, { topicName }); + const result = useQuery(getTopicConfigurations, request, { + enabled: options?.enabled !== false && Boolean(topicName), + }); + const mode = result.data?.configurations.find((c) => c.name === 'redpanda.iceberg.mode')?.value; + return { ...result, isIceberg: Boolean(mode && mode !== 'disabled') }; +}; + +export const useExecuteQueryMutation = () => + useMutation(executeQuery, { + onError: (error) => toast.error(formatToastErrorMessageGRPC({ error, action: 'execute', entity: 'SQL query' })), + }); + +// Returns a function that refreshes the catalog/table listings, e.g. after a +// CREATE TABLE so the new table shows up in the tree. +export const useInvalidateSqlCatalog = () => { + const queryClient = useQueryClient(); + return () => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ schema: listCatalogs, cardinality: 'finite' }), + }), + queryClient.invalidateQueries({ queryKey: createConnectQueryKey({ schema: listTables, cardinality: 'finite' }) }), + ]); +}; diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index f3552c654c..a8cabfacd3 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'; import { Route as UploadLicenseRouteImport } from './routes/upload-license'; import { Route as TrialExpiredRouteImport } from './routes/trial-expired'; import { Route as TransformsSetupRouteImport } from './routes/transforms-setup'; +import { Route as SqlRouteImport } from './routes/sql'; import { Route as SecurityRouteImport } from './routes/security'; import { Route as ReassignPartitionsRouteImport } from './routes/reassign-partitions'; import { Route as QuotasRouteImport } from './routes/quotas'; @@ -102,6 +103,11 @@ const TransformsSetupRoute = TransformsSetupRouteImport.update({ path: '/transforms-setup', getParentRoute: () => rootRouteImport, } as any); +const SqlRoute = SqlRouteImport.update({ + id: '/sql', + path: '/sql', + getParentRoute: () => rootRouteImport, +} as any); const SecurityRoute = SecurityRouteImport.update({ id: '/security', path: '/security', @@ -504,6 +510,7 @@ export interface FileRoutesByFullPath { '/quotas': typeof QuotasRoute; '/reassign-partitions': typeof ReassignPartitionsRoute; '/security': typeof SecurityRouteWithChildren; + '/sql': typeof SqlRoute; '/transforms-setup': typeof TransformsSetupRoute; '/trial-expired': typeof TrialExpiredRoute; '/upload-license': typeof UploadLicenseRoute; @@ -582,6 +589,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute; '/quotas': typeof QuotasRoute; '/reassign-partitions': typeof ReassignPartitionsRoute; + '/sql': typeof SqlRoute; '/transforms-setup': typeof TransformsSetupRoute; '/trial-expired': typeof TrialExpiredRoute; '/upload-license': typeof UploadLicenseRoute; @@ -662,6 +670,7 @@ export interface FileRoutesById { '/quotas': typeof QuotasRoute; '/reassign-partitions': typeof ReassignPartitionsRoute; '/security': typeof SecurityRouteWithChildren; + '/sql': typeof SqlRoute; '/transforms-setup': typeof TransformsSetupRoute; '/trial-expired': typeof TrialExpiredRoute; '/upload-license': typeof UploadLicenseRoute; @@ -743,6 +752,7 @@ export interface FileRouteTypes { | '/quotas' | '/reassign-partitions' | '/security' + | '/sql' | '/transforms-setup' | '/trial-expired' | '/upload-license' @@ -821,6 +831,7 @@ export interface FileRouteTypes { | '/' | '/quotas' | '/reassign-partitions' + | '/sql' | '/transforms-setup' | '/trial-expired' | '/upload-license' @@ -900,6 +911,7 @@ export interface FileRouteTypes { | '/quotas' | '/reassign-partitions' | '/security' + | '/sql' | '/transforms-setup' | '/trial-expired' | '/upload-license' @@ -980,6 +992,7 @@ export interface RootRouteChildren { QuotasRoute: typeof QuotasRoute; ReassignPartitionsRoute: typeof ReassignPartitionsRoute; SecurityRoute: typeof SecurityRouteWithChildren; + SqlRoute: typeof SqlRoute; TransformsSetupRoute: typeof TransformsSetupRoute; TrialExpiredRoute: typeof TrialExpiredRoute; UploadLicenseRoute: typeof UploadLicenseRoute; @@ -1063,6 +1076,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof TransformsSetupRouteImport; parentRoute: typeof rootRouteImport; }; + '/sql': { + id: '/sql'; + path: '/sql'; + fullPath: '/sql'; + preLoaderRoute: typeof SqlRouteImport; + parentRoute: typeof rootRouteImport; + }; '/security': { id: '/security'; path: '/security'; @@ -1629,6 +1649,7 @@ const rootRouteChildren: RootRouteChildren = { QuotasRoute: QuotasRoute, ReassignPartitionsRoute: ReassignPartitionsRoute, SecurityRoute: SecurityRouteWithChildren, + SqlRoute: SqlRoute, TransformsSetupRoute: TransformsSetupRoute, TrialExpiredRoute: TrialExpiredRoute, UploadLicenseRoute: UploadLicenseRoute, diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index 70c029bda2..dde1044963 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -11,7 +11,7 @@ import type { Transport } from '@connectrpc/connect'; import type { QueryClient } from '@tanstack/react-query'; -import { createRootRouteWithContext, Outlet, useLocation } from '@tanstack/react-router'; +import { createRootRouteWithContext, Outlet, useLocation, useMatches } from '@tanstack/react-router'; import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'; import AnnouncementBar from 'components/builder-io/announcement-bar'; import { Toaster } from 'components/redpanda-ui/components/sonner'; @@ -51,14 +51,10 @@ function RootLayout() { {isEmbedded() ? : } + {process.env.NODE_ENV === 'development' && } - {process.env.NODE_ENV === 'development' && ( - <> - - - - )} + {process.env.NODE_ENV === 'development' && } ); } @@ -90,6 +86,24 @@ function EmbeddedLayout() { } function AppContent() { + const matches = useMatches(); + const isFullscreen = matches.some((m) => (m.staticData as { fullscreen?: boolean }).fullscreen); + + if (isFullscreen) { + return ( +
+ + + + + + + + +
+ ); + } + return (
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 ; +} diff --git a/frontend/src/utils/route-utils.tsx b/frontend/src/utils/route-utils.tsx index 33088e7ea6..8b356729eb 100644 --- a/frontend/src/utils/route-utils.tsx +++ b/frontend/src/utils/route-utils.tsx @@ -15,6 +15,7 @@ */ import type { LucideIcon } from 'lucide-react'; +import { Database } from 'lucide-react'; import type { ReactNode } from 'react'; import { type AppFeature, AppFeatures } from './env'; @@ -249,6 +250,13 @@ export const SIDEBAR_ITEMS: SidebarItem[] = [ group: SidebarSection.STREAMING, visibilityCheck: routeVisibility(true, [Feature.GetQuotas], ['canListQuotas']), }, + { + path: '/sql', + title: 'SQL', + icon: Database, + group: SidebarSection.STREAMING, + visibilityCheck: routeVisibility(() => isFeatureFlagEnabled('enableSqlInConsole')), + }, { path: '/connect-clusters', title: 'Connect',