Find a flow's declared inputs and outputs in the global search

/search lists each input and batch output under its qualified message,
and picking one opens the flow on the label's id, which the editor now
treats like a click on the label: it opens the flow's settings.
This commit is contained in:
2026-10-05 18:15:35 +02:00
parent 901e85f3fb
commit a677409079
9 changed files with 110 additions and 21 deletions
+29 -3
View File
@@ -16,6 +16,7 @@ from fluksio.api.routes.alerts import read_config as read_alerts_config
from fluksio.flow import modules, panels
from fluksio.flow.controller import FlowController
from fluksio.flow.dashboards import DashboardNotFound, DashboardStore
from fluksio.flow.messages import qualify
from fluksio.flow.secrets import get_secrets
from fluksio.flow.store import FlowNotFound
@@ -29,6 +30,8 @@ router = APIRouter(
Category = Literal[
"flow",
"node",
"input",
"output",
"dashboard",
"widget",
"panel",
@@ -48,13 +51,15 @@ class SearchEntry(BaseModel):
"""
category: Category
#: The id the frontend routes on.
#: The id the frontend routes on. For an input or output, the qualified
#: message, which is what the canvas names its label by.
name: str
#: Human title, often empty — a flow is usually only its name.
title: str = ""
#: The flow a node sits in, or the dashboard a widget sits on.
#: The flow a node, input or output sits in, or the dashboard a widget
#: sits on.
parent: str = ""
#: Node type, widget type, channel kind.
#: Node type, input dtype, widget type, channel kind.
kind: str = ""
@@ -84,6 +89,27 @@ def _build(
)
for node in flow.nodes
)
# Its boundary, as the canvas draws it: a live flow never finishes, so
# only a batch one has outputs to report.
boundary: list[tuple[Category, str, str]] = [
("input", flow_input.spec.name, flow_input.spec.dtype.value)
for flow_input in flow.inputs
]
if flow.mode == "batch":
boundary += [("output", output, "") for output in flow.outputs]
for category, declared, kind in boundary:
message = qualify(flow.name, declared)
if message:
entries.append(
SearchEntry(
category=category,
name=message,
# The label the canvas draws: bare when it is the flow's own.
title=message.removeprefix(f"{flow.name}."),
parent=flow.name,
kind=kind,
)
)
for summary in dashboards.list():
try:
+23
View File
@@ -61,6 +61,29 @@ def test_index_reaches_inside_flows_and_dashboards(
assert found[("widget", "hall", "temperature")]["kind"] == "stat"
def test_index_lists_a_flows_inputs_and_outputs(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""Named by the qualified message, which is the label the canvas draws."""
client.put(
f"{FLOWS}/fit",
headers=superuser_token_headers,
json={
"name": "fit",
"mode": "batch",
"inputs": [{"spec": {"name": "epochs", "dtype": "int"}}],
"outputs": ["accuracy"],
},
)
entries = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
found = {(e["category"], e["name"]): e for e in entries if e["parent"] == "fit"}
assert found[("input", "fit.epochs")]["title"] == "epochs"
assert found[("input", "fit.epochs")]["kind"] == "int"
assert found[("output", "fit.accuracy")]["title"] == "accuracy"
def test_secrets_are_named_only_to_a_superuser(
client: TestClient,
superuser_token_headers: dict[str, str],
+5 -4
View File
@@ -30,10 +30,11 @@ phone the sidebar collapses to a sheet.
### Search
**Search** at the foot of the sidebar, or ⌘K / Ctrl-K from anywhere, opens a
panel that finds things by name as you type: flows and the nodes inside them,
dashboards and the widgets on them, panels, secrets, modules, workers and alert
channels. Picking a node opens its flow with that node in focus; picking a
widget opens its dashboard.
panel that finds things by name as you type: flows with the nodes inside them
and the inputs and outputs they declare, dashboards and the widgets on them,
panels, secrets, modules, workers and alert channels. Picking a node opens its
flow with that node in focus, an input or output opens its flow's settings,
where it is declared, and a widget opens its dashboard.
It searches this instance. Reached through a portal, other instances
are behind **All instances** at the top of the sidebar.
+1 -1
View File
@@ -3456,7 +3456,7 @@ export const SearchEntrySchema = {
properties: {
category: {
type: 'string',
enum: ['flow', 'node', 'dashboard', 'widget', 'panel', 'secret', 'module', 'worker', 'alert'],
enum: ['flow', 'node', 'input', 'output', 'dashboard', 'widget', 'panel', 'secret', 'module', 'worker', 'alert'],
title: 'Category'
},
name: {
+2 -2
View File
@@ -1160,14 +1160,14 @@ export type RunRequest = {
* called, which is all the matching needs.
*/
export type SearchEntry = {
category: 'flow' | 'node' | 'dashboard' | 'widget' | 'panel' | 'secret' | 'module' | 'worker' | 'alert';
category: 'flow' | 'node' | 'input' | 'output' | 'dashboard' | 'widget' | 'panel' | 'secret' | 'module' | 'worker' | 'alert';
name: string;
title?: string;
parent?: string;
kind?: string;
};
export type category = 'flow' | 'node' | 'dashboard' | 'widget' | 'panel' | 'secret' | 'module' | 'worker' | 'alert';
export type category = 'flow' | 'node' | 'input' | 'output' | 'dashboard' | 'widget' | 'panel' | 'secret' | 'module' | 'worker' | 'alert';
export type SecretNames = {
data: Array<(string)>;
@@ -6,6 +6,8 @@ import {
KeyRound,
LayoutDashboard,
LayoutGrid,
LogIn,
LogOut,
type LucideIcon,
MonitorSmartphone,
Package,
@@ -45,6 +47,8 @@ const GROUPS: {
}[] = [
{ category: "flow", label: "Flows", icon: Workflow },
{ category: "node", label: "Nodes", icon: Box },
{ category: "input", label: "Inputs", icon: LogIn },
{ category: "output", label: "Outputs", icon: LogOut },
{ category: "dashboard", label: "Dashboards", icon: LayoutDashboard },
{ category: "widget", label: "Widgets", icon: LayoutGrid },
{ category: "panel", label: "Panels", icon: MonitorSmartphone },
@@ -106,7 +110,7 @@ function couldMatch(value: string, query: string): boolean {
* narrow as they are typed without a round trip per keystroke.
*
* cmdk does the matching over what is mounted, so the entries are bucketed by
* category once per fetch rather than filtered nine times per keystroke, and
* category once per fetch rather than filtered eleven times per keystroke, and
* each group is capped — a list nobody can scroll to the end of is not a
* better answer than its first twenty.
*/
@@ -129,7 +133,7 @@ export function GlobalSearch({
const entries = data ?? []
const typed = query.trim()
const typing = typed.length > 0
// One pass over the index rather than one per group — nine passes over
// One pass over the index rather than one per group — eleven passes over
// 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
@@ -157,6 +161,14 @@ export function GlobalSearch({
params: { flowName: entry.parent ?? "" },
search: { node: entry.name },
})
// Arriving on the label's id does what clicking the label does.
case "input":
case "output":
return navigate({
to: "/flows/$flowName",
params: { flowName: entry.parent ?? "" },
search: { node: `${entry.category}:${entry.name}` },
})
case "dashboard":
return navigate({
to: "/dashboards/$name",
+17 -6
View File
@@ -240,7 +240,7 @@ function FlowEditorInner({
onReload,
}: {
flowName: string
/** A node to arrive on, from the address bar. */
/** A node or endpoint to arrive on, from the address bar. */
focus?: string
onReload: () => void
}) {
@@ -272,7 +272,10 @@ function FlowEditorInner({
const [canvasNodes, setCanvasNodes, onNodesChange] = useUnpositionedNodes(
detail.definition.nodes ?? [],
)
const [selectedId, setSelectedId] = useState<string | null>(focus ?? null)
// An endpoint is never selected; arriving on one opens it instead (below).
const [selectedId, setSelectedId] = useState<string | null>(
focus && !isEndpointNode({ id: focus }) ? focus : null,
)
const [paletteOpen, setPaletteOpen] = useState(false)
const [inspected, setInspected] = useState<InspectedEdge | null>(null)
// The edges are derived, so xyflow's own selection would be thrown away on
@@ -642,10 +645,18 @@ function FlowEditorInner({
// The canvas is not remounted per node — that would throw the session away
// on every click in the brain graph — so arriving at a flow already open
// only changes the address. Seeded above for the first arrival, set here for
// the ones after it.
// the ones after it. An endpoint — an input or output found by the search —
// does what clicking its label does, which also covers one the canvas does
// not draw because a dashboard already accounts for it.
useEffect(() => {
if (focus) setSelectedId(focus)
}, [focus])
if (!focus) return
if (isEndpointNode({ id: focus })) {
openEndpoint(focus)
} else {
setFlowPanelOpen(false)
setSelectedId(focus)
}
}, [focus, openEndpoint])
const selected = definitions.find((node) => node.id === selectedId) ?? null
// A panel is the view you are working in, so it takes the room — but never
@@ -1499,7 +1510,7 @@ export function FlowEditor({
focus,
}: {
flowName: string
/** A node to select on arrival — see `FlowEditorInner`. */
/** A node or endpoint to arrive on — see `FlowEditorInner`. */
focus?: string
}) {
const navigate = useNavigate()
@@ -8,9 +8,9 @@ type Search = { node?: string }
export const Route = createFileRoute("/_canvas/flows/$flowName")({
component: FlowRoute,
// Which node to arrive on. A search param rather than a hash so the brain
// graph's click-through — and any link out of an alert — is a whole address
// someone can send.
// Which node, or input or output label, to arrive on. A search param rather
// than a hash so the brain graph's click-through — and any link out of an
// alert — is a whole address someone can send.
validateSearch: (search: Record<string, unknown>): Search => ({
node: typeof search.node === "string" ? search.node : undefined,
}),
+16
View File
@@ -22,6 +22,7 @@ test.beforeAll(async ({ browser }) => {
name: flowName,
title: "Searchable flow",
nodes: [{ id: "findme", type: "python", title: "Find me" }],
inputs: [{ spec: { name: "findme_input", dtype: "float" } }],
},
})
await page.close()
@@ -41,6 +42,21 @@ test("finds a node by name and opens its flow on it", async ({ page }) => {
await expect(page).toHaveURL(new RegExp(`/flows/${flowName}\\?node=findme$`))
})
test("finds a flow input and opens the settings declaring it", async ({
page,
}) => {
await page.goto("/")
await page.getByRole("button", { name: "Search", exact: true }).click()
await page.getByTestId("global-search-input").fill("findme_input")
await page.getByRole("option", { name: /findme_input/ }).click()
await expect(page).toHaveURL(
new RegExp(`/flows/${flowName}\\?node=input%3A${flowName}\\.findme_input$`),
)
await expect(page.getByTestId("flow-panel")).toBeVisible()
})
test("opens on the keyboard from anywhere", async ({ page }) => {
await page.goto("/dashboards")
// The binding lives in the sidebar, so wait for it to be there to press at.