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;