Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 3 additions & 20 deletions snuba/admin/static/clickhouse_queries/index.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -40,7 +38,7 @@ function ClickhouseQueries(props: { api: Client }) {
<Table
headerData={queryResult.column_names}
rowData={queryResult.rows}
customStyles={stickyHeaderStyles}
customStyles={stickyTableHeaderStyles}
/>
);
}
Expand Down Expand Up @@ -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;
26 changes: 19 additions & 7 deletions snuba/admin/static/clickhouse_queries/prefill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, string | null | undefined>
): 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,
});
}

/**
Expand Down
222 changes: 44 additions & 178 deletions snuba/admin/static/clickhouse_queries/query_display.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -32,10 +29,7 @@ import {
type QueryState = Partial<QueryRequest>;

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;
Expand Down Expand Up @@ -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<HTMLHeadingElement>(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<HTMLDivElement>(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<Error | null>(null);

Expand Down Expand Up @@ -230,91 +208,39 @@ function QueryDisplay(props: {
};

return (
<div
style={
{
[EDITOR_PANEL_HEIGHT_VAR]: `${editorPanelHeight}px`,
} as React.CSSProperties
}
>
<div
ref={editorPanelRef}
style={editorPanelStyle}
data-testid="editor-panel"
>
{!editorOpen && (
// The whole bar opens the editor. The "button" inside is only a
// span, since a real button can't be nested in another.
<UnstyledButton
onClick={() => setEditorOpen(true)}
aria-label="Open editor"
style={editorClosedBarStyle}
sx={{ "&:hover": { backgroundColor: COLORS.BG_GRAY_LIGHTER } }}
>
<Button
component="span"
size="xs"
rightIcon={<IconChevronDown size={14} />}
style={{ flexShrink: 0 }}
>
Open Editor
</Button>
<Text size="sm" style={editorSummaryStyle}>
{query.sudo && (
<span style={{ color: COLORS.DANGER, fontWeight: 700 }}>
SUDO{" "}
</span>
)}
{query.storage ?? "(no storage)"}
{" · "}
{query.host ? `${query.host}:${query.port}` : "(no host)"}
{" · "}
<code>{query.sql || "(no query)"}</code>
</Text>
</UnstyledButton>
)}
{/* Hidden rather than unmounted, so the editor keeps its state. */}
<form style={editorOpen ? editorFormStyle : { display: "none" }}>
{prefill && prefillWarningOpen && (
<Alert
color="orange"
title="This query was filled in from a link"
withCloseButton
onClose={() => setPrefillWarningOpen(false)}
style={prefillAlertStyle}
data-testid="prefill-warning"
>
<p style={prefillAlertTextStyle}>
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.
</p>
<p style={prefillAlertTextStyle}>
Storage: <code>{prefill.storage ?? "(not set)"}</code>
{" · "}
Host:{" "}
<code>
{prefill.host ? `${prefill.host}:${prefill.port}` : "(not set)"}
</code>
</p>
</Alert>
<EditorPanelLayout
title="Construct a ClickHouse System Query"
open={editorOpen}
onOpenChange={setEditorOpen}
summary={
<>
{query.sudo && (
<span style={{ color: COLORS.DANGER, fontWeight: 700 }}>
SUDO{" "}
</span>
)}
<div style={editorTitleRowStyle}>
<h2 style={{ ...SECTION_HEADING_STYLE, margin: 0 }}>
Construct a ClickHouse System Query
</h2>
<Button
size="xs"
variant="light"
color="red"
leftIcon={<IconX size={14} />}
aria-label="Close editor"
onClick={() => setEditorOpen(false)}
>
Close
</Button>
</div>
{query.storage ?? "(no storage)"}
{" · "}
{query.host ? `${query.host}:${query.port}` : "(no host)"}
{" · "}
<code>{query.sql || "(no query)"}</code>
</>
}
banner={
prefill &&
prefillWarningOpen && (
<PrefillWarning onClose={() => setPrefillWarningOpen(false)}>
Storage: <code>{prefill.storage ?? "(not set)"}</code>
{" · "}
Host:{" "}
<code>
{prefill.host ? `${prefill.host}:${prefill.port}` : "(not set)"}
</code>
</PrefillWarning>
)
}
editor={
<>
<div style={blockStyle}>
<div style={targetRowStyle}>
<div style={{ ...targetFieldStyle, width: "auto" }}>
Expand Down Expand Up @@ -445,8 +371,9 @@ function QueryDisplay(props: {
</Tooltip>
</div>
</div>
</form>
</div>
</>
}
>
<div>
{getErrorDomElement(queryError, collapseOpened, setCollapseOpened)}
</div>
Expand All @@ -468,7 +395,7 @@ function QueryDisplay(props: {
/>
))}
</div>
</div>
</EditorPanelLayout>
);
}

Expand Down Expand Up @@ -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,
Expand All @@ -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',
Expand All @@ -596,5 +463,4 @@ const executeActionsStyle = {
gap: 16,
};

export { STICKY_HEADER_TOP };
export default QueryDisplay;
Loading
Loading