diff --git a/frontend/nginx.conf b/frontend/nginx.conf index ba4d9aa..a8a42be 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,11 +1,42 @@ server { listen 80; + # The bundle is ~770 kB of JavaScript and ~85 kB of CSS, and nginx's base + # image ships gzip commented out — so every first load shipped all of it + # uncompressed. Roughly a third of the bytes with this on. + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_types + application/javascript + application/json + application/manifest+json + application/wasm + image/svg+xml + text/css + text/plain; + location / { root /usr/share/nginx/html; index index.html index.htm; try_files $uri /index.html =404; } + # Vite puts a content hash in every asset's name, so one can never change + # under its URL. Without this each of the ~50 of them cost a 304 round trip + # on every reload. + location /assets/ { + root /usr/share/nginx/html; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # The one file that must not be cached: it is what names the current + # assets, so a stale copy pins the browser to the previous build. + location = /index.html { + root /usr/share/nginx/html; + add_header Cache-Control "no-cache"; + } + include /etc/nginx/extra-conf.d/*.conf; } diff --git a/frontend/src/components/Common/GlobalSearch.tsx b/frontend/src/components/Common/GlobalSearch.tsx index 4dbdba1..efc524e 100644 --- a/frontend/src/components/Common/GlobalSearch.tsx +++ b/frontend/src/components/Common/GlobalSearch.tsx @@ -31,6 +31,11 @@ export const searchQueryOptions = () => ({ }) /** 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 @@ -58,8 +63,10 @@ function hint(entry: SearchEntry): string { * 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. + * 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, @@ -79,6 +86,15 @@ export function GlobalSearch({ 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() + 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) @@ -144,9 +160,7 @@ export function GlobalSearch({ <> Nothing matches that. {GROUPS.map(({ category, label, icon: Icon }) => { - const found = entries.filter( - (entry) => entry.category === category, - ) + const found = (byCategory.get(category) ?? []).slice(0, GROUP_CAP) if (found.length === 0) return null return ( diff --git a/frontend/src/components/Dashboard/widgets.tsx b/frontend/src/components/Dashboard/widgets.tsx index 63e2c36..a6fa019 100644 --- a/frontend/src/components/Dashboard/widgets.tsx +++ b/frontend/src/components/Dashboard/widgets.tsx @@ -1,4 +1,6 @@ +import { TriangleAlert } from "lucide-react" import { useState } from "react" +import { ErrorBoundary } from "react-error-boundary" import type { WidgetDef } from "@/client" import { cn } from "@/lib/utils" @@ -637,5 +639,22 @@ export function WidgetBody({ widget, dashboard }: WidgetProps) {

) } - return + return ( + // Per tile, because a dashboard is often the only thing on a screen + // nobody is standing at: one widget whose config the renderer cannot make + // sense of used to throw past every ancestor and leave a wall panel + // blank, with no navigation to recover from. + } resetKeys={[widget.id]}> + + + ) +} + +function WidgetFailed() { + return ( +

+ + This tile could not be drawn. +

+ ) } diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index 69e0d76..3f2da2b 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -45,6 +45,8 @@ import { import useCustomToast from "@/hooks/useCustomToast" import { useIsMobile } from "@/hooks/useMobile" import { scaleIn } from "@/lib/motion" +import { notify } from "@/lib/notificationStore" +import { safeStorage } from "@/lib/safeStorage" import { inCodeEditor, useShortcuts } from "@/lib/shortcuts" import { cn } from "@/lib/utils" import { CanvasTitle } from "./CanvasTitle" @@ -999,10 +1001,16 @@ function FlowEditorInner({ }), ).catch(() => undefined) - localStorage.setItem( + // A multi-node copy carries whole Python sources, which can genuinely + // reach the origin's quota — and a throw from a key handler would take + // the app down rather than the copy. + const stored = safeStorage.set( CLIPBOARD_KEY, JSON.stringify({ nodes: picked, sources } satisfies NodeClipboard), ) + if (!stored) { + notify("That is too much to copy — the browser store is full.", "error") + } }, [canvasNodes, definitions, flowName, selectedId, sourceTypes]) /** @@ -1011,7 +1019,7 @@ function FlowEditorInner({ * the definition is on its way. */ const pasteNodes = useCallback(() => { - const stored = localStorage.getItem(CLIPBOARD_KEY) + const stored = safeStorage.get(CLIPBOARD_KEY) if (!stored) return let clipboard: NodeClipboard try { diff --git a/frontend/src/components/Flow/LogsPanel.tsx b/frontend/src/components/Flow/LogsPanel.tsx index 849e80d..2348782 100644 --- a/frontend/src/components/Flow/LogsPanel.tsx +++ b/frontend/src/components/Flow/LogsPanel.tsx @@ -82,7 +82,16 @@ export function LogsTrigger({ * Opening it is not only the dock's to do: a failing node points straight at * its own traceback, which is why the open state lives in the editor. */ -export function LogsPanel({ +export function LogsPanel(props: { flow: string } & LogsFilter) { + // The dock renders this unconditionally and the `AnimatePresence` inside + // decides whether to draw — so with the panel shut it still subscribed to + // the log store and re-filtered five hundred lines on every line a flow + // published. Nothing below runs until it is open. + if (!props.open) return null + return +} + +function OpenLogsPanel({ flow, open, node, diff --git a/frontend/src/components/Flow/liveStore.ts b/frontend/src/components/Flow/liveStore.ts index ce72b10..e18be4c 100644 --- a/frontend/src/components/Flow/liveStore.ts +++ b/frontend/src/components/Flow/liveStore.ts @@ -297,6 +297,25 @@ export function useEngineEvents(): EngineEvent[] { ) } +/** + * The minute the newest engine event landed in, or 0 for none yet. + * + * A scalar, so a subscriber that only wants "has something gone wrong + * lately" re-renders when the answer changes rather than on every event. + * `recordEngineEvent` allocates a new array each time, so subscribing to the + * array itself means a flapping node re-renders whatever is watching — which + * on Home is two uPlot charts that rebuild their series from scratch. + */ +export function useEngineEventMinute(): number { + return useSyncExternalStore( + (listener) => subscribeKey("engine", listener), + () => + engineEvents.length + ? Math.floor(engineEvents[engineEvents.length - 1].ts / 60) + : 0, + ) +} + /** Increments each time the node publishes something. */ export function useNodeEmits(nodeId: string): number { return useSyncExternalStore( diff --git a/frontend/src/components/Health/HealthActivity.tsx b/frontend/src/components/Health/HealthActivity.tsx index 4e4dbfd..f3bfca4 100644 --- a/frontend/src/components/Health/HealthActivity.tsx +++ b/frontend/src/components/Health/HealthActivity.tsx @@ -5,7 +5,7 @@ import { useEffect, useState } from "react" import type { EventRow, HistoryPoint } from "@/client" import type { Range } from "@/components/Common/RangePicker" import { UplotChart } from "@/components/Common/UplotChart" -import { useEngineEvents } from "@/components/Flow/liveStore" +import { useEngineEventMinute } from "@/components/Flow/liveStore" import { PANEL_SECTION } from "@/components/Flow/SidePanel" import { Button } from "@/components/ui/button" import { cn, dur, si } from "@/lib/utils" @@ -218,7 +218,7 @@ function Failure({ event }: { event: EventRow }) { * the charts are drawn from rather than however far their newest rows reach. */ export function HealthActivity({ range }: { range: Range }) { - const live = useEngineEvents() + const seen = useEngineEventMinute() const queryClient = useQueryClient() const runsAt = useMoment() const failuresAt = useMoment() @@ -241,8 +241,9 @@ export function HealthActivity({ range }: { range: Range }) { // // The newest event's minute, not the count: the count stops changing once the // ring is full, and a per-event key would let a flapping node restart the - // timer forever without it ever firing. - const seen = live.length ? Math.floor(live[live.length - 1].ts / 60) : 0 + // timer forever without it ever firing. Subscribed to as that scalar rather + // than as the events array, so an event inside a minute already seen does + // not re-render the two charts below. useEffect(() => { if (!seen) return const timer = setTimeout(() => { diff --git a/frontend/src/components/theme-provider.tsx b/frontend/src/components/theme-provider.tsx index 9d9d9d4..2d91fb4 100644 --- a/frontend/src/components/theme-provider.tsx +++ b/frontend/src/components/theme-provider.tsx @@ -6,6 +6,8 @@ import { useState, } from "react" +import { safeStorage } from "@/lib/safeStorage" + export type Theme = "dark" | "light" | "system" type ThemeProviderProps = { @@ -37,7 +39,7 @@ export function ThemeProvider({ const [theme, setTheme] = useState( () => (typeof localStorage !== "undefined" - ? (localStorage.getItem(storageKey) as Theme) + ? (safeStorage.get(storageKey) as Theme) : null) || defaultTheme, ) @@ -99,7 +101,7 @@ export function ThemeProvider({ theme, resolvedTheme, setTheme: (theme: Theme) => { - localStorage.setItem(storageKey, theme) + safeStorage.set(storageKey, theme) setTheme(theme) }, } diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 67ba127..6828fa6 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -10,6 +10,7 @@ import { } from "@/client" import { liveStore } from "@/components/Flow/liveStore" import { isPortal } from "@/lib/portal" +import { safeStorage } from "@/lib/safeStorage" import { handleError } from "@/utils" import useCustomToast from "./useCustomToast" @@ -17,7 +18,7 @@ const isLoggedIn = () => { // Under a portal the credential comes with the page rather than from // storage, and the sign-in already happened there. if (isPortal()) return true - return localStorage.getItem("access_token") !== null + return safeStorage.get("access_token") !== null } /** @@ -58,7 +59,7 @@ const useAuth = () => { const response = await LoginService.loginAccessToken({ formData: data, }) - localStorage.setItem("access_token", response.access_token) + safeStorage.set("access_token", response.access_token) } const loginMutation = useMutation({ @@ -72,7 +73,7 @@ const useAuth = () => { }) const logout = () => { - localStorage.removeItem("access_token") + safeStorage.remove("access_token") // Live values belong to the session that was watching them. liveStore.reset() navigate({ to: "/login" }) diff --git a/frontend/src/lib/safeStorage.ts b/frontend/src/lib/safeStorage.ts new file mode 100644 index 0000000..961be95 --- /dev/null +++ b/frontend/src/lib/safeStorage.ts @@ -0,0 +1,40 @@ +/** + * `localStorage` that cannot throw. + * + * Reaching `window.localStorage` at all raises `SecurityError` where the + * browser is set to block site data, and `setItem` raises `QuotaExceededError` + * once the origin's five megabytes are full — which the flow editor's node + * clipboard, carrying whole Python sources, can genuinely reach. Either one + * thrown from an event handler escapes to `window.onerror`, and with a single + * error boundary at the root that blanks the app. + * + * The pre-paint theme script in `index.html` already guards its own read this + * way; this is the same thing for everything that runs after it. + */ +export const safeStorage = { + get(key: string): string | null { + try { + return localStorage.getItem(key) + } catch { + return null + } + }, + + /** Whether it was actually stored, for the rare caller that wants to know. */ + set(key: string, value: string): boolean { + try { + localStorage.setItem(key, value) + return true + } catch { + return false + } + }, + + remove(key: string): void { + try { + localStorage.removeItem(key) + } catch { + // Nothing to clean up if it was never reachable. + } + }, +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b3a52a1..2ccb887 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -16,6 +16,7 @@ import "./index.css" import { connectionStore, offlineDetail } from "./lib/connectionStore" import { notify } from "./lib/notificationStore" import { apiToken, appPath, appRoute, portalConfig } from "./lib/portal" +import { safeStorage } from "./lib/safeStorage" import { routeTree } from "./routeTree.gen" const portal = portalConfig() @@ -58,7 +59,7 @@ const handleApiError = (error: Error) => { // A paired wall panel has no login screen to go back to — it asks for a // new code instead. if (appRoute().startsWith("/panel")) { - localStorage.removeItem("access_token") + safeStorage.remove("access_token") window.location.href = appPath("/panel") return } @@ -68,7 +69,7 @@ const handleApiError = (error: Error) => { window.location.href = `${portal.portalUrl}?reauth=${portal.installationId}` return } - localStorage.removeItem("access_token") + safeStorage.remove("access_token") window.location.href = appPath("/login") return } @@ -94,6 +95,14 @@ const queryClient = new QueryClient({ queries: { // Retrying an expired session only delays the trip to the login screen. retry: (count, error) => !isPointlessToRetry(error) && count < 3, + // Below every poll interval on any screen, so nothing polls less often + // than it did — but a route that mounts twice in a few seconds, or a + // tab coming back to the foreground, stops refetching everything it + // touches. Home alone reads about ten queries. + staleTime: 5_000, + // The socket pushes what changes, and a reconnect invalidates what it + // feeds, so a focus event has nothing of its own to tell us. + refetchOnWindowFocus: false, }, mutations: { retry: false, diff --git a/frontend/src/routes/_layout.tsx b/frontend/src/routes/_layout.tsx index 63bd00f..771dfd7 100644 --- a/frontend/src/routes/_layout.tsx +++ b/frontend/src/routes/_layout.tsx @@ -1,6 +1,7 @@ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router" import { ConnectionNotice } from "@/components/Common/ConnectionNotice" +import ErrorComponent from "@/components/Common/ErrorComponent" import { Footer } from "@/components/Common/Footer" import { useFlowSocket } from "@/components/Flow/useFlowSocket" import AppSidebar from "@/components/Sidebar/AppSidebar" @@ -13,6 +14,10 @@ import { isLoggedIn } from "@/hooks/useAuth" export const Route = createFileRoute("/_layout")({ component: Layout, + // Inside the shell rather than at the root, so a screen that throws leaves + // the sidebar standing and somebody can navigate away from it. The root's + // own boundary replaces the whole app, navigation included. + errorComponent: () => , beforeLoad: async () => { if (!isLoggedIn()) { throw redirect({