From 4215e057d1338f7a81381db957e341bd78028cf9 Mon Sep 17 00:00:00 2001 From: stroblme Date: Fri, 28 Aug 2026 22:19:08 +0200 Subject: [PATCH] Add a global search, and stop the sidebar logo squeezing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) Claude-Session: https://claude.ai/code/session_016vGH7jqcXxWKP9wZFPyVdU --- backend/fluksio/api/main.py | 2 + backend/fluksio/api/routes/search.py | 156 +++++++++++++++ backend/tests/api/routes/test_search.py | 80 ++++++++ docs/code/connectors.md | 2 +- docs/getting-started/facility-automation.md | 2 +- docs/interface/flow-editor.md | 4 +- docs/interface/index.md | 12 ++ frontend/src/client/schemas.gen.ts | 37 ++++ frontend/src/client/sdk.gen.ts | 23 ++- frontend/src/client/types.gen.ts | 19 ++ .../src/components/Common/GlobalSearch.tsx | 185 ++++++++++++++++++ .../src/components/Flow/CommandPalette.tsx | 2 +- frontend/src/components/Flow/FlowDock.tsx | 2 +- frontend/src/components/Flow/FlowEditor.tsx | 8 +- .../src/components/Sidebar/AppSidebar.tsx | 79 +++++--- .../src/routes/_layout/dashboards/index.tsx | 15 +- frontend/tests/search.spec.ts | 53 +++++ 17 files changed, 642 insertions(+), 39 deletions(-) create mode 100644 backend/fluksio/api/routes/search.py create mode 100644 backend/tests/api/routes/test_search.py create mode 100644 frontend/src/components/Common/GlobalSearch.tsx create mode 100644 frontend/tests/search.spec.ts diff --git a/backend/fluksio/api/main.py b/backend/fluksio/api/main.py index ba56eaa..a4e1bdf 100644 --- a/backend/fluksio/api/main.py +++ b/backend/fluksio/api/main.py @@ -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) diff --git a/backend/fluksio/api/routes/search.py b/backend/fluksio/api/routes/search.py new file mode 100644 index 0000000..2cdd5c5 --- /dev/null +++ b/backend/fluksio/api/routes/search.py @@ -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 + ) diff --git a/backend/tests/api/routes/test_search.py b/backend/tests/api/routes/test_search.py new file mode 100644 index 0000000..160282c --- /dev/null +++ b/backend/tests/api/routes/test_search.py @@ -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() diff --git a/docs/code/connectors.md b/docs/code/connectors.md index c9df560..967a710 100644 --- a/docs/code/connectors.md +++ b/docs/code/connectors.md @@ -54,7 +54,7 @@ Two things to know about this: make dev-frontend # Vite on :5173 ``` -Restart the backend and your node type appears in the add-node palette (⌘K), +Restart the backend and your node type appears in the add-node palette (⌘P), labelled with the package it came from. ## Write the node diff --git a/docs/getting-started/facility-automation.md b/docs/getting-started/facility-automation.md index 2f83b35..b3a8faf 100644 --- a/docs/getting-started/facility-automation.md +++ b/docs/getting-started/facility-automation.md @@ -134,7 +134,7 @@ safe, fan-in is free, and two flows can share a value by naming it. ### Read a sensor -Press **Add node** (or ⌘K / Ctrl-K, which opens the command palette) and pick +Press **Add node** (or ⌘P / Ctrl-P, which opens the command palette) and pick **MQTT**. In its panel on the right: - **Broker host** — your broker's hostname, `mosquitto` if you are using the diff --git a/docs/interface/flow-editor.md b/docs/interface/flow-editor.md index 1536341..b484b59 100644 --- a/docs/interface/flow-editor.md +++ b/docs/interface/flow-editor.md @@ -29,7 +29,7 @@ banner tells you when it is not. ## Adding a node -**Add node** on the dock, or ⌘K / Ctrl-K for the command palette, which also +**Add node** on the dock, or ⌘P / Ctrl-P for the command palette, which also jumps between flows and offers your shared nodes. Pick a type and it appears on the canvas with its panel open. @@ -122,7 +122,7 @@ the edges, then publish. | Chord | Action | |---|---| -| ⌘K / Ctrl-K | command palette | +| ⌘P / Ctrl-P | command palette | | ⌘S / Ctrl-S | publish the flow — or, with focus in the code editor, apply the code | | ⌘Z / ⌘⇧Z | undo / redo (the flow; the code editor has its own) | | ⌘C / ⌘V | copy and paste nodes, including between flows | diff --git a/docs/interface/index.md b/docs/interface/index.md index 32ae071..d9b932d 100644 --- a/docs/interface/index.md +++ b/docs/interface/index.md @@ -22,8 +22,20 @@ phone the sidebar collapses to a sheet. | **Modules** | the Python packages your node code may import | | **Alerts** | where failures get sent | | **Admin** | users (superusers only) | +| **Search** | anything in this installation, by name | | **Settings** | your account, appearance, and remote access | +### 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. + +It searches this installation. Reached through a portal, other installations +are behind **All installations** at the top of the sidebar. + ## Home The one screen you leave open. Four things share it. diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index f2181a6..3ed0d50 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -2960,6 +2960,43 @@ export const RunRequestSchema = { title: 'RunRequest' } as const; +export const SearchEntrySchema = { + properties: { + category: { + type: 'string', + enum: ['flow', 'node', 'dashboard', 'widget', 'panel', 'secret', 'module', 'worker', 'alert'], + title: 'Category' + }, + name: { + type: 'string', + title: 'Name' + }, + title: { + type: 'string', + title: 'Title', + default: '' + }, + parent: { + type: 'string', + title: 'Parent', + default: '' + }, + kind: { + type: 'string', + title: 'Kind', + default: '' + } + }, + type: 'object', + required: ['category', 'name'], + title: 'SearchEntry', + description: `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.` +} as const; + export const SecretNamesSchema = { properties: { data: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 89bf192..9d49031 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; +import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; export class AlertsService { /** @@ -2120,6 +2120,27 @@ export class RunsService { } } +export class SearchService { + /** + * Read Search Index + * 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. + * @returns SearchEntry Successful Response + * @throws ApiError + */ + public static readSearchIndex(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/search/' + }); + } +} + export class SecretsService { /** * Read Secrets diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 05b0a4d..a416a53 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -1052,6 +1052,23 @@ export type RunRequest = { }; }; +/** + * 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. + */ +export type SearchEntry = { + category: 'flow' | 'node' | '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 SecretNames = { data: Array<(string)>; count: number; @@ -1830,6 +1847,8 @@ export type RunsCompareMetricData = { export type RunsCompareMetricResponse = (SeriesAnswer); +export type SearchReadSearchIndexResponse = (Array); + export type SecretsReadSecretsResponse = (SecretNames); export type SecretsSaveSecretData = { diff --git a/frontend/src/components/Common/GlobalSearch.tsx b/frontend/src/components/Common/GlobalSearch.tsx new file mode 100644 index 0000000..4dbdba1 --- /dev/null +++ b/frontend/src/components/Common/GlobalSearch.tsx @@ -0,0 +1,185 @@ +import { useQuery } from "@tanstack/react-query" +import { useNavigate } from "@tanstack/react-router" +import { + Bell, + Box, + KeyRound, + LayoutDashboard, + LayoutGrid, + type LucideIcon, + MonitorSmartphone, + Package, + Server, + Workflow, +} from "lucide-react" +import { useState } from "react" + +import { type SearchEntry, SearchService } from "@/client" +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" + +export const searchQueryOptions = () => ({ + queryKey: ["search"] as const, + queryFn: () => SearchService.readSearchIndex(), + staleTime: 30_000, +}) + +/** The categories, in the order they are offered, with what to draw each as. */ +const GROUPS: { + category: SearchEntry["category"] + label: string + icon: LucideIcon +}[] = [ + { category: "flow", label: "Flows", icon: Workflow }, + { category: "node", label: "Nodes", icon: Box }, + { category: "dashboard", label: "Dashboards", icon: LayoutDashboard }, + { category: "widget", label: "Widgets", icon: LayoutGrid }, + { category: "panel", label: "Panels", icon: MonitorSmartphone }, + { category: "secret", label: "Secrets", icon: KeyRound }, + { category: "module", label: "Modules", icon: Package }, + { category: "worker", label: "Workers", icon: Server }, + { category: "alert", label: "Alerts", icon: Bell }, +] + +/** The second line: where the thing lives, and what kind it is. */ +function hint(entry: SearchEntry): string { + return [entry.parent, entry.kind].filter(Boolean).join(" · ") +} + +/** + * Everything in this installation, by name, from anywhere. + * + * The whole index arrives in one fetch and `cmdk` does the matching, so results + * narrow as they are typed without a round trip per keystroke. + * + * ponytail: every entry is rendered and cmdk hides the ones that do not match. + * Cap the groups if an installation ever grows big enough to feel it. + */ +export function GlobalSearch({ + open, + onOpenChange, +}: { + open: boolean + onOpenChange: (open: boolean) => void +}) { + const navigate = useNavigate() + const [query, setQuery] = useState("") + const { data } = useQuery({ ...searchQueryOptions(), enabled: open }) + + // Picking an item navigates, which can interrupt the dialog's exit animation + // and leave its overlay swallowing clicks — the same reason the flow canvas + // palette unmounts outright rather than fading out. + if (!open) return null + + const entries = data ?? [] + const typing = query.trim().length > 0 + + const go = (entry: SearchEntry) => { + onOpenChange(false) + setQuery("") + switch (entry.category) { + case "flow": + return navigate({ + to: "/flows/$flowName", + params: { flowName: entry.name }, + }) + case "node": + return navigate({ + to: "/flows/$flowName", + params: { flowName: entry.parent ?? "" }, + search: { node: entry.name }, + }) + case "dashboard": + return navigate({ + to: "/dashboards/$name", + params: { name: entry.name }, + }) + case "widget": + return navigate({ + to: "/dashboards/$name", + params: { name: entry.parent ?? "" }, + }) + // Panels are managed in a dialog on the dashboards screen, which opens + // itself when the address says so. + case "panel": + return navigate({ to: "/dashboards", search: { panels: true } }) + case "secret": + return navigate({ to: "/secrets" }) + case "module": + return navigate({ to: "/modules" }) + case "worker": + return navigate({ to: "/workers" }) + case "alert": + return navigate({ to: "/alerts" }) + } + } + + return ( + // Frosted chrome, a little above centre. `top-[40%]` against the dialog's + // own `-translate-y-1/2` puts the panel's middle at two fifths of the + // viewport; the inner Command paints its own surface, which has to give way + // to this one. + + + + {typing ? ( + <> + Nothing matches that. + {GROUPS.map(({ category, label, icon: Icon }) => { + const found = entries.filter( + (entry) => entry.category === category, + ) + if (found.length === 0) return null + return ( + + {found.map((entry) => ( + go(entry)} + className="min-h-11 md:min-h-8" + > + + + + {entry.title || entry.name} + + {hint(entry) ? ( + + {hint(entry)} + + ) : null} + + + ))} + + ) + })} + + ) : ( +

+ Start typing to search this installation. +

+ )} +
+
+ ) +} diff --git a/frontend/src/components/Flow/CommandPalette.tsx b/frontend/src/components/Flow/CommandPalette.tsx index 57b95cd..a29ed16 100644 --- a/frontend/src/components/Flow/CommandPalette.tsx +++ b/frontend/src/components/Flow/CommandPalette.tsx @@ -13,7 +13,7 @@ import { import { libraryQueryOptions } from "./queries" /** - * ⌘K: add a node, jump to another flow, or run the current one, without + * ⌘P: add a node, jump to another flow, or run the current one, without * reaching for the dock. */ export function CommandPalette({ diff --git a/frontend/src/components/Flow/FlowDock.tsx b/frontend/src/components/Flow/FlowDock.tsx index 297add5..67b4af1 100644 --- a/frontend/src/components/Flow/FlowDock.tsx +++ b/frontend/src/components/Flow/FlowDock.tsx @@ -157,7 +157,7 @@ export function FlowDock({ - Add a node (⌘K) + Add a node (⌘P) step(false), "mod+c": () => void copyNodes(), "mod+v": pasteNodes, - "mod+k": () => setPaletteOpen((open) => !open), + // ⌘P, not ⌘K: the sidebar's global search owns that everywhere, and this + // palette is the canvas's own, narrower thing. + "mod+p": () => setPaletteOpen((open) => !open), // Inside the code editor ⌘S applies that code, which the node panel // owns; anywhere else on the canvas it puts the flow live. "mod+s": (event) => { @@ -1064,7 +1066,7 @@ function FlowEditorInner({ }, // Both stay reachable while typing: one is the editor's own save, the // other is how you reach anything at all. - ["mod+s", "mod+k"], + ["mod+s", "mod+p"], ) return ( @@ -1243,7 +1245,7 @@ function FlowEditorInner({

This flow is empty

- Add a node to get started. Press ⌘K, or use the plus in the bar + Add a node to get started. Press ⌘P, or use the plus in the bar below.

diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index 6ba694d..6c5ef45 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -8,12 +8,15 @@ import { LayoutDashboard, LogOut, Package, + Search, Server, Settings, Users, Workflow, } from "lucide-react" +import { useState } from "react" +import { GlobalSearch } from "@/components/Common/GlobalSearch" import { Logo } from "@/components/Common/Logo" import { Sidebar, @@ -24,6 +27,7 @@ import { } from "@/components/ui/sidebar" import useAuth from "@/hooks/useAuth" import { portalConfig } from "@/lib/portal" +import { useShortcuts } from "@/lib/shortcuts" import { type Item, Main } from "./Main" /** @@ -55,6 +59,12 @@ const baseItems: Item[] = [ export function AppSidebar() { const { user: currentUser, logout } = useAuth() const portal = portalConfig() + const [searchOpen, setSearchOpen] = useState(false) + + // Mounted by both shells, so this one binding covers every screen. Listed as + // firing inside text entry too, because reaching anything at all should not + // depend on where the caret happens to be. + useShortcuts({ "mod+k": () => setSearchOpen((open) => !open) }, ["mod+k"]) const withAdmin = currentUser?.is_superuser ? [...baseItems, { icon: Users, title: "Admin", path: "/admin" }] @@ -81,41 +91,56 @@ export function AppSidebar() { onClick: () => window.open(DOCS_URL, "_blank", "noopener"), } + const search: Item = { + icon: Search, + title: "Search", + onClick: () => setSearchOpen(true), + } + const footerItems: Item[] = portal - ? [docs, { icon: Settings, title: "Settings", path: "/settings" }] + ? [search, docs, { icon: Settings, title: "Settings", path: "/settings" }] : [ + search, docs, { icon: Settings, title: "Settings", path: "/settings" }, { icon: LogOut, title: "Log Out", onClick: logout }, ] return ( - // Floating frosted chrome over whatever surface the shell paints; see the - // root DESIGN-GUIDELINES.md → Shells and → Overlay surfaces & content chips. - - -
- {/* Collapsed, the rail has room for one thing, and that is the way - back out. */} - - - - {/* On a phone the sidebar is a sheet with its own way in and out. */} - -
-
- -
- - {/* Main already pads horizontally; the footer only adds the bottom gap. */} - -
- - + <> + {/* Floating frosted chrome over whatever surface the shell paints; see the + root DESIGN-GUIDELINES.md → Shells and → Overlay surfaces & content + chips. */} + + +
+ {/* Collapsed, the rail has room for one thing, and that is the way + back out. `shrink-0` because the rail's width animates while the + logo is already back: a flex item short of room is squeezed, and + a wordmark would rather be clipped than squashed. */} + + + + {/* On a phone the sidebar is a sheet with its own way in and out. */} + +
+
+ +
+ + {/* Main already pads horizontally; the footer only adds the bottom gap. */} + +
+ + + {/* Outside the sidebar: on a phone that is a sheet, and a dialog is not + one of its children. */} + + ) } diff --git a/frontend/src/routes/_layout/dashboards/index.tsx b/frontend/src/routes/_layout/dashboards/index.tsx index 45b7f6c..144c629 100644 --- a/frontend/src/routes/_layout/dashboards/index.tsx +++ b/frontend/src/routes/_layout/dashboards/index.tsx @@ -35,8 +35,15 @@ import { import useCustomToast from "@/hooks/useCustomToast" import { handleError } from "@/utils" +type Search = { panels?: boolean } + export const Route = createFileRoute("/_layout/dashboards/")({ component: Dashboards, + // The panels dialog has no route of its own, so the address is how anything + // else — the global search among them — arrives at it. + validateSearch: (search: Record): Search => ({ + panels: search.panels === true || search.panels === "true" || undefined, + }), }) function Dashboards() { @@ -47,8 +54,12 @@ function Dashboards() { const [name, setName] = useState("") const [search, setSearch] = useState("") const [dialogOpen, setDialogOpen] = useState(false) - const [panelsOpen, setPanelsOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) + // Which screens exist is a question with an address, so anything can link to + // it — the global search lands a panel here. + const { panels: panelsOpen } = Route.useSearch() + const setPanelsOpen = (open: boolean) => + navigate({ to: "/dashboards", search: open ? { panels: true } : {} }) const create = useMutation({ mutationFn: (dashboard: string) => @@ -110,7 +121,7 @@ function Dashboards() { {/* Its own root rather than a nested one: which screens exist is a different question from which dashboards do. */} - + diff --git a/frontend/tests/search.spec.ts b/frontend/tests/search.spec.ts new file mode 100644 index 0000000..a9ad035 --- /dev/null +++ b/frontend/tests/search.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from "@playwright/test" +import { api, apiPage, deleteAll } from "./utils/api" + +/** + * The global search: the only way to reach a node without knowing its flow. + * + * The load-bearing part is what the flat index turns back into — a node hit is + * a flow address with that node in focus, which no list endpoint could have + * answered. + */ + +const flowName = `test_search_${Date.now().toString(36)}` + +test.use({ storageState: "playwright/.auth/user.json" }) +test.describe.configure({ mode: "serial" }) + +test.beforeAll(async ({ browser }) => { + const page = await apiPage(browser) + await api(page, `/flows/${flowName}`, { + method: "PUT", + data: { + name: flowName, + title: "Searchable flow", + nodes: [{ id: "findme", type: "python", title: "Find me" }], + }, + }) + await page.close() +}) + +test.afterAll(async ({ browser }) => { + await deleteAll(browser, [`/flows/${flowName}`]) +}) + +test("finds a node by name and opens its flow on it", async ({ page }) => { + await page.goto("/") + await page.getByRole("button", { name: "Search", exact: true }).click() + + await page.getByTestId("global-search-input").fill("findme") + await page.getByRole("option", { name: /Find me/ }).click() + + await expect(page).toHaveURL(new RegExp(`/flows/${flowName}\\?node=findme$`)) +}) + +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. + await expect( + page.getByRole("button", { name: "Search", exact: true }), + ).toBeVisible() + await page.keyboard.press("ControlOrMeta+k") + + await expect(page.getByTestId("global-search-input")).toBeFocused() +})