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.'} +

+ ) : ( + + + + + + + + + + + + + {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'}`} + > + + + + + + + + ))} + +
TimemsUserClientRowsStatement
{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 (