From de627d20178402d2274af27218e0df12e517e8c8 Mon Sep 17 00:00:00 2001
From: huyplb
Date: Mon, 26 Oct 2026 10:31:15 -0600
Subject: [PATCH 1/2] =?UTF-8?q?feat(utilities):=20SQL=20Tracer=20=E2=80=94?=
=?UTF-8?q?=20live=20sessions=20and=20Fox=20activity,=20redacted=20and=20t?=
=?UTF-8?q?ime-boxed?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
apps/e2e/src/generated/test-ids.ts | 47 +++
.../features/utilities/api/sqlTraceApi.ts | 42 ++
.../components/SqlTracerPanel.test.tsx | 107 +++++
.../utilities/components/SqlTracerPanel.tsx | 365 ++++++++++++++++++
.../components/UtilitiesView.test.tsx | 31 ++
.../utilities/components/UtilitiesView.tsx | 33 +-
docs/testing/TEST_IDS.md | 18 +-
packages/server/src/api/http-contract.test.ts | 3 +
packages/server/src/app/feature-registry.ts | 4 +
packages/server/src/features/access/index.ts | 3 +
.../sql-editor/editor-activity.test.ts | 77 ++++
.../src/features/sql-editor/editor.routes.ts | 32 +-
.../server/src/features/sql-editor/index.ts | 1 +
.../server/src/features/sql-trace/index.ts | 35 ++
.../sql-trace/sql-trace.routes.test.ts | 94 +++++
.../features/sql-trace/sql-trace.routes.ts | 102 +++++
.../sql-trace/sql-trace.service.test.ts | 214 ++++++++++
.../features/sql-trace/sql-trace.service.ts | 308 +++++++++++++++
.../platform/authorization/rbac.service.ts | 7 +
.../server/src/platform/feature-module.ts | 3 +
.../src/platform/sql-activity/sql-activity.ts | 69 ++++
packages/shared/src/permissions.ts | 3 +
packages/sql/src/index.ts | 9 +
.../src/modules/utilities/sql-trace.test.ts | 38 ++
.../sql/src/modules/utilities/sql-trace.ts | 84 ++++
packages/ui-shared/src/index.ts | 4 +
26 files changed, 1726 insertions(+), 7 deletions(-)
create mode 100644 apps/web/src/frontend/features/utilities/api/sqlTraceApi.ts
create mode 100644 apps/web/src/frontend/features/utilities/components/SqlTracerPanel.test.tsx
create mode 100644 apps/web/src/frontend/features/utilities/components/SqlTracerPanel.tsx
create mode 100644 packages/server/src/features/sql-editor/editor-activity.test.ts
create mode 100644 packages/server/src/features/sql-trace/index.ts
create mode 100644 packages/server/src/features/sql-trace/sql-trace.routes.test.ts
create mode 100644 packages/server/src/features/sql-trace/sql-trace.routes.ts
create mode 100644 packages/server/src/features/sql-trace/sql-trace.service.test.ts
create mode 100644 packages/server/src/features/sql-trace/sql-trace.service.ts
create mode 100644 packages/server/src/platform/sql-activity/sql-activity.ts
create mode 100644 packages/sql/src/modules/utilities/sql-trace.test.ts
create mode 100644 packages/sql/src/modules/utilities/sql-trace.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/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..c30f24b40 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,16 @@ 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);
+ // Subscribe to the user, not to `can` (a stable function), so a role change re-renders.
+ const user = useAuthStore((s) => s.user);
+ const can = useAuthStore((s) => s.can);
+ const tools = useMemo(() => TOOLS.filter((t) => !t.permission || can(t.permission)), [user, can]);
+ 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 +238,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 (