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

+ ) : ( + + + + + + + + + + + + + {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..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 (