From 853fccdf3653e2700f8b5dc3e61492371a6e42d6 Mon Sep 17 00:00:00 2001 From: Pierre Massat Date: Fri, 30 Jan 2026 14:07:56 -0800 Subject: [PATCH] feat(admin): Add output format switching to SQL shells Add FORMAT command to switch between TABLE, JSON, CSV, and VERTICAL output formats in both tracing and system shells. The format setting is displayed in the status bar and persists for the session. Co-Authored-By: Claude Opus 4.5 --- .../admin/static/sql_shell/command_parser.ts | 11 +- snuba/admin/static/sql_shell/shell.tsx | 19 ++ .../admin/static/sql_shell/shell_context.tsx | 1 + snuba/admin/static/sql_shell/shell_output.tsx | 247 ++++++++++++++++-- snuba/admin/static/sql_shell/styles.ts | 57 ++++ snuba/admin/static/sql_shell/types.ts | 4 + 6 files changed, 310 insertions(+), 29 deletions(-) diff --git a/snuba/admin/static/sql_shell/command_parser.ts b/snuba/admin/static/sql_shell/command_parser.ts index 0c97e587b1..6a74e02a1d 100644 --- a/snuba/admin/static/sql_shell/command_parser.ts +++ b/snuba/admin/static/sql_shell/command_parser.ts @@ -1,4 +1,4 @@ -import { ParsedCommand, ShellMode } from "./types"; +import { ParsedCommand, ShellMode, OutputFormat } from "./types"; /** * Command definition for the shell parser. @@ -90,6 +90,15 @@ const COMMANDS: CommandDefinition[] = [ modes: ["system"], }, + // FORMAT TABLE|JSON|CSV|VERTICAL - Set output format + { + pattern: /^FORMAT\s+(TABLE|JSON|CSV|VERTICAL)$/i, + parse: (match) => ({ + type: "format", + format: match[1].toLowerCase() as OutputFormat, + }), + }, + // HELP - Show help message { pattern: /^HELP$/i, diff --git a/snuba/admin/static/sql_shell/shell.tsx b/snuba/admin/static/sql_shell/shell.tsx index ee6809b6f5..a277c21bb1 100644 --- a/snuba/admin/static/sql_shell/shell.tsx +++ b/snuba/admin/static/sql_shell/shell.tsx @@ -212,6 +212,18 @@ function SQLShell({ api, mode }: SQLShellProps) { }); break; + case "format": + setState((prev) => ({ + ...prev, + outputFormat: parsed.format, + })); + addHistoryEntry({ + type: "info", + content: `Output format set to: ${parsed.format.toUpperCase()}`, + timestamp: Date.now(), + }); + break; + case "sql": if (!state.currentStorage) { addHistoryEntry({ @@ -451,6 +463,7 @@ function SQLShell({ api, mode }: SQLShellProps) { entries={state.history} traceFormatted={state.traceFormatted} mode={mode} + outputFormat={state.outputFormat} isExecuting={state.isExecuting} /> {suggestions.length > 0 && ( @@ -550,6 +563,12 @@ function SQLShell({ api, mode }: SQLShellProps) { )} +
+ FORMAT: + + {state.outputFormat.toUpperCase()} + +
Tab: Autocomplete | Enter: Execute | Shift+Enter: New line | ↑↓: History
diff --git a/snuba/admin/static/sql_shell/shell_context.tsx b/snuba/admin/static/sql_shell/shell_context.tsx index c52ab51ce1..27a7cff166 100644 --- a/snuba/admin/static/sql_shell/shell_context.tsx +++ b/snuba/admin/static/sql_shell/shell_context.tsx @@ -35,6 +35,7 @@ function createInitialState(mode: ShellMode): ShellState { profileEnabled: true, traceFormatted: true, sudoEnabled: false, + outputFormat: "table", history: [], commandHistory: loadCommandHistory(mode), historyIndex: -1, diff --git a/snuba/admin/static/sql_shell/shell_output.tsx b/snuba/admin/static/sql_shell/shell_output.tsx index a02a2b2b87..35786041d0 100644 --- a/snuba/admin/static/sql_shell/shell_output.tsx +++ b/snuba/admin/static/sql_shell/shell_output.tsx @@ -1,7 +1,7 @@ import React, { useRef, useEffect, useCallback, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useShellStyles } from "SnubaAdmin/sql_shell/styles"; -import { ShellHistoryEntry, ShellMode } from "SnubaAdmin/sql_shell/types"; +import { ShellHistoryEntry, ShellMode, OutputFormat } from "SnubaAdmin/sql_shell/types"; import { TracingResult, TracingSummary, @@ -52,6 +52,7 @@ interface ShellOutputProps { entries: ShellHistoryEntry[]; traceFormatted: boolean; mode: ShellMode; + outputFormat: OutputFormat; isExecuting?: boolean; } @@ -69,7 +70,7 @@ function estimateEntryHeight(entry: ShellHistoryEntry): number { return heights[entry.type] || 50; } -export function ShellOutput({ entries, traceFormatted, mode, isExecuting }: ShellOutputProps) { +export function ShellOutput({ entries, traceFormatted, mode, outputFormat, isExecuting }: ShellOutputProps) { const { classes } = useShellStyles(); const scrollContainerRef = useRef(null); const wasAtBottomRef = useRef(true); @@ -137,6 +138,7 @@ export function ShellOutput({ entries, traceFormatted, mode, isExecuting }: Shel entry={entries[virtualItem.index]} traceFormatted={traceFormatted} mode={mode} + outputFormat={outputFormat} classes={classes} /> @@ -153,11 +155,13 @@ function ShellEntry({ entry, traceFormatted, mode, + outputFormat, classes, }: { entry: ShellHistoryEntry; traceFormatted: boolean; mode: ShellMode; + outputFormat: OutputFormat; classes: Record; }) { switch (entry.type) { @@ -172,6 +176,7 @@ function ShellEntry({ ); @@ -179,6 +184,7 @@ function ShellEntry({ return ( ); @@ -292,6 +298,7 @@ function HelpOutput({ mode, classes }: { mode: ShellMode; classes: Record", desc: "Execute SQL with tracing" }, @@ -303,6 +310,7 @@ function HelpOutput({ mode, classes }: { mode: ShellMode; classes: Record", desc: "Execute SQL query" }, @@ -368,13 +376,105 @@ function HelpOutput({ mode, classes }: { mode: ShellMode; classes: Record +): React.ReactNode { + const data = rows.slice(0, 100).map((row) => { + const obj: Record = {}; + cols.forEach((col, idx) => { + obj[col] = row[idx]; + }); + return obj; + }); + + return ( +
+      {JSON.stringify(data, null, 2)}
+    
+ ); +} + +// Render data as CSV format +function renderAsCsv( + cols: string[], + rows: any[][], + classes: Record +): React.ReactNode { + const headerRow = cols.map(escapeCsvValue).join(","); + const dataRows = rows.slice(0, 100).map((row) => + row.map(escapeCsvValue).join(",") + ); + + return ( +
+      {[headerRow, ...dataRows].join("\n")}
+    
+ ); +} + +// Render data as vertical format (one column per line per row) +function renderAsVertical( + cols: string[], + rows: any[][], + classes: Record +): React.ReactNode { + const maxColWidth = Math.max(...cols.map((c) => c.length)); + + return ( +
+ {rows.slice(0, 100).map((row, rowIdx) => ( +
+
+ *************************** {rowIdx + 1}. row *************************** +
+ {cols.map((col, colIdx) => { + const value = row[colIdx]; + const displayValue = + value === null || value === undefined + ? "NULL" + : typeof value === "object" + ? JSON.stringify(value) + : String(value); + return ( +
+ + {col.padStart(maxColWidth)}: + + {displayValue} +
+ ); + })} +
+ ))} +
+ ); +} + function ResultsOutput({ result, traceFormatted, + outputFormat, classes, }: { result: TracingResult; traceFormatted: boolean; + outputFormat: OutputFormat; classes: Record; }) { if (!result) { @@ -389,15 +489,51 @@ function ResultsOutput({ const rows = result.result || []; const rowCount = result.num_rows_result || rows.length || 0; - return ( -
- - {cols.length > 0 ? ( + // Extract column names for format renderers (cols are [name, type] arrays) + const colNames = cols.map((col: string[] | undefined) => col ? col[0] : "?"); + + const renderResultContent = () => { + if (cols.length === 0) { + return
No results returned
; + } + + switch (outputFormat) { + case "json": + return ( + <> + {renderAsJson(colNames, rows, classes)} + {rows.length > 100 && ( +
+ ... showing first 100 of {rows.length} rows +
+ )} + + ); + case "csv": + return ( + <> + {renderAsCsv(colNames, rows, classes)} + {rows.length > 100 && ( +
+ ... showing first 100 of {rows.length} rows +
+ )} + + ); + case "vertical": + return ( + <> + {renderAsVertical(colNames, rows, classes)} + {rows.length > 100 && ( +
+ ... showing first 100 of {rows.length} rows +
+ )} + + ); + case "table": + default: + return (
@@ -431,9 +567,19 @@ function ResultsOutput({ )} - ) : ( -
No results returned
- )} + ); + } + }; + + return ( +
+ + {renderResultContent()} {traceFormatted && result.summarized_trace_output ? ( @@ -455,9 +601,11 @@ function ResultsOutput({ function SystemResultsOutput({ result, + outputFormat, classes, }: { result: QueryResult; + outputFormat: OutputFormat; classes: Record; }) { if (!result) { @@ -468,18 +616,51 @@ function SystemResultsOutput({ return ; } - const cols = result.column_names || []; - const rows = result.rows || []; + const cols = (result.column_names || []) as string[]; + const rows = (result.rows || []) as any[][]; - return ( -
- - {cols.length > 0 ? ( + const renderResultContent = () => { + if (cols.length === 0) { + return
No results returned
; + } + + switch (outputFormat) { + case "json": + return ( + <> + {renderAsJson(cols, rows, classes)} + {rows.length > 100 && ( +
+ ... showing first 100 of {rows.length} rows +
+ )} + + ); + case "csv": + return ( + <> + {renderAsCsv(cols, rows, classes)} + {rows.length > 100 && ( +
+ ... showing first 100 of {rows.length} rows +
+ )} + + ); + case "vertical": + return ( + <> + {renderAsVertical(cols, rows, classes)} + {rows.length > 100 && ( +
+ ... showing first 100 of {rows.length} rows +
+ )} + + ); + case "table": + default: + return (
@@ -511,9 +692,19 @@ function SystemResultsOutput({ )} - ) : ( -
No results returned
- )} + ); + } + }; + + return ( +
+ + {renderResultContent()}
); diff --git a/snuba/admin/static/sql_shell/styles.ts b/snuba/admin/static/sql_shell/styles.ts index 8454849e18..cd3d396be5 100644 --- a/snuba/admin/static/sql_shell/styles.ts +++ b/snuba/admin/static/sql_shell/styles.ts @@ -424,4 +424,61 @@ export const useShellStyles = createStyles((theme) => ({ wordBreak: "break-word", overflowWrap: "break-word", }, + // JSON output format styles + jsonOutput: { + color: "#e6edf3", + fontSize: "11px", + lineHeight: 1.5, + fontFamily: "inherit", + margin: 0, + padding: "8px", + backgroundColor: "rgba(13, 17, 23, 0.4)", + borderRadius: "4px", + overflow: "auto", + maxHeight: "400px", + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }, + // CSV output format styles + csvOutput: { + color: "#e6edf3", + fontSize: "11px", + lineHeight: 1.5, + fontFamily: "inherit", + margin: 0, + padding: "8px", + backgroundColor: "rgba(13, 17, 23, 0.4)", + borderRadius: "4px", + overflow: "auto", + maxHeight: "400px", + whiteSpace: "pre", + }, + // Vertical output format styles + verticalOutput: { + fontSize: "11px", + lineHeight: 1.5, + fontFamily: "inherit", + }, + verticalRow: { + marginBottom: "12px", + }, + verticalRowHeader: { + color: "#ffa657", + marginBottom: "4px", + fontWeight: 500, + }, + verticalField: { + display: "flex", + color: "#e6edf3", + lineHeight: 1.6, + }, + verticalFieldName: { + color: "#58a6ff", + whiteSpace: "pre", + minWidth: "120px", + }, + verticalFieldValue: { + color: "#e6edf3", + wordBreak: "break-word", + }, })); diff --git a/snuba/admin/static/sql_shell/types.ts b/snuba/admin/static/sql_shell/types.ts index d6ae932774..a6a6470e07 100644 --- a/snuba/admin/static/sql_shell/types.ts +++ b/snuba/admin/static/sql_shell/types.ts @@ -3,6 +3,8 @@ import { QueryResult } from "SnubaAdmin/clickhouse_queries/types"; export type ShellMode = "tracing" | "system"; +export type OutputFormat = "table" | "json" | "csv" | "vertical"; + export type ParsedCommand = | { type: "use"; storage: string } | { type: "host"; host: string; port: number } @@ -11,6 +13,7 @@ export type ParsedCommand = | { type: "profile"; enabled: boolean } | { type: "trace_mode"; formatted: boolean } | { type: "sudo"; enabled: boolean } + | { type: "format"; format: OutputFormat } | { type: "help" } | { type: "clear" } | { type: "sql"; query: string }; @@ -41,6 +44,7 @@ export interface ShellState { profileEnabled: boolean; traceFormatted: boolean; sudoEnabled: boolean; + outputFormat: OutputFormat; history: ShellHistoryEntry[]; commandHistory: string[]; historyIndex: number;