Add a global search, and stop the sidebar logo squeezing
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m14s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m53s
pre-commit / pre-commit (push) Failing after 2m13s
Test Backend / test-backend (push) Successful in 2m38s
Compose Smoke Test / test-compose (push) Successful in 38s
Playwright Tests / merge-reports (push) Successful in 1m8s

`GET /api/v1/search/` hands the client one flat index of everything worth
jumping to — flows and the nodes inside them, dashboards and the widgets on
them, panels, secrets, modules, workers and alert channels — and cmdk matches
it in the browser, so results narrow while typing without a round trip per
keystroke. A node hit is the one thing no list endpoint could answer: it opens
its flow with that node in focus.

The panel is reached from **Search** above Documentation in the sidebar, or
⌘K anywhere. The flow canvas palette moves to ⌘P, being the narrower of the two.

The panels dialog gains an address (`/dashboards?panels`) so a panel hit has
somewhere to land, and the sidebar logo gets `shrink-0`: the rail's width
animates while the logo is already back, and a flex item short of room is
squeezed rather than clipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vGH7jqcXxWKP9wZFPyVdU
This commit is contained in:
2026-08-28 22:19:08 +02:00
co-authored by Claude Opus 5
parent 971bd430c7
commit 4215e057d1
17 changed files with 642 additions and 39 deletions
+2
View File
@@ -15,6 +15,7 @@ from fluksio.api.routes import (
panels,
private,
runs,
search,
secrets,
users,
utils,
@@ -38,6 +39,7 @@ api_router.include_router(observability.router)
api_router.include_router(runs.router)
api_router.include_router(artifacts.router)
api_router.include_router(workers.router)
api_router.include_router(search.router)
# Remote access through a portal. Always mounted; with no enrolment the
# endpoints only ever report that there is none.
api_router.include_router(cloud.router)
+156
View File
@@ -0,0 +1,156 @@
"""One index of everything in this installation 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.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-installation 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",
"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.
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.
parent: str = ""
#: Node type, 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
)
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
installation 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
)
+80
View File
@@ -0,0 +1,80 @@
"""The one index the global search matches against."""
from fastapi.testclient import TestClient
from fluksio.core.config import settings
PREFIX = f"{settings.API_V1_STR}/search"
FLOWS = f"{settings.API_V1_STR}/flows"
DASHBOARDS = f"{settings.API_V1_STR}/dashboards"
SECRETS = f"{settings.API_V1_STR}/secrets"
def test_search_requires_authentication(client: TestClient) -> None:
assert client.get(f"{PREFIX}/").status_code == 401
def test_index_reaches_inside_flows_and_dashboards(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""A node and a widget are the point: neither is on any list endpoint."""
client.put(
f"{FLOWS}/searchable",
headers=superuser_token_headers,
json={
"name": "searchable",
"title": "Searchable",
"nodes": [{"id": "sensor", "type": "python", "title": "Hall sensor"}],
},
)
client.put(
f"{DASHBOARDS}/hall",
headers=superuser_token_headers,
json={
"name": "hall",
"title": "Hall",
"widgets": [
{
"id": "temperature",
"type": "stat",
"title": "Temperature",
"layout": {"lg": {"x": 0, "y": 0, "w": 3, "h": 2}},
"config": {"message": "hall.temperature", "dtype": "float"},
}
],
"version": 0,
},
)
entries = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
# Keyed on the parent too: an id is only unique within the document it is
# in, and the other suites seed their own `sensor` and `temperature`.
found = {
(entry["category"], entry["parent"], entry["name"]): entry for entry in entries
}
assert found[("flow", "", "searchable")]["title"] == "Searchable"
assert found[("node", "searchable", "sensor")]["title"] == "Hall sensor"
assert found[("node", "searchable", "sensor")]["kind"] == "python"
assert found[("dashboard", "", "hall")]["title"] == "Hall"
assert found[("widget", "hall", "temperature")]["title"] == "Temperature"
assert found[("widget", "hall", "temperature")]["kind"] == "stat"
def test_secrets_are_named_only_to_a_superuser(
client: TestClient,
superuser_token_headers: dict[str, str],
normal_user_token_headers: dict[str, str],
) -> None:
client.put(
f"{SECRETS}/broker_password",
headers=superuser_token_headers,
json={"value": "hunter2"},
)
def secrets(headers: dict[str, str]) -> set[str]:
entries = client.get(f"{PREFIX}/", headers=headers).json()
return {e["name"] for e in entries if e["category"] == "secret"}
assert "broker_password" in secrets(superuser_token_headers)
assert secrets(normal_user_token_headers) == set()