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;