From 06ae8fa64378879f1467492a93ee1ccf330086f4 Mon Sep 17 00:00:00 2001 From: Oliver Newland Date: Mon, 19 Oct 2026 14:33:21 -0700 Subject: [PATCH] feat(admin): Bring the System Queries layout to the Querylog page Give the Querylog page the sticky, closable editor panel, history cards and share links from System Queries. Links of the form #querylog?query=... pre-fill the editor; unlike System Queries there's no warning, since the querylog cluster isn't production. The querylog schema moves out of the results into a drawer beside the editor, which starts below the page header. The editor panel, prefill warning and history card are extracted into shared components so both pages use the same code. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../admin/static/clickhouse_queries/index.tsx | 23 +- .../static/clickhouse_queries/prefill.tsx | 26 +- .../clickhouse_queries/query_display.tsx | 222 ++++------------ .../query_history_entry.tsx | 185 +------------ snuba/admin/static/editor_panel.tsx | 215 +++++++++++++++ snuba/admin/static/header.tsx | 6 +- snuba/admin/static/query_history_entry.tsx | 199 ++++++++++++++ snuba/admin/static/querylog/index.tsx | 42 +-- snuba/admin/static/querylog/prefill.tsx | 23 ++ snuba/admin/static/querylog/query_display.tsx | 246 ++++++++++++++---- .../static/tests/querylog/index.spec.tsx | 120 +++++++++ 11 files changed, 854 insertions(+), 453 deletions(-) create mode 100644 snuba/admin/static/editor_panel.tsx create mode 100644 snuba/admin/static/query_history_entry.tsx create mode 100644 snuba/admin/static/querylog/prefill.tsx create mode 100644 snuba/admin/static/tests/querylog/index.spec.tsx diff --git a/snuba/admin/static/clickhouse_queries/index.tsx b/snuba/admin/static/clickhouse_queries/index.tsx index ec2b8b8d7fe..6097de302ba 100644 --- a/snuba/admin/static/clickhouse_queries/index.tsx +++ b/snuba/admin/static/clickhouse_queries/index.tsx @@ -1,10 +1,8 @@ import React, { useState, useEffect } from "react"; import Client from "SnubaAdmin/api_client"; import { Table } from "SnubaAdmin/table"; -import QueryDisplay, { - STICKY_HEADER_TOP, -} from "SnubaAdmin/clickhouse_queries/query_display"; -import { COLORS } from "SnubaAdmin/theme"; +import QueryDisplay from "SnubaAdmin/clickhouse_queries/query_display"; +import { stickyTableHeaderStyles } from "SnubaAdmin/editor_panel"; import { parseSystemQueryPrefill } from "SnubaAdmin/clickhouse_queries/prefill"; import { QueryResult, PredefinedQuery } from "./types"; @@ -40,7 +38,7 @@ function ClickhouseQueries(props: { api: Client }) { ); } @@ -70,19 +68,4 @@ const selectStyle = { height: 30, }; -// 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/prefill.tsx b/snuba/admin/static/clickhouse_queries/prefill.tsx index d83e4b7b0c2..dc4b7848af0 100644 --- a/snuba/admin/static/clickhouse_queries/prefill.tsx +++ b/snuba/admin/static/clickhouse_queries/prefill.tsx @@ -64,19 +64,31 @@ export function parseSystemQueryPrefill( }; } +/** Builds a link to a page (e.g. "querylog") with these hash params. */ +export function buildHashUrl( + route: string, + values: Record +): string { + const params = new URLSearchParams(); + Object.entries(values).forEach(([key, value]) => { + if (value) params.set(key, value); + }); + // %20 reads better than "+" when the link is pasted somewhere. + const query = params.toString().replace(/\+/g, "%20"); + return `${window.location.origin}${window.location.pathname}#${route}?${query}`; +} + /** Builds a link that opens System Queries pre-filled with these values. */ export function buildSystemQueryUrl(prefill: { sql?: string; storage?: string | null; host?: string | null; }): string { - const params = new URLSearchParams(); - if (prefill.sql) params.set("query", prefill.sql); - if (prefill.storage) params.set("storage", prefill.storage); - if (prefill.host) params.set("host", prefill.host); - // %20 reads better than "+" when the link is pasted somewhere. - const query = params.toString().replace(/\+/g, "%20"); - return `${window.location.origin}${window.location.pathname}#system-queries?${query}`; + return buildHashUrl("system-queries", { + query: prefill.sql, + storage: prefill.storage, + host: prefill.host, + }); } /** diff --git a/snuba/admin/static/clickhouse_queries/query_display.tsx b/snuba/admin/static/clickhouse_queries/query_display.tsx index 908d8861e1f..770b384b704 100644 --- a/snuba/admin/static/clickhouse_queries/query_display.tsx +++ b/snuba/admin/static/clickhouse_queries/query_display.tsx @@ -2,17 +2,14 @@ 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, - Button, - SegmentedControl, - Text, - Tooltip, - UnstyledButton, -} from "@mantine/core"; -import { IconChevronDown, IconX } from "@tabler/icons-react"; +import { SegmentedControl, Text, Tooltip } from "@mantine/core"; import { getRecentHistory, setRecentHistory } from "SnubaAdmin/query_history"; -import { COLORS, SECTION_HEADING_STYLE } from "SnubaAdmin/theme"; +import { COLORS } from "SnubaAdmin/theme"; +import { + EditorPanelLayout, + PrefillWarning, + resultsHeadingStyle, +} 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"; @@ -32,10 +29,7 @@ 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; @@ -65,22 +59,6 @@ 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); @@ -230,91 +208,39 @@ 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. - setEditorOpen(true)} - aria-label="Open editor" - style={editorClosedBarStyle} - sx={{ "&:hover": { backgroundColor: COLORS.BG_GRAY_LIGHTER } }} - > - - - {query.sudo && ( - - SUDO{" "} - - )} - {query.storage ?? "(no storage)"} - {" · "} - {query.host ? `${query.host}:${query.port}` : "(no host)"} - {" · "} - {query.sql || "(no query)"} - - - )} - {/* 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)"} - -

-
+ + {query.sudo && ( + + SUDO{" "} + )} -
-

- Construct a ClickHouse System Query -

- -
+ {query.storage ?? "(no storage)"} + {" · "} + {query.host ? `${query.host}:${query.port}` : "(no host)"} + {" · "} + {query.sql || "(no query)"} + + } + banner={ + prefill && + prefillWarningOpen && ( + setPrefillWarningOpen(false)}> + Storage: {prefill.storage ?? "(not set)"} + {" · "} + Host:{" "} + + {prefill.host ? `${prefill.host}:${prefill.port}` : "(not set)"} + + + ) + } + editor={ + <>
@@ -445,8 +371,9 @@ function QueryDisplay(props: {
- -
+ + } + >
{getErrorDomElement(queryError, collapseOpened, setCollapseOpened)}
@@ -468,7 +395,7 @@ function QueryDisplay(props: { /> ))}
-
+ ); } @@ -511,57 +438,6 @@ const needsInputHintStyle = { margin: 0, }; -// Separates the results from the form above. -const resultsHeadingStyle = { - ...SECTION_HEADING_STYLE, - marginTop: 32, - // Keeps the heading clear of the sticky editor bar when scrolled to. - scrollMarginTop: 72, -}; - -// Sticks to the top of the page's scroll area, just below the header. -const editorPanelStyle = { - position: "sticky" as const, - top: 0, - zIndex: 10, - backgroundColor: COLORS.PANEL_BG, - border: `1px solid ${COLORS.BORDER_GRAY}`, - borderRadius: 4, - boxShadow: "0 2px 6px rgba(0, 0, 0, 0.08)", -}; - -// Scrolls on its own when the open editor is taller than the screen. -const editorFormStyle = { - maxHeight: "calc(100vh - 100px)", - overflowY: "auto" as const, - padding: "12px 16px 0", -}; - -const editorTitleRowStyle = { - display: "flex", - justifyContent: "space-between", - alignItems: "center", - marginBottom: 12, -}; - -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 = { - minWidth: 0, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap" as const, - color: COLORS.TEXT_LIGHTER, -}; - // Takes the space between the mode toggle and Execute. const modeHelpTextStyle = { flex: 1, @@ -570,15 +446,6 @@ const modeHelpTextStyle = { margin: 0, }; -const prefillAlertStyle = { - margin: "0 0 16px 0", -}; - -const prefillAlertTextStyle = { - fontSize: 16, - margin: "4px 0px", -}; - const inputStyle = { fontSize: 16, minHeight: '2.25rem', @@ -596,5 +463,4 @@ 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 88d4cf595db..0e87e229463 100644 --- a/snuba/admin/static/clickhouse_queries/query_history_entry.tsx +++ b/snuba/admin/static/clickhouse_queries/query_history_entry.tsx @@ -1,6 +1,5 @@ -import React, { useState } from "react"; -import QueryResultCopier from "SnubaAdmin/utils/query_result_copier"; -import { COLORS } from "SnubaAdmin/theme"; +import React from "react"; +import { QueryHistoryEntry as HistoryEntry } from "SnubaAdmin/query_history_entry"; import { QueryResult } from "SnubaAdmin/clickhouse_queries/types"; import { buildSystemQueryUrl } from "SnubaAdmin/clickhouse_queries/prefill"; @@ -21,179 +20,17 @@ function QueryHistoryEntry(props: { csv: string; resultDataPopulator: (queryResult: QueryResult) => JSX.Element; }) { - const { queryResult, isLatest } = props; - const [expanded, setExpanded] = useState(props.defaultExpanded); - const { sql, storage, host } = parseInputQuery(queryResult.input_query); - const rowCount = queryResult.rows.length; - + const { sql, storage, host } = parseInputQuery(props.queryResult.input_query); return ( -
-
setExpanded((prev) => !prev)} - onKeyDown={(evt) => { - if (evt.key === "Enter" || evt.key === " ") { - evt.preventDefault(); - setExpanded((prev) => !prev); - } - }} - > - - - - {isLatest && Latest} - {storage && {storage}} - {host && {host}} - - {rowCount} {rowCount === 1 ? "row" : "rows"} - {queryResult.timestamp - ? ` · ${new Date(queryResult.timestamp * 1000).toLocaleTimeString()}` - : ""} - - {/* Copying shouldn't toggle the entry. */} -
evt.stopPropagation()}> - -
-
- {expanded ? ( - <> -
{sql}
-
- {props.resultDataPopulator(queryResult)} -
- - ) : ( -
- {sql.replace(/\s+/g, " ")} -
- )} -
+ ); } -const cardStyle = { - backgroundColor: COLORS.PANEL_BG, - border: `1px solid ${COLORS.BORDER_GRAY_LIGHT}`, - borderRadius: 8, - marginBottom: 12, - // Clips to the rounded corners. Unlike "hidden", "clip" doesn't stop the - // result table's header from sticking. - overflow: "clip", -}; - -const headerStyle = { - display: "flex", - alignItems: "center", - flexWrap: "wrap" as const, - gap: 8, - padding: "8px 12px", - cursor: "pointer", - fontSize: 14, - userSelect: "none" as const, -}; - -const chevronBoxStyle = { - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - width: 14, - height: 14, -}; - -// Drawn from two borders; points right when collapsed, down when expanded. -const chevronStyle = { - width: 7, - height: 7, - borderRight: `2px solid ${COLORS.TEXT_DEFAULT}`, - borderBottom: `2px solid ${COLORS.TEXT_DEFAULT}`, - transition: "transform 150ms ease", -}; - -const badgeStyle = { - fontSize: 12, - fontWeight: 600, - padding: "2px 8px", - borderRadius: 4, - whiteSpace: "nowrap" as const, -}; - -const latestBadgeStyle = { - ...badgeStyle, - color: COLORS.SNUBA_BLUE, - backgroundColor: "rgba(31, 111, 235, 0.1)", -}; - -const storageBadgeStyle = { - ...badgeStyle, - color: COLORS.TEXT_DEFAULT, - backgroundColor: COLORS.BG_GRAY_LIGHT, -}; - -const hostStyle = { - fontFamily: "monospace", - fontSize: 13, - color: COLORS.TEXT_LIGHTER, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap" as const, - minWidth: 0, -}; - -const metaStyle = { - color: COLORS.TEXT_LIGHTER, - whiteSpace: "nowrap" as const, -}; - -const copierStyle = { - marginLeft: "auto", - cursor: "default", -}; - -const sqlBase = { - fontFamily: "monospace", - fontSize: 13, - lineHeight: 1.5, - margin: 0, - padding: "8px 12px", - backgroundColor: COLORS.BG_GRAY_LIGHTER, - borderTop: `1px solid ${COLORS.BORDER_GRAY_LIGHT}`, - color: COLORS.TEXT_DEFAULT, -}; - -const sqlStyle = { - ...sqlBase, - whiteSpace: "pre-wrap" as const, - wordBreak: "break-word" as const, - maxHeight: 240, - overflowY: "auto" as const, -}; - -// One line, cut off with an ellipsis; the full query is in the tooltip. -const sqlPreviewStyle = { - ...sqlBase, - whiteSpace: "nowrap" as const, - overflow: "hidden", - textOverflow: "ellipsis", - color: COLORS.TEXT_LIGHTER, -}; - -const resultsStyle = { - padding: "12px 12px 0", - borderTop: `1px solid ${COLORS.BORDER_GRAY_LIGHT}`, -}; - export { QueryHistoryEntry, parseInputQuery }; diff --git a/snuba/admin/static/editor_panel.tsx b/snuba/admin/static/editor_panel.tsx new file mode 100644 index 00000000000..ca9ae2d9bf1 --- /dev/null +++ b/snuba/admin/static/editor_panel.tsx @@ -0,0 +1,215 @@ +import React, { ReactNode, useEffect, useRef, useState } from "react"; +import { Alert, Button, Text, UnstyledButton } from "@mantine/core"; +import { IconChevronDown, IconX } from "@tabler/icons-react"; +import { COLORS, SECTION_HEADING_STYLE } from "SnubaAdmin/theme"; + +// Set on the page to the editor pane's height; see STICKY_HEADER_TOP. +const EDITOR_PANEL_HEIGHT_VAR = "--editor-panel-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)`; + +/** + * A query page laid out as a sticky editor pane above its results. The pane + * can be reopened from anywhere in the results, and collapses to a one-line + * summary of the query when closed. + */ +function EditorPanelLayout(props: { + title: string; + open: boolean; + onOpenChange: (open: boolean) => void; + // Shown in the closed bar, so the user can see what will run. + summary: ReactNode; + // Extra buttons next to Close in the title row. + titleActions?: ReactNode; + // Shown above the title, e.g. a PrefillWarning. + banner?: ReactNode; + // The editor form's contents. + editor: ReactNode; + // The results, below the pane. + children: ReactNode; +}) { + // 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 panelRef = useRef(null); + const [panelHeight, setPanelHeight] = useState(0); + + useEffect(() => { + const panel = panelRef.current; + if (!panel || typeof ResizeObserver === "undefined") { + return; + } + const observer = new ResizeObserver(() => + setPanelHeight(panel.offsetHeight) + ); + observer.observe(panel); + return () => observer.disconnect(); + }, []); + + return ( +
+
+ {!props.open && ( + // The whole bar opens the editor. The "button" inside is only a + // span, since a real button can't be nested in another. + props.onOpenChange(true)} + aria-label="Open editor" + style={closedBarStyle} + sx={{ "&:hover": { backgroundColor: COLORS.BG_GRAY_LIGHTER } }} + > + + + {props.summary} + + + )} + {/* Hidden rather than unmounted, so the editor keeps its state. */} +
+ {props.banner} +
+

+ {props.title} +

+
+ {props.titleActions} + +
+
+ {props.editor} + +
+ {props.children} +
+ ); +} + +/** Warns that the editor was filled in from a link, which anyone can write. */ +function PrefillWarning(props: { onClose: () => void; children?: ReactNode }) { + return ( + +

+ 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. +

+ {props.children &&

{props.children}

} +
+ ); +} + +// 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 stickyTableHeaderStyles = { + 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}`, + }, +}; + +// Separates the results from the editor pane above. +const resultsHeadingStyle = { + ...SECTION_HEADING_STYLE, + marginTop: 32, + // Keeps the heading clear of the sticky editor bar when scrolled to. + scrollMarginTop: 72, +}; + +// Sticks to the top of the page's scroll area, just below the header. +const panelStyle = { + position: "sticky" as const, + top: 0, + zIndex: 10, + backgroundColor: COLORS.PANEL_BG, + border: `1px solid ${COLORS.BORDER_GRAY}`, + borderRadius: 4, + boxShadow: "0 2px 6px rgba(0, 0, 0, 0.08)", +}; + +// Scrolls on its own when the open editor is taller than the screen. +const formStyle = { + maxHeight: "calc(100vh - 100px)", + overflowY: "auto" as const, + padding: "12px 16px 0", +}; + +const titleRowStyle = { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 12, +}; + +const titleActionsStyle = { + display: "flex", + alignItems: "center", + gap: 8, +}; + +const closedBarStyle = { + 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 summaryStyle = { + minWidth: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" as const, + color: COLORS.TEXT_LIGHTER, +}; + +const prefillAlertStyle = { + margin: "0 0 16px 0", +}; + +const prefillAlertTextStyle = { + fontSize: 16, + margin: "4px 0px", +}; + +export { + EditorPanelLayout, + PrefillWarning, + STICKY_HEADER_TOP, + resultsHeadingStyle, + stickyTableHeaderStyles, +}; diff --git a/snuba/admin/static/header.tsx b/snuba/admin/static/header.tsx index e473aecb43c..ba71b5ffdb7 100644 --- a/snuba/admin/static/header.tsx +++ b/snuba/admin/static/header.tsx @@ -271,11 +271,14 @@ function Header(props: { api: Client; active: string | null }) { ); } +// Including padding, since Mantine's global styles use border-box. +const HEADER_HEIGHT_PX = 45; + // Equal outer columns keep the breadcrumb centered whatever its width; the // right one is empty. const headerStyle = { backgroundColor: COLORS.HEADER_BG, - height: "45px", + height: `${HEADER_HEIGHT_PX}px`, display: "grid", gridTemplateColumns: "1fr auto 1fr", // A definite row height lets the logo size itself to the header. @@ -398,4 +401,5 @@ const menuCardStyle = { padding: 12, }; +export { HEADER_HEIGHT_PX }; export default Header; diff --git a/snuba/admin/static/query_history_entry.tsx b/snuba/admin/static/query_history_entry.tsx new file mode 100644 index 00000000000..b75f75031d6 --- /dev/null +++ b/snuba/admin/static/query_history_entry.tsx @@ -0,0 +1,199 @@ +import React, { useState } from "react"; +import QueryResultCopier from "SnubaAdmin/utils/query_result_copier"; +import { COLORS } from "SnubaAdmin/theme"; + +type HistoryResult = { + timestamp?: number; + rows: unknown[]; +}; + +// A past query and its result, as a card that expands to show the result. +function QueryHistoryEntry(props: { + queryResult: R; + sql: string; + // Where the query ran, if it ran somewhere specific. + storage?: string | null; + host?: string | null; + // A link that opens the page pre-filled with this query. + shareUrl?: string; + rawOutput?: string; + isLatest: boolean; + defaultExpanded: boolean; + csv: string; + resultDataPopulator: (queryResult: R) => JSX.Element; +}) { + const { queryResult, isLatest, sql, storage, host } = props; + const [expanded, setExpanded] = useState(props.defaultExpanded); + const rowCount = queryResult.rows.length; + + return ( +
+
setExpanded((prev) => !prev)} + onKeyDown={(evt) => { + if (evt.key === "Enter" || evt.key === " ") { + evt.preventDefault(); + setExpanded((prev) => !prev); + } + }} + > + + + + {isLatest && Latest} + {storage && {storage}} + {host && {host}} + + {rowCount} {rowCount === 1 ? "row" : "rows"} + {queryResult.timestamp + ? ` · ${new Date(queryResult.timestamp * 1000).toLocaleTimeString()}` + : ""} + + {/* Copying shouldn't toggle the entry. */} +
evt.stopPropagation()}> + +
+
+ {expanded ? ( + <> +
{sql}
+
+ {props.resultDataPopulator(queryResult)} +
+ + ) : ( +
+ {sql.replace(/\s+/g, " ")} +
+ )} +
+ ); +} + +const cardStyle = { + backgroundColor: COLORS.PANEL_BG, + border: `1px solid ${COLORS.BORDER_GRAY_LIGHT}`, + borderRadius: 8, + marginBottom: 12, + // Clips to the rounded corners. Unlike "hidden", "clip" doesn't stop the + // result table's header from sticking. + overflow: "clip", +}; + +const headerStyle = { + display: "flex", + alignItems: "center", + flexWrap: "wrap" as const, + gap: 8, + padding: "8px 12px", + cursor: "pointer", + fontSize: 14, + userSelect: "none" as const, +}; + +const chevronBoxStyle = { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 14, + height: 14, +}; + +// Drawn from two borders; points right when collapsed, down when expanded. +const chevronStyle = { + width: 7, + height: 7, + borderRight: `2px solid ${COLORS.TEXT_DEFAULT}`, + borderBottom: `2px solid ${COLORS.TEXT_DEFAULT}`, + transition: "transform 150ms ease", +}; + +const badgeStyle = { + fontSize: 12, + fontWeight: 600, + padding: "2px 8px", + borderRadius: 4, + whiteSpace: "nowrap" as const, +}; + +const latestBadgeStyle = { + ...badgeStyle, + color: COLORS.SNUBA_BLUE, + backgroundColor: "rgba(31, 111, 235, 0.1)", +}; + +const storageBadgeStyle = { + ...badgeStyle, + color: COLORS.TEXT_DEFAULT, + backgroundColor: COLORS.BG_GRAY_LIGHT, +}; + +const hostStyle = { + fontFamily: "monospace", + fontSize: 13, + color: COLORS.TEXT_LIGHTER, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" as const, + minWidth: 0, +}; + +const metaStyle = { + color: COLORS.TEXT_LIGHTER, + whiteSpace: "nowrap" as const, +}; + +const copierStyle = { + marginLeft: "auto", + cursor: "default", +}; + +const sqlBase = { + fontFamily: "monospace", + fontSize: 13, + lineHeight: 1.5, + margin: 0, + padding: "8px 12px", + backgroundColor: COLORS.BG_GRAY_LIGHTER, + borderTop: `1px solid ${COLORS.BORDER_GRAY_LIGHT}`, + color: COLORS.TEXT_DEFAULT, +}; + +const sqlStyle = { + ...sqlBase, + whiteSpace: "pre-wrap" as const, + wordBreak: "break-word" as const, + maxHeight: 240, + overflowY: "auto" as const, +}; + +// One line, cut off with an ellipsis; the full query is in the tooltip. +const sqlPreviewStyle = { + ...sqlBase, + whiteSpace: "nowrap" as const, + overflow: "hidden", + textOverflow: "ellipsis", + color: COLORS.TEXT_LIGHTER, +}; + +const resultsStyle = { + padding: "12px 12px 0", + borderTop: `1px solid ${COLORS.BORDER_GRAY_LIGHT}`, +}; + +export { QueryHistoryEntry }; diff --git a/snuba/admin/static/querylog/index.tsx b/snuba/admin/static/querylog/index.tsx index 2b3b93380f1..4059a559fba 100644 --- a/snuba/admin/static/querylog/index.tsx +++ b/snuba/admin/static/querylog/index.tsx @@ -2,12 +2,27 @@ import React, { useState, useEffect } from "react"; import Client from "SnubaAdmin/api_client"; import { Table } from "SnubaAdmin/table"; import QueryDisplay from "SnubaAdmin/querylog/query_display"; +import { stickyTableHeaderStyles } from "SnubaAdmin/editor_panel"; +import { parseQuerylogPrefill } from "SnubaAdmin/querylog/prefill"; import { QuerylogResult, PredefinedQuery } from "./types"; function QuerylogQueries(props: { api: Client }) { const [predefinedQueryOptions, setPredefinedQueryOptions] = useState< PredefinedQuery[] >([]); + // Pasting a share link while already on this page only changes the hash, + // which doesn't reload the page. Remount the form so it reads the new link. + const [linkKey, setLinkKey] = useState(0); + + useEffect(() => { + function onHashChange() { + if (parseQuerylogPrefill(window.location.hash)) { + setLinkKey((key) => key + 1); + } + } + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, []); useEffect(() => { props.api.getPredefinedQuerylogOptions().then((res) => { @@ -20,12 +35,11 @@ function QuerylogQueries(props: { api: Client }) { function tablePopulator(queryResult: QuerylogResult) { return ( -
-
- +
); } @@ -39,11 +53,12 @@ function QuerylogQueries(props: { api: Client }) { return (
- {QueryDisplay({ - api: props.api, - resultDataPopulator: tablePopulator, - predefinedQueryOptions: predefinedQueryOptions, - })} +
); } @@ -53,9 +68,4 @@ const selectStyle = { height: 30, }; -const scroll = { - overflowX: "scroll" as const, - width: "100%", -}; - export default QuerylogQueries; diff --git a/snuba/admin/static/querylog/prefill.tsx b/snuba/admin/static/querylog/prefill.tsx new file mode 100644 index 00000000000..1d3c3b45445 --- /dev/null +++ b/snuba/admin/static/querylog/prefill.tsx @@ -0,0 +1,23 @@ +// Like System Queries (see clickhouse_queries/prefill.tsx), the Querylog page +// can be opened with the editor pre-filled from the URL: +// +// /#querylog?query=SELECT%20count()%20FROM%20querylog_local +// +// Nothing here runs the query: the user still has to click Execute. Unlike +// System Queries there's no warning, since the querylog cluster isn't +// production. +import { + buildHashUrl, + splitHash, +} from "SnubaAdmin/clickhouse_queries/prefill"; + +/** Returns the query encoded in the hash, or null if there is none. */ +export function parseQuerylogPrefill(hash: string): { sql: string } | null { + const sql = splitHash(hash).params.get("query"); + return sql ? { sql } : null; +} + +/** Builds a link that opens the Querylog page pre-filled with this query. */ +export function buildQuerylogUrl(sql: string): string { + return buildHashUrl("querylog", { query: sql }); +} diff --git a/snuba/admin/static/querylog/query_display.tsx b/snuba/admin/static/querylog/query_display.tsx index cd79669e669..40614c1d63e 100644 --- a/snuba/admin/static/querylog/query_display.tsx +++ b/snuba/admin/static/querylog/query_display.tsx @@ -1,26 +1,76 @@ -import React, { useState } from "react"; -import { Button } from "@mantine/core"; +import React, { useEffect, useRef, useState } from "react"; +import { Button, Drawer, Text, TextInput, Tooltip } from "@mantine/core"; +import { IconTable } from "@tabler/icons-react"; import Client from "SnubaAdmin/api_client"; -import { Collapse } from "SnubaAdmin/collapse"; import QueryEditor from "SnubaAdmin/query_editor"; import { getRecentHistory, setRecentHistory } from "SnubaAdmin/query_history"; +import { QueryHistoryEntry } from "SnubaAdmin/query_history_entry"; +import { + EditorPanelLayout, + resultsHeadingStyle, +} from "SnubaAdmin/editor_panel"; +import { Table } from "SnubaAdmin/table"; +import { COLORS } from "SnubaAdmin/theme"; +import { HEADER_HEIGHT_PX } from "SnubaAdmin/header"; +import { clearHashParams } from "SnubaAdmin/clickhouse_queries/prefill"; +import { + buildQuerylogUrl, + parseQuerylogPrefill, +} from "SnubaAdmin/querylog/prefill"; +import { getErrorDomElement } from "SnubaAdmin/utils/clickhouse_node_utils"; import { QuerylogRequest, QuerylogResult, PredefinedQuery } from "./types"; import ExecuteButton from "SnubaAdmin/utils/execute_button"; -import QueryResultCopier from "SnubaAdmin/utils/query_result_copier"; type QueryState = Partial; const HISTORY_KEY = "querylog"; +// DESCRIBE returns more than fits in the drawer; these are the useful ones. +const SCHEMA_COLUMNS = ["name", "type", "comment"]; +const MISSING_QUERY_HINT = "Write or pick a query to run."; function QueryDisplay(props: { api: Client; resultDataPopulator: (queryResult: QuerylogResult) => JSX.Element; predefinedQueryOptions: Array; }) { + // A link may pre-fill the query (see prefill.tsx). + const [prefill] = useState(() => + parseQuerylogPrefill(window.location.hash) + ); const [query, setQuery] = useState({}); const [queryResultHistory, setQueryResultHistory] = useState< QuerylogResult[] >(getRecentHistory(HISTORY_KEY)); + // Only a result the user just ran opens automatically; saved history loads + // collapsed. + const [justRan, setJustRan] = useState(null); + // The editor closes after a successful run to make room for the results. + const [editorOpen, setEditorOpen] = useState(true); + const resultsHeadingRef = useRef(null); + const [queryError, setQueryError] = useState(null); + // this is used to collapse the stack trace in error messages + const [collapseOpened, setCollapseOpened] = useState(false); + + // The schema is reference material, so it opens beside the editor rather + // than among the results. It's fetched the first time it's opened. + const [schemaOpen, setSchemaOpen] = useState(false); + const [schema, setSchema] = useState(null); + const [schemaError, setSchemaError] = useState(null); + const [schemaFilter, setSchemaFilter] = useState(""); + + useEffect(() => { + if (prefill) { + clearHashParams(); + } + }, []); + + // 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 updateQuerySql(sql: string) { setQuery((prevQuery) => { @@ -35,24 +85,64 @@ function QueryDisplay(props: { return props.api .executeQuerylogQuery(query as QuerylogRequest) .then((result) => { + setQueryError(null); result.input_query = query.sql || ""; + // The server doesn't timestamp results; record when this one ran. + result.timestamp = Math.floor(Date.now() / 1000); setRecentHistory(HISTORY_KEY, result); setQueryResultHistory((prevHistory) => [result, ...prevHistory]); + setJustRan(result); + setEditorOpen(false); }); } - function getQuerylogSchema() { + function openSchema() { + setSchemaOpen(true); + if (schema) { + return; + } + setSchemaError(null); props.api .getQuerylogSchema() - .then((result) => { - result.input_query = "View Querylog Schema"; - setQueryResultHistory((prevHistory) => [result, ...prevHistory]); - }) + .then(setSchema) .catch((err) => { - window.alert("An error occurred: " + err.error.message); + setSchemaError(err?.error?.message ?? String(err)); }); } + function renderSchema() { + if (schemaError) { + return An error occurred: {schemaError}; + } + if (!schema) { + return Loading…; + } + const columnIndexes = SCHEMA_COLUMNS.map((name) => + schema.column_names.indexOf(name) + ).filter((idx) => idx !== -1); + const filter = schemaFilter.trim().toLowerCase(); + const rows = schema.rows + .filter( + (row) => !filter || String(row[0]).toLowerCase().includes(filter) + ) + .map((row) => columnIndexes.map((idx) => row[idx])); + return ( + <> + setSchemaFilter(evt.currentTarget.value)} + mb="sm" + /> +
schema.column_names[idx])} + rowData={rows} + /> + + ); + } + function convertResultsToCSV(queryResult: QuerylogResult) { let output = queryResult.column_names.join(","); for (const row of queryResult.rows) { @@ -65,62 +155,104 @@ function QueryDisplay(props: { } return ( -
-

Construct a Querylog Query

- { - updateQuerySql(sql); + {query.sql || "(no query)"}} + titleActions={ + + } + editor={ + <> +
+ { + updateQuerySql(sql); + }} + predefinedQueryOptions={props.predefinedQueryOptions} + initialQuery={prefill?.sql} + /> +
+
+ {/* A disabled button gets no hover events, so the tooltip hangs + off a wrapper. */} + +
+ +
+
+
+ + } + > + setSchemaOpen(false)} + title={Querylog schema} + position="right" + size="lg" + // Leaves the editor usable while the schema is open. + withOverlay={false} + lockScroll={false} + // Starts below the page header, so the header stays in view. + styles={{ + inner: { top: HEADER_HEIGHT_PX, height: `calc(100% - ${HEADER_HEIGHT_PX}px)` }, + content: { borderLeft: `1px solid ${COLORS.BORDER_GRAY}` }, }} - predefinedQueryOptions={props.predefinedQueryOptions} - /> -
-
- - -
+ > + {renderSchema()} + +
+ {getErrorDomElement(queryError, collapseOpened, setCollapseOpened)}
-

Query results

- {queryResultHistory.map((queryResult, idx) => { - if (idx === 0) { - return ( -
-

{queryResult.input_query}

- - {props.resultDataPopulator(queryResult)} -
- ); - } - - return ( - - - {props.resultDataPopulator(queryResult)} - - ); - })} +

+ Query results +

+ {queryResultHistory.map((queryResult, idx) => ( + + ))}
-
+
); } +// Spaces out the query and execute rows. +const blockStyle = { + marginBottom: 16, +}; + const executeActionsStyle = { display: "flex", - justifyContent: "space-between", - marginTop: 8, + justifyContent: "flex-end", }; export default QueryDisplay; diff --git a/snuba/admin/static/tests/querylog/index.spec.tsx b/snuba/admin/static/tests/querylog/index.spec.tsx new file mode 100644 index 00000000000..77eb9f0b5ed --- /dev/null +++ b/snuba/admin/static/tests/querylog/index.spec.tsx @@ -0,0 +1,120 @@ +import React from "react"; +import { it, expect, describe, jest, afterEach } from "@jest/globals"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import Client from "SnubaAdmin/api_client"; +import QueryDisplay from "SnubaAdmin/querylog/query_display"; +import { QuerylogRequest, QuerylogResult } from "SnubaAdmin/querylog/types"; +import { + buildQuerylogUrl, + parseQuerylogPrefill, +} from "SnubaAdmin/querylog/prefill"; + +jest.mock("SnubaAdmin/common/components/sql_editor"); + +describe("parseQuerylogPrefill", () => { + it("returns null without a query", () => { + expect(parseQuerylogPrefill("#querylog")).toBeNull(); + }); + + it("round-trips a link built from a query", () => { + const sql = "SELECT count() FROM querylog_local\nWHERE x = 'a&b+c'"; + const url = buildQuerylogUrl(sql); + expect(url).toContain("#querylog?"); + expect(parseQuerylogPrefill(new URL(url).hash)).toEqual({ sql }); + }); +}); + +describe("Querylog QueryDisplay", () => { + global.ResizeObserver = require("resize-observer-polyfill"); + afterEach(() => { + cleanup(); + window.location.hash = ""; + localStorage.clear(); + }); + + function renderWithHash(hash: string) { + window.location.hash = hash; + const api = { + ...Client(), + executeQuerylogQuery: jest + .fn<(req: QuerylogRequest) => Promise>() + .mockResolvedValue({ + input_query: "", + timestamp: 0, + column_names: ["x"], + rows: [["1"]], + } as QuerylogResult), + getQuerylogSchema: jest + .fn<() => Promise>() + .mockResolvedValue({ + input_query: "", + timestamp: 0, + column_names: ["name", "type", "default_type", "comment"], + rows: [ + ["request_id", "UUID", "", ""], + ["dataset", "LowCardinality(String)", "", ""], + ], + } as unknown as QuerylogResult), + }; + render( +
} + predefinedQueryOptions={[]} + /> + ); + return api; + } + + it("fills the editor from a link and waits for Execute", async () => { + const api = renderWithHash("#querylog?query=SELECT%201"); + + // The querylog cluster isn't production, so no warning. + expect(screen.queryByTestId("prefill-warning")).toBeNull(); + expect(screen.getByRole("textbox").textContent).toBe("SELECT 1"); + await waitFor(() => expect(window.location.hash).toBe("#querylog")); + expect(api.executeQuerylogQuery).not.toBeCalled(); + }); + + it("closes the editor after a run and shares the result", async () => { + const writeText = jest.fn<(text: string) => Promise>(); + Object.assign(window.navigator, { clipboard: { writeText } }); + const api = renderWithHash("#querylog?query=SELECT%201"); + + fireEvent.click(screen.getByText("Execute Query")); + await screen.findByText("Open Editor"); + expect(api.executeQuerylogQuery).toBeCalledWith({ sql: "SELECT 1" }); + expect(screen.getByTestId("editor-panel").textContent).toContain( + "SELECT 1" + ); + + fireEvent.click(screen.getByText("Share query")); + const url = writeText.mock.calls[0][0]; + expect(parseQuerylogPrefill(new URL(url).hash)).toEqual({ + sql: "SELECT 1", + }); + }); + + it("shows the schema in a drawer, not the results", async () => { + const api = renderWithHash("#querylog"); + + fireEvent.click(screen.getByText("Querylog schema")); + expect(await screen.findByText("request_id")).toBeTruthy(); + expect(api.getQuerylogSchema).toBeCalledTimes(1); + // Only the useful DESCRIBE columns. + expect(screen.queryByText("default_type")).toBeNull(); + expect(screen.queryByText("Share query")).toBeNull(); + + fireEvent.change(screen.getByLabelText("Filter columns"), { + target: { value: "data" }, + }); + expect(screen.queryByText("request_id")).toBeNull(); + expect(screen.getByText("dataset")).toBeTruthy(); + }); +});