Give both summaries a modified time, and the dashboard list a footprint
Home's "recently modified" order was a proxy — drafts first, then the version counter, then the name. Both summaries now carry `updated_at`, read as the mtime of the working copy: every write in the store commits immediately, so a file's mtime is its commit time, and `git log -1 -- <path>` costs ~990ms across this instance's 15 documents (it walks the history back to the last commit touching each one, so it is slowest for the stalest) against ~1.8ms for the stats. No cache needed. `DashboardSummary` also carries `footprint`: each widget as its type plus the placement a panel resolves, which is the whole of what the mosaic draws. That drops the document fetch per tile and with it the cap of eight, past which tiles showed a name and nothing else. Verified against this instance's 8 dashboards: the blocks are identical to what the old client-side derivation produced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CL9zvnnvcp1mvA8o7impxk
This commit is contained in:
@@ -575,6 +575,30 @@ export const DashboardSummarySchema = {
|
||||
type: 'integer',
|
||||
title: 'Version',
|
||||
default: 1
|
||||
},
|
||||
columns: {
|
||||
type: 'integer',
|
||||
title: 'Columns',
|
||||
default: 12
|
||||
},
|
||||
footprint: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/WidgetFootprint'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Footprint'
|
||||
},
|
||||
updated_at: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string',
|
||||
format: 'date-time'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Updated At'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
@@ -1303,6 +1327,18 @@ export const FlowSummarySchema = {
|
||||
type: 'integer',
|
||||
title: 'Version',
|
||||
default: 1
|
||||
},
|
||||
updated_at: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string',
|
||||
format: 'date-time'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Updated At'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
@@ -3834,6 +3870,45 @@ The first two are a \`\`list\`\` message, the third a \`\`str\`\`, which is what
|
||||
\`\`COLOR_DTYPES\`\` records and the check below holds a binding to.`
|
||||
} as const;
|
||||
|
||||
export const WidgetFootprintSchema = {
|
||||
properties: {
|
||||
x: {
|
||||
type: 'integer',
|
||||
title: 'X',
|
||||
default: 0
|
||||
},
|
||||
y: {
|
||||
type: 'integer',
|
||||
title: 'Y',
|
||||
default: 0
|
||||
},
|
||||
w: {
|
||||
type: 'integer',
|
||||
title: 'W',
|
||||
default: 3
|
||||
},
|
||||
h: {
|
||||
type: 'integer',
|
||||
title: 'H',
|
||||
default: 2
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'bar', 'icon', 'forecast', 'clock', 'media', 'player', 'embed', 'button', 'switch', 'slider', 'input', 'dropdown', 'color'],
|
||||
title: 'Type'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['type'],
|
||||
title: 'WidgetFootprint',
|
||||
description: `A widget reduced to the shape it draws: what it is, and where it sits.
|
||||
|
||||
The whole of what sketching a dashboard's outline needs — a mosaic tile
|
||||
shades a block by kind and puts it in the grid, and reads nothing else. So
|
||||
no id (the drawing has no use for one), no title, no config, and one
|
||||
placement rather than the layout's breakpoint per screen size.`
|
||||
} as const;
|
||||
|
||||
export const WorkerInfoSchema = {
|
||||
properties: {
|
||||
name: {
|
||||
|
||||
@@ -158,6 +158,9 @@ export type DashboardSummary = {
|
||||
widget_count?: number;
|
||||
has_draft?: boolean;
|
||||
version?: number;
|
||||
columns?: number;
|
||||
footprint?: Array<WidgetFootprint>;
|
||||
updated_at?: (string | null);
|
||||
};
|
||||
|
||||
export type DeadLetter = {
|
||||
@@ -408,6 +411,7 @@ export type FlowSummary = {
|
||||
paused?: boolean;
|
||||
quarantined?: boolean;
|
||||
version?: number;
|
||||
updated_at?: (string | null);
|
||||
};
|
||||
|
||||
export type fluksio__api__routes__dashboards__PublishRequest = {
|
||||
@@ -1314,6 +1318,22 @@ export type WidgetDef = {
|
||||
|
||||
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color';
|
||||
|
||||
/**
|
||||
* A widget reduced to the shape it draws: what it is, and where it sits.
|
||||
*
|
||||
* The whole of what sketching a dashboard's outline needs — a mosaic tile
|
||||
* shades a block by kind and puts it in the grid, and reads nothing else. So
|
||||
* no id (the drawing has no use for one), no title, no config, and one
|
||||
* placement rather than the layout's breakpoint per screen size.
|
||||
*/
|
||||
export type WidgetFootprint = {
|
||||
x?: number;
|
||||
y?: number;
|
||||
w?: number;
|
||||
h?: number;
|
||||
type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color';
|
||||
};
|
||||
|
||||
export type WorkerInfo = {
|
||||
name: string;
|
||||
labels?: Array<(string)>;
|
||||
|
||||
@@ -1,31 +1,13 @@
|
||||
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 type { DashboardSummary, WidgetFootprint } from "@/client"
|
||||
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([
|
||||
@@ -44,20 +26,7 @@ const shade = (type: string) =>
|
||||
? "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
|
||||
@@ -65,18 +34,17 @@ type Block = {
|
||||
h: number
|
||||
}
|
||||
|
||||
/** The dashboard's widgets as one grid. */
|
||||
function blocksOf(dashboard: DashboardDef_Output): {
|
||||
/** The dashboard's widget shapes as one grid. */
|
||||
function blocksOf(footprint: WidgetFootprint[]): {
|
||||
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)
|
||||
for (const shape of footprint) {
|
||||
const { x = 0, y = 0, w = 3, h = 2 } = shape
|
||||
blocks.push({
|
||||
id: widget.id,
|
||||
type: widget.type,
|
||||
type: shape.type,
|
||||
x: Math.max(0, x),
|
||||
y: Math.max(0, y),
|
||||
w: Math.max(1, w),
|
||||
@@ -94,10 +62,10 @@ function blocksOf(dashboard: DashboardDef_Output): {
|
||||
* 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.
|
||||
* dashboard. The summary carries the shapes, so it costs no request either.
|
||||
*/
|
||||
function Footprint({ dashboard }: { dashboard: DashboardDef_Output }) {
|
||||
const { blocks, rows } = blocksOf(dashboard)
|
||||
function Footprint({ dashboard }: { dashboard: DashboardSummary }) {
|
||||
const { blocks, rows } = blocksOf(dashboard.footprint ?? [])
|
||||
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.
|
||||
@@ -123,11 +91,11 @@ function Footprint({ dashboard }: { dashboard: DashboardDef_Output }) {
|
||||
}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{blocks.map((block) => {
|
||||
{blocks.map((block, index) => {
|
||||
const width = Math.min(columns, block.w)
|
||||
return (
|
||||
<span
|
||||
key={block.id}
|
||||
key={index}
|
||||
className={cn("rounded-[2px]", shade(block.type))}
|
||||
style={
|
||||
placed
|
||||
@@ -144,30 +112,15 @@ function Footprint({ dashboard }: { dashboard: DashboardDef_Output }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** One dashboard in the mosaic. */
|
||||
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"
|
||||
@@ -178,15 +131,7 @@ function Tile({
|
||||
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>
|
||||
)}
|
||||
<Footprint dashboard={dashboard} />
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{dashboard.title || dashboard.name}
|
||||
@@ -201,22 +146,21 @@ function Tile({
|
||||
)
|
||||
}
|
||||
|
||||
/** When the working copy was last written, or the epoch for a summary without one. */
|
||||
const modifiedAt = (summary: { updated_at?: string | null }) =>
|
||||
summary.updated_at ? Date.parse(summary.updated_at) : 0
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Both summaries carry the modified time of the working copy, so this is the
|
||||
* order it says it is. The name only settles a tie, which is two documents
|
||||
* written in the same instant — what a seed does.
|
||||
*/
|
||||
export function byRecency<
|
||||
T extends { name: string; has_draft?: boolean; version?: number },
|
||||
T extends { name: string; updated_at?: string | null },
|
||||
>(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)
|
||||
)
|
||||
return modifiedAt(b) - modifiedAt(a) || a.name.localeCompare(b.name)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -273,13 +217,8 @@ export function DashboardMosaic({
|
||||
|
||||
return (
|
||||
<div className={container}>
|
||||
{dashboards.map((dashboard, index) => (
|
||||
<Tile
|
||||
key={dashboard.name}
|
||||
dashboard={dashboard}
|
||||
preview={index < PREVIEWS}
|
||||
className={tile}
|
||||
/>
|
||||
{dashboards.map((dashboard) => (
|
||||
<Tile key={dashboard.name} dashboard={dashboard} className={tile} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user