diff --git a/backend/package-lock.json b/backend/package-lock.json
index 6c2085b0377..4b1403a93b6 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -102,6 +102,7 @@
"knex": "^3.0.1",
"ldapjs": "^3.0.7",
"ldif": "0.5.1",
+ "libpg-query": "^17.7.3",
"libsodium-wrappers": "^0.7.13",
"lodash.isequal": "^4.5.0",
"mongodb": "^6.8.1",
@@ -126,6 +127,7 @@
"passport-ldapauth": "^3.0.1",
"passport-oauth2": "^1.8.0",
"pg": "^8.11.3",
+ "pg-cursor": "^2.19.0",
"pg-query-stream": "^4.5.3",
"pg-types": "^2.2.0",
"picomatch": "^3.0.1",
@@ -5681,9 +5683,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -5701,9 +5700,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -5721,9 +5717,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -5741,9 +5734,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -5761,9 +5751,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -5781,9 +5768,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -5801,9 +5785,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -5821,9 +5802,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -5841,9 +5819,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -5867,9 +5842,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -5893,9 +5865,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -5919,9 +5888,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -5945,9 +5911,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -5971,9 +5934,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -5997,9 +5957,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -6023,9 +5980,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -7131,9 +7085,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -7151,9 +7102,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -7171,9 +7119,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -7191,9 +7136,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -8866,6 +8808,12 @@
"tsyringe": "^4.10.0"
}
},
+ "node_modules/@pgsql/types": {
+ "version": "17.6.2",
+ "resolved": "https://registry.npmjs.org/@pgsql/types/-/types-17.6.2.tgz",
+ "integrity": "sha512-1UtbELdbqNdyOShhrVfSz3a1gDi0s9XXiQemx+6QqtsrXe62a6zOGU+vjb2GRfG5jeEokI1zBBcfD42enRv0Rw==",
+ "license": "MIT"
+ },
"node_modules/@phc/format": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
@@ -9945,9 +9893,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -9962,9 +9907,6 @@
"arm"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -9979,9 +9921,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -9996,9 +9935,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -10013,9 +9949,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -10030,9 +9963,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -10047,9 +9977,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -10064,9 +9991,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -10081,9 +10005,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -10098,9 +10019,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -10115,9 +10033,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -10132,9 +10047,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -10149,9 +10061,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -19419,6 +19328,15 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/libpg-query": {
+ "version": "17.7.3",
+ "resolved": "https://registry.npmjs.org/libpg-query/-/libpg-query-17.7.3.tgz",
+ "integrity": "sha512-lHKBvoWRsXt/9bJxpAeFxkLu0CA6tELusqy3o1z6/DwGXSETxhKJDaNlNdrNV8msvXDLBhpg/4RE/fKKs5rYFA==",
+ "license": "MIT",
+ "dependencies": {
+ "@pgsql/types": "^17.6.2"
+ }
+ },
"node_modules/libsodium": {
"version": "0.7.13",
"resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.13.tgz",
@@ -21962,9 +21880,10 @@
"integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg=="
},
"node_modules/pg-cursor": {
- "version": "2.10.3",
- "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.10.3.tgz",
- "integrity": "sha512-rDyBVoqPVnx/PTmnwQAYgusSeAKlTL++gmpf5klVK+mYMFEqsOc6VHHZnPKc/4lOvr4r6fiMuoxSFuBF1dx4FQ==",
+ "version": "2.19.0",
+ "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.19.0.tgz",
+ "integrity": "sha512-J5cF1MUz7LRJ9emOqF/06QjabMHMZy587rSPF0UuA8rCwKeeYl2co8Pp+6k5UU9YrAYHMzWkLxilfZB0hqsWWw==",
+ "license": "MIT",
"peerDependencies": {
"pg": "^8"
}
diff --git a/backend/package.json b/backend/package.json
index f598a02d5f0..d61682cf783 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -224,6 +224,7 @@
"knex": "^3.0.1",
"ldapjs": "^3.0.7",
"ldif": "0.5.1",
+ "libpg-query": "^17.7.3",
"libsodium-wrappers": "^0.7.13",
"lodash.isequal": "^4.5.0",
"mongodb": "^6.8.1",
@@ -248,6 +249,7 @@
"passport-ldapauth": "^3.0.1",
"passport-oauth2": "^1.8.0",
"pg": "^8.11.3",
+ "pg-cursor": "^2.19.0",
"pg-query-stream": "^4.5.3",
"pg-types": "^2.2.0",
"picomatch": "^3.0.1",
diff --git a/backend/src/@types/pg-cursor.d.ts b/backend/src/@types/pg-cursor.d.ts
new file mode 100644
index 00000000000..11415e9fae7
--- /dev/null
+++ b/backend/src/@types/pg-cursor.d.ts
@@ -0,0 +1,27 @@
+declare module "pg-cursor" {
+ import type { Connection } from "pg";
+
+ interface CursorConfig {
+ types?: {
+ getTypeParser: (oid: number) => (val: string | Buffer) => unknown;
+ };
+ }
+
+ class Cursor = Record> {
+ // Internal result object populated after read() completes.
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ _result: {
+ fields: { name: string }[];
+ command: string | null;
+ rowCount: number | null;
+ };
+
+ constructor(text: string, values?: unknown[] | null, config?: CursorConfig);
+
+ submit(connection: Connection): void;
+ read(rows: number): Promise;
+ close(): Promise;
+ }
+
+ export = Cursor;
+}
diff --git a/backend/src/ee/services/pam-web-access/pam-postgres-session-handler.test.ts b/backend/src/ee/services/pam-web-access/pam-postgres-session-handler.test.ts
index c5070c3b74d..5a4bf5635eb 100644
--- a/backend/src/ee/services/pam-web-access/pam-postgres-session-handler.test.ts
+++ b/backend/src/ee/services/pam-web-access/pam-postgres-session-handler.test.ts
@@ -30,7 +30,7 @@ vi.mock("./pam-web-access-repl", () => ({
// Mock pg
vi.mock("pg", () => {
- const mockQuery = vi.fn();
+ const mockQuery = vi.fn().mockResolvedValue({ rows: [] });
const mockConnect = vi.fn().mockResolvedValue(undefined);
const mockEnd = vi.fn().mockResolvedValue(undefined);
const mockOn = vi.fn();
diff --git a/backend/src/ee/services/pam-web-access/pam-postgres-session-handler.ts b/backend/src/ee/services/pam-web-access/pam-postgres-session-handler.ts
index d60d1277ed7..43d3d89c22e 100644
--- a/backend/src/ee/services/pam-web-access/pam-postgres-session-handler.ts
+++ b/backend/src/ee/services/pam-web-access/pam-postgres-session-handler.ts
@@ -1,4 +1,6 @@
+import { parse as parseSql } from "libpg-query";
import pg from "pg";
+import Cursor from "pg-cursor";
import {
TPostgresAccountCredentials,
@@ -35,6 +37,22 @@ export const handlePostgresSession = async (
ctx;
const { connectionDetails, credentials } = params;
+ // Type parser shared between the pg.Client and pg-cursor instances so all
+ // results — whether fetched via the simple query path or cursor — apply the
+ // same normalisation rules.
+ const pgTypes = {
+ getTypeParser: (oid: number) => {
+ // Boolean (OID 16): Postgres wire protocol sends 't'/'f' — expand to 'true'/'false'
+ // so the Data Explorer UI displays human-readable literals.
+ if (oid === 16)
+ return (val: string | Buffer) => {
+ const raw = typeof val === "string" ? val : val.toString("utf8");
+ return raw === "t" ? "true" : "false";
+ };
+ return (val: string | Buffer) => (typeof val === "string" ? val : val.toString("hex"));
+ }
+ };
+
const pgClient = new pg.Client({
host: "localhost",
port: relayPort,
@@ -44,22 +62,14 @@ export const handlePostgresSession = async (
ssl: false,
connectionTimeoutMillis: 30_000,
statement_timeout: 30_000,
- types: {
- getTypeParser: (oid: number) => {
- // Boolean (OID 16): Postgres wire protocol sends 't'/'f' — expand to 'true'/'false'
- // so the Data Explorer UI displays human-readable literals.
- if (oid === 16)
- return (val: string | Buffer) => {
- const raw = typeof val === "string" ? val : val.toString("utf8");
- return raw === "t" ? "true" : "false";
- };
- return (val: string | Buffer) => (typeof val === "string" ? val : val.toString("hex"));
- }
- }
+ types: pgTypes
});
await pgClient.connect();
+ const { rows: pidRows } = await pgClient.query<{ pid: number }>("SELECT pg_backend_pid() AS pid");
+ const backendPid = pidRows[0]?.pid;
+
const repl = createPamSqlRepl(pgClient);
sendMessage({
@@ -80,6 +90,10 @@ export const handlePostgresSession = async (
}
};
+ // Server-side transaction state — updated after every query so the client
+ // always receives the authoritative value, including for multi-statement SQL.
+ let isInTransaction = false;
+
// Shared error handler for correlated query messages
const sendQueryError = async (id: string, err: unknown) => {
const pgErr = err as { message?: string; detail?: string; hint?: string };
@@ -92,6 +106,8 @@ export const handlePostgresSession = async (
// ROLLBACK fails if there was no active transaction — safe to ignore.
}
+ isInTransaction = false;
+
sendResponse({
type: PostgresServerMessageType.Error,
id,
@@ -101,13 +117,45 @@ export const handlePostgresSession = async (
});
};
+ // Cancel the currently running query via pg_cancel_backend.
+ // Runs on a separate connection so it is not blocked by the sequential queue.
+ const cancelRunningQuery = async () => {
+ if (!backendPid) return;
+ const pid = backendPid;
+ const cancelClient = new pg.Client({
+ host: "localhost",
+ port: relayPort,
+ user: credentials.username,
+ database: connectionDetails.database,
+ password: "",
+ ssl: false,
+ connectionTimeoutMillis: 5_000
+ });
+ try {
+ await cancelClient.connect();
+ await cancelClient.query("SELECT pg_cancel_backend($1)", [pid]);
+ } catch (err) {
+ logger.debug(err, "Failed to cancel backend query");
+ } finally {
+ await cancelClient.end().catch(() => {});
+ }
+ };
+
// Sequential message processing to prevent concurrent query issues
let processingPromise = Promise.resolve();
socket.on("message", (rawData: Buffer | ArrayBuffer | Buffer[]) => {
+ const message = parseClientMessage(rawData, PostgresClientMessageSchema);
+
+ // Cancel is handled immediately outside the sequential queue so it can
+ // interrupt a running query rather than waiting behind it.
+ if (message?.type === PostgresClientMessageType.Cancel) {
+ void cancelRunningQuery();
+ return;
+ }
+
processingPromise = processingPromise
.then(async () => {
- const message = parseClientMessage(rawData, PostgresClientMessageSchema);
if (!message) {
sendMessage({
type: TerminalServerMessageType.Output,
@@ -193,19 +241,61 @@ export const handlePostgresSession = async (
case PostgresClientMessageType.Query: {
try {
- // Multi-statement SQL (transactions) is executed via PostgreSQL's simple query
- // protocol, which returns only the last statement's result. For transaction-wrapped
- // batches (BEGIN;...;COMMIT;), the result is from COMMIT (no rows/fields).
const startTime = performance.now();
- const result = await pgClient.query(message.sql);
+ const MAX_ROWS = 1000;
+
+ // Split the SQL into individual statements using the real PostgreSQL C parser
+ // (via libpg-query WASM). Each statement is then run through a pg-cursor with
+ // an explicit row cap — the server sends at most MAX_ROWS+1 rows at the wire
+ // level regardless of result set size, so memory usage is bounded.
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const parsed = await parseSql(message.sql);
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ const stmtTexts = (parsed.stmts as Array<{ stmt_location?: number; stmt_len?: number }>).map((s) => {
+ const location = s.stmt_location ?? 0;
+ return s.stmt_len !== undefined
+ ? message.sql.slice(location, location + s.stmt_len)
+ : message.sql.slice(location).trim();
+ });
+
+ let lastRows: Record[] = [];
+ let lastFields: { name: string }[] = [];
+ let lastRowCount: number | null = null;
+ let lastCommand = "";
+ let lastIsTruncated = false;
+
+ for (const stmtSql of stmtTexts) {
+ const cursor = pgClient.query(new Cursor(stmtSql.trim(), null, { types: pgTypes }));
+ // eslint-disable-next-line no-await-in-loop
+ const stmtRows = await cursor.read(MAX_ROWS + 1);
+ const stmtIsTruncated = stmtRows.length > MAX_ROWS;
+ if (stmtIsTruncated) stmtRows.splice(MAX_ROWS);
+ // eslint-disable-next-line no-await-in-loop
+ await cursor.close();
+
+ // eslint-disable-next-line no-underscore-dangle
+ const cursorResult = cursor._result;
+ const cmd = (cursorResult.command ?? "").toUpperCase();
+ if (cmd === "BEGIN" || cmd === "START") isInTransaction = true;
+ if (cmd === "COMMIT" || cmd === "ROLLBACK") isInTransaction = false;
+
+ lastRows = stmtRows;
+ lastFields = (cursorResult.fields ?? []).map((f) => ({ name: f.name }));
+ lastRowCount = cursorResult.rowCount;
+ lastCommand = cursorResult.command ?? "";
+ lastIsTruncated = stmtIsTruncated;
+ }
+
const executionTimeMs = Math.round(performance.now() - startTime);
sendResponse({
type: PostgresServerMessageType.QueryResult,
id: message.id,
- rows: (result.rows ?? []) as Record[],
- fields: (result.fields ?? []).map((f) => ({ name: f.name })),
- rowCount: result.rowCount,
- command: result.command ?? "",
+ rows: lastRows,
+ fields: lastFields,
+ rowCount: lastRowCount,
+ isTruncated: lastIsTruncated,
+ transactionOpen: isInTransaction,
+ command: lastCommand,
executionTimeMs
});
} catch (err) {
diff --git a/backend/src/ee/services/pam-web-access/pam-postgres-ws-types.ts b/backend/src/ee/services/pam-web-access/pam-postgres-ws-types.ts
index c88208b5b97..bda4395a9e0 100644
--- a/backend/src/ee/services/pam-web-access/pam-postgres-ws-types.ts
+++ b/backend/src/ee/services/pam-web-access/pam-postgres-ws-types.ts
@@ -8,7 +8,8 @@ export enum PostgresClientMessageType {
GetSchemas = "get-schemas",
GetTables = "get-tables",
GetTableDetail = "get-table-detail",
- Query = "query"
+ Query = "query",
+ Cancel = "cancel"
}
export enum PostgresServerMessageType {
@@ -54,13 +55,16 @@ const QueryRequestSchema = CorrelatedBaseSchema.extend({
sql: z.string().max(50 * 1024)
});
+const CancelSchema = z.object({ type: z.literal(PostgresClientMessageType.Cancel) });
+
export const PostgresClientMessageSchema = z.discriminatedUnion("type", [
InputSchema,
ControlSchema,
GetSchemasRequestSchema,
GetTablesRequestSchema,
GetTableDetailRequestSchema,
- QueryRequestSchema
+ QueryRequestSchema,
+ CancelSchema
]);
export type TPostgresClientMessage = z.infer;
@@ -135,6 +139,8 @@ const QueryResultResponseSchema = CorrelatedBaseSchema.extend({
})
),
rowCount: z.number().nullable(),
+ isTruncated: z.boolean(),
+ transactionOpen: z.boolean(),
command: z.string(),
executionTimeMs: z.number()
});
diff --git a/docs/documentation/platform/pam/product-reference/web-access/overview.mdx b/docs/documentation/platform/pam/product-reference/web-access/overview.mdx
index 23c02697fbe..a5fc06fcaba 100644
--- a/docs/documentation/platform/pam/product-reference/web-access/overview.mdx
+++ b/docs/documentation/platform/pam/product-reference/web-access/overview.mdx
@@ -1,13 +1,13 @@
---
title: "Web Access"
sidebarTitle: "Overview"
-description: "Access PAM-managed resources directly from your browser with an interactive terminal."
+description: "Access PAM-managed resources directly from your browser."
---
Infisical PAM Web Access provides a browser-based portal for interacting with your infrastructure directly from your browser via the Infisical dashboard — no client tools or CLI required.
- Web Access currently supports **PostgreSQL** databases (with a visual Data Explorer and SQL terminal), **Redis** servers, and **SSH** servers.
+ Web Access currently supports **PostgreSQL** databases (with visual table browsing, inline editing, and a SQL query runner), **Redis** servers, and **SSH** servers.
## How It Works
@@ -46,7 +46,7 @@ Sessions that exceed the idle timeout or max duration are automatically terminat
- Visual Data Explorer for browsing and editing tables, plus an interactive SQL terminal.
+ Visual table browsing and editing, plus an integrated SQL query runner.
Interactive Redis REPL with redis-cli-style output formatting.
diff --git a/docs/documentation/platform/pam/product-reference/web-access/postgresql.mdx b/docs/documentation/platform/pam/product-reference/web-access/postgresql.mdx
index c28a52e70dd..920bac0b6ee 100644
--- a/docs/documentation/platform/pam/product-reference/web-access/postgresql.mdx
+++ b/docs/documentation/platform/pam/product-reference/web-access/postgresql.mdx
@@ -6,13 +6,9 @@ description: "Browse, query, and edit PostgreSQL databases directly from your br

-PostgreSQL web access provides two modes for interacting with your database directly from the browser: the **Data Explorer** for visual browsing and editing, and the **SQL Terminal** for running ad-hoc queries.
+PostgreSQL web access provides a unified interface for interacting with your database directly from the browser. It combines visual table browsing and editing with an integrated SQL query runner, all in one interface.
-## Data Explorer
-
-The Data Explorer is a visual interface for browsing and editing PostgreSQL tables. It provides schema and table navigation, filtering, sorting, inline editing, and full CRUD operations — all without writing SQL.
-
-### Connecting
+## Connecting
@@ -25,10 +21,10 @@ The Data Explorer is a visual interface for browsing and editing PostgreSQL tabl

-
- In the connect modal, click **Open Data Explorer** to launch the Data Explorer in a new tab.
+
+ In the connect modal, click **Connect in Browser** to launch the web access interface in a new tab.
- 
+ 
@@ -38,65 +34,29 @@ Once connected, you get a spreadsheet-like interface for your database where you
- Filter and sort to quickly find what you need
- Edit data directly — add, update, or delete rows
-
-
-## SQL Terminal
+
-The SQL terminal provides a `psql`-like experience in your browser. It is designed for ad-hoc usage — quick queries, debugging, and administrative tasks.
-
-### Connecting
-
-
-
- Go to the **Resources** tab in your PAM project, open the PostgreSQL resource, and find the account you want to access. Click the **Connect** button next to the account.
+## SQL Queries
- 
+The SQL query runner lets you run arbitrary SQL against your database without leaving the browser.
- Alternatively, if you are on the account page, click the **Access** button.
+
- 
-
-
-
- In the connect modal, click **Open Console** to launch the SQL terminal in a new tab.
-
- 
-
-
-
- Click the **Disconnect** button from the web access terminal status bar or type `\q`, `quit`, or `exit`. You can reconnect from the same page.
-
-
+### Usage
-
+Click the **+** button in the tab bar to open a new query tab. Each tab has its own SQL editor and results panel.
-### Usage
+Write your query in the editor and run it by clicking **Run** or pressing `Cmd+Enter` (Mac) / `Ctrl+Enter` (Windows/Linux). Results are displayed in a table below the editor with column types, primary key, and foreign key indicators.
-Once connected, you'll see a `=>` prompt. Type SQL queries as you would in `psql`:
+You can open multiple query tabs at once. Tabs are independent: each has its own SQL content and results.
-```sql
-=> SELECT * FROM users LIMIT 5;
- id | name | email
-----+------------+---------------------
- 1 | Alice | alice@example.com
- 2 | Bob | bob@example.com
-(2 rows)
-```
+#### Multi-statement queries
-#### Transactions
+If you run multiple statements at once, the results of the last statement are shown, the same behaviour as `psql`.
-Transactions are fully supported. You can use `BEGIN`, `COMMIT`, and `ROLLBACK` as you would in `psql`:
+#### Mutation queries
-```sql
-=> BEGIN;
-BEGIN
-=> UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-UPDATE 1
-=> UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-UPDATE 1
-=> COMMIT;
-COMMIT
-```
+`INSERT`, `UPDATE`, and `DELETE` statements display the number of affected rows instead of a result table.
## Limits
@@ -109,10 +69,13 @@ In addition to the [common session limits](/documentation/platform/pam/product-r
## FAQ
-
- No. The SQL terminal is not a `psql` client. It is a custom SQL REPL that connects directly to PostgreSQL and provides a `psql`-like experience, including familiar prompts and tabular output formatting. Backslash commands like `\dt` or `\d` are not supported.
-
-
+
Editing is only available for tables that have a primary key defined. Tables without a primary key, as well as views, are displayed in read-only mode.
+
+ Yes. You can use `BEGIN`, `COMMIT`, and `ROLLBACK` to manage transactions. An active transaction is shared across all query tabs — changes made in one tab are visible in others until you commit or roll back. The toolbar will show a **Transaction open** indicator while a transaction is in progress.
+
+
+ Yes. Select any portion of the SQL in the editor and click **Run Selection** (or press ⌘ Enter / Ctrl Enter) to execute only the selected text.
+
diff --git a/docs/images/pam/product-reference/web-access/pg-data-explorer-connect-button.png b/docs/images/pam/product-reference/web-access/pg-data-explorer-connect-button.png
index 76cb1e54bae..deb55a27a2c 100644
Binary files a/docs/images/pam/product-reference/web-access/pg-data-explorer-connect-button.png and b/docs/images/pam/product-reference/web-access/pg-data-explorer-connect-button.png differ
diff --git a/docs/images/pam/product-reference/web-access/pg-sql-query-runner.png b/docs/images/pam/product-reference/web-access/pg-sql-query-runner.png
new file mode 100644
index 00000000000..a25b4968489
Binary files /dev/null and b/docs/images/pam/product-reference/web-access/pg-sql-query-runner.png differ
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index ab852cf0192..b95751b5539 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -10,6 +10,11 @@
"dependencies": {
"@casl/ability": "^6.7.2",
"@casl/react": "^4.0.0",
+ "@codemirror/commands": "^6.10.3",
+ "@codemirror/lang-sql": "^6.10.0",
+ "@codemirror/language": "^6.12.3",
+ "@codemirror/state": "^6.6.0",
+ "@codemirror/view": "^6.41.0",
"@dagrejs/dagre": "^1.1.4",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
@@ -25,6 +30,7 @@
"@headlessui/react": "^1.7.19",
"@hookform/resolvers": "^3.9.1",
"@lexical/react": "^0.29.0",
+ "@lezer/highlight": "^1.2.3",
"@lottiefiles/dotlottie-react": "^0.12.0",
"@lottiefiles/dotlottie-web": "^0.38.2",
"@octokit/rest": "^21.0.2",
@@ -525,6 +531,79 @@
"react": "^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
+ "node_modules/@codemirror/autocomplete": {
+ "version": "6.20.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
+ "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/commands": {
+ "version": "6.10.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
+ "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.6.0",
+ "@codemirror/view": "^6.27.0",
+ "@lezer/common": "^1.1.0"
+ }
+ },
+ "node_modules/@codemirror/lang-sql": {
+ "version": "6.10.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz",
+ "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/language": {
+ "version": "6.12.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
+ "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
+ "license": "MIT",
+ "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"
+ }
+ },
+ "node_modules/@codemirror/state": {
+ "version": "6.6.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
+ "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@marijn/find-cluster-break": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/view": {
+ "version": "6.41.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.0.tgz",
+ "integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.6.0",
+ "crelt": "^1.0.6",
+ "style-mod": "^4.1.0",
+ "w3c-keyname": "^2.2.4"
+ }
+ },
"node_modules/@dagrejs/dagre": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.4.tgz",
@@ -2006,6 +2085,30 @@
"yjs": ">=13.5.22"
}
},
+ "node_modules/@lezer/common": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
+ "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
+ "license": "MIT"
+ },
+ "node_modules/@lezer/highlight": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
+ "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/lr": {
+ "version": "1.4.8",
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz",
+ "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
"node_modules/@lottiefiles/dotlottie-react": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.12.0.tgz",
@@ -2024,6 +2127,12 @@
"integrity": "sha512-01d+UjJ8NG7ZStYQxtb8FPzknzGmauG7gEkcH+wHfSdiSQJY9PoBNVSTB9V6F5hAnmFqOxaocTtd7TIEEnzMnA==",
"license": "MIT"
},
+ "node_modules/@marijn/find-cluster-break": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
+ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
+ "license": "MIT"
+ },
"node_modules/@mdx-js/react": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz",
@@ -9049,6 +9158,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/crelt": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
+ "license": "MIT"
+ },
"node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
@@ -16141,6 +16256,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/style-mod": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
+ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
+ "license": "MIT"
+ },
"node_modules/style-to-object": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz",
@@ -17163,6 +17284,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 2f770fdf7f4..fc3a5db9387 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -22,6 +22,11 @@
"dependencies": {
"@casl/ability": "^6.7.2",
"@casl/react": "^4.0.0",
+ "@codemirror/commands": "^6.10.3",
+ "@codemirror/lang-sql": "^6.10.0",
+ "@codemirror/language": "^6.12.3",
+ "@codemirror/state": "^6.6.0",
+ "@codemirror/view": "^6.41.0",
"@dagrejs/dagre": "^1.1.4",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
@@ -37,6 +42,7 @@
"@headlessui/react": "^1.7.19",
"@hookform/resolvers": "^3.9.1",
"@lexical/react": "^0.29.0",
+ "@lezer/highlight": "^1.2.3",
"@lottiefiles/dotlottie-react": "^0.12.0",
"@lottiefiles/dotlottie-web": "^0.38.2",
"@octokit/rest": "^21.0.2",
diff --git a/frontend/src/const/routes.ts b/frontend/src/const/routes.ts
index 8892e7d070d..062ca67c25c 100644
--- a/frontend/src/const/routes.ts
+++ b/frontend/src/const/routes.ts
@@ -394,10 +394,7 @@ export const ROUTE_PATHS = Object.freeze({
"/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/access",
"/_authenticate/_inject-org-details/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/access"
),
- PamDataExplorerPage: setRoute(
- "/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/data-explorer",
- "/_authenticate/_inject-org-details/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/data-explorer"
- ),
+
DiscoveryPage: setRoute(
"/organizations/$orgId/projects/pam/$projectId/discovery",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/discovery/"
diff --git a/frontend/src/pages/pam/PamAccountAccessPage/PamAccountAccessPage.tsx b/frontend/src/pages/pam/PamAccountAccessPage/PamAccountAccessPage.tsx
index 128a2522c45..6e6d9e635c1 100644
--- a/frontend/src/pages/pam/PamAccountAccessPage/PamAccountAccessPage.tsx
+++ b/frontend/src/pages/pam/PamAccountAccessPage/PamAccountAccessPage.tsx
@@ -2,31 +2,27 @@ import { useState } from "react";
import { Helmet } from "react-helmet";
import { useParams } from "@tanstack/react-router";
-import { useGetPamAccountById } from "@app/hooks/api/pam";
+import { PamResourceType, useGetPamAccountById } from "@app/hooks/api/pam";
+import { PamDataExplorerPage } from "@app/pages/pam/PamDataExplorerPage/PamDataExplorerPage";
import { useWebAccessSession } from "./useWebAccessSession";
-const PageContent = () => {
- const params = useParams({
- strict: false
- }) as {
- accountId?: string;
- projectId?: string;
- orgId?: string;
- resourceType?: string;
- resourceId?: string;
- };
-
- const { accountId, projectId, orgId } = params;
-
+const TerminalContent = ({
+ accountId,
+ projectId,
+ orgId
+}: {
+ accountId: string;
+ projectId: string;
+ orgId: string;
+}) => {
const { data: account, isPending } = useGetPamAccountById(accountId);
-
const [sessionEnded, setSessionEnded] = useState(false);
const { containerRef, isConnected, disconnect, reconnect } = useWebAccessSession({
- accountId: accountId!,
- projectId: projectId!,
- orgId: orgId!,
+ accountId,
+ projectId,
+ orgId,
resourceName: account?.resource.name ?? "",
accountName: account?.name ?? "",
resourceType: account?.resource.resourceType ?? "",
@@ -111,6 +107,35 @@ const PageContent = () => {
);
};
+const PageContent = () => {
+ const params = useParams({
+ strict: false
+ }) as {
+ accountId?: string;
+ projectId?: string;
+ orgId?: string;
+ resourceType?: string;
+ resourceId?: string;
+ };
+
+ const { accountId, projectId, orgId } = params;
+ const { data: account, isPending } = useGetPamAccountById(accountId);
+
+ if (isPending) {
+ return (
+
+ Loading...
+
+ );
+ }
+
+ if (account?.resource.resourceType === PamResourceType.Postgres) {
+ return ;
+ }
+
+ return ;
+};
+
export const PamAccountAccessPage = () => {
return (
<>
diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx
index 14ef7a4a83c..84783c51758 100644
--- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx
+++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { faCopy } from "@fortawesome/free-regular-svg-icons";
-import { faTable, faTerminal, faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
+import { faTerminal, faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link } from "@tanstack/react-router";
import ms from "ms";
@@ -93,8 +93,6 @@ export const PamAccessAccountModal = ({ isOpen, onOpenChange, account, projectId
account.resource.resourceType === PamResourceType.SSH ||
account.resource.resourceType === PamResourceType.Redis;
- const showDataExplorer = account.resource.resourceType === PamResourceType.Postgres;
-
return (
Browser
Connect directly from your browser
- {showDataExplorer && (
-
-
- Open Data Explorer
-
- )}
- {showDataExplorer ? "Open Console" : "Connect in Browser"}
+ Connect in Browser
diff --git a/frontend/src/pages/pam/PamDataExplorerPage/PamDataExplorerPage.tsx b/frontend/src/pages/pam/PamDataExplorerPage/PamDataExplorerPage.tsx
index 682c2e420ea..789ef9cb000 100644
--- a/frontend/src/pages/pam/PamDataExplorerPage/PamDataExplorerPage.tsx
+++ b/frontend/src/pages/pam/PamDataExplorerPage/PamDataExplorerPage.tsx
@@ -1,6 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "@tanstack/react-router";
-import { AlertTriangleIcon, DatabaseIcon, ShieldCheckIcon, UnplugIcon } from "lucide-react";
+import {
+ AlertTriangleIcon,
+ DatabaseIcon,
+ PlusIcon,
+ ShieldCheckIcon,
+ TableIcon,
+ TerminalSquareIcon,
+ UnplugIcon,
+ XIcon
+} from "lucide-react";
import { Spinner } from "@app/components/v2";
import {
@@ -14,12 +23,15 @@ import {
AlertDialogTitle
} from "@app/components/v3/generic/AlertDialog";
import { Button } from "@app/components/v3/generic/Button";
+import { cn } from "@app/components/v3/utils";
import { useGetPamAccountById } from "@app/hooks/api/pam";
import { DataExplorerGrid } from "./components/DataExplorerGrid";
import { DataExplorerSidebar } from "./components/DataExplorerSidebar";
+import { QueryPanel } from "./components/QueryPanel";
import type { SchemaInfo, TableDetail, TableInfo } from "./data-explorer-types";
import { useDataExplorerSession } from "./use-data-explorer-session";
+import { BROWSE_TAB_ID, useQueryTabs } from "./use-query-tabs";
export const PamDataExplorerPage = () => {
const { accountId, projectId, orgId } = useParams({
@@ -49,6 +61,10 @@ export const PamDataExplorerPage = () => {
const [approvalJustification, setApprovalJustification] = useState("");
+ const { tabs, activeTabId, atTabLimit, addTab, closeTab, setActiveTab, updateTabSql } =
+ useQueryTabs();
+ const [isInTransaction, setIsInTransaction] = useState(false);
+
const {
isConnected,
isConnecting,
@@ -64,7 +80,8 @@ export const PamDataExplorerPage = () => {
fetchSchemas,
fetchTables,
fetchTableDetail,
- executeQuery
+ executeQuery,
+ cancelQuery
} = useDataExplorerSession({
accountId,
projectId,
@@ -201,15 +218,19 @@ export const PamDataExplorerPage = () => {
const handleTableSelect = useCallback(
(tableName: string) => {
- if (tableName === selectedTable) return;
+ if (tableName === selectedTable) {
+ setActiveTab(BROWSE_TAB_ID);
+ return;
+ }
if (unsavedChangeCountRef.current > 0) {
setPendingTableSwitch(tableName);
return;
}
setSelectedTable(tableName);
loadTableDetail(selectedSchema, tableName);
+ setActiveTab(BROWSE_TAB_ID);
},
- [selectedTable, selectedSchema, loadTableDetail]
+ [selectedTable, selectedSchema, loadTableDetail, setActiveTab]
);
const handleDiscardAndSwitch = useCallback(() => {
@@ -218,11 +239,8 @@ export const PamDataExplorerPage = () => {
setSelectedTable(pendingTableSwitch);
loadTableDetail(selectedSchema, pendingTableSwitch);
setPendingTableSwitch(null);
- }, [pendingTableSwitch, selectedSchema, loadTableDetail]);
-
- const handleChangeCountUpdate = useCallback((count: number) => {
- unsavedChangeCountRef.current = count;
- }, []);
+ setActiveTab(BROWSE_TAB_ID);
+ }, [pendingTableSwitch, selectedSchema, loadTableDetail, setActiveTab]);
const handleFullRefresh = useCallback(async () => {
// 1. Re-fetch tables for current schema (picks up new/dropped tables)
@@ -390,28 +408,111 @@ export const PamDataExplorerPage = () => {
isLoadingTables={isLoadingTables}
/>
- {selectedTable ? (
- t.name === selectedTable)?.tableType}
- schema={selectedSchema}
- table={selectedTable}
- executeQuery={executeQuery}
- isLoading={isLoadingDetail}
- onChangeCountUpdate={handleChangeCountUpdate}
- onFullRefresh={handleFullRefresh}
- />
- ) : (
-
-
-
-
- Select a table from the sidebar to browse data
-
-
+
+
+
+
+ {tabs.map((tab) => (
+
setActiveTab(tab.id)}
+ onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && setActiveTab(tab.id)}
+ className={cn(
+ "group flex shrink-0 cursor-pointer items-center gap-1 border-b-2 px-3 py-2 text-xs font-medium transition-colors",
+ activeTabId === tab.id
+ ? "border-info text-mineshaft-100"
+ : "border-transparent text-mineshaft-400 hover:text-mineshaft-200"
+ )}
+ >
+
+
+ {tab.title}
+
+
+
+ ))}
+
+
- )}
+
+
+ {selectedTable ? (
+
t.name === selectedTable)?.tableType}
+ schema={selectedSchema}
+ table={selectedTable}
+ executeQuery={executeQuery}
+ isLoading={isLoadingDetail}
+ onChangeCountUpdate={(count) => {
+ unsavedChangeCountRef.current = count;
+ }}
+ onFullRefresh={handleFullRefresh}
+ />
+ ) : (
+
+
+
+
+ Select a table from the sidebar to browse data
+
+
+
+ )}
+
+
+ {tabs.map((tab) => (
+
+ updateTabSql(tab.id, sql)}
+ onTransactionStateChange={setIsInTransaction}
+ />
+
+ ))}
+
{/* Status bar */}
diff --git a/frontend/src/pages/pam/PamDataExplorerPage/components/DataExplorerGrid.tsx b/frontend/src/pages/pam/PamDataExplorerPage/components/DataExplorerGrid.tsx
index a6f9b8bfb53..68beb9d70a1 100644
--- a/frontend/src/pages/pam/PamDataExplorerPage/components/DataExplorerGrid.tsx
+++ b/frontend/src/pages/pam/PamDataExplorerPage/components/DataExplorerGrid.tsx
@@ -11,6 +11,7 @@ import { DataGrid, useDataGrid } from "@app/components/v3/generic/DataGrid";
import { Skeleton } from "@app/components/v3/generic/Skeleton";
import type { ColumnInfo, FieldInfo, ForeignKeyInfo, TableDetail } from "../data-explorer-types";
+import { getColumnIndicator } from "../data-explorer-utils";
import type { FilterCondition, SortCondition } from "../sql-generation";
import {
buildCountQuery,
@@ -117,23 +118,6 @@ const SELECT_COLUMN: ColumnDef = {
enableResizing: false
};
-function getColumnIndicator(
- colName: string,
- primaryKeys: string[],
- fkMap: Map
-): { type: "pk" | "fk"; tooltip?: string } | undefined {
- if (primaryKeys.includes(colName)) return { type: "pk" };
- const fk = fkMap.get(colName);
- if (fk) {
- const targetCol = fk.targetColumns[fk.columns.indexOf(colName)] ?? fk.targetColumns[0];
- return {
- type: "fk",
- tooltip: `\u2192 ${fk.targetSchema}.${fk.targetTable}(${targetCol})`
- };
- }
- return undefined;
-}
-
function buildColumnDefs(
cols: ColumnInfo[],
primaryKeys: string[],
diff --git a/frontend/src/pages/pam/PamDataExplorerPage/components/QueryPanel.tsx b/frontend/src/pages/pam/PamDataExplorerPage/components/QueryPanel.tsx
new file mode 100644
index 00000000000..ab4204717e3
--- /dev/null
+++ b/frontend/src/pages/pam/PamDataExplorerPage/components/QueryPanel.tsx
@@ -0,0 +1,188 @@
+import { useEffect, useRef, useState } from "react";
+import { GripHorizontalIcon } from "lucide-react";
+
+import { cn } from "@app/components/v3/utils";
+
+import type { FieldInfo } from "../data-explorer-types";
+import type { QueryTab } from "../use-query-tabs";
+import { QueryResultsTable } from "./QueryResultsTable";
+import { QueryToolbar } from "./QueryToolbar";
+import { SqlEditor } from "./SqlEditor";
+
+function getRowLabel(rowCount: number, isTruncated: boolean): string {
+ if (isTruncated) return `Showing 1,000 of ${rowCount.toLocaleString()} rows`;
+ return `${rowCount} row${rowCount !== 1 ? "s" : ""}`;
+}
+
+type QueryResult = {
+ rows: Record[];
+ fields: FieldInfo[];
+ rowCount: number | null;
+ isTruncated: boolean;
+ transactionOpen: boolean;
+ command: string;
+ executionTimeMs: number;
+};
+
+type Props = {
+ tab: QueryTab;
+ executeQuery: (sql: string) => Promise;
+ cancelQuery: () => void;
+ isInTransaction: boolean;
+ onSqlChange: (sql: string) => void;
+ onTransactionStateChange: (open: boolean) => void;
+};
+
+export function QueryPanel({
+ tab,
+ executeQuery,
+ cancelQuery,
+ isInTransaction,
+ onSqlChange,
+ onTransactionStateChange
+}: Props) {
+ const [isRunning, setIsRunning] = useState(false);
+ const [result, setResult] = useState(null);
+ const [error, setError] = useState(null);
+ const [isDragging, setIsDragging] = useState(false);
+ const [hasSelection, setHasSelection] = useState(false);
+ const sqlToRunRef = useRef(tab.sql);
+
+ const containerRef = useRef(null);
+ const editorPaneRef = useRef(null);
+ const splitPctRef = useRef(40);
+
+ useEffect(() => {
+ if (editorPaneRef.current) {
+ editorPaneRef.current.style.height = `${splitPctRef.current}%`;
+ }
+ }, []);
+
+ const runSql = async (sqlToRun: string) => {
+ setIsRunning(true);
+ setError(null);
+ try {
+ const res = await executeQuery(sqlToRun);
+ onTransactionStateChange(res.transactionOpen);
+ setResult(res);
+ } catch (err) {
+ onTransactionStateChange(false);
+ setError(err instanceof Error ? err.message : String(err));
+ setResult(null);
+ } finally {
+ setIsRunning(false);
+ }
+ };
+
+ const handleRun = async (sqlToRun?: string) => {
+ const query = sqlToRun ?? sqlToRunRef.current;
+ if (!query.trim() || isRunning) return;
+ await runSql(query);
+ };
+
+ const handleCommit = async () => {
+ await runSql("COMMIT");
+ };
+
+ const handleRollback = async () => {
+ await runSql("ROLLBACK");
+ };
+
+ useEffect(() => {
+ if (!isDragging) return undefined;
+
+ const onMouseMove = (e: MouseEvent) => {
+ const container = containerRef.current;
+ const editorPane = editorPaneRef.current;
+ if (!container || !editorPane) return;
+ const rect = container.getBoundingClientRect();
+ const pct = Math.min(85, Math.max(10, ((e.clientY - rect.top) / rect.height) * 100));
+ splitPctRef.current = pct;
+ editorPane.style.height = `${pct}%`;
+ };
+
+ const onMouseUp = () => setIsDragging(false);
+
+ window.addEventListener("mousemove", onMouseMove);
+ window.addEventListener("mouseup", onMouseUp);
+ return () => {
+ window.removeEventListener("mousemove", onMouseMove);
+ window.removeEventListener("mouseup", onMouseUp);
+ };
+ }, [isDragging]);
+
+ return (
+
+
+
+
+ handleRun(s)}
+ onSelectionChange={setHasSelection}
+ onSqlToRunChange={(s) => {
+ sqlToRunRef.current = s;
+ }}
+ />
+
+
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
+
{
+ e.preventDefault();
+ setIsDragging(true);
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ {!isRunning && result && !error && (
+
+
+ {result.rowCount != null
+ ? getRowLabel(result.rowCount, result.isTruncated)
+ : result.command}
+ {" · "}
+ {result.executionTimeMs}ms
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/pam/PamDataExplorerPage/components/QueryResultsTable.tsx b/frontend/src/pages/pam/PamDataExplorerPage/components/QueryResultsTable.tsx
new file mode 100644
index 00000000000..6b7c29e0631
--- /dev/null
+++ b/frontend/src/pages/pam/PamDataExplorerPage/components/QueryResultsTable.tsx
@@ -0,0 +1,134 @@
+import { useCallback, useMemo } from "react";
+import type { ColumnDef } from "@tanstack/react-table";
+
+import { Spinner } from "@app/components/v2";
+import { DataGrid, useDataGrid } from "@app/components/v3/generic/DataGrid";
+
+import type { FieldInfo } from "../data-explorer-types";
+
+type QueryResult = {
+ rows: Record[];
+ fields: FieldInfo[];
+ rowCount: number | null;
+ isTruncated: boolean;
+ command: string;
+ executionTimeMs: number;
+};
+
+type Props = {
+ result: QueryResult | null;
+ error: string | null;
+ isRunning: boolean;
+};
+
+type RowData = Record;
+
+function buildColumns(fields: FieldInfo[]): ColumnDef[] {
+ return fields.map((f) => ({
+ id: f.name,
+ accessorKey: f.name,
+ header: f.name,
+ meta: {
+ label: f.name,
+ cell: { variant: "short-text" as const }
+ },
+ enableSorting: true,
+ enablePinning: true,
+ enableHiding: true
+ }));
+}
+
+function ResultsGrid({ result }: { result: QueryResult }) {
+ const { isTruncated } = result;
+
+ const columns = useMemo(() => buildColumns(result.fields), [result.fields]);
+
+ const getRowId = useCallback((_row: RowData, index: number) => String(index), []);
+
+ const gridProps = useDataGrid({
+ data: result.rows,
+ columns,
+ getRowId,
+ readOnly: true,
+ rowHeight: "short",
+ enableSearch: true
+ });
+
+ return (
+
+
+
+
+ {isTruncated && (
+
+ Showing first {result.rows.length.toLocaleString()} rows (results truncated)
+
+ )}
+
+ );
+}
+
+export function QueryResultsTable({ result, error, isRunning }: Props) {
+ if (isRunning) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (!result) {
+ return (
+
+
Run a query to see results
+
+ );
+ }
+
+ if (result.rows.length === 0) {
+ const cmdUpper = result.command.toUpperCase();
+ const count = result.rowCount ?? 0;
+ const verb: Record = {
+ INSERT: "inserted",
+ UPDATE: "updated",
+ DELETE: "deleted"
+ };
+ const staticMessage: Record = {
+ BEGIN: "Transaction started",
+ COMMIT: "Transaction committed",
+ ROLLBACK: "Transaction rolled back"
+ };
+
+ const isMutation = verb[cmdUpper] !== undefined && result.fields.length === 0;
+
+ let message: string;
+ if (staticMessage[cmdUpper]) {
+ message = staticMessage[cmdUpper];
+ } else if (isMutation) {
+ message = `${count} row${count !== 1 ? "s" : ""} ${verb[cmdUpper]}`;
+ } else {
+ message = "No rows returned";
+ }
+
+ return (
+
+
+ {message}
+ {isMutation && · No rows returned}
+
+
+ );
+ }
+
+ return ;
+}
diff --git a/frontend/src/pages/pam/PamDataExplorerPage/components/QueryToolbar.tsx b/frontend/src/pages/pam/PamDataExplorerPage/components/QueryToolbar.tsx
new file mode 100644
index 00000000000..edb5ae893ed
--- /dev/null
+++ b/frontend/src/pages/pam/PamDataExplorerPage/components/QueryToolbar.tsx
@@ -0,0 +1,73 @@
+import { CheckIcon, PlayIcon, RotateCcwIcon, SquareIcon } from "lucide-react";
+
+import { Button } from "@app/components/v3/generic/Button";
+
+const isMac = typeof navigator !== "undefined" && navigator.userAgent.includes("Mac");
+
+type Props = {
+ isRunning: boolean;
+ isInTransaction: boolean;
+ hasSelection: boolean;
+ onRun: () => void;
+ onCommit: () => void;
+ onRollback: () => void;
+ onCancel: () => void;
+};
+
+export function QueryToolbar({
+ isRunning,
+ isInTransaction,
+ hasSelection,
+ onRun,
+ onCommit,
+ onRollback,
+ onCancel
+}: Props) {
+ return (
+
+
+ {isRunning ? (
+
+ ) : (
+
+ )}
+ {isInTransaction && (
+ <>
+
+
+ >
+ )}
+
+ {isInTransaction && (
+
+ Transaction open
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/pam/PamDataExplorerPage/components/SqlEditor.tsx b/frontend/src/pages/pam/PamDataExplorerPage/components/SqlEditor.tsx
new file mode 100644
index 00000000000..0426094cd72
--- /dev/null
+++ b/frontend/src/pages/pam/PamDataExplorerPage/components/SqlEditor.tsx
@@ -0,0 +1,154 @@
+import { useEffect, useRef } from "react";
+import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
+import { PostgreSQL, sql } from "@codemirror/lang-sql";
+import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
+import { EditorState, type Transaction } from "@codemirror/state";
+import { EditorView, keymap, type ViewUpdate } from "@codemirror/view";
+import { tags } from "@lezer/highlight";
+
+const infisicalTheme = EditorView.theme({
+ "&": { height: "100%", fontSize: "13px", backgroundColor: "#16181a" },
+ "&.cm-editor": { backgroundColor: "#16181a" },
+ ".cm-scroller": {
+ overflow: "auto",
+ fontFamily: "ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, monospace",
+ backgroundColor: "#16181a",
+ scrollbarWidth: "thin",
+ scrollbarColor: "#39393d transparent"
+ },
+ ".cm-scroller::-webkit-scrollbar": { width: "4px", height: "4px" },
+ ".cm-scroller::-webkit-scrollbar-track": { background: "transparent" },
+ ".cm-scroller::-webkit-scrollbar-thumb": { background: "#39393d", borderRadius: "2px" },
+ ".cm-content": { padding: "8px 0", caretColor: "#e0ed34", backgroundColor: "#16181a" },
+ ".cm-line": { backgroundColor: "transparent" },
+ ".cm-gutters": {
+ backgroundColor: "#16181a",
+ borderRight: "1px solid #2b2c30",
+ color: "#707174"
+ },
+ ".cm-lineNumbers .cm-gutterElement": { padding: "0 12px 0 8px" },
+ ".cm-activeLine": { backgroundColor: "rgba(45, 47, 51, 0.5)" },
+ ".cm-activeLineGutter": { backgroundColor: "rgba(45, 47, 51, 0.5)" },
+ ".cm-cursor": { borderLeftColor: "#e0ed34" },
+ ".cm-selectionBackground": { backgroundColor: "#2d2f33 !important" },
+ "&.cm-focused .cm-selectionBackground": { backgroundColor: "#2d2f33 !important" },
+ ".cm-matchingBracket": { backgroundColor: "#323439", color: "#e0ed34 !important" }
+});
+
+const infisicalHighlight = HighlightStyle.define(
+ [
+ { tag: tags.keyword, color: "#63b0bd", fontWeight: "600" },
+ { tag: tags.string, color: "#29b866" },
+ { tag: tags.number, color: "#f39c12" },
+ { tag: tags.comment, color: "#707174", fontStyle: "italic" },
+ { tag: tags.operator, color: "#adaeb0" },
+ { tag: tags.punctuation, color: "#adaeb0" },
+ { tag: tags.separator, color: "#adaeb0" },
+ { tag: tags.bracket, color: "#adaeb0" },
+ { tag: tags.name, color: "#ebebeb" },
+ { tag: tags.function(tags.name), color: "#63b0bd" },
+ { tag: tags.typeName, color: "#f39c12" },
+ { tag: tags.bool, color: "#63b0bd" },
+ { tag: tags.null, color: "#707174" },
+ { tag: tags.special(tags.string), color: "#29b866" },
+ { tag: tags.invalid, color: "#e74c3c" }
+ ],
+ { all: { color: "#ebebeb" } }
+);
+
+const MAX_SQL_BYTES = 50 * 1024; // 50KB — matches backend Zod limit
+
+const maxSqlLength = EditorState.transactionFilter.of((tr: Transaction) => {
+ if (tr.docChanged && tr.newDoc.length > MAX_SQL_BYTES) return [];
+ return tr;
+});
+
+type Props = {
+ value: string;
+ onChange: (value: string) => void;
+ onExecute: (sql: string) => void;
+ onSelectionChange: (hasSelection: boolean) => void;
+ onSqlToRunChange: (sql: string) => void;
+};
+
+export function SqlEditor({
+ value,
+ onChange,
+ onExecute,
+ onSelectionChange,
+ onSqlToRunChange
+}: Props) {
+ const containerRef = useRef(null);
+ const viewRef = useRef(null);
+ const onExecuteRef = useRef(onExecute);
+ const onChangeRef = useRef(onChange);
+ const onSelectionChangeRef = useRef(onSelectionChange);
+ const onSqlToRunChangeRef = useRef(onSqlToRunChange);
+ onExecuteRef.current = onExecute;
+ onChangeRef.current = onChange;
+ onSelectionChangeRef.current = onSelectionChange;
+ onSqlToRunChangeRef.current = onSqlToRunChange;
+
+ useEffect(() => {
+ if (!containerRef.current) return undefined;
+
+ const view = new EditorView({
+ state: EditorState.create({
+ doc: value,
+ extensions: [
+ history(),
+ keymap.of([
+ {
+ key: "Mod-Enter",
+ run: (v) => {
+ const selection = v.state.selection.main;
+ const selectedText = selection.empty
+ ? v.state.doc.toString()
+ : v.state.sliceDoc(selection.from, selection.to);
+ onExecuteRef.current(selectedText);
+ return true;
+ }
+ },
+ ...historyKeymap,
+ ...defaultKeymap
+ ]),
+ maxSqlLength,
+ sql({ dialect: PostgreSQL }),
+ infisicalTheme,
+ syntaxHighlighting(infisicalHighlight),
+ EditorView.updateListener.of((update: ViewUpdate) => {
+ if (update.docChanged) {
+ const doc = update.state.doc.toString();
+ onChangeRef.current(doc);
+ if (update.state.selection.main.empty) {
+ onSqlToRunChangeRef.current(doc);
+ }
+ }
+ if (update.selectionSet) {
+ const selection = update.state.selection.main;
+ if (selection.empty) {
+ onSqlToRunChangeRef.current(update.state.doc.toString());
+ onSelectionChangeRef.current(false);
+ } else {
+ onSqlToRunChangeRef.current(update.state.sliceDoc(selection.from, selection.to));
+ onSelectionChangeRef.current(true);
+ }
+ }
+ })
+ ]
+ }),
+ parent: containerRef.current
+ });
+
+ viewRef.current = view;
+ onSqlToRunChangeRef.current(value);
+
+ return () => {
+ view.destroy();
+ viewRef.current = null;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return ;
+}
diff --git a/frontend/src/pages/pam/PamDataExplorerPage/data-explorer-types.ts b/frontend/src/pages/pam/PamDataExplorerPage/data-explorer-types.ts
index 435da716ec6..6ed264e1c8f 100644
--- a/frontend/src/pages/pam/PamDataExplorerPage/data-explorer-types.ts
+++ b/frontend/src/pages/pam/PamDataExplorerPage/data-explorer-types.ts
@@ -16,6 +16,8 @@ export type DataExplorerServerMessage =
rows: Record[];
fields: FieldInfo[];
rowCount: number | null;
+ isTruncated: boolean;
+ transactionOpen: boolean;
command: string;
executionTimeMs: number;
}
diff --git a/frontend/src/pages/pam/PamDataExplorerPage/data-explorer-utils.ts b/frontend/src/pages/pam/PamDataExplorerPage/data-explorer-utils.ts
new file mode 100644
index 00000000000..a00fc9540a7
--- /dev/null
+++ b/frontend/src/pages/pam/PamDataExplorerPage/data-explorer-utils.ts
@@ -0,0 +1,15 @@
+import type { ForeignKeyInfo } from "./data-explorer-types";
+
+export function getColumnIndicator(
+ colName: string,
+ primaryKeys: string[],
+ fkMap: Map
+): { type: "pk" | "fk"; tooltip?: string } | undefined {
+ if (primaryKeys.includes(colName)) return { type: "pk" };
+ const fk = fkMap.get(colName);
+ if (fk) {
+ const targetCol = fk.targetColumns[fk.columns.indexOf(colName)] ?? fk.targetColumns[0];
+ return { type: "fk", tooltip: `\u2192 ${fk.targetSchema}.${fk.targetTable}(${targetCol})` };
+ }
+ return undefined;
+}
diff --git a/frontend/src/pages/pam/PamDataExplorerPage/route.tsx b/frontend/src/pages/pam/PamDataExplorerPage/route.tsx
deleted file mode 100644
index 67dd85a4395..00000000000
--- a/frontend/src/pages/pam/PamDataExplorerPage/route.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import { createFileRoute } from "@tanstack/react-router";
-
-import { PamDataExplorerPage } from "./PamDataExplorerPage";
-
-export const Route = createFileRoute(
- "/_authenticate/_inject-org-details/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/data-explorer"
-)({
- component: PamDataExplorerPage
-});
diff --git a/frontend/src/pages/pam/PamDataExplorerPage/use-data-explorer-session.ts b/frontend/src/pages/pam/PamDataExplorerPage/use-data-explorer-session.ts
index 5ec38ce200e..3c1894b3a41 100644
--- a/frontend/src/pages/pam/PamDataExplorerPage/use-data-explorer-session.ts
+++ b/frontend/src/pages/pam/PamDataExplorerPage/use-data-explorer-session.ts
@@ -392,6 +392,13 @@ export const useDataExplorerSession = ({
[sendRequest]
);
+ const cancelQuery = useCallback(() => {
+ const ws = wsRef.current;
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: "cancel" }));
+ }
+ }, []);
+
const executeQuery = useCallback(
async (
sql: string
@@ -399,6 +406,8 @@ export const useDataExplorerSession = ({
rows: Record[];
fields: FieldInfo[];
rowCount: number | null;
+ isTruncated: boolean;
+ transactionOpen: boolean;
command: string;
executionTimeMs: number;
}> => {
@@ -410,6 +419,8 @@ export const useDataExplorerSession = ({
rows: resp.rows,
fields: resp.fields,
rowCount: resp.rowCount,
+ isTruncated: resp.isTruncated,
+ transactionOpen: resp.transactionOpen,
command: resp.command,
executionTimeMs: resp.executionTimeMs
};
@@ -435,6 +446,7 @@ export const useDataExplorerSession = ({
fetchSchemas,
fetchTables,
fetchTableDetail,
- executeQuery
+ executeQuery,
+ cancelQuery
};
};
diff --git a/frontend/src/pages/pam/PamDataExplorerPage/use-query-tabs.ts b/frontend/src/pages/pam/PamDataExplorerPage/use-query-tabs.ts
new file mode 100644
index 00000000000..878cb968a39
--- /dev/null
+++ b/frontend/src/pages/pam/PamDataExplorerPage/use-query-tabs.ts
@@ -0,0 +1,69 @@
+import { useState } from "react";
+
+export type QueryTab = {
+ id: string;
+ title: string;
+ sql: string;
+};
+
+type TabState = {
+ tabs: QueryTab[];
+ activeTabId: string;
+ nextTabNumber: number;
+};
+
+export const BROWSE_TAB_ID = "browse";
+const MAX_QUERY_TABS = 20;
+
+const DEFAULT_STATE: TabState = { tabs: [], activeTabId: BROWSE_TAB_ID, nextTabNumber: 1 };
+
+export function useQueryTabs() {
+ const [state, setState] = useState(DEFAULT_STATE);
+
+ const addTab = () => {
+ setState((prev) => {
+ if (prev.tabs.length >= MAX_QUERY_TABS) return prev;
+ const newTab: QueryTab = {
+ id: crypto.randomUUID(),
+ title: `Query ${prev.nextTabNumber}`,
+ sql: ""
+ };
+ return {
+ tabs: [...prev.tabs, newTab],
+ activeTabId: newTab.id,
+ nextTabNumber: prev.nextTabNumber + 1
+ };
+ });
+ };
+
+ const closeTab = (id: string) => {
+ setState((prev) => {
+ const idx = prev.tabs.findIndex((t) => t.id === id);
+ const newTabs = prev.tabs.filter((t) => t.id !== id);
+ const newActiveId =
+ prev.activeTabId === id
+ ? (newTabs[idx - 1]?.id ?? newTabs[0]?.id ?? BROWSE_TAB_ID)
+ : prev.activeTabId;
+ return { ...prev, tabs: newTabs, activeTabId: newActiveId };
+ });
+ };
+
+ const setActiveTab = (id: string) => setState((prev) => ({ ...prev, activeTabId: id }));
+
+ const updateTabSql = (id: string, sql: string) => {
+ setState((prev) => ({
+ ...prev,
+ tabs: prev.tabs.map((t) => (t.id === id ? { ...t, sql } : t))
+ }));
+ };
+
+ return {
+ tabs: state.tabs,
+ activeTabId: state.activeTabId,
+ atTabLimit: state.tabs.length >= MAX_QUERY_TABS,
+ addTab,
+ closeTab,
+ setActiveTab,
+ updateTabSql
+ };
+}
diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts
index 948a8f60127..814bb0af3b7 100644
--- a/frontend/src/routeTree.gen.ts
+++ b/frontend/src/routeTree.gen.ts
@@ -247,7 +247,6 @@ import { Route as secretManagerIntegrationsAwsParameterStoreConfigurePageRouteIm
import { Route as secretManagerIntegrationsAwsParameterStoreAuthorizePageRouteImport } from './pages/secret-manager/integrations/AwsParameterStoreAuthorizePage/route'
import { Route as pamPamDiscoveryDetailPageRouteImport } from './pages/pam/PamDiscoveryDetailPage/route'
import { Route as certManagerInstallationDetailsByIDPageRouteImport } from './pages/cert-manager/InstallationDetailsByIDPage/route'
-import { Route as pamPamDataExplorerPageRouteImport } from './pages/pam/PamDataExplorerPage/route'
import { Route as pamPamAccountAccessPageRouteImport } from './pages/pam/PamAccountAccessPage/route'
import { Route as secretManagerIntegrationsVercelOauthCallbackPageRouteImport } from './pages/secret-manager/integrations/VercelOauthCallbackPage/route'
import { Route as secretManagerSecretSyncDetailsByIDPageRouteImport } from './pages/secret-manager/SecretSyncDetailsByIDPage/route'
@@ -2363,13 +2362,6 @@ const AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdProjectsSecretManag
} as any,
)
-const pamPamDataExplorerPageRouteRoute =
- pamPamDataExplorerPageRouteImport.update({
- id: '/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/data-explorer',
- path: '/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/data-explorer',
- getParentRoute: () => middlewaresInjectOrgDetailsRoute,
- } as any)
-
const pamPamAccountAccessPageRouteRoute =
pamPamAccountAccessPageRouteImport.update({
id: '/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/access',
@@ -4446,13 +4438,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof pamPamAccountAccessPageRouteImport
parentRoute: typeof middlewaresInjectOrgDetailsImport
}
- '/_authenticate/_inject-org-details/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/data-explorer': {
- id: '/_authenticate/_inject-org-details/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/data-explorer'
- path: '/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/data-explorer'
- fullPath: '/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/data-explorer'
- preLoaderRoute: typeof pamPamDataExplorerPageRouteImport
- parentRoute: typeof middlewaresInjectOrgDetailsImport
- }
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/commits/$environment/$folderId/$commitId': {
id: '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/commits/$environment/$folderId/$commitId'
path: '/$commitId'
@@ -5606,7 +5591,6 @@ interface middlewaresInjectOrgDetailsRouteChildren {
organizationLayoutRoute: typeof organizationLayoutRouteWithChildren
AuthenticateInjectOrgDetailsAdminRoute: typeof AuthenticateInjectOrgDetailsAdminRouteWithChildren
pamPamAccountAccessPageRouteRoute: typeof pamPamAccountAccessPageRouteRoute
- pamPamDataExplorerPageRouteRoute: typeof pamPamDataExplorerPageRouteRoute
}
const middlewaresInjectOrgDetailsRouteChildren: middlewaresInjectOrgDetailsRouteChildren =
@@ -5615,7 +5599,6 @@ const middlewaresInjectOrgDetailsRouteChildren: middlewaresInjectOrgDetailsRoute
AuthenticateInjectOrgDetailsAdminRoute:
AuthenticateInjectOrgDetailsAdminRouteWithChildren,
pamPamAccountAccessPageRouteRoute: pamPamAccountAccessPageRouteRoute,
- pamPamDataExplorerPageRouteRoute: pamPamDataExplorerPageRouteRoute,
}
const middlewaresInjectOrgDetailsRouteWithChildren =
@@ -6013,7 +5996,6 @@ export interface FileRoutesByFullPath {
'/organizations/$orgId/projects/secret-management/$projectId/integrations/secret-syncs/$destination/$syncId': typeof secretManagerSecretSyncDetailsByIDPageRouteRoute
'/organizations/$orgId/projects/secret-management/$projectId/integrations/vercel/oauth2/callback': typeof secretManagerIntegrationsVercelOauthCallbackPageRouteRoute
'/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/access': typeof pamPamAccountAccessPageRouteRoute
- '/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/data-explorer': typeof pamPamDataExplorerPageRouteRoute
'/organizations/$orgId/projects/secret-management/$projectId/commits/$environment/$folderId/$commitId': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdProjectsSecretManagementProjectIdSecretManagerLayoutCommitsEnvironmentFolderIdCommitIdRouteWithChildren
'/organizations/$orgId/projects/secret-management/$projectId/commits/$environment/$folderId/$commitId/': typeof secretManagerCommitDetailsPageRouteRoute
'/organizations/$orgId/projects/secret-management/$projectId/commits/$environment/$folderId/$commitId/restore': typeof secretManagerCommitDetailsPageComponentsRollbackPreviewTabRouteRoute
@@ -6267,7 +6249,6 @@ export interface FileRoutesByTo {
'/organizations/$orgId/projects/secret-management/$projectId/integrations/secret-syncs/$destination/$syncId': typeof secretManagerSecretSyncDetailsByIDPageRouteRoute
'/organizations/$orgId/projects/secret-management/$projectId/integrations/vercel/oauth2/callback': typeof secretManagerIntegrationsVercelOauthCallbackPageRouteRoute
'/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/access': typeof pamPamAccountAccessPageRouteRoute
- '/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/data-explorer': typeof pamPamDataExplorerPageRouteRoute
'/organizations/$orgId/projects/secret-management/$projectId/commits/$environment/$folderId/$commitId': typeof secretManagerCommitDetailsPageRouteRoute
'/organizations/$orgId/projects/secret-management/$projectId/commits/$environment/$folderId/$commitId/restore': typeof secretManagerCommitDetailsPageComponentsRollbackPreviewTabRouteRoute
'/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId': typeof pamPamAccountByIDPageRouteRoute
@@ -6551,7 +6532,6 @@ export interface FileRoutesById {
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/integrations/secret-syncs/$destination/$syncId': typeof secretManagerSecretSyncDetailsByIDPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/integrations/vercel/oauth2/callback': typeof secretManagerIntegrationsVercelOauthCallbackPageRouteRoute
'/_authenticate/_inject-org-details/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/access': typeof pamPamAccountAccessPageRouteRoute
- '/_authenticate/_inject-org-details/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/data-explorer': typeof pamPamDataExplorerPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/commits/$environment/$folderId/$commitId': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdProjectsSecretManagementProjectIdSecretManagerLayoutCommitsEnvironmentFolderIdCommitIdRouteWithChildren
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/commits/$environment/$folderId/$commitId/': typeof secretManagerCommitDetailsPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/commits/$environment/$folderId/$commitId/restore': typeof secretManagerCommitDetailsPageComponentsRollbackPreviewTabRouteRoute
@@ -6826,7 +6806,6 @@ export interface FileRouteTypes {
| '/organizations/$orgId/projects/secret-management/$projectId/integrations/secret-syncs/$destination/$syncId'
| '/organizations/$orgId/projects/secret-management/$projectId/integrations/vercel/oauth2/callback'
| '/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/access'
- | '/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/data-explorer'
| '/organizations/$orgId/projects/secret-management/$projectId/commits/$environment/$folderId/$commitId'
| '/organizations/$orgId/projects/secret-management/$projectId/commits/$environment/$folderId/$commitId/'
| '/organizations/$orgId/projects/secret-management/$projectId/commits/$environment/$folderId/$commitId/restore'
@@ -7079,7 +7058,6 @@ export interface FileRouteTypes {
| '/organizations/$orgId/projects/secret-management/$projectId/integrations/secret-syncs/$destination/$syncId'
| '/organizations/$orgId/projects/secret-management/$projectId/integrations/vercel/oauth2/callback'
| '/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/access'
- | '/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/data-explorer'
| '/organizations/$orgId/projects/secret-management/$projectId/commits/$environment/$folderId/$commitId'
| '/organizations/$orgId/projects/secret-management/$projectId/commits/$environment/$folderId/$commitId/restore'
| '/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId'
@@ -7361,7 +7339,6 @@ export interface FileRouteTypes {
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/integrations/secret-syncs/$destination/$syncId'
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/integrations/vercel/oauth2/callback'
| '/_authenticate/_inject-org-details/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/access'
- | '/_authenticate/_inject-org-details/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/data-explorer'
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/commits/$environment/$folderId/$commitId'
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/commits/$environment/$folderId/$commitId/'
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/commits/$environment/$folderId/$commitId/restore'
@@ -7481,8 +7458,7 @@ export const routeTree = rootRoute
"children": [
"/_authenticate/_inject-org-details/_org-layout",
"/_authenticate/_inject-org-details/admin",
- "/_authenticate/_inject-org-details/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/access",
- "/_authenticate/_inject-org-details/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/data-explorer"
+ "/_authenticate/_inject-org-details/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/access"
]
},
"/_authenticate/personal-settings": {
@@ -8868,10 +8844,6 @@ export const routeTree = rootRoute
"filePath": "pam/PamAccountAccessPage/route.tsx",
"parent": "/_authenticate/_inject-org-details"
},
- "/_authenticate/_inject-org-details/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/data-explorer": {
- "filePath": "pam/PamDataExplorerPage/route.tsx",
- "parent": "/_authenticate/_inject-org-details"
- },
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/commits/$environment/$folderId/$commitId": {
"filePath": "",
"parent": "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/commits/$environment/$folderId",
diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts
index 9ef979376ec..4d75233a966 100644
--- a/frontend/src/routes.ts
+++ b/frontend/src/routes.ts
@@ -414,11 +414,6 @@ const pamAccessRoute = route(
"pam/PamAccountAccessPage/route.tsx"
);
-const pamDataExplorerRoute = route(
- "/organizations/$orgId/projects/pam/$projectId/resources/$resourceType/$resourceId/accounts/$accountId/data-explorer",
- "pam/PamDataExplorerPage/route.tsx"
-);
-
const organizationRoutes = route("/organizations/$orgId", [
route("/projects", "organization/ProjectsPage/route.tsx"),
route("/access-management", "organization/AccessManagementPage/route.tsx"),
@@ -482,7 +477,6 @@ export const routes = rootRoute("root.tsx", [
middleware("inject-org-details.tsx", [
adminRoute,
pamAccessRoute,
- pamDataExplorerRoute,
layout("org-layout", "organization/layout.tsx", [
organizationRoutes,
route("/organizations/$orgId/secret-manager/$projectId", [