diff --git a/apps/e2e/scripts/run-all.mjs b/apps/e2e/scripts/run-all.mjs
index d21c4aa66..d65564d63 100644
--- a/apps/e2e/scripts/run-all.mjs
+++ b/apps/e2e/scripts/run-all.mjs
@@ -124,6 +124,7 @@ const ALWAYS = [
'src/tests/sql-editor-copy-export.test.ts',
'src/tests/sql-editor-running-state.test.ts',
'src/tests/sql-editor-utilities.test.ts',
+ 'src/tests/utilities-sql-tracer.test.ts',
'src/tests/sql-editor-column-picker.test.ts',
'src/tests/sql-editor-code-cell-faker.test.ts',
'src/tests/lazy-loading.test.ts',
diff --git a/apps/e2e/src/generated/test-ids.ts b/apps/e2e/src/generated/test-ids.ts
index ad81ef3cb..f28a5115d 100644
--- a/apps/e2e/src/generated/test-ids.ts
+++ b/apps/e2e/src/generated/test-ids.ts
@@ -3174,6 +3174,36 @@ export const TestIds = {
/** button · Unlock */
serverInsightsUnlock: 'server-insights-unlock',
},
+ SqlTracerPanel: {
+ /** aside */
+ sqlTracerDetail: 'sql-tracer-detail',
+ /** pre */
+ sqlTracerDetailText: 'sql-tracer-detail-text',
+ /** p */
+ sqlTracerEmpty: 'sql-tracer-empty',
+ /** p */
+ sqlTracerError: 'sql-tracer-error',
+ /** input · Text, user, client… */
+ sqlTracerFilter: 'sql-tracer-filter',
+ /** select · Fox activity Live (engine sessions) */
+ sqlTracerMode: 'sql-tracer-mode',
+ /** button · Open in editor */
+ sqlTracerOpenEditor: 'sql-tracer-open-editor',
+ /** div */
+ sqlTracerPanel: 'sql-tracer-panel',
+ /** input · •••••••• */
+ sqlTracerPasswordDraft: 'sql-tracer-password-draft',
+ /** tr */
+ sqlTracerRow: (seq: string | number) => `sql-tracer-row-${seq}`,
+ /** button · Start */
+ sqlTracerStart: 'sql-tracer-start',
+ /** div */
+ sqlTracerStatus: 'sql-tracer-status',
+ /** button · Stop */
+ sqlTracerStop: 'sql-tracer-stop',
+ /** button · Unlock */
+ sqlTracerUnlock: 'sql-tracer-unlock',
+ },
UtilitiesView: {
/** div */
dbAccessModal: 'db-access-modal',
@@ -3197,6 +3227,8 @@ export const TestIds = {
utilitiesObjectSizes: 'utilities-object-sizes',
/** data · Query files */
utilitiesQueryFiles: 'utilities-query-files',
+ /** data · SQL Tracer */
+ utilitiesSqlTracer: 'utilities-sql-tracer',
/** data · System Info */
utilitiesSystemInfo: 'utilities-system-info',
/** data · User Connections */
@@ -4974,6 +5006,20 @@ export type TestId =
| `sql-tab-close-${string}`
| 'sql-tab-rename'
| `sql-tab-rename-btn-${string}`
+ | 'sql-tracer-detail'
+ | 'sql-tracer-detail-text'
+ | 'sql-tracer-empty'
+ | 'sql-tracer-error'
+ | 'sql-tracer-filter'
+ | 'sql-tracer-mode'
+ | 'sql-tracer-open-editor'
+ | 'sql-tracer-panel'
+ | 'sql-tracer-password-draft'
+ | `sql-tracer-row-${string}`
+ | 'sql-tracer-start'
+ | 'sql-tracer-status'
+ | 'sql-tracer-stop'
+ | 'sql-tracer-unlock'
| `sql-variable-${string}`
| 'sql-variable-add'
| 'sql-variable-add-confirm'
@@ -5128,6 +5174,7 @@ export type TestId =
| 'utilities-index-management'
| 'utilities-object-sizes'
| 'utilities-query-files'
+ | 'utilities-sql-tracer'
| 'utilities-system-info'
| 'utilities-user-connections'
| 'utilities-view'
diff --git a/apps/e2e/src/tests/utilities-sql-tracer.test.ts b/apps/e2e/src/tests/utilities-sql-tracer.test.ts
new file mode 100644
index 000000000..99269e833
--- /dev/null
+++ b/apps/e2e/src/tests/utilities-sql-tracer.test.ts
@@ -0,0 +1,181 @@
+/**
+ * Utilities → SQL Tracer, end to end.
+ *
+ * Fox activity (SQLite): a trace on a saved connection shows the statements
+ * run on that connection through the API — what another person or another
+ * tab does — with secrets masked, and a failure marked.
+ *
+ * Live (PostgreSQL): a statement still running on the engine shows from its
+ * session list, masked the same way, and the tracer's own probe never does.
+ *
+ * Statements are sent through /api/sql/execute rather than the editor because
+ * opening the editor would close the Utilities screen, and closing it stops
+ * the trace. Requires the web app + API, a `sqlite3` CLI for the first part
+ * and E2E_SOURCE_POSTGRES_* for the second.
+ */
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { execFileSync, execSync } from 'node:child_process';
+import { mkdirSync, rmSync } from 'node:fs';
+import { join } from 'node:path';
+import type { Page } from 'playwright';
+import { buildDriver, clickWhen, quitDriver } from '../helpers/driver.js';
+import { getSourceConfig, hasConfig } from '../helpers/db-config.js';
+import { sessionCookie } from '../helpers/app-session.js';
+import { deleteSavedConnections, runStatements } from '../helpers/sql-exec.js';
+import { saveScreenshot } from '../helpers/screenshot.js';
+import { byTestId } from '../helpers/test-ids.js';
+import { AppPage } from '../pages/AppPage.js';
+import { SqlEditorPage } from '../pages/SqlEditorPage.js';
+
+const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
+const RUN = Date.now().toString(36);
+const DIR = `/tmp/foxschema-e2e-tracer-${RUN}`;
+const SQLITE = { file: join(DIR, 'traced.db'), name: `E2E Tracer SQLite ${RUN}` };
+const PG_NAME = `E2E Tracer PG ${RUN}`;
+
+function hasSqlite3(): boolean {
+ try {
+ execSync('which sqlite3', { stdio: 'ignore' });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function connectionIdOf(name: string): Promise {
+ const res = await fetch(`${BASE_URL}/api/connections`, { headers: { cookie: await sessionCookie() } });
+ const body = (await res.json()) as Array<{ id: string; name: string }> | { connections: Array<{ id: string; name: string }> };
+ const rows = Array.isArray(body) ? body : body.connections;
+ const row = rows.find((r) => r.name === name);
+ if (!row) throw new Error(`no saved connection named ${name}`);
+ return row.id;
+}
+
+/** Run statements on a saved connection, as the SQL editor does. */
+async function runOnSaved(connectionId: string, statements: string[]): Promise {
+ const res = await fetch(`${BASE_URL}/api/sql/execute`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', cookie: await sessionCookie() },
+ body: JSON.stringify({ connectionId, statements }),
+ });
+ if (!res.ok) throw new Error(`execute failed: ${res.status} ${await res.text()}`);
+}
+
+describe('Utilities · SQL Tracer', () => {
+ let driver: Page;
+ let sql: SqlEditorPage;
+ const made: string[] = [];
+
+ const rowTexts = () =>
+ driver.$$eval('[data-testid^="sql-tracer-row-"]', (rows) => rows.map((r) => r.lastElementChild?.textContent ?? ''));
+
+ async function openTracerOn(name: string): Promise {
+ await sql.openUtilitiesView();
+ await clickWhen(driver, byTestId('utilities-sql-tracer'));
+ await driver.waitForFunction(
+ (n) =>
+ [...((document.querySelector('[data-testid="utilities-connection"]') as HTMLSelectElement | null)?.options ?? [])].some((o) =>
+ (o.textContent ?? '').includes(n)
+ ),
+ name,
+ { timeout: 15_000 }
+ );
+ await sql.selectUtilityConnection(name, 'utilities-connection');
+ await driver.waitForSelector(byTestId('sql-tracer-panel'));
+ }
+
+ beforeAll(async () => {
+ driver = await buildDriver();
+ sql = new SqlEditorPage(driver);
+ await new AppPage(driver).open();
+ }, 120_000);
+
+ afterAll(async () => {
+ await deleteSavedConnections(made);
+ rmSync(DIR, { recursive: true, force: true });
+ if (driver) await quitDriver(driver);
+ });
+
+ describe.skipIf(!hasSqlite3())('Fox activity', () => {
+ let connectionId = '';
+
+ beforeAll(async () => {
+ rmSync(DIR, { recursive: true, force: true });
+ mkdirSync(DIR, { recursive: true });
+ execFileSync('sqlite3', [SQLITE.file], { input: 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT);' });
+ await sql.addSqliteCredential(SQLITE.name, SQLITE.file);
+ made.push(SQLITE.name);
+ connectionId = await connectionIdOf(SQLITE.name);
+ }, 120_000);
+
+ it('shows each statement run on the connection, masked, until stopped', async () => {
+ await openTracerOn(SQLITE.name);
+ await driver.locator(byTestId('sql-tracer-mode')).selectOption('fox');
+ await clickWhen(driver, byTestId('sql-tracer-start'));
+ await driver.waitForSelector(byTestId('sql-tracer-stop'));
+
+ await runOnSaved(connectionId, [
+ `INSERT INTO items (id, name) VALUES (1, 'tracer-${RUN}')`,
+ `SELECT name FROM items /* PASSWORD 'hunter2' */`,
+ 'SELECT * FROM no_such_table',
+ ]);
+
+ await driver.waitForFunction(() => document.querySelectorAll('[data-testid^="sql-tracer-row-"]').length >= 3, null, {
+ timeout: 15_000,
+ });
+ const texts = await rowTexts();
+ expect(texts[0]).toContain(`tracer-${RUN}`);
+ expect(texts[1]).toContain("PASSWORD '***'");
+ expect(texts.join('\n')).not.toContain('hunter2');
+
+ // The failed one carries its error in the detail pane.
+ await driver.locator(byTestId('sql-tracer-row-3')).click();
+ await driver.waitForSelector(byTestId('sql-tracer-detail'));
+ expect(await driver.locator(byTestId('sql-tracer-detail')).innerText()).toMatch(/no such table/i);
+ await saveScreenshot(driver, 'sql-tracer-fox-activity');
+
+ await clickWhen(driver, byTestId('sql-tracer-stop'));
+ await driver.waitForSelector(byTestId('sql-tracer-start'));
+ expect(await driver.locator(byTestId('sql-tracer-status')).innerText()).toContain('Stopped');
+
+ // Nothing more arrives once stopped.
+ await runOnSaved(connectionId, ['SELECT 42']);
+ await driver.waitForTimeout(2_500);
+ expect((await rowTexts()).some((t) => t.includes('42'))).toBe(false);
+ }, 90_000);
+ });
+
+ describe.skipIf(!hasConfig('postgres'))('Live (PostgreSQL)', () => {
+ beforeAll(async () => {
+ await sql.addCredential(PG_NAME, getSourceConfig('postgres')!);
+ made.push(PG_NAME);
+ }, 120_000);
+
+ it('shows a statement still running, masked, and not its own probe', async () => {
+ await openTracerOn(PG_NAME);
+ await driver.locator(byTestId('sql-tracer-mode')).selectOption('live');
+ await clickWhen(driver, byTestId('sql-tracer-start'));
+ await driver.waitForSelector(byTestId('sql-tracer-stop'));
+
+ const marker = `tracer-${RUN}`;
+ // Not awaited: it has to be running while the tracer reads the sessions.
+ const running = runStatements('postgres', [`SELECT pg_sleep(6) /* ${marker} PASSWORD 'hunter2' */`]);
+
+ await driver.waitForFunction(
+ (m) =>
+ [...document.querySelectorAll('[data-testid^="sql-tracer-row-"]')].some((r) => (r.textContent ?? '').includes(m)),
+ marker,
+ { timeout: 15_000 }
+ );
+ const texts = await rowTexts();
+ const mine = texts.find((t) => t.includes(marker))!;
+ expect(mine).toContain("PASSWORD '***'");
+ expect(texts.join('\n')).not.toContain('hunter2');
+ expect(texts.some((t) => /pg_stat_activity/i.test(t))).toBe(false);
+ await saveScreenshot(driver, 'sql-tracer-live-postgres');
+
+ await clickWhen(driver, byTestId('sql-tracer-stop'));
+ await running;
+ }, 90_000);
+ });
+});
diff --git a/apps/web/src/frontend/features/utilities/api/sqlTraceApi.ts b/apps/web/src/frontend/features/utilities/api/sqlTraceApi.ts
new file mode 100644
index 000000000..4d3a0f4c2
--- /dev/null
+++ b/apps/web/src/frontend/features/utilities/api/sqlTraceApi.ts
@@ -0,0 +1,42 @@
+/**
+ * Fox Schema (foxschema)
+ * Copyright 2024-2026 Huy Phan
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The SQL Tracer API: start a trace on a saved connection, read its events
+ * since the last one seen, stop it.
+ */
+import type { TraceEvent, TraceMode } from '@foxschema/ui-shared';
+import { api } from '@/shared/api/client';
+
+export type TraceStopReason = 'stopped' | 'time' | 'events' | 'replaced' | 'error';
+
+export interface TraceStatus {
+ id: string;
+ connectionId: string;
+ mode: TraceMode;
+ startedAt: string;
+ endsAt: string;
+ running: boolean;
+ stopReason: TraceStopReason | null;
+ error: string | null;
+ eventCount: number;
+}
+
+export async function startTrace(connectionId: string, mode: TraceMode, password?: string): Promise {
+ const { status } = await api.post<{ status: TraceStatus }>('/sql-trace/start', { connectionId, mode, password });
+ return status;
+}
+
+export function traceEvents(id: string, after: number, signal?: AbortSignal) {
+ return api.get<{ status: TraceStatus; events: TraceEvent[] }>(`/sql-trace/${encodeURIComponent(id)}/events`, {
+ query: { after },
+ signal,
+ noStore: true,
+ });
+}
+
+export async function stopTrace(id: string): Promise {
+ const { status } = await api.post<{ status: TraceStatus }>(`/sql-trace/${encodeURIComponent(id)}/stop`);
+ return status;
+}
diff --git a/apps/web/src/frontend/features/utilities/components/SqlTracerPanel.test.tsx b/apps/web/src/frontend/features/utilities/components/SqlTracerPanel.test.tsx
new file mode 100644
index 000000000..db2081c37
--- /dev/null
+++ b/apps/web/src/frontend/features/utilities/components/SqlTracerPanel.test.tsx
@@ -0,0 +1,107 @@
+/**
+ * Fox Schema (foxschema)
+ * Copyright 2024-2026 Huy Phan
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import React from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import type { TraceEvent } from '@foxschema/ui-shared';
+import { useSyncStore } from '@/features/compare';
+import { SqlTracerPanel, traceEventMatches } from './SqlTracerPanel';
+
+const startTrace = vi.fn();
+const traceEvents = vi.fn();
+const stopTrace = vi.fn();
+vi.mock('../api/sqlTraceApi', () => ({
+ startTrace: (...a: unknown[]) => startTrace(...a),
+ traceEvents: (...a: unknown[]) => traceEvents(...a),
+ stopTrace: (...a: unknown[]) => stopTrace(...a),
+}));
+
+const status = (over: Record = {}) => ({
+ id: 't1',
+ connectionId: 'c1',
+ mode: 'fox',
+ startedAt: '2026-10-25T00:00:00Z',
+ endsAt: new Date(Date.now() + 15 * 60_000).toISOString(),
+ running: true,
+ stopReason: null,
+ error: null,
+ eventCount: 0,
+ ...over,
+});
+
+const event = (seq: number, text: string, over: Partial = {}): TraceEvent => ({
+ seq,
+ at: '2026-10-25T00:00:01Z',
+ durationMs: 2,
+ user: 'app',
+ database: 'shop',
+ client: 'Fox',
+ sessionId: null,
+ rows: 1,
+ text,
+ error: null,
+ state: null,
+ source: 'fox',
+ ...over,
+});
+
+beforeEach(() => {
+ startTrace.mockReset().mockResolvedValue(status());
+ stopTrace.mockReset().mockResolvedValue(status({ running: false, stopReason: 'stopped' }));
+ traceEvents.mockReset();
+ useSyncStore.setState({
+ connections: [{ id: 'c1', name: 'shop', dialect: 'sqlite', database: '/tmp/shop.db' }],
+ } as never);
+});
+afterEach(cleanup);
+
+describe('SqlTracerPanel', () => {
+ it('starts a Fox activity trace, shows what arrives, and asks only for newer events', async () => {
+ traceEvents
+ .mockResolvedValueOnce({ status: status({ eventCount: 2 }), events: [event(1, 'SELECT 1'), event(2, 'SELECT 2')] })
+ .mockResolvedValue({ status: status({ running: false, stopReason: 'time', eventCount: 2 }), events: [] });
+ render();
+ fireEvent.click(screen.getByTestId('sql-tracer-start'));
+
+ await screen.findByTestId('sql-tracer-row-2');
+ expect(startTrace).toHaveBeenCalledWith('c1', 'fox', undefined);
+ await waitFor(() => expect(screen.getByTestId('sql-tracer-status').textContent).toMatch(/15 minutes/), { timeout: 3000 });
+ expect(traceEvents.mock.calls.map((c) => c[1])).toEqual([0, 2]);
+ });
+
+ it('offers Live only where the engine lists its sessions', () => {
+ render();
+ const live = screen.getByTestId('sql-tracer-mode').querySelector('option[value="live"]') as HTMLOptionElement;
+ expect(live.disabled).toBe(true);
+ });
+
+ it('shows the full statement for the row picked', async () => {
+ traceEvents.mockResolvedValue({ status: status({ running: false, stopReason: 'stopped' }), events: [event(1, 'SELECT *\nFROM orders')] });
+ render();
+ fireEvent.click(screen.getByTestId('sql-tracer-start'));
+ fireEvent.click(await screen.findByTestId('sql-tracer-row-1'));
+ expect(screen.getByTestId('sql-tracer-detail-text').textContent).toBe('SELECT *\nFROM orders');
+ });
+
+ it('stops the trace when the screen closes', async () => {
+ traceEvents.mockReturnValue(new Promise(() => undefined));
+ const { unmount } = render();
+ fireEvent.click(screen.getByTestId('sql-tracer-start'));
+ await screen.findByTestId('sql-tracer-stop');
+ unmount();
+ expect(stopTrace).toHaveBeenCalledWith('t1');
+ });
+});
+
+describe('traceEventMatches', () => {
+ it('matches any text field, case-blind', () => {
+ const e = event(1, 'SELECT 1', { user: 'Alice', client: 'psql' });
+ expect(traceEventMatches(e, 'alice')).toBe(true);
+ expect(traceEventMatches(e, 'PSQL')).toBe(true);
+ expect(traceEventMatches(e, 'orders')).toBe(false);
+ expect(traceEventMatches(e, ' ')).toBe(true);
+ });
+});
diff --git a/apps/web/src/frontend/features/utilities/components/SqlTracerPanel.tsx b/apps/web/src/frontend/features/utilities/components/SqlTracerPanel.tsx
new file mode 100644
index 000000000..49e5364f9
--- /dev/null
+++ b/apps/web/src/frontend/features/utilities/components/SqlTracerPanel.tsx
@@ -0,0 +1,365 @@
+/**
+ * Fox Schema (foxschema)
+ * Copyright 2024-2026 Huy Phan
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * Utilities → SQL Tracer: watch the statements on the workspace credential,
+ * either as the engine lists them (Live) or as Fox runs them (Fox activity).
+ *
+ * A trace is time-boxed and capped on the server; this screen asks for new
+ * events about once a second while it runs, and stops the trace when it
+ * closes. Text arrives already redacted.
+ */
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { Circle, FileCode2, Loader2, Play, Square } from 'lucide-react';
+import { dialectSupportsDbaUtility, TRACE_MAX_EVENTS, type TraceEvent, type TraceMode } from '@foxschema/ui-shared';
+import { useSyncStore } from '@/features/compare';
+import { useSqlEditorStore } from '@/features/sql-editor/state';
+import { useUiStore } from '@/platform/uiStore';
+import { needsSessionPassword } from '@/shared/lib/provider-settings';
+import { sectionLabelCls } from '@/shared/components/surfaces';
+import { startTrace, stopTrace, traceEvents, type TraceStatus } from '../api/sqlTraceApi';
+
+interface Props {
+ /** The Utilities workspace credential. */
+ lockedConnectionId?: string;
+}
+
+const POLL_MS = 1000;
+
+const STOP_REASON: Record = {
+ stopped: 'Stopped.',
+ time: 'Stopped: a trace runs for at most 15 minutes.',
+ events: `Stopped: a trace keeps at most ${TRACE_MAX_EVENTS.toLocaleString()} statements.`,
+ replaced: 'Stopped: a newer trace on this connection replaced it.',
+ error: 'Stopped after an error.',
+};
+
+const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err));
+
+const clock = (iso: string) => {
+ const d = new Date(iso);
+ return Number.isNaN(d.getTime()) ? iso : d.toLocaleTimeString();
+};
+
+function minutesLeft(endsAt: string, now: number): string {
+ const ms = new Date(endsAt).getTime() - now;
+ if (!(ms > 0)) return '0:00';
+ const s = Math.ceil(ms / 1000);
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
+}
+
+/** Whether `event` matches the filter box: any of its text fields, case-blind. */
+export function traceEventMatches(event: TraceEvent, filter: string): boolean {
+ const needle = filter.trim().toLowerCase();
+ if (!needle) return true;
+ return [event.text, event.user, event.database, event.client, event.sessionId, event.error]
+ .some((field) => field?.toLowerCase().includes(needle));
+}
+
+export const SqlTracerPanel: React.FC = ({ lockedConnectionId }) => {
+ const connections = useSyncStore((s) => s.connections);
+ const sessionPasswords = useSqlEditorStore((s) => s.sessionPasswords);
+ const ensureConnectionSelected = useSqlEditorStore((s) => s.ensureConnectionSelected);
+ const submitSessionPassword = useSqlEditorStore((s) => s.submitSessionPassword);
+ const setSql = useSqlEditorStore((s) => s.setSql);
+ const setActiveView = useUiStore((s) => s.setActiveView);
+
+ const connectionId = lockedConnectionId || '';
+ const conn = connections.find((c) => c.id === connectionId) || null;
+ const liveSupport = useMemo(() => dialectSupportsDbaUtility(conn?.dialect || '', 'sessions'), [conn?.dialect]);
+
+ const [mode, setMode] = useState('fox');
+ const [status, setStatus] = useState(null);
+ const [events, setEvents] = useState([]);
+ const [error, setError] = useState(null);
+ const [starting, setStarting] = useState(false);
+ const [filter, setFilter] = useState('');
+ const [selected, setSelected] = useState(null);
+ const [passwordDraft, setPasswordDraft] = useState('');
+ const [now, setNow] = useState(() => Date.now());
+
+ /** The trace this screen is reading; a reply about any other is dropped. */
+ const traceId = useRef(null);
+ const lastSeq = useRef(0);
+
+ const needsPassword = Boolean(conn && needsSessionPassword(conn, sessionPasswords[connectionId]));
+ const running = Boolean(status?.running);
+
+ // Live is not on offer where the engine has no session list.
+ useEffect(() => {
+ if (!liveSupport.query && mode === 'live') setMode('fox');
+ }, [liveSupport.query, mode]);
+
+ // A new credential is a new subject: drop what the old trace showed.
+ useEffect(() => {
+ const id = traceId.current;
+ traceId.current = null;
+ if (id) void stopTrace(id).catch(() => undefined);
+ setStatus(null);
+ setEvents([]);
+ setSelected(null);
+ setError(null);
+ lastSeq.current = 0;
+ }, [connectionId]);
+
+ // Leaving the screen ends the trace: nobody is watching it any more.
+ useEffect(
+ () => () => {
+ const id = traceId.current;
+ traceId.current = null;
+ if (id) void stopTrace(id).catch(() => undefined);
+ },
+ []
+ );
+
+ // Poll while the trace runs. One request at a time; the next is scheduled
+ // only after the last answered, so a slow engine is never asked twice.
+ useEffect(() => {
+ const id = status?.id;
+ if (!id || !status?.running) return;
+ const controller = new AbortController();
+ let timer: ReturnType | undefined;
+ const tick = async () => {
+ try {
+ const page = await traceEvents(id, lastSeq.current, controller.signal);
+ if (traceId.current !== id) return;
+ if (page.events.length > 0) {
+ lastSeq.current = page.events[page.events.length - 1]!.seq;
+ setEvents((cur) => [...cur, ...page.events].slice(-TRACE_MAX_EVENTS));
+ }
+ setStatus(page.status);
+ setNow(Date.now());
+ if (page.status.running) timer = setTimeout(() => void tick(), POLL_MS);
+ } catch (err) {
+ if (controller.signal.aborted || traceId.current !== id) return;
+ setError(errorText(err));
+ timer = setTimeout(() => void tick(), POLL_MS * 3);
+ }
+ };
+ timer = setTimeout(() => void tick(), 0);
+ return () => {
+ controller.abort();
+ if (timer) clearTimeout(timer);
+ };
+ // Restart only for a different trace, or when one stops: the status object changes every tick.
+ }, [status?.id, status?.running]);
+
+ const start = useCallback(async () => {
+ if (!connectionId) return;
+ setStarting(true);
+ setError(null);
+ try {
+ const next = await startTrace(connectionId, mode, sessionPasswords[connectionId] || undefined);
+ traceId.current = next.id;
+ lastSeq.current = 0;
+ setEvents([]);
+ setSelected(null);
+ setStatus(next);
+ setNow(Date.now());
+ } catch (err) {
+ setError(errorText(err));
+ } finally {
+ setStarting(false);
+ }
+ }, [connectionId, mode, sessionPasswords]);
+
+ const stop = useCallback(async () => {
+ const id = traceId.current;
+ if (!id) return;
+ try {
+ const next = await stopTrace(id);
+ if (traceId.current === id) setStatus(next);
+ } catch (err) {
+ setError(errorText(err));
+ }
+ }, []);
+
+ const unlock = () => {
+ if (!connectionId || !passwordDraft.trim()) return;
+ ensureConnectionSelected(connectionId);
+ submitSessionPassword(passwordDraft.trim());
+ setPasswordDraft('');
+ };
+
+ const shown = useMemo(() => events.filter((e) => traceEventMatches(e, filter)), [events, filter]);
+ const detail = selected === null ? null : events.find((e) => e.seq === selected) ?? null;
+
+ const openInEditor = (text: string) => {
+ if (!connectionId) return;
+ setSql(text);
+ ensureConnectionSelected(connectionId);
+ setActiveView('sqlEditor');
+ };
+
+ const statusLine = !status
+ ? mode === 'live'
+ ? 'Live reads the engine’s session list once a second: statements show while they run.'
+ : 'Fox activity shows each statement anyone runs on this connection from Fox, as it finishes.'
+ : status.running
+ ? `Tracing · ${status.eventCount.toLocaleString()} statements · ${minutesLeft(status.endsAt, now)} left`
+ : STOP_REASON[status.stopReason ?? 'stopped'] ?? 'Stopped.';
+
+ return (
+
+
+
+ {needsPassword && (
+
+ )}
+ {running ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {running && }
+ {statusLine}
+ {filter.trim() && events.length > 0 && (
+ · showing {shown.length.toLocaleString()} of {events.length.toLocaleString()}
+ )}
+
+ {mode === 'live' && !liveSupport.query && liveSupport.hint && (
+
{liveSupport.hint}
+ )}
+ {(error || status?.error) && (
+
+ {error || status?.error}
+
+ )}
+
+
+
+ {shown.length === 0 ? (
+
+ {status ? (filter.trim() && events.length ? 'No statement matches the filter.' : 'No statements yet.') : 'Start a trace to see statements here.'}
+
+ ) : (
+
+
+
+ | Time |
+ ms |
+ User |
+ Client |
+ Rows |
+ Statement |
+
+
+
+ {shown.map((e) => (
+ setSelected(e.seq)}
+ className={`cursor-pointer border-b border-slate-800/70 ${
+ e.seq === selected ? 'bg-slate-800' : 'hover:bg-slate-800/50'
+ } ${e.error ? 'text-rose-200' : 'text-slate-200'}`}
+ >
+ | {clock(e.at)} |
+ {e.durationMs ?? '—'} |
+ {e.user ?? '—'} |
+ {e.client ?? '—'} |
+ {e.rows ?? '—'} |
+ {e.text} |
+
+ ))}
+
+
+ )}
+
+ {detail && (
+
+ )}
+
+
+ );
+};
diff --git a/apps/web/src/frontend/features/utilities/components/UtilitiesView.test.tsx b/apps/web/src/frontend/features/utilities/components/UtilitiesView.test.tsx
index cd8043ffe..63dc6cb46 100644
--- a/apps/web/src/frontend/features/utilities/components/UtilitiesView.test.tsx
+++ b/apps/web/src/frontend/features/utilities/components/UtilitiesView.test.tsx
@@ -7,6 +7,8 @@ import React from 'react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { UtilitiesView } from './UtilitiesView';
+import { useAuthStore } from '@/platform/authStore';
+import type { AuthUser } from '@/shared/api/authApi';
vi.mock('@/features/compare/state/useSyncStore', () => ({
useSyncStore: (sel: (s: { connections: { id: string; name: string; dialect: string }[] }) => unknown) =>
@@ -54,6 +56,11 @@ vi.mock('./ServerInsightsModal', () => ({
) : null,
}));
+vi.mock('./SqlTracerPanel', () => ({
+ SqlTracerPanel: ({ lockedConnectionId }: { lockedConnectionId?: string }) => (
+
+ ),
+}));
vi.mock('./DatabaseAccessModal', () => ({
DatabaseAccessModal: ({ open }: { open: boolean }) =>
open ? grants
: null,
@@ -81,6 +88,30 @@ const UTILITY_BUTTONS = [
describe('UtilitiesView', () => {
beforeEach(() => {
localStorage.clear();
+ useAuthStore.setState({ user: null });
+ });
+
+ const signInAs = (permissions: string[]) =>
+ useAuthStore.setState({ user: { id: 'u1', email: 'u1@x.test', role: 'owner', permissions } as unknown as AuthUser });
+
+ it('offers the SQL Tracer only to someone with utility.trace', () => {
+ signInAs(['utility.access']);
+ const { unmount } = render();
+ expect(screen.queryByTestId('utilities-sql-tracer')).toBeNull();
+ unmount();
+
+ signInAs(['utility.access', 'utility.trace']);
+ render();
+ fireEvent.click(screen.getByTestId('utilities-sql-tracer'));
+ expect(screen.getByTestId('sql-tracer-embed').getAttribute('data-locked')).toBe('c1');
+ });
+
+ it('does not reopen a remembered tracer for someone who lost utility.trace', () => {
+ localStorage.setItem('foxschema-utilities-tool', 'trace');
+ signInAs(['utility.access']);
+ render();
+ expect(screen.queryByTestId('sql-tracer-embed')).toBeNull();
+ expect(screen.getByTestId('index-management-embed')).toBeTruthy();
});
it('lists every utility in the workspace nav, not the SQL sidebar', () => {
diff --git a/apps/web/src/frontend/features/utilities/components/UtilitiesView.tsx b/apps/web/src/frontend/features/utilities/components/UtilitiesView.tsx
index d589516ab..69a9c3a8f 100644
--- a/apps/web/src/frontend/features/utilities/components/UtilitiesView.tsx
+++ b/apps/web/src/frontend/features/utilities/components/UtilitiesView.tsx
@@ -8,7 +8,8 @@
* Connection-first: one credential chip at the top; tools dock as panes.
* Left list keeps the e2e `utilities-*` / `sql-sidebar-utilities` testids.
*/
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useMemo, useState } from 'react';
+import type { Permission } from '@foxschema/shared';
import {
Activity,
Copy,
@@ -18,6 +19,7 @@ import {
FileSpreadsheet,
HardDrive,
KeyRound,
+ ScanSearch,
Users,
Wrench,
} from 'lucide-react';
@@ -31,6 +33,8 @@ import { DatabaseAccessModal } from './DatabaseAccessModal';
import { FileQueryModal } from './FileQueryModal';
import { IndexManagementModal } from './IndexManagementModal';
import { ServerInsightsModal, type ServerInsightsTab } from './ServerInsightsModal';
+import { SqlTracerPanel } from './SqlTracerPanel';
+import { useAuthStore } from '@/platform/authStore';
import { sectionLabelCls } from '@/shared/components/surfaces';
import { connectionOptionLabel } from '@/shared/lib/dialectLabel';
@@ -43,7 +47,8 @@ export type UtilityTool =
| 'pool'
| 'sessions'
| 'system'
- | 'sizes';
+ | 'sizes'
+ | 'trace';
const LS_TOOL = 'foxschema-utilities-tool';
const LS_CONN = 'foxschema-utilities-connection';
@@ -55,6 +60,8 @@ const TOOLS: {
group: string;
blurb: string;
icon: React.ElementType;
+ /** Shown only to someone with this permission, on top of Utilities itself. */
+ permission?: Permission;
}[] = [
{
id: 'indexes',
@@ -112,6 +119,15 @@ const TOOLS: {
blurb: 'On-disk size plus average fragmentation per table.',
icon: HardDrive,
},
+ {
+ id: 'trace',
+ testId: 'utilities-sql-tracer',
+ label: 'SQL Tracer',
+ group: 'Insights',
+ blurb: 'Watch the statements on this connection for up to 15 minutes. Secrets are masked.',
+ icon: ScanSearch,
+ permission: 'utility.trace',
+ },
{
id: 'dbAccess',
testId: 'utilities-database-access',
@@ -162,10 +178,15 @@ function insightTab(tool: UtilityTool): ServerInsightsTab | null {
export const UtilitiesView: React.FC = () => {
const setActiveView = useUiStore((s) => s.setActiveView);
const connections = useSyncStore((s) => s.connections);
- const [tool, setTool] = useState(loadTool);
+ // The ids this person may open, as one string: re-renders when a role change alters them.
+ const allowed = useAuthStore((s) => TOOLS.filter((t) => !t.permission || s.can(t.permission)).map((t) => t.id).join(' '));
+ const tools = useMemo(() => TOOLS.filter((t) => allowed.split(' ').includes(t.id)), [allowed]);
+ const [chosen, setTool] = useState(loadTool);
+ // A remembered tool this person may no longer open falls back to the first.
+ const tool = tools.some((t) => t.id === chosen) ? chosen : tools[0]!.id;
const [connectionId, setConnectionId] = useState('');
const [fileImportsKey, setFileImportsKey] = useState(0);
- const active = TOOLS.find((t) => t.id === tool) ?? TOOLS[0]!;
+ const active = tools.find((t) => t.id === tool) ?? tools[0]!;
const insights = insightTab(tool);
const conn = connections.find((c) => c.id === connectionId);
const lockedId = connectionId || undefined;
@@ -216,7 +237,7 @@ export const UtilitiesView: React.FC = () => {
{group}
- {TOOLS.filter((t) => t.group === group).map((t) => {
+ {tools.filter((t) => t.group === group).map((t) => {
const on = t.id === tool;
return (