diff --git a/snuba/admin/static/clickhouse_queries/query_display.tsx b/snuba/admin/static/clickhouse_queries/query_display.tsx index 9e415999d6..f54f539492 100644 --- a/snuba/admin/static/clickhouse_queries/query_display.tsx +++ b/snuba/admin/static/clickhouse_queries/query_display.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import Client from "SnubaAdmin/api_client"; import QueryEditor from "SnubaAdmin/query_editor"; import ExecuteButton from "SnubaAdmin/utils/execute_button"; @@ -24,7 +24,17 @@ import { import { COLORS, SECTION_HEADING_STYLE } from "SnubaAdmin/theme"; import { PrefillWarning } from "SnubaAdmin/editor_panel"; import { CustomSelect, getParamFromStorage } from "SnubaAdmin/select"; -import { getHostsForStorage, getErrorDomElement } from "SnubaAdmin/utils/clickhouse_node_utils"; +import { + NodeOption, + getAllNodes, + getErrorDomElement, + getHostsForStorage, + nodeMatchesSearch, + nodeSearchMatch, + nodeSelectItem, + rankNodes, + storageForNode, +} from "SnubaAdmin/utils/clickhouse_node_utils"; import { QueryHistoryEntry } from "SnubaAdmin/clickhouse_queries/query_history_entry"; import { clearHashParams, @@ -42,6 +52,25 @@ import { type QueryState = Partial; const HISTORY_KEY = "clickhouse_queries"; +// Whether hosts are picked by storage, as before the node search. +const BY_STORAGE_KEY = "system-queries-by-storage"; + +function getSavedByStorage(): boolean { + try { + return localStorage.getItem(BY_STORAGE_KEY) === "true"; + } catch { + return false; + } +} + +function saveByStorage(value: boolean) { + try { + localStorage.setItem(BY_STORAGE_KEY, String(value)); + } catch { + // Not remembering the mode is fine. + } +} + // The storage's first node, to have a host ready as soon as a storage is // picked. function firstHost(nodeData: ClickhouseNodeData[], storage?: string) { @@ -49,6 +78,15 @@ function firstHost(nodeData: ClickhouseNodeData[], storage?: string) { return first ? parseHostPort(first.value) : {}; } +// What picking a node sets on the query: the node, and a storage that lists +// it so the backend can find its cluster. +function queryForNode(node: NodeOption, preferredStorage?: string) { + return { + ...parseHostPort(node.value), + storage: storageForNode(node, preferredStorage), + }; +} + function QueryDisplay(props: { api: Client; resultDataPopulator: (queryResult: QueryResult) => JSX.Element; @@ -62,10 +100,12 @@ function QueryDisplay(props: { prefill !== null ); const [nodeData, setNodeData] = useState([]); + const nodeOptions = useMemo(() => getAllNodes(nodeData), [nodeData]); // The host is filled in once the nodes arrive, so until then it isn't // flagged as missing. const [nodesLoaded, setNodesLoaded] = useState(false); const [manualHostInput, setManualHostInput] = useState(""); + const [byStorage, setByStorage] = useState(getSavedByStorage); const [query, setQuery] = useState({ storage: prefill?.storage ?? getParamFromStorage("storage"), host: prefill?.host, @@ -97,43 +137,61 @@ function QueryDisplay(props: { props.api.getClickhouseNodes().then((res) => { setNodeData(res); setNodesLoaded(true); - // A linked host the storage's node list doesn't know about can only be - // shown (and checked) via manual host entry. + const nodes = getAllNodes(res); + // A linked host no storage knows about can only be shown (and checked) + // via manual host entry. if (prefill?.host) { const hostPort = `${prefill.host}:${prefill.port}`; - const isListed = getHostsForStorage(res, prefill.storage).some( - (option) => option.value === hostPort - ); - if (!isListed) { + const node = nodes.find((option) => option.value === hostPort); + if (node) { + setQuery((prevQuery) => ({ + ...prevQuery, + ...queryForNode(node, prefill.storage), + })); + } else { setManualHostInput(hostPort); setClusterlessHost(true); } } else if (!prefill) { - // The saved storage comes back without a host. - setQuery((prevQuery) => - prevQuery.host - ? prevQuery - : { ...prevQuery, ...firstHost(res, prevQuery.storage) } - ); + // The node picked last time (the host select saves it). + const saved = getParamFromStorage("host"); + const node = nodes.find((option) => option.value === saved); + if (node) { + setQuery((prevQuery) => ({ + ...prevQuery, + ...queryForNode(node, prevQuery.storage), + })); + } } }); }, []); + // For picking hosts by storage, and for manual host entry, where the + // storage picks the cluster whose credentials are used. function selectStorage(storage: string) { setQuery((prevQuery) => { - // clear old host port - delete prevQuery.host; - delete prevQuery.port; - - return { - ...prevQuery, - storage: storage, + if (prevQuery.clusterless) { // A typed host isn't replaced; it's up to the user. - ...(prevQuery.clusterless ? {} : firstHost(nodeData, storage)), - }; + return { ...prevQuery, storage }; + } + const { host, port, ...rest } = prevQuery; + return { ...rest, storage, ...firstHost(nodeData, storage) }; }); } + function setHostEntry(value: string) { + setClusterlessHost(value === "manual"); + if (value !== "manual") { + setByStorage(value === "storage"); + saveByStorage(value === "storage"); + } + } + + // A host picked from the storage's own list keeps that storage. + function selectStorageHost(hostPort: string) { + setQuery((prevQuery) => ({ ...prevQuery, ...parseHostPort(hostPort) })); + } + function setSudo(value: boolean) { setQuery((prevQuery) => { return { @@ -168,16 +226,15 @@ function QueryDisplay(props: { }); } - function selectHost(hostString: string) { - const [host, portAsString] = hostString.split(":"); - - setQuery((prevQuery) => { - return { - ...prevQuery, - host: host, - port: parseInt(portAsString, 10), - }; - }); + function selectNode(hostPort: string) { + const node = nodeOptions.find((option) => option.value === hostPort); + if (!node) { + return; + } + setQuery((prevQuery) => ({ + ...prevQuery, + ...queryForNode(node, prevQuery.storage), + })); } function updateQuerySql(sql: string) { @@ -230,16 +287,23 @@ function QueryDisplay(props: { // The first thing still needed before the query can run, if any. It's // flagged on its field and explains why Execute is disabled. - const missing = !query.storage - ? "storage" - : !query.host || !query.port - ? "host" + // Searching nodes also sets the node's storage, so only the other ways of + // picking a host ask for one. + const missing = + (query.clusterless || byStorage) && !query.storage + ? "storage" + : !query.host || !query.port + ? "host" + : !query.storage + ? "storage" : !query.sql ? "query" : null; const missingHint = { storage: "Select a storage to run the query.", - host: "Select a host to run the query.", + host: query.clusterless + ? "Enter a host to run the query." + : "Select a host to run the query.", query: "Write or pick a query to run.", }; // What to flag on the page; Execute stays disabled either way. @@ -292,22 +356,24 @@ function QueryDisplay(props: { )}
-
- - Storage - -
- storage.storage_name)} - /> + {(query.clusterless || byStorage) && ( +
+ + Storage + +
+ storage.storage_name)} + /> +
+ {flagged === "storage" && ( +

{missingHint.storage}

+ )}
- {flagged === "storage" && ( -

{missingHint.storage}

- )} -
+ )}
{/* The manual-entry rules live in a tooltip so they don't make the row taller. */} @@ -348,17 +414,39 @@ function QueryDisplay(props: { ) : ( <>
- + {byStorage ? ( + + ) : ( + + nodeMatchesSearch(search, item.value) + } + highlight={(search, value) => + nodeSearchMatch(search, value)?.indexes ?? null + } + sortOptions={(search) => + rankNodes(search, nodeOptions).map(nodeSelectItem) + } + /> + )}
{flagged === "host" && (

{missingHint.host}

@@ -372,10 +460,13 @@ function QueryDisplay(props: { setClusterlessHost(value === "manual")} + value={ + query.clusterless ? "manual" : byStorage ? "storage" : "search" + } + onChange={setHostEntry} data={[ - { label: "Pick a node", value: "pick" }, + { label: "Search nodes", value: "search" }, + { label: "By storage", value: "storage" }, { label: "Manual", value: "manual" }, ]} /> diff --git a/snuba/admin/static/select.tsx b/snuba/admin/static/select.tsx index 703dd9bcb1..295b4e00b2 100644 --- a/snuba/admin/static/select.tsx +++ b/snuba/admin/static/select.tsx @@ -1,5 +1,11 @@ -import React, { forwardRef } from "react"; -import { Select, SelectItem, Group, Text } from '@mantine/core'; +import React, { + createContext, + forwardRef, + useContext, + useRef, + useState, +} from "react"; +import { Select, SelectItem, Group, Mark, Text } from '@mantine/core'; type SelectProps = { value: string; @@ -7,11 +13,45 @@ type SelectProps = { disabled?: boolean; onChange: (value: string) => void; options: string[] | SelectItem[]; + filter?: (search: string, item: SelectItem) => boolean; + // The indexes of the label's characters that match the search, to mark + // them in the dropdown, or null if none do. + highlight?: (search: string, value: string) => number[] | null; + // The options in the order to list them for the search. + sortOptions?: (search: string, options: SelectItem[]) => SelectItem[]; } interface ItemProps extends React.ComponentPropsWithoutRef<'div'> { image: string; label: string; + value: string; + description?: string; +} + +// The search's highlight for an item, by value. The dropdown renders items +// itself, so this reaches them through context. +const HighlightContext = createContext< + ((value: string) => number[] | null) | null +>(null); + +function highlightLabel(label: string, indexes: number[] | null) { + if (!indexes || indexes.length === 0) { + return label; + } + const marked = new Set(indexes); + // Consecutive matched characters share one mark. + const runs: Array<{ text: string; marked: boolean }> = []; + Array.from(label).forEach((char, idx) => { + const last = runs[runs.length - 1]; + if (last && last.marked === marked.has(idx)) { + last.text += char; + } else { + runs.push({ text: char, marked: marked.has(idx) }); + } + }); + return runs.map((run, idx) => + run.marked ? {run.text} : run.text + ); } export function getParamFromStorage(key: string) { @@ -20,27 +60,71 @@ export function getParamFromStorage(key: string) { } const SelectItem = forwardRef( - ({ image, label, ...others }: ItemProps, ref) => ( -
- -
- {label} -
-
-
- ) + ({ image, label, value, description, ...others }: ItemProps, ref) => { + const highlight = useContext(HighlightContext); + return ( +
+ +
+ + {highlightLabel(label, highlight ? highlight(value) : null)} + + {description && ( + + {description} + + )} +
+
+
+ ); + } ); export function CustomSelect(props: SelectProps) { - const {value, onChange, options, name, disabled} = props; + const {value, onChange, name, disabled, filter, highlight, sortOptions} = + props; + const [search, setSearch] = useState(""); + // Focusing selects the text, so typing replaces the current value. A click + // that focuses ends in a mouseup, which would put the caret back, so the + // first mouseup after focusing is ignored. + const justFocused = useRef(false); + function selectAllOnFocus(event: React.FocusEvent) { + event.currentTarget.select(); + justFocused.current = true; + } + function keepSelectionOnMouseUp(event: React.MouseEvent) { + if (justFocused.current) { + event.preventDefault(); + justFocused.current = false; + } + } function updateStorage(value: string) { if (name) { localStorage.setItem(`select-${name}`, value); } onChange(value); } - options.sort(); + // With the selected item's label in the box, the dropdown lists everything, + // so there's nothing to mark or rank by. + const selected = (props.options as Array).find( + (option) => (typeof option === "string" ? option : option.value) === value + ); + const selectedLabel = + typeof selected === "string" ? selected : selected?.label ?? selected?.value; + const activeSearch = search && search !== selectedLabel ? search : ""; + const highlightValue = + highlight && activeSearch + ? (itemValue: string) => highlight(activeSearch, itemValue) + : null; + let options = props.options; + if (sortOptions) { + options = sortOptions(activeSearch, options as SelectItem[]); + } else { + options.sort(); + } return ( +