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