From 2cca57eeb738512d54378a0f3c45dbddc9f5d3e9 Mon Sep 17 00:00:00 2001 From: Oliver Newland Date: Fri, 16 Oct 2026 11:59:54 -0700 Subject: [PATCH 1/3] feat(admin): Put the System Queries editor in a sticky, closable panel The editor took up most of a laptop screen, pushing results below the fold. It now sits in a panel that sticks below the header and closes to a one-line bar showing the storage, host and query, so it can be reopened from anywhere in the results. The whole bar opens it. The panel closes after a successful run and scrolls to the results. It stays open on error so the query can be fixed. The editor is hidden rather than unmounted, so its state survives closing. Also add space above the results heading. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../clickhouse_queries/query_display.tsx | 419 +++++++++++------- .../tests/clickhouse_queries/prefill.spec.tsx | 36 ++ 2 files changed, 305 insertions(+), 150 deletions(-) diff --git a/snuba/admin/static/clickhouse_queries/query_display.tsx b/snuba/admin/static/clickhouse_queries/query_display.tsx index dab886da33..4aa859848b 100644 --- a/snuba/admin/static/clickhouse_queries/query_display.tsx +++ b/snuba/admin/static/clickhouse_queries/query_display.tsx @@ -1,8 +1,18 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import Client from "SnubaAdmin/api_client"; import QueryEditor from "SnubaAdmin/query_editor"; import ExecuteButton from "SnubaAdmin/utils/execute_button"; -import { Alert, Box, SegmentedControl, Text, Tooltip } from "@mantine/core"; +import { + Alert, + Box, + Button, + CloseButton, + SegmentedControl, + Text, + Tooltip, + UnstyledButton, +} from "@mantine/core"; +import { IconChevronDown } from "@tabler/icons-react"; import { getRecentHistory, setRecentHistory } from "SnubaAdmin/query_history"; import { COLORS, SECTION_HEADING_STYLE } from "SnubaAdmin/theme"; import { CustomSelect, getParamFromStorage } from "SnubaAdmin/select"; @@ -49,6 +59,10 @@ function QueryDisplay(props: { // Only a result the user just ran opens automatically; saved history loads // collapsed. const [justRan, setJustRan] = useState(null); + // The editor sits in a sticky panel so it can be reopened from anywhere in + // the results. It closes after a successful run to make room for them. + const [editorOpen, setEditorOpen] = useState(true); + const resultsHeadingRef = useRef(null); const [queryError, setQueryError] = useState(null); @@ -76,6 +90,14 @@ function QueryDisplay(props: { }); }, []); + // Bring a fresh result into view, since it may be above where the user + // scrolled to. (jsdom has no scrollIntoView.) + useEffect(() => { + if (justRan) { + resultsHeadingRef.current?.scrollIntoView?.({ block: "start" }); + } + }, [justRan]); + function selectStorage(storage: string) { setQuery((prevQuery) => { // clear old host port @@ -155,6 +177,7 @@ function QueryDisplay(props: { setRecentHistory(HISTORY_KEY, result); setQueryResultHistory((prevHistory) => [result, ...prevHistory]); setJustRan(result); + setEditorOpen(false); }); } @@ -190,170 +213,215 @@ function QueryDisplay(props: { return (
-
- {prefill && prefillWarningOpen && ( - setPrefillWarningOpen(false)} - style={prefillAlertStyle} - data-testid="prefill-warning" +
+ {!editorOpen && ( + // The whole bar opens the editor. The "button" inside is only a + // span, since a real button can't be nested in another. + setEditorOpen(true)} + aria-label="Open editor" + style={editorClosedBarStyle} + sx={{ "&:hover": { backgroundColor: COLORS.BG_GRAY_LIGHTER } }} > -

- Whoever clicks Execute is responsible for the query, no matter - who or what wrote it. Read the whole query and check where it - will run before you execute it. Don't run it just because an - agent or a link suggested it. -

-

- Storage: {prefill.storage ?? "(not set)"} + + + {query.sudo && ( + + SUDO{" "} + + )} + {query.storage ?? "(no storage)"} {" · "} - Host:{" "} - - {prefill.host ? `${prefill.host}:${prefill.port}` : "(not set)"} - -

- + {query.host ? `${query.host}:${query.port}` : "(no host)"} + {" · "} + {query.sql || "(no query)"} + +
)} -

Construct a ClickHouse System Query

-
-
-
- - Host entry - - setClusterlessHost(value === "manual")} - data={[ - { label: "Pick a node", value: "pick" }, - { label: "Enter host manually", value: "manual" }, - ]} - /> -
-
- - Storage - -
- storage.storage_name)} + {/* Hidden rather than unmounted, so the editor keeps its state. */} + + {prefill && prefillWarningOpen && ( + setPrefillWarningOpen(false)} + style={prefillAlertStyle} + data-testid="prefill-warning" + > +

+ Whoever clicks Execute is responsible for the query, no matter + who or what wrote it. Read the whole query and check where it + will run before you execute it. Don't run it just because an + agent or a link suggested it. +

+

+ Storage: {prefill.storage ?? "(not set)"} + {" · "} + Host:{" "} + + {prefill.host ? `${prefill.host}:${prefill.port}` : "(not set)"} + +

+
+ )} +
+

+ Construct a ClickHouse System Query +

+ setEditorOpen(false)} + /> +
+
+
+
+ + Host entry + + setClusterlessHost(value === "manual")} + data={[ + { label: "Pick a node", value: "pick" }, + { label: "Enter host manually", value: "manual" }, + ]} />
- {missing === "storage" && ( -

{missingHint.storage}

- )} -
-
- - Host - - {query.clusterless ? ( - <> - selectHostIp(evt.target.value)} - name="host" - placeholder="enter host or host:port..." - type="text" +
+ + Storage + +
+ storage.storage_name)} /> - {missing === "host" && ( -

{missingHint.host}

- )} -

- Must be a known cluster node or listed in - admin.copy_tables_allowed_target_hosts. Defaults to port - 8123. -

- - ) : ( - <> -
- + {missing === "storage" && ( +

{missingHint.storage}

+ )} +
+
+ + Host + + {query.clusterless ? ( + <> + selectHostIp(evt.target.value)} + name="host" + placeholder="enter host or host:port..." + type="text" /> -
- {missing === "host" && ( -

{missingHint.host}

- )} - - )} -
-
-
-
- { - updateQuerySql(sql); - }} - predefinedQueryOptions={props.predefinedQueryOptions} - initialQuery={prefill?.sql} - /> -
-
-
-
- setSudo(value === "sudo")} - color={query.sudo ? "red" : undefined} - data={[ - { label: "User mode", value: "user" }, - { label: "Sudo mode", value: "sudo" }, - ]} - style={segmentedStyle} - /> -

- {query.sudo - ? "Sudo only runs SYSTEM, ALTER, OPTIMIZE and DROP statements. It needs the sudo role, and every sudo query is audit-logged with a notification." - : "SELECT, SHOW and DESCRIBE. SELECTs may only read system tables."} -

+ {missing === "host" && ( +

{missingHint.host}

+ )} +

+ Must be a known cluster node or listed in + admin.copy_tables_allowed_target_hosts. Defaults to port + 8123. +

+ + ) : ( + <> +
+ +
+ {missing === "host" && ( +

{missingHint.host}

+ )} + + )} +
- {/* A disabled button gets no hover events, so the tooltip - hangs off a wrapper. */} - -
- +
+ { + updateQuerySql(sql); + }} + predefinedQueryOptions={props.predefinedQueryOptions} + initialQuery={prefill?.sql} + /> +
+
+
+
+ setSudo(value === "sudo")} color={query.sudo ? "red" : undefined} - onError={handleQueryError} - onClick={executeQuery} - disabled={missing !== null} + data={[ + { label: "User mode", value: "user" }, + { label: "Sudo mode", value: "sudo" }, + ]} + style={segmentedStyle} /> +

+ {query.sudo + ? "Sudo only runs SYSTEM, ALTER, OPTIMIZE and DROP statements. It needs the sudo role, and every sudo query is audit-logged with a notification." + : "SELECT, SHOW and DESCRIBE. SELECTs may only read system tables."} +

- -
-
- + {/* A disabled button gets no hover events, so the tooltip + hangs off a wrapper. */} + +
+ +
+
+
+
+ +
{getErrorDomElement(queryError, collapseOpened, setCollapseOpened)}
-

Query results

+

+ Query results +

{queryResultHistory.map((queryResult, idx) => ( { expect(screen.queryByText("Select a host to run the query.")).toBeNull(); }); + it("closes the editor after a run and reopens it with the query kept", async () => { + const api = renderWithHash( + "#system-queries?query=SELECT%201&storage=errors&host=clickhouse-01%3A9000" + ); + await waitFor(() => expect(api.getClickhouseNodes).toBeCalled()); + await screen.findAllByDisplayValue("clickhouse-01:9000"); + + fireEvent.click(screen.getByText("Execute Query")); + await screen.findByText("Open Editor"); + expect(api.executeSystemQuery).toBeCalled(); + expect(screen.queryByRole("textbox")).toBeNull(); + // The closed bar says what will run. + expect(screen.getByTestId("editor-panel").textContent).toContain( + "errors · clickhouse-01:9000 · SELECT 1" + ); + + // Anywhere on the bar opens it, not just the button. + fireEvent.click( + within(screen.getByTestId("editor-panel")).getByText("SELECT 1", { + selector: "code", + }) + ); + expect(screen.getByRole("textbox").textContent).toBe("SELECT 1"); + expect(screen.queryByText("Open Editor")).toBeNull(); + }); + + it("closes the editor with the X", async () => { + const api = renderWithHash("#system-queries"); + await waitFor(() => expect(api.getClickhouseNodes).toBeCalled()); + + fireEvent.click(screen.getByLabelText("Close editor")); + expect(screen.getByText("Open Editor")).toBeTruthy(); + expect(api.executeSystemQuery).not.toBeCalled(); + }); + it("shows no warning without params", async () => { const api = renderWithHash("#system-queries"); await waitFor(() => expect(api.getClickhouseNodes).toBeCalled()); From 3207743bd92dba734c64dbde46420b44add82da1 Mon Sep 17 00:00:00 2001 From: Oliver Newland Date: Fri, 16 Oct 2026 12:05:23 -0700 Subject: [PATCH 2/3] feat(admin): Drop the System Queries form boxes and tighten its rows The Where, What and How boxes added space without helping, so the rows are now separated by spacing only. Sudo mode keeps a red frame around the execute row, since it can change data. Also make the editor's close button a clearly labelled red button, put the predefined query label beside its picker, and put the mode explainer on the same line as the mode toggle and Execute. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../clickhouse_queries/query_display.tsx | 123 ++++++------------ snuba/admin/static/query_editor.tsx | 14 +- 2 files changed, 52 insertions(+), 85 deletions(-) diff --git a/snuba/admin/static/clickhouse_queries/query_display.tsx b/snuba/admin/static/clickhouse_queries/query_display.tsx index 4aa859848b..ad2cc4173a 100644 --- a/snuba/admin/static/clickhouse_queries/query_display.tsx +++ b/snuba/admin/static/clickhouse_queries/query_display.tsx @@ -4,15 +4,13 @@ import QueryEditor from "SnubaAdmin/query_editor"; import ExecuteButton from "SnubaAdmin/utils/execute_button"; import { Alert, - Box, Button, - CloseButton, SegmentedControl, Text, Tooltip, UnstyledButton, } from "@mantine/core"; -import { IconChevronDown } from "@tabler/icons-react"; +import { IconChevronDown, IconX } from "@tabler/icons-react"; import { getRecentHistory, setRecentHistory } from "SnubaAdmin/query_history"; import { COLORS, SECTION_HEADING_STYLE } from "SnubaAdmin/theme"; import { CustomSelect, getParamFromStorage } from "SnubaAdmin/select"; @@ -276,13 +274,18 @@ function QueryDisplay(props: {

Construct a ClickHouse System Query

- } aria-label="Close editor" - title="Close editor" onClick={() => setEditorOpen(false)} - /> + > + Close +
-
+
@@ -364,8 +367,8 @@ function QueryDisplay(props: { )}
-
-
+
+
{ updateQuerySql(sql); @@ -373,27 +376,26 @@ function QueryDisplay(props: { predefinedQueryOptions={props.predefinedQueryOptions} initialQuery={prefill?.sql} /> -
-
+
+ {/* Sudo can change data, so it gets a red frame. */} +
-
- setSudo(value === "sudo")} - color={query.sudo ? "red" : undefined} - data={[ - { label: "User mode", value: "user" }, - { label: "Sudo mode", value: "sudo" }, - ]} - style={segmentedStyle} - /> -

- {query.sudo - ? "Sudo only runs SYSTEM, ALTER, OPTIMIZE and DROP statements. It needs the sudo role, and every sudo query is audit-logged with a notification." - : "SELECT, SHOW and DESCRIBE. SELECTs may only read system tables."} -

-
+ setSudo(value === "sudo")} + color={query.sudo ? "red" : undefined} + data={[ + { label: "User mode", value: "user" }, + { label: "Sudo mode", value: "sudo" }, + ]} + style={{ flexShrink: 0 }} + /> +

+ {query.sudo + ? "Sudo only runs SYSTEM, ALTER, OPTIMIZE and DROP statements. It needs the sudo role, and every sudo query is audit-logged with a notification." + : "SELECT, SHOW and DESCRIBE. SELECTs may only read system tables."} +

{/* A disabled button gets no hover events, so the tooltip hangs off a wrapper. */}
- +
@@ -440,60 +442,19 @@ function QueryDisplay(props: { ); } -function Section(props: { - title: string; - // Highlights the section in red, e.g. for sudo mode. - danger?: boolean; - children: React.ReactNode; -}) { - return ( - - - {props.title} - - {props.children} - - ); -} - -const sectionStyle = { - border: "1px solid #ced4da", - borderRadius: 4, +// Spaces out the target, query and execute rows. +const blockStyle = { + marginBottom: 16, }; -const dangerSectionStyle = { - ...sectionStyle, +const sudoBlockStyle = { + ...blockStyle, + padding: 12, border: `2px solid ${COLORS.DANGER}`, + borderRadius: 4, backgroundColor: "rgba(168, 50, 45, 0.06)", }; -// A step smaller than the page headings, so sections read as nested. -// Bootstrap makes legends large, full-width and floated; undo that so the -// label sits in the border. -const sectionLegendStyle = { - ...SECTION_HEADING_STYLE, - fontSize: 12, - float: "none" as const, - width: "auto", - padding: "0 4px", - margin: 0, -}; - -const segmentedStyle = { - marginBottom: 8, -}; - const targetRowStyle = { display: "flex", flexWrap: "wrap" as const, @@ -571,7 +532,10 @@ const editorSummaryStyle = { color: COLORS.TEXT_LIGHTER, }; +// Takes the space between the mode toggle and Execute. const modeHelpTextStyle = { + flex: 1, + minWidth: 0, fontSize: "0.85em", margin: 0, }; @@ -598,8 +562,7 @@ const hostHelpTextStyle = { const executeActionsStyle = { display: "flex", - justifyContent: "space-between", - alignItems: "flex-start", + alignItems: "center", gap: 16, }; diff --git a/snuba/admin/static/query_editor.tsx b/snuba/admin/static/query_editor.tsx index 7129983858..aed09eb325 100644 --- a/snuba/admin/static/query_editor.tsx +++ b/snuba/admin/static/query_editor.tsx @@ -210,10 +210,10 @@ function QueryEditor(props: { function renderPredefinedQueriesSelectors() { return (
- + Predefined query -
+
{ @@ -475,11 +475,15 @@ function QueryEditor(props: { ); } +// Label and picker on one line. const predefinedQueryStyle = { - width: "20em", display: "flex", - flexDirection: "column" as const, - gap: 4, + alignItems: "center", + gap: 8, +}; + +const predefinedQuerySelectStyle = { + width: "20em", }; const timeRangeStyle = { From ba56ba8cd322b3505796257d30ad998c540c415b Mon Sep 17 00:00:00 2001 From: Oliver Newland Date: Fri, 16 Oct 2026 12:13:21 -0700 Subject: [PATCH 3/3] feat(admin): Keep System Queries column headers in view below the editor Result table headers now stick just below the editor pane, open or closed, so a long result can be scrolled with the editor still on screen. The pane's height is measured and passed down as a CSS variable. Sticky positioning breaks under a scrolling or overflow: hidden ancestor, so the result card clips with overflow: clip and the table no longer scrolls sideways. Wide results wrap instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../admin/static/clickhouse_queries/index.tsx | 32 +++++++++++------ .../clickhouse_queries/query_display.tsx | 35 +++++++++++++++++-- .../query_history_entry.tsx | 4 ++- 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/snuba/admin/static/clickhouse_queries/index.tsx b/snuba/admin/static/clickhouse_queries/index.tsx index e41b9647c1..ec2b8b8d7f 100644 --- a/snuba/admin/static/clickhouse_queries/index.tsx +++ b/snuba/admin/static/clickhouse_queries/index.tsx @@ -1,7 +1,10 @@ import React, { useState, useEffect } from "react"; import Client from "SnubaAdmin/api_client"; import { Table } from "SnubaAdmin/table"; -import QueryDisplay from "SnubaAdmin/clickhouse_queries/query_display"; +import QueryDisplay, { + STICKY_HEADER_TOP, +} from "SnubaAdmin/clickhouse_queries/query_display"; +import { COLORS } from "SnubaAdmin/theme"; import { parseSystemQueryPrefill } from "SnubaAdmin/clickhouse_queries/prefill"; import { QueryResult, PredefinedQuery } from "./types"; @@ -34,12 +37,11 @@ function ClickhouseQueries(props: { api: Client }) { function tablePopulator(queryResult: QueryResult) { return ( -
- - +
); } @@ -68,9 +70,19 @@ const selectStyle = { height: 30, }; -const scroll = { - overflowX: "scroll" as const, - width: "100%", +// Keeps column names in view below the editor pane while scrolling a long +// result. This needs no scrolling ancestor between the table and the page, +// so wide results wrap rather than scroll sideways. +const stickyHeaderStyles = { + thStyle: { + position: "sticky" as const, + top: STICKY_HEADER_TOP, + zIndex: 1, + backgroundColor: COLORS.TABLE_HEADER_BG, + // Collapsed borders don't move with a stuck cell, so draw its bottom + // edge here. + boxShadow: `inset 0 -1px 0 ${COLORS.TABLE_BORDER}`, + }, }; export default ClickhouseQueries; diff --git a/snuba/admin/static/clickhouse_queries/query_display.tsx b/snuba/admin/static/clickhouse_queries/query_display.tsx index ad2cc4173a..908d8861e1 100644 --- a/snuba/admin/static/clickhouse_queries/query_display.tsx +++ b/snuba/admin/static/clickhouse_queries/query_display.tsx @@ -32,6 +32,10 @@ import { type QueryState = Partial; const HISTORY_KEY = "clickhouse_queries"; +// Set on the page to the editor pane's height; see STICKY_HEADER_TOP. +const EDITOR_PANEL_HEIGHT_VAR = "--system-queries-editor-height"; +// Where content stuck to the top of the results sits: just below the pane. +const STICKY_HEADER_TOP = `var(${EDITOR_PANEL_HEIGHT_VAR}, 0px)`; function QueryDisplay(props: { api: Client; resultDataPopulator: (queryResult: QueryResult) => JSX.Element; @@ -61,6 +65,22 @@ function QueryDisplay(props: { // the results. It closes after a successful run to make room for them. const [editorOpen, setEditorOpen] = useState(true); const resultsHeadingRef = useRef(null); + // Result tables pin their headers just below the pane, so they need its + // height, which changes as it opens, closes and the query grows. + const editorPanelRef = useRef(null); + const [editorPanelHeight, setEditorPanelHeight] = useState(0); + + useEffect(() => { + const panel = editorPanelRef.current; + if (!panel || typeof ResizeObserver === "undefined") { + return; + } + const observer = new ResizeObserver(() => + setEditorPanelHeight(panel.offsetHeight) + ); + observer.observe(panel); + return () => observer.disconnect(); + }, []); const [queryError, setQueryError] = useState(null); @@ -210,8 +230,18 @@ function QueryDisplay(props: { }; return ( -
-
+
+
{!editorOpen && ( // The whole bar opens the editor. The "button" inside is only a // span, since a real button can't be nested in another. @@ -566,4 +596,5 @@ const executeActionsStyle = { gap: 16, }; +export { STICKY_HEADER_TOP }; export default QueryDisplay; diff --git a/snuba/admin/static/clickhouse_queries/query_history_entry.tsx b/snuba/admin/static/clickhouse_queries/query_history_entry.tsx index 226573c309..88d4cf595d 100644 --- a/snuba/admin/static/clickhouse_queries/query_history_entry.tsx +++ b/snuba/admin/static/clickhouse_queries/query_history_entry.tsx @@ -90,7 +90,9 @@ const cardStyle = { border: `1px solid ${COLORS.BORDER_GRAY_LIGHT}`, borderRadius: 8, marginBottom: 12, - overflow: "hidden", + // Clips to the rounded corners. Unlike "hidden", "clip" doesn't stop the + // result table's header from sticking. + overflow: "clip", }; const headerStyle = {