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(); + }); +});