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:
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user