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
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user