Files
app/backend/fluksio/api/routes/search.py
T
stroblme a677409079 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.
2026-10-05 18:15:35 +02:00

183 lines
5.9 KiB
Python

"""One index of everything in this instance worth jumping to by name."""
from typing import Any, Literal
from fastapi import APIRouter, Depends, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from fluksio.api.deps import (
CurrentUser,
DashboardStoreDep,
FlowControllerDep,
get_current_user,
)
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
# A wall panel never reaches this route: ``deps._panel_may`` is a whitelist that
# ends in a 403, and a whole-instance index is the opposite of what a screen
# on a wall is allowed to read.
router = APIRouter(
prefix="/search", tags=["search"], dependencies=[Depends(get_current_user)]
)
Category = Literal[
"flow",
"node",
"input",
"output",
"dashboard",
"widget",
"panel",
"secret",
"module",
"worker",
"alert",
]
class SearchEntry(BaseModel):
"""One thing somebody might be looking for.
Deliberately not a route: where a category lands is the frontend's business,
and it already owns the router. This says what the thing is and what it is
called, which is all the matching needs.
"""
category: Category
#: 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, input or output sits in, or the dashboard a widget
#: sits on.
parent: str = ""
#: Node type, input dtype, widget type, channel kind.
kind: str = ""
def _build(
controller: FlowController, dashboards: DashboardStore, hub: Any, secrets: bool
) -> list[SearchEntry]:
"""Read every store once. Blocking: disk and git throughout.
# ponytail: rebuilt per call. Key it on ``controller.store.revision`` if a
# store large enough to feel it ever shows up in a profile.
"""
entries: list[SearchEntry] = []
for name in controller.store.list_flows():
try:
flow = controller.store.read_flow(name, draft=True)
except FlowNotFound:
continue
entries.append(SearchEntry(category="flow", name=flow.name, title=flow.title))
entries.extend(
SearchEntry(
category="node",
name=node.id,
title=node.title,
parent=flow.name,
kind=node.type,
)
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:
dashboard = dashboards.read(summary.name, draft=True)
except DashboardNotFound:
continue
entries.append(
SearchEntry(
category="dashboard", name=dashboard.name, title=dashboard.title
)
)
entries.extend(
SearchEntry(
category="widget",
name=widget.id,
title=widget.title,
parent=dashboard.name,
kind=widget.type,
)
for widget in dashboard.widgets
)
entries.extend(
SearchEntry(category="panel", name=panel.id, title=panel.title)
for panel in panels.read_config().panels
)
if secrets:
entries.extend(
SearchEntry(category="secret", name=name) for name in get_secrets().list()
)
entries.extend(
SearchEntry(category="module", name=package.name, kind=package.version)
for package in modules.info(controller.store).packages
)
entries.extend(
SearchEntry(category="worker", name=worker.name)
for worker in (hub.workers() if hub is not None else [])
)
entries.extend(
SearchEntry(category="alert", name=channel.name, kind=channel.kind)
for channel in read_alerts_config().channels
)
return entries
@router.get("/", response_model=list[SearchEntry])
async def read_search_index(
current_user: CurrentUser,
request: Request,
controller: FlowControllerDep,
dashboards: DashboardStoreDep,
) -> Any:
"""Everything searchable, for the client to match against as it is typed.
The whole index rather than a query: it is a few hundred short rows for an
instance of any ordinary size, so one fetch when the panel opens beats a
round trip per keystroke — and the client already has a matcher.
Secrets are named only to a superuser, which is who ``/secrets`` answers to.
"""
# Absent when remote workers are switched off — not a reason to fail a search.
hub = getattr(request.app.state, "worker_hub", None)
return await run_in_threadpool(
_build, controller, dashboards, hub, current_user.is_superuser
)