Files
app/frontend/src/components/Common/DashboardMosaic.tsx
T
stroblmeandClaude Opus 5 d01a8dad37 Rename Installation to Instance
Follows the portal: the noun is "instance" everywhere the app says it —
UI strings, CLI output, error details, docs and comments. The wire keys
(`instance_id`, `instance_token`) and the hub route this calls move with it.

An existing cloud.json is adopted rather than refused: without the key
alias the dataclass fails to parse, which the caller swallows and reads as
"never enrolled" instead of "reconnect".

`instance_key` on a node type becomes `target_key`. It means the outside
thing a node points at, which is a different sense of the word, and keeping
both would put two meanings of "instance" in one codebase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
2026-08-31 10:12:01 +02:00

287 lines
8.5 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 instance
* has, and shared with the editor's own cache. The ceiling is an instance
* 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>
)
}