Home mosaic, multi-select delete, offline banner and loading states

- Home puts the dashboards beside the flows: two equal-height columns,
  capped and scrollable, most recently worked on first. Each tile is a
  schematic footprint built from the stored widget placements.
- Flows and dashboards can be picked by long press or ctrl-click; the
  create button becomes a trash and one dialog covers the batch.
- The offline banner is drawn on the body so it centres on the viewport,
  and the live socket now releases the offline latch a stray 503 set.
- A boot spinner before React's first commit, a router pending screen for
  code-split pages, and skeletons where an empty list used to flash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
This commit is contained in:
2026-08-22 12:02:14 +02:00
co-authored by Claude Opus 5
parent 6d84316ce5
commit f17d51c12f
13 changed files with 837 additions and 89 deletions
+20 -1
View File
@@ -26,7 +26,7 @@ phone the sidebar collapses to a sheet.
## Home
The one screen you leave open. Three things share it.
The one screen you leave open. Four things share it.
### The brain graph
@@ -38,6 +38,18 @@ its label showing so you can see which one it is without hovering.
Each flow also has a switch beside it in the list, which starts and stops it.
### Flows and dashboards
Two columns under the graph, exactly as tall as each other, most recently
worked on first. The flows column is the list with the switches; the dashboards
column is a mosaic, each tile a schematic of that dashboard's layout — blocks
where its widgets sit, shaded by what kind of widget each one is. Neither
column grows past about six rows: past that it scrolls in place rather than
pushing the health block down the page.
The tiles are a footprint, not a live view. They show you which dashboard is
which at a glance; the readings are on the dashboard itself.
### Health
Always answers, degraded or not. The tiles cover:
@@ -70,6 +82,13 @@ creates, and offers **Publish all changes** when several flows have drafts.
Opening one takes you to [the flow editor](flow-editor.md).
### Deleting several at once
Press and hold a card — or ctrl-click it — to pick it, then tap the rest. While
anything is picked, **New flow** in the toolbar becomes a trash button, and it
asks once before deleting the lot. Unpicking the last one puts the list back;
so does Escape. The Dashboards screen works the same way.
## Everything else
- [The flow editor](flow-editor.md) — the canvas, the code editor, running and
+31 -1
View File
@@ -17,9 +17,39 @@
} catch (_e) { }
})()
</script>
<!-- The boot spinner, until React's first commit clears `#root`. Styled
here with the literal values of --background / --border / --primary
because the stylesheet carrying those tokens has not necessarily
arrived yet; the theme class above is already on <html>. -->
<style>
#boot {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
}
#boot i {
width: 28px;
height: 28px;
border: 2px solid #e4e4e4;
border-top-color: #4a7189;
border-radius: 9999px;
animation: boot-spin 0.8s linear infinite;
}
html.dark #boot { background: #0a0a0a; }
html.dark #boot i { border-color: #2a2a2a; border-top-color: #7ba3b8; }
@keyframes boot-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
#boot i { animation-duration: 2.4s; }
}
</style>
</head>
<body>
<div id="root"></div>
<div id="root">
<div id="boot" role="status" aria-label="Loading Fluksio"><i></i></div>
</div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
@@ -1,6 +1,7 @@
import { WifiOff } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { useSyncExternalStore } from "react"
import { createPortal } from "react-dom"
import { ago } from "@/components/Health/queries"
import { connectionStore } from "@/lib/connectionStore"
@@ -15,6 +16,11 @@ import { isPortal } from "@/lib/portal"
* itself. The screen underneath keeps its last data rather than blanking —
* stale readings with a timestamp are more use than an empty page, which is
* why the banner leads with when we last heard anything.
*
* Drawn on `document.body` rather than where it is mounted. `position: fixed`
* resolves against the nearest ancestor carrying a transform, a filter or a
* `backdrop-filter`, and the shell floats several of those — inside them the
* banner centres on the content column instead of the viewport.
*/
export function ConnectionBanner() {
const connection = useSyncExternalStore(
@@ -24,7 +30,7 @@ export function ConnectionBanner() {
)
if (!isPortal()) return null
return (
return createPortal(
<AnimatePresence>
{connection.offline && (
<motion.div
@@ -47,6 +53,7 @@ export function ConnectionBanner() {
</div>
</motion.div>
)}
</AnimatePresence>
</AnimatePresence>,
document.body,
)
}
@@ -0,0 +1,263 @@
import { useQuery } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { LayoutDashboard } from "lucide-react"
import type {
DashboardDef_Output,
DashboardSummary,
Placement,
WidgetDef,
} from "@/client"
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
/** Columns a dashboard is cut into when its document does not say. */
const DEFAULT_COLUMNS = 12
/**
* How many tiles are worth a request of their own.
*
* ponytail: the list endpoint carries no placements, so a footprint means
* reading that dashboard's document — cheap for the handful an installation
* has, and shared with the editor's own cache. The ceiling is an installation
* with dozens: the tiles past this show their name and nothing else, and the
* fix would be a stored footprint on `DashboardSummary`.
*/
const PREVIEWS = 8
/** Widgets that draw a shape, and widgets that are controls. The rest read out. */
const GRAPHIC = new Set(["chart", "forecast", "bar", "gauge"])
const INPUT = new Set(["button", "switch", "slider", "input", "dropdown"])
const shade = (type: string) =>
INPUT.has(type)
? "bg-muted-foreground/30"
: GRAPHIC.has(type)
? "bg-primary/45"
: "bg-primary/20"
/**
* Where a widget sits, at the width a panel is arranged for.
*
* The same three-line fallback as `Dashboard/DashboardView`, written out again
* rather than imported: that module pulls the whole dashboard chunk, and this
* draws a schematic on a screen that shows no dashboards.
*/
const placement = (widget: WidgetDef): Placement => {
const layout = (widget.layout ?? {}) as Record<string, Placement>
return layout.lg ?? layout.md ?? layout.sm ?? {}
}
type Block = {
id: string
type: string
x: number
y: number
w: number
h: number
}
/**
* The first page's widgets as one grid.
*
* Sections are separate grids on the real thing, each starting at its own row
* zero, so each is pushed down past the one before it to keep them apart here.
*/
function blocksOf(dashboard: DashboardDef_Output): {
blocks: Block[]
rows: number
} {
const blocks: Block[] = []
let offset = 0
for (const section of (dashboard.pages ?? [])[0]?.sections ?? []) {
let bottom = 0
for (const widget of section.widgets ?? []) {
const { x = 0, y = 0, w = 3, h = 2 } = placement(widget)
blocks.push({
id: widget.id,
type: widget.type,
x: Math.max(0, x),
y: Math.max(0, y) + offset,
w: Math.max(1, w),
h: Math.max(1, h),
})
bottom = Math.max(bottom, Math.max(0, y) + Math.max(1, h))
}
offset += bottom
}
return { blocks, rows: Math.max(1, offset) }
}
/**
* What a dashboard looks like from across the room: its widgets as blocks,
* shaded by what kind of thing each one is.
*
* A footprint rather than a live render. Nothing here subscribes to a message
* or reads a value — recognising "the one with the big chart on the left" is
* the whole job, and it has to cost nothing on a screen that is not the
* dashboard.
*/
function Footprint({ dashboard }: { dashboard: DashboardDef_Output }) {
const { blocks, rows } = blocksOf(dashboard)
const columns = dashboard.columns || DEFAULT_COLUMNS
// Before the editor could place things, every widget was written at 0,0;
// honouring that would pile the whole page onto one cell.
const placed = blocks.some((block) => block.x > 0 || block.y > 0)
const area = blocks.reduce((sum, block) => sum + block.w * block.h, 0)
if (blocks.length === 0) {
return (
<div className="flex aspect-video items-center justify-center rounded-sm bg-muted">
<LayoutDashboard className="size-5 text-muted-foreground" />
</div>
)
}
return (
<div
aria-hidden
className="grid aspect-video gap-0.5 overflow-hidden rounded-sm bg-muted p-1"
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${
placed ? rows : Math.max(1, Math.ceil(area / columns))
}, minmax(0, 1fr))`,
}}
>
{blocks.map((block) => {
const width = Math.min(columns, block.w)
return (
<span
key={block.id}
className={cn("rounded-[2px]", shade(block.type))}
style={
placed
? {
gridColumn: `${Math.min(columns - width, block.x) + 1} / span ${width}`,
gridRow: `${block.y + 1} / span ${block.h}`,
}
: { gridColumn: `span ${width}`, gridRow: `span ${block.h}` }
}
/>
)
})}
</div>
)
}
/**
* One dashboard in the mosaic.
*
* Its own query, so the tiles fill in as their documents arrive instead of the
* whole panel waiting for the slowest of them.
*/
function Tile({
dashboard,
preview,
}: {
dashboard: DashboardSummary
/** Read the document for a footprint, or settle for the name alone. */
preview: boolean
}) {
// The working copy, which is what the list itself is a summary of, so the
// preview shows what an editor would open rather than the last publish.
const { data, isPending } = useQuery({
...dashboardQueryOptions(dashboard.name, true),
enabled: preview,
})
return (
<Link
to="/dashboards/$name"
params={{ name: dashboard.name }}
data-testid="home-dashboard-tile"
className="grid content-start gap-2 rounded-lg border border-border p-2 transition-colors hover:bg-accent/50"
>
{preview && isPending ? (
<Skeleton className="aspect-video rounded-sm" />
) : data ? (
<Footprint dashboard={data} />
) : (
<div className="flex aspect-video items-center justify-center rounded-sm bg-muted">
<LayoutDashboard className="size-5 text-muted-foreground" />
</div>
)}
<span className="flex items-center gap-2">
<span className="truncate text-sm font-medium">
{dashboard.title || dashboard.name}
</span>
{dashboard.has_draft ? (
<span className="size-1.5 shrink-0 rounded-full bg-primary">
<span className="sr-only">Unpublished changes</span>
</span>
) : null}
</span>
</Link>
)
}
/**
* Which of two documents was worked on more recently.
*
* ponytail: neither `FlowSummary` nor `DashboardSummary` carries a modified
* time, so this reads the two things that come close — an unpublished edit is
* the one someone has open, and a higher version counter has been saved more
* often. An `updated_at` on both summaries is what would make it exact.
*/
export function byRecency<
T extends { name: string; has_draft?: boolean; version?: number },
>(a: T, b: T): number {
return (
Number(b.has_draft ?? false) - Number(a.has_draft ?? false) ||
(b.version ?? 0) - (a.version ?? 0) ||
a.name.localeCompare(b.name)
)
}
/** The dashboards, as the shapes they are, beside the flows on the home view. */
export function DashboardMosaic({
dashboards,
isPending,
}: {
dashboards: DashboardSummary[]
isPending: boolean
}) {
if (isPending) {
return (
<div className="grid gap-3 p-3 sm:grid-cols-2">
{Array.from({ length: 2 }).map((_, index) => (
<Skeleton key={index} className="aspect-[4/3] rounded-lg" />
))}
</div>
)
}
if (dashboards.length === 0) {
return (
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
<span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<LayoutDashboard className="size-5" />
</span>
<p className="text-sm text-muted-foreground">
Dashboards you build show up here, as the shapes they are.
</p>
<Link to="/dashboards" className="text-sm font-medium underline">
Go to dashboards
</Link>
</div>
)
}
return (
<div className="grid gap-3 p-3 sm:grid-cols-2">
{dashboards.map((dashboard, index) => (
<Tile
key={dashboard.name}
dashboard={dashboard}
preview={index < PREVIEWS}
/>
))}
</div>
)
}
@@ -0,0 +1,21 @@
import { Loader2 } from "lucide-react"
/**
* The router's pending screen, shown while a page's code chunk is on its way.
*
* Deliberately only the spinner. The shell around it is already painted — this
* fills the content region alone — and the page behind it draws its own
* skeletons once its code arrives, so a second guess at that layout here would
* only be a shape to correct a moment later.
*/
export function PageLoading() {
return (
<output
aria-label="Loading"
data-testid="page-loading"
className="flex min-h-64 items-center justify-center"
>
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</output>
)
}
@@ -0,0 +1,158 @@
import { Link, type LinkProps } from "@tanstack/react-router"
import { Check, type LucideIcon } from "lucide-react"
import { type ReactNode, useEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils"
/** How long a press has to last before it means "select this one". */
const LONG_PRESS = 450
/**
* Which of the listed documents are picked, and what turns picking on.
*
* Selection mode is simply "something is selected": a long press picks the
* first one and clearing the last one puts the list back to normal. So there
* is no mode to be stuck in, nothing to cancel, and no control on screen that
* exists only to turn selecting on.
*
* @param names everything currently listed. A document that is deleted, or
* filtered out by the search, cannot stay picked — deleting therefore clears
* the selection on its own, once the list comes back without it.
*/
export function useSelection(names: string[]) {
const [picked, setPicked] = useState<string[]>([])
const selected = picked.filter((name) => names.includes(name))
// Escape is the way out for anyone who started this by accident.
useEffect(() => {
if (selected.length === 0) return
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setPicked([])
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [selected.length])
return {
selected,
selecting: selected.length > 0,
toggle: (name: string) =>
setPicked((current) =>
current.includes(name)
? current.filter((other) => other !== name)
: [...current, name],
),
clear: () => setPicked([]),
}
}
/**
* One entry of the flows or dashboards overview.
*
* Stays a link in both modes rather than swapping element: while selecting it
* carries `role="checkbox"` instead, which is what it now behaves as. Ctrl or
* Cmd click is the same gesture for a mouse, and reaches the keyboard for
* free — a focused link answers Ctrl+Enter with a click carrying the modifier.
*/
export function OverviewCard({
link,
icon: Icon,
title,
detail,
draft,
selecting,
selected,
onToggle,
testId,
}: {
link: LinkProps
icon: LucideIcon
title: ReactNode
detail: ReactNode
/** Has unpublished changes. */
draft?: boolean
selecting: boolean
selected: boolean
onToggle: () => void
testId?: string
}) {
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const held = useRef(false)
const stop = () => {
if (timer.current) clearTimeout(timer.current)
timer.current = null
}
return (
<Link
{...link}
data-testid={testId}
data-selected={selected || undefined}
role={selecting ? "checkbox" : undefined}
aria-checked={selecting ? selected : undefined}
className={cn(
"relative grid gap-1 rounded-lg border border-border bg-card p-4 shadow-e1 transition-colors",
// Both states have to stay tellable apart, so hover is the half tint.
selected ? "border-primary bg-accent" : "hover:bg-accent/50",
selecting && "select-none",
)}
onPointerDown={(event) => {
// Right-click is the browser's own gesture; leave it alone.
if (event.button !== 0) return
held.current = false
timer.current = setTimeout(() => {
held.current = true
onToggle()
}, LONG_PRESS)
}}
onPointerUp={stop}
onPointerLeave={stop}
onPointerCancel={stop}
onClick={(event) => {
// The press already picked it; the click that ends the press must not
// undo it, and must certainly not navigate.
if (held.current) {
held.current = false
event.preventDefault()
return
}
if (selecting || event.ctrlKey || event.metaKey) {
event.preventDefault()
onToggle()
}
}}
onContextMenu={(event) => {
// A touch hold raises this a moment after the press has been taken as
// a selection; the link preview on top of it would be a second answer
// to one gesture.
if (held.current) event.preventDefault()
}}
>
<span className="flex items-center gap-2">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{title}</span>
{draft ? (
<span className="size-1.5 shrink-0 rounded-full bg-primary">
<span className="sr-only">Unpublished changes</span>
</span>
) : null}
</span>
<span className="truncate text-sm text-muted-foreground">{detail}</span>
{selecting ? (
<span
aria-hidden
className={cn(
"absolute top-3 right-3 flex size-5 items-center justify-center rounded-sm border",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card",
)}
>
{selected ? <Check className="size-3.5" /> : null}
</span>
) : null}
</Link>
)
}
@@ -1,10 +1,18 @@
import { useMutation } from "@tanstack/react-query"
import { Check, Loader2, Plus, Search } from "lucide-react"
import { Check, Loader2, Plus, Search, Trash2 } from "lucide-react"
import { motion } from "motion/react"
import { type ReactNode, useRef, useState } from "react"
import { Button } from "@/components/ui/button"
import { DialogTrigger } from "@/components/ui/dialog"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import {
Tooltip,
@@ -13,6 +21,7 @@ import {
} from "@/components/ui/tooltip"
import useCustomToast from "@/hooks/useCustomToast"
import { transitions } from "@/lib/motion"
import { cn } from "@/lib/utils"
/** The icon buttons here and in the flow dock are the same touch target. */
const ICON = "size-11 text-muted-foreground md:size-8"
@@ -26,7 +35,9 @@ const ICON = "size-11 text-muted-foreground md:size-8"
* once it is empty and left alone, which keeps the row down to three targets.
*
* The create button is a `DialogTrigger`, so the page wrapping this in its own
* `Dialog` owns what asking for a name looks like.
* `Dialog` owns what asking for a name looks like. While anything in the list
* is selected it stands down for the trash instead: the row keeps its three
* targets, and the one primary action is whichever the list is currently for.
*/
export function OverviewToolbar({
search,
@@ -38,6 +49,8 @@ export function OverviewToolbar({
draftCount,
publishing,
onPublishAll,
selectedCount = 0,
onDeleteSelected,
children,
}: {
search: string
@@ -50,6 +63,9 @@ export function OverviewToolbar({
draftCount: number
publishing: boolean
onPublishAll: () => void
/** How many entries are picked; above zero the list is in selection mode. */
selectedCount?: number
onDeleteSelected?: () => void
/** Anything this particular overview adds, drawn ahead of the shared icons. */
children?: ReactNode
}) {
@@ -110,19 +126,34 @@ export function OverviewToolbar({
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
{selectedCount > 0 ? (
<Button
variant="ghost"
size="icon"
className={ICON}
aria-label={createLabel}
data-testid={createTestId}
className={cn(ICON, "text-destructive")}
aria-label={`Delete ${selectedCount} selected`}
data-testid="delete-selected"
onClick={onDeleteSelected}
>
<Plus />
<Trash2 />
</Button>
</DialogTrigger>
) : (
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className={ICON}
aria-label={createLabel}
data-testid={createTestId}
>
<Plus />
</Button>
</DialogTrigger>
)}
</TooltipTrigger>
<TooltipContent>{createLabel}</TooltipContent>
<TooltipContent>
{selectedCount > 0 ? `Delete ${selectedCount} selected` : createLabel}
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -193,3 +224,98 @@ export function usePublishAll(
},
})
}
/**
* Delete the selected documents, and say how many actually went.
*
* Same one-request-each shape as `usePublishAll`: one that someone else has
* already removed, or that the server refuses, fails on its own rather than
* taking the rest of the batch with it. Nothing here clears the selection —
* the list coming back without those names is what does that.
*/
export function useDeleteSelected(
remove: (name: string) => Promise<unknown>,
noun: string,
onDone: () => void,
) {
const { showSuccessToast, showErrorToast } = useCustomToast()
return useMutation({
mutationFn: async (names: string[]) => {
const failed: string[] = []
for (const name of names) {
try {
await remove(name)
} catch {
failed.push(name)
}
}
return failed
},
// Some of them are gone even when others are not.
onSettled: onDone,
onSuccess: (failed, names) => {
if (failed.length)
showErrorToast(
`Deleted ${names.length - failed.length} of ${names.length}. Still there: ${failed.join(", ")}`,
)
else
showSuccessToast(
`Deleted ${names.length} ${noun}${names.length === 1 ? "" : "s"}`,
)
},
})
}
/**
* The one question a batch delete asks, however many were picked.
*
* Names the single one it is about, counts the rest: a list of twelve names in
* a dialog is read as decoration rather than as a check.
*/
export function ConfirmDelete({
open,
onOpenChange,
names,
noun,
pending,
onConfirm,
}: {
open: boolean
onOpenChange: (open: boolean) => void
names: string[]
noun: string
pending: boolean
onConfirm: () => void
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{names.length === 1
? `Delete "${names[0]}"?`
: `Delete these ${names.length} ${noun}s?`}
</DialogTitle>
<DialogDescription>
{names.length === 1 ? "It goes" : "They go"} from the installation
at once. The store's git history keeps what was there, but nothing
in the app brings {names.length === 1 ? "it" : "them"} back.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Keep {names.length === 1 ? "it" : "them"}
</Button>
<Button
variant="destructive"
disabled={pending}
onClick={onConfirm}
data-testid="confirm-delete"
>
Delete {names.length === 1 ? noun : `${names.length} ${noun}s`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -144,7 +144,7 @@ function connect() {
retry = RECONNECT_MIN
rejected = 0
liveStore.setConnected(true)
connectionStore.setOnline()
connectionStore.setSocketOpen(true)
// Whatever happened while the socket was down was missed, so nothing
// held in cache can be trusted to still be current.
client?.invalidateQueries()
@@ -260,6 +260,7 @@ function connect() {
if (socket !== ws) return
socket = null
liveStore.setConnected(false)
connectionStore.setSocketOpen(false)
if (watchers === 0) return
if (event.code === 1008) {
rejected += 1
@@ -289,6 +290,8 @@ function release(onAuthFailure?: () => void) {
socket = null
ws?.close()
liveStore.setConnected(false)
// Its `onclose` is dropped by the guard above, so say so here instead.
connectionStore.setSocketOpen(false)
}
/**
+25
View File
@@ -16,6 +16,16 @@ type Connection = {
}
let state: Connection = { offline: false, lastSeen: null }
/**
* Whether the live socket is up right now.
*
* The socket is the continuous signal; a failed request is only a lagging one.
* Latching `offline` on a single refused request is what used to leave the
* banner up for good: the socket had never dropped, so no reconnect was coming
* to clear it, and nothing else on a quiet page refetches. Reloading was the
* only way out.
*/
let socketOpen = false
const listeners = new Set<() => void>()
function emit(next: Connection) {
@@ -28,6 +38,10 @@ function emit(next: Connection) {
export const connectionStore = {
setOffline(lastSeen: string | number | null) {
// The socket is up, so the installation is reachable: this was one bad
// answer rather than a lost tunnel, and the banner would have nothing to
// take it back down again.
if (socketOpen) return
const at =
typeof lastSeen === "string"
? Date.parse(lastSeen)
@@ -39,6 +53,17 @@ export const connectionStore = {
setOnline() {
emit({ offline: false, lastSeen: null })
},
/**
* The live socket opened or closed.
*
* An open socket is proof the installation can be heard, so it also clears
* the banner — and since the socket reconnects on its own, that is the one
* release the offline state can always count on.
*/
setSocketOpen(open: boolean) {
socketOpen = open
if (open) emit({ offline: false, lastSeen: null })
},
subscribe(listener: () => void) {
listeners.add(listener)
return () => listeners.delete(listener)
+9 -1
View File
@@ -10,6 +10,7 @@ import { StrictMode } from "react"
import ReactDOM from "react-dom/client"
import { toast } from "sonner"
import { ApiError, OpenAPI } from "./client"
import { PageLoading } from "./components/Common/Loading"
import { ThemeProvider } from "./components/theme-provider"
import { Toaster } from "./components/ui/sonner"
import "./index.css"
@@ -100,7 +101,14 @@ const queryClient = new QueryClient({
},
})
const router = createRouter({ routeTree, basepath: portal?.basePath })
const router = createRouter({
routeTree,
basepath: portal?.basePath,
// Routes are code-split, so going somewhere new fetches a chunk first. Under
// the threshold that arrives faster than a spinner would be worth looking at.
defaultPendingComponent: PageLoading,
defaultPendingMs: 250,
})
declare module "@tanstack/react-router" {
interface Register {
router: typeof router
@@ -1,11 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { LayoutDashboard, MonitorSmartphone } from "lucide-react"
import { useState } from "react"
import { DashboardsService } from "@/client"
import { OverviewCard, useSelection } from "@/components/Common/OverviewCard"
import {
ConfirmDelete,
OverviewToolbar,
useDeleteSelected,
usePublishAll,
} from "@/components/Common/OverviewToolbar"
import { PanelsDialog } from "@/components/Dashboard/PanelsDialog"
@@ -23,6 +26,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
@@ -36,7 +40,7 @@ export const Route = createFileRoute("/_layout/dashboards/")({
})
function Dashboards() {
const { data } = useQuery(dashboardsQueryOptions())
const { data, isPending } = useQuery(dashboardsQueryOptions())
const queryClient = useQueryClient()
const navigate = useNavigate()
const { showErrorToast } = useCustomToast()
@@ -44,6 +48,7 @@ function Dashboards() {
const [search, setSearch] = useState("")
const [dialogOpen, setDialogOpen] = useState(false)
const [panelsOpen, setPanelsOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const create = useMutation({
mutationFn: (dashboard: string) =>
@@ -87,6 +92,13 @@ function Dashboards() {
.filter((dashboard) => dashboard.has_draft)
.map((dashboard) => dashboard.name)
const selection = useSelection(dashboards.map((dashboard) => dashboard.name))
const remove = useDeleteSelected(
(dashboard) => DashboardsService.deleteDashboard({ name: dashboard }),
"dashboard",
() => queryClient.invalidateQueries({ queryKey: dashboardKeys.all }),
)
return (
<div className="grid gap-6">
<div className="grid gap-1">
@@ -113,6 +125,8 @@ function Dashboards() {
draftCount={drafts.length}
publishing={publishAll.isPending}
onPublishAll={() => publishAll.mutate(drafts)}
selectedCount={selection.selected.length}
onDeleteSelected={() => setDeleteOpen(true)}
>
<Tooltip>
<TooltipTrigger asChild>
@@ -165,7 +179,25 @@ function Dashboards() {
</DialogContent>
</Dialog>
{dashboards.length === 0 ? (
<ConfirmDelete
open={deleteOpen}
onOpenChange={setDeleteOpen}
names={selection.selected}
noun="dashboard"
pending={remove.isPending}
onConfirm={() => {
setDeleteOpen(false)
remove.mutate(selection.selected)
}}
/>
{isPending ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-[5.5rem] rounded-lg" />
))}
</div>
) : dashboards.length === 0 ? (
<p className="text-sm text-muted-foreground">
{needle
? "No dashboard matches that."
@@ -174,29 +206,25 @@ function Dashboards() {
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{dashboards.map((dashboard) => (
<Link
<OverviewCard
key={dashboard.name}
to="/dashboards/$name"
params={{ name: dashboard.name }}
className="grid gap-1 rounded-lg border border-border bg-card p-4 shadow-e1 transition-colors hover:bg-accent/50"
data-testid="dashboard-card"
>
<span className="flex items-center gap-2">
<LayoutDashboard className="size-4 text-muted-foreground" />
{dashboard.title || dashboard.name}
{dashboard.has_draft ? (
<span className="size-1.5 shrink-0 rounded-full bg-primary">
<span className="sr-only">Unpublished changes</span>
</span>
) : null}
</span>
<span className="text-sm text-muted-foreground">
{dashboard.widget_count} widget
{dashboard.widget_count === 1 ? "" : "s"} ·{" "}
{dashboard.page_count} page
{dashboard.page_count === 1 ? "" : "s"}
</span>
</Link>
link={{
to: "/dashboards/$name",
params: { name: dashboard.name },
}}
icon={LayoutDashboard}
title={dashboard.title || dashboard.name}
draft={dashboard.has_draft}
detail={`${dashboard.widget_count} widget${
dashboard.widget_count === 1 ? "" : "s"
} · ${dashboard.page_count} page${
dashboard.page_count === 1 ? "" : "s"
}`}
selecting={selection.selecting}
selected={selection.selected.includes(dashboard.name)}
onToggle={() => selection.toggle(dashboard.name)}
testId="dashboard-card"
/>
))}
</div>
)}
+50 -25
View File
@@ -1,11 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { Workflow } from "lucide-react"
import { useState } from "react"
import { FlowsService } from "@/client"
import { OverviewCard, useSelection } from "@/components/Common/OverviewCard"
import {
ConfirmDelete,
OverviewToolbar,
useDeleteSelected,
usePublishAll,
} from "@/components/Common/OverviewToolbar"
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
@@ -19,6 +22,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
@@ -31,13 +35,14 @@ export const Route = createFileRoute("/_layout/flows/")({
const NAME = /^[a-z][a-z0-9_]*$/
function Flows() {
const { data } = useQuery(flowsQueryOptions())
const { data, isPending } = useQuery(flowsQueryOptions())
const queryClient = useQueryClient()
const navigate = useNavigate()
const { showErrorToast } = useCustomToast()
const [name, setName] = useState("")
const [search, setSearch] = useState("")
const [dialogOpen, setDialogOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const create = useMutation({
mutationFn: (flow: string) =>
@@ -79,6 +84,13 @@ function Flows() {
.filter((flow) => flow.has_draft)
.map((flow) => flow.name)
const selection = useSelection(flows.map((flow) => flow.name))
const remove = useDeleteSelected(
(flow) => FlowsService.deleteFlow({ name: flow }),
"flow",
() => queryClient.invalidateQueries({ queryKey: flowKeys.all }),
)
return (
<div className="grid gap-6">
<div className="grid gap-1">
@@ -100,6 +112,8 @@ function Flows() {
draftCount={drafts.length}
publishing={publishAll.isPending}
onPublishAll={() => publishAll.mutate(drafts)}
selectedCount={selection.selected.length}
onDeleteSelected={() => setDeleteOpen(true)}
/>
<DialogContent>
<form
@@ -136,7 +150,25 @@ function Flows() {
</DialogContent>
</Dialog>
{flows.length === 0 ? (
<ConfirmDelete
open={deleteOpen}
onOpenChange={setDeleteOpen}
names={selection.selected}
noun="flow"
pending={remove.isPending}
onConfirm={() => {
setDeleteOpen(false)
remove.mutate(selection.selected)
}}
/>
{isPending ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-[5.5rem] rounded-lg" />
))}
</div>
) : flows.length === 0 ? (
<p className="text-sm text-muted-foreground">
{needle
? "No flow matches that."
@@ -145,29 +177,22 @@ function Flows() {
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{flows.map((flow) => (
<Link
<OverviewCard
key={flow.name}
to="/flows/$flowName"
params={{ flowName: flow.name }}
className="grid gap-1 rounded-lg border border-border bg-card p-4 shadow-e1 transition-colors hover:bg-accent/50"
data-testid="flow-card"
>
<span className="flex items-center gap-2">
<Workflow className="size-4 text-muted-foreground" />
{flow.title || flow.name}
{flow.has_draft ? (
<span className="size-1.5 shrink-0 rounded-full bg-primary">
<span className="sr-only">Unpublished changes</span>
</span>
) : null}
</span>
<span className="text-sm text-muted-foreground">
{flow.node_count ?? 0} node
{flow.node_count === 1 ? "" : "s"} ·{" "}
{flow.enabled === false ? "stopped" : "running"}
{flow.error_count ? ` · ${flow.error_count} to fix` : ""}
</span>
</Link>
link={{ to: "/flows/$flowName", params: { flowName: flow.name } }}
icon={Workflow}
title={flow.title || flow.name}
draft={flow.has_draft}
detail={`${flow.node_count ?? 0} node${
flow.node_count === 1 ? "" : "s"
} · ${flow.enabled === false ? "stopped" : "running"}${
flow.error_count ? ` · ${flow.error_count} to fix` : ""
}`}
selecting={selection.selecting}
selected={selection.selected.includes(flow.name)}
onToggle={() => selection.toggle(flow.name)}
testId="flow-card"
/>
))}
</div>
)}
+58 -23
View File
@@ -4,7 +4,9 @@ import { AlertCircle, Workflow } from "lucide-react"
import { useState } from "react"
import { type FlowSummary, FlowsService } from "@/client"
import { byRecency, DashboardMosaic } from "@/components/Common/DashboardMosaic"
import { DEFAULT_RANGE } from "@/components/Common/RangePicker"
import { dashboardsQueryOptions } from "@/components/Dashboard/queries"
import { BrainView } from "@/components/Flow/BrainView"
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
import { HealthActivity } from "@/components/Health/HealthActivity"
@@ -30,6 +32,17 @@ export const Route = createFileRoute("/_layout/")({
/** Another tab can stop a flow, and the engine can fail one on its own. */
const REFRESH_INTERVAL = 10_000
/**
* How tall the two lists beside each other are allowed to get: about six flow
* rows, and whatever the mosaic fits in the same space. Past it each column
* scrolls on its own rather than pushing the health block off the screen.
*/
const LISTS = "grid max-h-96 grid-rows-[auto_minmax(0,1fr)] gap-2"
/** DESIGN-GUIDELINES.md → Typography, the canonical section header. */
const HEADER =
"text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"
function FlowRow({ flow }: { flow: FlowSummary }) {
const queryClient = useQueryClient()
const { showErrorToast } = useCustomToast()
@@ -108,8 +121,11 @@ function Home() {
...flowsQueryOptions(),
refetchInterval: REFRESH_INTERVAL,
})
// Its own query beside the flows one, so neither list waits for the other.
const boards = useQuery(dashboardsQueryOptions())
const flows = data?.data ?? []
const flows = [...(data?.data ?? [])].sort(byRecency)
const dashboards = [...(boards.data?.data ?? [])].sort(byRecency)
return (
// `[&>*]:min-w-0`: a grid item's automatic minimum is its content, so one
@@ -126,28 +142,47 @@ function Home() {
rather than flow count: a flow made a minute ago has none. */}
{flows.some((flow) => (flow.node_count ?? 0) > 0) ? <BrainView /> : null}
<Card className="gap-0 py-0">
{isPending ? (
<div className="grid gap-3 p-5">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-5 w-28" />
</div>
) : flows.length === 0 ? (
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
<span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Workflow className="size-5" />
</span>
<p className="text-sm text-muted-foreground">
Flows you build show up here, with what they are doing.
</p>
<Link to="/flows" className="text-sm font-medium underline">
Go to flows
</Link>
</div>
) : (
flows.map((flow) => <FlowRow key={flow.name} flow={flow} />)
)}
</Card>
{/* One grid row holding both: a grid item stretches to the row, so the
two columns come out exactly as tall as each other whatever is in
them — and with one flow and one dashboard that is simply the taller
of the two, which is the floor the cap never goes under. */}
<div className="grid gap-6 [&>*]:min-w-0 lg:grid-cols-2">
<section className={LISTS}>
<h2 className={HEADER}>Flows</h2>
<Card className="gap-0 overflow-y-auto py-0">
{isPending ? (
<div className="grid gap-3 p-5">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-5 w-28" />
</div>
) : flows.length === 0 ? (
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
<span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Workflow className="size-5" />
</span>
<p className="text-sm text-muted-foreground">
Flows you build show up here, with what they are doing.
</p>
<Link to="/flows" className="text-sm font-medium underline">
Go to flows
</Link>
</div>
) : (
flows.map((flow) => <FlowRow key={flow.name} flow={flow} />)
)}
</Card>
</section>
<section className={LISTS}>
<h2 className={HEADER}>Dashboards</h2>
<Card className="gap-0 overflow-y-auto py-0">
<DashboardMosaic
dashboards={dashboards}
isPending={boards.isPending}
/>
</Card>
</section>
</div>
<HealthOverview range={range} onRangeChange={setRange} />
<HealthActivity range={range} />