Files
app/frontend/src/components/Common/DashboardMosaic.tsx
T
stroblme 8fa66039ce Act on a run, and read Home top-down
Runs: a run can now be deleted (DELETE /runs/{id}, cancelling a live one
first), exported as csv from the screen's own filters, and its flow label
opens the flow. Its "Parameters" panel became "Inputs" and lists every
input the flow declares, marking the ones that took the flow's own value
rather than the run's — the comparison table resolves the same defaults
instead of printing "unset".

Home reads brain, dashboards, health, flows: the mosaic is one full-width
scrolling strip, and the flows list and the flow-activity rollups merged
into a single left-joined table so a flow's state and its numbers sit on
one row.

Charts take a drag to narrow the x window and a double click or tap to
come back out. UplotChart holds the scale and passes resetScales:false
while a window is held, which is what the old comment said made this
impossible.
2026-08-29 08:37:11 +02:00

287 lines
8.6 KiB
TypeScript

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",
"color",
])
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 dashboard's widgets as one grid. */
function blocksOf(dashboard: DashboardDef_Output): {
blocks: Block[]
rows: number
} {
const blocks: Block[] = []
let rows = 0
for (const widget of dashboard.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),
w: Math.max(1, w),
h: Math.max(1, h),
})
rows = Math.max(rows, Math.max(0, y) + Math.max(1, h))
}
return { blocks, rows: Math.max(1, rows) }
}
/**
* 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,
className,
}: {
dashboard: DashboardSummary
/** Read the document for a footprint, or settle for the name alone. */
preview: boolean
/** What the layout needs of it — a width, in the scrolling strip. */
className?: string
}) {
// 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={cn(
"grid content-start gap-2 rounded-lg border border-border p-2 transition-colors hover:bg-accent/50",
className,
)}
>
{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.
*
* Two layouts, because it is read two ways: a column of pairs where it sits
* beside something else, and one wide strip where it has the page to itself.
* A tile is `content-start` around an `aspect-video` footprint and so has no
* width of its own — the strip has to give it one.
*/
export function DashboardMosaic({
dashboards,
isPending,
row = false,
}: {
dashboards: DashboardSummary[]
isPending: boolean
/** One scrolling strip instead of a two-column grid. */
row?: boolean
}) {
const container = row
? "flex snap-x snap-mandatory gap-3 overflow-x-auto p-3"
: "grid gap-3 p-3 sm:grid-cols-2"
const tile = row ? "w-56 shrink-0 snap-start" : ""
if (isPending) {
return (
<div className={container}>
{Array.from({ length: 2 }).map((_, index) => (
<Skeleton
key={index}
className={cn("aspect-[4/3] rounded-lg", tile)}
/>
))}
</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={container}>
{dashboards.map((dashboard, index) => (
<Tile
key={dashboard.name}
dashboard={dashboard}
preview={index < PREVIEWS}
className={tile}
/>
))}
</div>
)
}