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:
@@ -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:
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user