From d4cdae68bc56429d10ab1c7909eca573d162c057 Mon Sep 17 00:00:00 2001 From: Oliver Newland Date: Mon, 26 Oct 2026 15:26:01 -0700 Subject: [PATCH 1/3] feat(admin): Search all nodes in System Queries instead of picking a storage System Queries made you pick a storage before a host, and the storage list repeats every cluster under many names. The host field is now one autocomplete across every node the admin knows about. Picking a node still sends a storage that lists it, since the backend uses the storage to find the cluster's credentials and validate the node, so the backend is unchanged. Manual host entry keeps a storage picker for the same reason. The search understands hostnames: separators are optional, runs of words can be typed as initials (eap for events-analytics-platform), and a typed separator can skip the rest of a word (err-1 for errors-1). Results rank word-start matches first, then closer word-break alignment, with distributed nodes last, and the matched characters are highlighted. The rules are written up in snuba/admin/static/utils/NODE_SEARCH.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../clickhouse_queries/query_display.tsx | 152 ++++---- snuba/admin/static/select.tsx | 93 ++++- .../tests/clickhouse_queries/prefill.spec.tsx | 29 +- snuba/admin/static/tests/select.spec.tsx | 88 +++++ .../utils/clickhouse_node_utils.spec.tsx | 222 +++++++++++ snuba/admin/static/utils/NODE_SEARCH.md | 87 +++++ .../static/utils/clickhouse_node_utils.tsx | 358 ++++++++++++++++++ 7 files changed, 949 insertions(+), 80 deletions(-) create mode 100644 snuba/admin/static/tests/select.spec.tsx create mode 100644 snuba/admin/static/tests/utils/clickhouse_node_utils.spec.tsx create mode 100644 snuba/admin/static/utils/NODE_SEARCH.md diff --git a/snuba/admin/static/clickhouse_queries/query_display.tsx b/snuba/admin/static/clickhouse_queries/query_display.tsx index 9e415999d69..e5fc15a41a9 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,16 @@ 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, + nodeMatchesSearch, + nodeSearchMatch, + nodeSelectItem, + rankNodes, + storageForNode, +} from "SnubaAdmin/utils/clickhouse_node_utils"; import { QueryHistoryEntry } from "SnubaAdmin/clickhouse_queries/query_history_entry"; import { clearHashParams, @@ -42,11 +51,13 @@ import { type QueryState = Partial; const HISTORY_KEY = "clickhouse_queries"; -// The storage's first node, to have a host ready as soon as a storage is -// picked. -function firstHost(nodeData: ClickhouseNodeData[], storage?: string) { - const [first] = getHostsForStorage(nodeData, storage); - 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: { @@ -62,6 +73,7 @@ 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); @@ -97,41 +109,36 @@ 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) })); + } } }); }, []); + // Only 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, - // A typed host isn't replaced; it's up to the user. - ...(prevQuery.clusterless ? {} : firstHost(nodeData, storage)), - }; - }); + setQuery((prevQuery) => ({ ...prevQuery, storage })); } function setSudo(value: boolean) { @@ -168,16 +175,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 +236,22 @@ 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" + // Picking a node also sets its storage, so only manual entry asks for one. + const missing = + query.clusterless && !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 +304,24 @@ function QueryDisplay(props: { )}
-
- - Storage - -
- storage.storage_name)} - /> + {query.clusterless && ( +
+ + 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. */} @@ -349,15 +363,23 @@ function QueryDisplay(props: { <>
+ nodeMatchesSearch(search, item.value) + } + highlight={(search, value) => + nodeSearchMatch(search, value)?.indexes ?? null + } + sortOptions={(search) => + rankNodes(search, nodeOptions).map(nodeSelectItem) + } />
{flagged === "host" && ( diff --git a/snuba/admin/static/select.tsx b/snuba/admin/static/select.tsx index 703dd9bcb1e..f2799b3b476 100644 --- a/snuba/admin/static/select.tsx +++ b/snuba/admin/static/select.tsx @@ -1,5 +1,5 @@ -import React, { forwardRef } from "react"; -import { Select, SelectItem, Group, Text } from '@mantine/core'; +import React, { createContext, forwardRef, useContext, useState } from "react"; +import { Select, SelectItem, Group, Mark, Text } from '@mantine/core'; type SelectProps = { value: string; @@ -7,11 +7,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 +54,57 @@ 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(""); 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 ( +