+
);
}
-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 } }}
+ >
+ }
+ style={{ flexShrink: 0 }}
+ >
+ Open Editor
+
+
+ {props.summary}
+
+
+ )}
+ {/* Hidden rather than unmounted, so the editor keeps its state. */}
+
+
+ {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 (
+