diff --git a/snuba/admin/static/clickhouse_queries/index.tsx b/snuba/admin/static/clickhouse_queries/index.tsx
index 6097de302b..4e7220b443 100644
--- a/snuba/admin/static/clickhouse_queries/index.tsx
+++ b/snuba/admin/static/clickhouse_queries/index.tsx
@@ -1,8 +1,7 @@
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 { stickyTableHeaderStyles } from "SnubaAdmin/editor_panel";
+import { ResultTable } from "SnubaAdmin/clickhouse_queries/result_table";
import { parseSystemQueryPrefill } from "SnubaAdmin/clickhouse_queries/prefill";
import { QueryResult, PredefinedQuery } from "./types";
@@ -35,10 +34,9 @@ function ClickhouseQueries(props: { api: Client }) {
function tablePopulator(queryResult: QueryResult) {
return (
-
);
}
diff --git a/snuba/admin/static/clickhouse_queries/query_display.tsx b/snuba/admin/static/clickhouse_queries/query_display.tsx
index 770b384b70..9e415999d6 100644
--- a/snuba/admin/static/clickhouse_queries/query_display.tsx
+++ b/snuba/admin/static/clickhouse_queries/query_display.tsx
@@ -1,15 +1,28 @@
-import React, { useEffect, useRef, useState } from "react";
+import React, { useEffect, useState } from "react";
import Client from "SnubaAdmin/api_client";
import QueryEditor from "SnubaAdmin/query_editor";
import ExecuteButton from "SnubaAdmin/utils/execute_button";
-import { SegmentedControl, Text, Tooltip } from "@mantine/core";
-import { getRecentHistory, setRecentHistory } from "SnubaAdmin/query_history";
-import { COLORS } from "SnubaAdmin/theme";
import {
- EditorPanelLayout,
- PrefillWarning,
- resultsHeadingStyle,
-} from "SnubaAdmin/editor_panel";
+ ActionIcon,
+ Button,
+ SegmentedControl,
+ Text,
+ Tooltip,
+ UnstyledButton,
+} from "@mantine/core";
+import {
+ IconInfoCircle,
+ IconPencil,
+ IconTrash,
+ IconX,
+} from "@tabler/icons-react";
+import {
+ clearRecentHistory,
+ getRecentHistory,
+ setRecentHistory,
+} from "SnubaAdmin/query_history";
+import { COLORS, SECTION_HEADING_STYLE } from "SnubaAdmin/theme";
+import { PrefillWarning } from "SnubaAdmin/editor_panel";
import { CustomSelect, getParamFromStorage } from "SnubaAdmin/select";
import { getHostsForStorage, getErrorDomElement } from "SnubaAdmin/utils/clickhouse_node_utils";
import { QueryHistoryEntry } from "SnubaAdmin/clickhouse_queries/query_history_entry";
@@ -29,6 +42,12 @@ import {
type QueryState = Partial;
const HISTORY_KEY = "clickhouse_queries";
+// The storage's first node, to have a host ready as soon as a storage is
+// picked.
+function firstHost(nodeData: ClickhouseNodeData[], storage?: string) {
+ const [first] = getHostsForStorage(nodeData, storage);
+ return first ? parseHostPort(first.value) : {};
+}
function QueryDisplay(props: {
api: Client;
@@ -43,6 +62,9 @@ function QueryDisplay(props: {
prefill !== null
);
const [nodeData, setNodeData] = useState([]);
+ // The host is filled in once the nodes arrive, so until then it isn't
+ // flagged as missing.
+ const [nodesLoaded, setNodesLoaded] = useState(false);
const [manualHostInput, setManualHostInput] = useState("");
const [query, setQuery] = useState({
storage: prefill?.storage ?? getParamFromStorage("storage"),
@@ -55,10 +77,13 @@ 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.
+ // The editor closes after a successful run to make room for the results.
+ // Closed, the panel sticks to the top so it can be reopened, or the query
+ // run again from beside the storage and host, from anywhere in them.
const [editorOpen, setEditorOpen] = useState(true);
- const resultsHeadingRef = useRef(null);
+ // The query editor puts its predefined query picker here, beside Close.
+ const [predefinedQuerySlot, setPredefinedQuerySlot] =
+ useState(null);
const [queryError, setQueryError] = useState(null);
@@ -71,6 +96,7 @@ function QueryDisplay(props: {
}
props.api.getClickhouseNodes().then((res) => {
setNodeData(res);
+ setNodesLoaded(true);
// A linked host the storage's node list doesn't know about can only be
// shown (and checked) via manual host entry.
if (prefill?.host) {
@@ -82,18 +108,17 @@ function QueryDisplay(props: {
setManualHostInput(hostPort);
setClusterlessHost(true);
}
+ } else if (!prefill) {
+ // The saved storage comes back without a host.
+ setQuery((prevQuery) =>
+ prevQuery.host
+ ? prevQuery
+ : { ...prevQuery, ...firstHost(res, prevQuery.storage) }
+ );
}
});
}, []);
- // 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
@@ -103,6 +128,8 @@ function QueryDisplay(props: {
return {
...prevQuery,
storage: storage,
+ // A typed host isn't replaced; it's up to the user.
+ ...(prevQuery.clusterless ? {} : firstHost(nodeData, storage)),
};
});
}
@@ -177,8 +204,17 @@ function QueryDisplay(props: {
});
}
+ function clearResults() {
+ clearRecentHistory(HISTORY_KEY);
+ setQueryResultHistory([]);
+ setJustRan(null);
+ }
+
function handleQueryError(error: Error) {
setQueryError(error);
+ // Errors only show in the editor, so a failed run from the closed bar
+ // opens it.
+ setEditorOpen(true);
}
function convertResultsToCSV(queryResult: QueryResult) {
@@ -206,122 +242,229 @@ function QueryDisplay(props: {
host: "Select a host to run the query.",
query: "Write or pick a query to run.",
};
+ // What to flag on the page; Execute stays disabled either way.
+ const flagged = nodesLoaded ? missing : null;
+
+ const errorElement = getErrorDomElement(
+ queryError,
+ collapseOpened,
+ setCollapseOpened
+ );
+
+ // Sits beside storage and host while the editor is closed, and at the
+ // bottom of the editor while it's open.
+ const executeControl = (
+ // A disabled button gets no hover events, so the tooltip hangs off a
+ // wrapper.
+
+
+ {/* An empty label lines the button up with the selects. */}
+
+
+
+ {executeControl}
+ )}
+
+
+ {!editorOpen && (
+ // The whole bar opens the editor. The icon 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 } }}
+ >
+
+ {query.sudo && (
+
+ SUDO{" "}
+
+ )}
+ {query.sql || "(no query)"}
+
+ {/* Where the editor's Close button sits, so opening and closing
+ are in the same place. */}
+
+
+
+
+
+
+ )}
+ {/* Hidden rather than unmounted, so the editor keeps its state. */}
+
);
}
@@ -412,19 +533,34 @@ const sudoBlockStyle = {
backgroundColor: "rgba(168, 50, 45, 0.06)",
};
+// Above the editor, so it shows whether the editor is open or closed.
+const targetPanelStyle = {
+ padding: "12px 16px",
+ borderBottom: `1px solid ${COLORS.BORDER_GRAY}`,
+};
+
const targetRowStyle = {
display: "flex",
flexWrap: "wrap" as const,
gap: 16,
};
+// Storage and host shrink before the row wraps, so it stays one line.
const targetFieldStyle = {
- width: "20em",
+ flex: "1 1 12em",
+ maxWidth: "20em",
display: "flex",
flexDirection: "column" as const,
gap: 4,
};
+// The host entry toggle and Execute keep their natural width.
+const fixedFieldStyle = {
+ ...targetFieldStyle,
+ flex: "none",
+ maxWidth: "none",
+};
+
// Marks the field the user needs to fill in next.
const needsInputStyle = {
outline: `2px solid ${COLORS.WARNING}`,
@@ -438,29 +574,102 @@ const needsInputHintStyle = {
margin: 0,
};
-// Takes the space between the mode toggle and Execute.
-const modeHelpTextStyle = {
+// Separates the results from the form above.
+const resultsHeadingRowStyle = {
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ marginTop: 32,
+ marginBottom: 12,
+};
+
+const resultsHeadingStyle = {
+ ...SECTION_HEADING_STYLE,
+ margin: 0,
+};
+
+const editorPanelStyle = {
+ backgroundColor: COLORS.PANEL_BG,
+ border: `1px solid ${COLORS.BORDER_GRAY}`,
+ borderRadius: 4,
+ boxShadow: "0 2px 6px rgba(0, 0, 0, 0.08)",
+};
+
+// Closed, the panel is a short bar, so it sticks to the top of the page's
+// scroll area to run or reopen from anywhere in the results. Open, it's
+// tall enough to cover them, so it scrolls with the page.
+const stickyEditorPanelStyle = {
+ ...editorPanelStyle,
+ position: "sticky" as const,
+ top: 0,
+ zIndex: 10,
+};
+
+const editorFormStyle = {
+ padding: "12px 16px 0",
+};
+
+const editorTitleRowStyle = {
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "center",
+ marginBottom: 12,
+};
+
+const editorTitleActionsStyle = {
+ display: "flex",
+ alignItems: "center",
+ gap: 16,
+};
+
+const editorClosedBarStyle = {
+ display: "flex",
+ width: "100%",
+ borderRadius: 4,
+ alignItems: "center",
+ gap: 12,
+ padding: "8px 16px",
+};
+
+// One line, so the closed bar stays short whatever the query.
+const editorSummaryStyle = {
+ // Fills the bar, pushing the open icon to the right end.
flex: 1,
minWidth: 0,
- fontSize: "0.85em",
- margin: 0,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap" as const,
+ color: COLORS.TEXT_LIGHTER,
};
+// Takes the space between the mode toggle and Execute.
const inputStyle = {
fontSize: 16,
minHeight: '2.25rem',
}
-const hostHelpTextStyle = {
- fontSize: '0.85em',
- marginTop: 4,
- marginBottom: 0,
-}
+const hostLabelStyle = {
+ display: "flex",
+ alignItems: "center",
+ gap: 4,
+};
+const hostInfoStyle = {
+ display: "inline-flex",
+ color: COLORS.TEXT_LIGHTER,
+ cursor: "help",
+};
+
+// Execute stays top right, however tall the error beside it gets.
const executeActionsStyle = {
display: "flex",
- alignItems: "center",
+ alignItems: "flex-start",
gap: 16,
};
+const errorSlotStyle = {
+ flex: 1,
+ minWidth: 0,
+};
+
export default QueryDisplay;
diff --git a/snuba/admin/static/clickhouse_queries/result_table.tsx b/snuba/admin/static/clickhouse_queries/result_table.tsx
new file mode 100644
index 0000000000..7d3ea015dc
--- /dev/null
+++ b/snuba/admin/static/clickhouse_queries/result_table.tsx
@@ -0,0 +1,228 @@
+import React, { useState } from "react";
+import { Button } from "@mantine/core";
+import { IconArrowsMaximize, IconArrowsMinimize } from "@tabler/icons-react";
+import { COLORS } from "SnubaAdmin/theme";
+
+// Past this many rows the table scrolls in a box of fixed height, unless the
+// user expands it.
+const COLLAPSED_ROW_LIMIT = 12;
+const COLLAPSED_MAX_HEIGHT = 480;
+// Longer values are cut off; clicking one shows all of it.
+const CELL_MAX_CHARS = 40;
+
+function displayText(value: unknown) {
+ if (value === null || value === undefined) {
+ return null;
+ }
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
+}
+
+function isNumeric(value: unknown) {
+ return (
+ typeof value === "number" ||
+ (typeof value === "string" && /^-?\d+(\.\d+)?$/.test(value))
+ );
+}
+
+function ResultTable(props: { columnNames: string[]; rows: unknown[][] }) {
+ const { columnNames, rows } = props;
+ const [expanded, setExpanded] = useState(false);
+ // "row:col" of each long value the user opened.
+ const [openCells, setOpenCells] = useState>(new Set());
+ const tall = rows.length > COLLAPSED_ROW_LIMIT;
+
+ function toggleCell(key: string) {
+ setOpenCells((prev) => {
+ const next = new Set(prev);
+ next.has(key) ? next.delete(key) : next.add(key);
+ return next;
+ });
+ }
+
+ return (
+