Search: node settings are indexed, with credentials masked on the server

/search gains a `setting` category, `<node>.<setting>` under its flow with the
node type and the value in a new `detail` field, so Ctrl-K finds which node
uses a broker or a timeout and opens the node. `setting_text` is the one place
that decides what may be said: a reference to a stored secret shows its name
(to superusers only, as the secrets list does), a plain value in an `x-secret`
field or under a key like password, token or api_key is masked and never
indexed, nested credentials in headers or query are masked, long values are
clipped. The global search ranks what contains the query as typed ahead of
loose spellings before it caps a group, which long setting values would
otherwise have crowded out.
This commit is contained in:
2026-10-28 11:07:27 +01:00
parent 2e0ba32124
commit 29f4d93c2a
7 changed files with 274 additions and 20 deletions
+6 -1
View File
@@ -4112,7 +4112,7 @@ export const SearchEntrySchema = {
properties: {
category: {
type: 'string',
enum: ['flow', 'node', 'input', 'output', 'dashboard', 'widget', 'device', 'secret', 'module', 'worker', 'alert'],
enum: ['flow', 'node', 'input', 'output', 'setting', 'dashboard', 'widget', 'device', 'secret', 'module', 'worker', 'alert'],
title: 'Category'
},
name: {
@@ -4133,6 +4133,11 @@ export const SearchEntrySchema = {
type: 'string',
title: 'Kind',
default: ''
},
detail: {
type: 'string',
title: 'Detail',
default: ''
}
},
type: 'object',
+3 -2
View File
@@ -1350,14 +1350,15 @@ export type RunRequest = {
* called, which is all the matching needs.
*/
export type SearchEntry = {
category: 'flow' | 'node' | 'input' | 'output' | 'dashboard' | 'widget' | 'device' | 'secret' | 'module' | 'worker' | 'alert';
category: 'flow' | 'node' | 'input' | 'output' | 'setting' | 'dashboard' | 'widget' | 'device' | 'secret' | 'module' | 'worker' | 'alert';
name: string;
title?: string;
parent?: string;
kind?: string;
detail?: string;
};
export type category = 'flow' | 'node' | 'input' | 'output' | 'dashboard' | 'widget' | 'device' | 'secret' | 'module' | 'worker' | 'alert';
export type category = 'flow' | 'node' | 'input' | 'output' | 'setting' | 'dashboard' | 'widget' | 'device' | 'secret' | 'module' | 'worker' | 'alert';
export type SecretNames = {
data: Array<(string)>;
+34 -11
View File
@@ -12,6 +12,7 @@ import {
MonitorSmartphone,
Package,
Server,
SlidersHorizontal,
Workflow,
} from "lucide-react"
import { useState } from "react"
@@ -49,6 +50,7 @@ const GROUPS: {
{ category: "node", label: "Nodes", icon: Box },
{ category: "input", label: "Inputs", icon: LogIn },
{ category: "output", label: "Outputs", icon: LogOut },
{ category: "setting", label: "Settings", icon: SlidersHorizontal },
{ category: "dashboard", label: "Dashboards", icon: LayoutDashboard },
{ category: "widget", label: "Widgets", icon: LayoutGrid },
{ category: "device", label: "Devices", icon: MonitorSmartphone },
@@ -58,14 +60,17 @@ const GROUPS: {
{ category: "alert", label: "Alerts", icon: Bell },
]
/** The second line: where the thing lives, and what kind it is. */
/**
* The second line: where the thing lives, what kind it is, and for a setting
* what it is set to.
*/
function hint(entry: SearchEntry): string {
return [entry.parent, entry.kind].filter(Boolean).join(" · ")
return [entry.parent, entry.kind, entry.detail].filter(Boolean).join(" · ")
}
/** Everything about an entry that is worth matching against. */
function searchValue(entry: SearchEntry): string {
return `${entry.name} ${entry.title ?? ""} ${entry.parent ?? ""} ${entry.kind ?? ""}`
return `${entry.name} ${entry.title ?? ""} ${entry.parent ?? ""} ${entry.kind ?? ""} ${entry.detail ?? ""}`
}
/** cmdk lowercases and flattens whitespace and hyphens before it scores. */
@@ -137,13 +142,21 @@ export function GlobalSearch({
// every entry on each keystroke. Not memoised: the component returns early
// while closed, so a hook cannot go here, and one pass is already the win.
// The same pass drops what the query cannot reach, so each bucket is already
// candidates by the time GROUP_CAP trims it.
const byCategory = new Map<string, SearchEntry[]>()
// candidates by the time GROUP_CAP trims it. What contains the query as
// typed goes first: a long value (a setting's) is reachable by a loose
// spelling of almost anything, and those must not crowd out the real hit.
const wanted = normalise(typed)
// Per category: what contains the query as typed, and what only spells it
// loosely.
const exact = new Map<string, SearchEntry[]>()
const loose = new Map<string, SearchEntry[]>()
for (const entry of entries) {
if (!couldMatch(searchValue(entry), typed)) continue
const bucket = byCategory.get(entry.category)
const value = searchValue(entry)
if (!couldMatch(value, typed)) continue
const into = normalise(value).includes(wanted) ? exact : loose
const bucket = into.get(entry.category)
if (bucket) bucket.push(entry)
else byCategory.set(entry.category, [entry])
else into.set(entry.category, [entry])
}
const go = (entry: SearchEntry) => {
@@ -161,6 +174,13 @@ export function GlobalSearch({
params: { flowName: entry.parent ?? "" },
search: { node: entry.name },
})
// A setting is `<node>.<setting>`, and a node id has no dot.
case "setting":
return navigate({
to: "/flows/$flowName",
params: { flowName: entry.parent ?? "" },
search: { node: entry.name.split(".")[0] },
})
// Arriving on the label's id does what clicking the label does.
case "input":
case "output":
@@ -201,14 +221,14 @@ export function GlobalSearch({
open={open}
onOpenChange={onOpenChange}
title="Search"
description="Find a flow, node, dashboard or widget"
description="Find a flow, node, setting, dashboard or widget"
showCloseButton={false}
className="top-[40%] bg-popover/80 shadow-e3 backdrop-blur-md sm:max-w-xl [&_[data-slot=command]]:bg-transparent"
>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Search flows, nodes, dashboards, widgets…"
placeholder="Search flows, nodes, settings, dashboards, widgets…"
data-testid="global-search-input"
/>
<CommandList className="max-h-[min(24rem,60svh)]">
@@ -216,7 +236,10 @@ export function GlobalSearch({
<>
<CommandEmpty>Nothing matches that.</CommandEmpty>
{GROUPS.map(({ category, label, icon: Icon }) => {
const found = (byCategory.get(category) ?? []).slice(0, GROUP_CAP)
const found = [
...(exact.get(category) ?? []),
...(loose.get(category) ?? []),
].slice(0, GROUP_CAP)
if (found.length === 0) return null
return (
<CommandGroup key={category} heading={label}>