Files
app/frontend/src/components/Common/GlobalSearch.tsx
T
stroblmeandClaude Opus 5 1873787ee6 Compress what the browser downloads, and stop one tile taking the page
**nginx served the bundle uncompressed and uncacheable.** The base image
ships gzip commented out and nothing set `Cache-Control`, so every load
carried the whole thing and every reload cost a 304 per asset. Measured on
the built image: the entry chunk 838 kB → 311 kB, the Monaco chunk 2.66 MB
→ 832 kB, and the ~50 content-hashed assets are now immutable for a year.
`index.html` is explicitly `no-cache`, since it is what names the rest.

**A widget that throws no longer blanks the screen.** There was one error
boundary in the app, on the root route, so anything that threw replaced
everything including the navigation — on `/view/{name}`, an unattended wall
panel with no way back. Each tile has its own boundary now, and the app
shell has one inside it so a screen that fails leaves the sidebar standing.
`react-error-boundary` was already a dependency and imported nowhere.

**`localStorage` cannot take the app down.** Reaching it raises where the
browser blocks site data, and `setItem` raises once the origin's quota is
full — which the flow editor's node clipboard, carrying whole Python
sources, can genuinely reach. Thrown from a key handler that escaped to
`window.onerror`, which the single root boundary then turned into a blank
page. `lib/safeStorage.ts` is the guarded pair the pre-paint theme script in
`index.html` was already using; a copy too large to store now says so.

Queries default to `staleTime: 5000` — below every poll interval on any
screen, so nothing polls less often than it did, but a route mounting twice
in a few seconds stops refetching everything it touches. Window-focus
refetching is off: the socket pushes what changes and a reconnect
invalidates what it feeds, so a focus event has nothing of its own to say.
Home alone reads about ten queries on every one of those.

Render cost, two that showed up in the audit:

- `LogsPanel` was rendered unconditionally by the dock and decided inside
  itself whether to draw, so with the panel *shut* it still subscribed to
  the log store and re-filtered five hundred lines per line a flow
  published. It returns before any of that now.
- `HealthActivity` subscribed to the whole engine-event array and used one
  number from it, so a flapping node re-rendered the component that draws
  Home's two uPlot charts — each of which rebuilds its series on every
  render by design. It subscribes to that number.
- the global search bucketed the index nine times per keystroke, once per
  group. One pass, and each group offers at most twenty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6hPWS6YEbT1P8LxhhFb2T
2026-08-29 21:00:02 +02:00

200 lines
6.8 KiB
TypeScript

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. */
//: The most entries one group shows. cmdk scores what is mounted, so this is
//: a cap on what is offered rather than on what is searched — and twenty of a
//: kind is already more than anybody reads before typing another letter.
const GROUP_CAP = 20
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.
*
* cmdk does the matching over what is mounted, so the entries are bucketed by
* category once per fetch rather than filtered nine times per keystroke, and
* each group is capped — a list nobody can scroll to the end of is not a
* better answer than its first twenty.
*/
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
// One pass over the index rather than one per group — nine passes over
// every entry on each keystroke. Not memoised: the component returns early
// while closed, so a hook cannot go here, and one pass is already the win.
const byCategory = new Map<string, SearchEntry[]>()
for (const entry of entries) {
const bucket = byCategory.get(entry.category)
if (bucket) bucket.push(entry)
else byCategory.set(entry.category, [entry])
}
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.
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title="Search"
description="Find a flow, node, dashboard or widget"
showCloseButton={false}
className="top-[40%] bg-popover/80 shadow-e3 backdrop-blur-md sm:max-w-xl [&_[data-slot=command]]:bg-transparent"
>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Search flows, nodes, dashboards, widgets…"
data-testid="global-search-input"
/>
<CommandList className="max-h-[min(24rem,60svh)]">
{typing ? (
<>
<CommandEmpty>Nothing matches that.</CommandEmpty>
{GROUPS.map(({ category, label, icon: Icon }) => {
const found = (byCategory.get(category) ?? []).slice(0, GROUP_CAP)
if (found.length === 0) return null
return (
<CommandGroup key={category} heading={label}>
{found.map((entry) => (
<CommandItem
key={`${category}:${entry.parent ?? ""}:${entry.name}`}
value={`${entry.name} ${entry.title ?? ""} ${entry.parent ?? ""} ${entry.kind ?? ""}`}
onSelect={() => go(entry)}
className="min-h-11 md:min-h-8"
>
<Icon />
<span className="flex min-w-0 flex-col">
<span className="truncate">
{entry.title || entry.name}
</span>
{hint(entry) ? (
<span className="truncate text-xs text-muted-foreground">
{hint(entry)}
</span>
) : null}
</span>
</CommandItem>
))}
</CommandGroup>
)
})}
</>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">
Start typing to search this installation.
</p>
)}
</CommandList>
</CommandDialog>
)
}