diff --git a/backend/fluksio/api/routes/search.py b/backend/fluksio/api/routes/search.py index 5933532..e146bdf 100644 --- a/backend/fluksio/api/routes/search.py +++ b/backend/fluksio/api/routes/search.py @@ -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: diff --git a/backend/tests/api/routes/test_search.py b/backend/tests/api/routes/test_search.py index 160282c..5ce875e 100644 --- a/backend/tests/api/routes/test_search.py +++ b/backend/tests/api/routes/test_search.py @@ -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], diff --git a/docs/interface/index.md b/docs/interface/index.md index b125252..bc5e618 100644 --- a/docs/interface/index.md +++ b/docs/interface/index.md @@ -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. diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 209099c..71244eb 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -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: { diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index af5136f..15c11ed 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -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)>; diff --git a/frontend/src/components/Common/GlobalSearch.tsx b/frontend/src/components/Common/GlobalSearch.tsx index f05874c..8007bee 100644 --- a/frontend/src/components/Common/GlobalSearch.tsx +++ b/frontend/src/components/Common/GlobalSearch.tsx @@ -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", diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index 08224f1..ec77e56 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -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(focus ?? null) + // An endpoint is never selected; arriving on one opens it instead (below). + const [selectedId, setSelectedId] = useState( + focus && !isEndpointNode({ id: focus }) ? focus : null, + ) const [paletteOpen, setPaletteOpen] = useState(false) const [inspected, setInspected] = useState(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() diff --git a/frontend/src/routes/_canvas/flows/$flowName.tsx b/frontend/src/routes/_canvas/flows/$flowName.tsx index 253946d..4481ae2 100644 --- a/frontend/src/routes/_canvas/flows/$flowName.tsx +++ b/frontend/src/routes/_canvas/flows/$flowName.tsx @@ -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): Search => ({ node: typeof search.node === "string" ? search.node : undefined, }), diff --git a/frontend/tests/search.spec.ts b/frontend/tests/search.spec.ts index a9ad035..6d93d39 100644 --- a/frontend/tests/search.spec.ts +++ b/frontend/tests/search.spec.ts @@ -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.