**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
200 lines
6.8 KiB
TypeScript
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>
|
|
)
|
|
}
|