diff --git a/docs/2-handbook/swarm/ui.md b/docs/2-handbook/swarm/ui.md index 647ceafa..96bf26d1 100644 --- a/docs/2-handbook/swarm/ui.md +++ b/docs/2-handbook/swarm/ui.md @@ -583,7 +583,8 @@ previous repo's issues under the error. ### Builds -The builds sit in a card under the job graph on the **jobs** tab. Both cards start +The builds sit in a card under the job graph on the **jobs** tab. While the graph +loads for the first time, it shows grey placeholder rows in the shape of a job tree. Both cards start shut, and each reads nothing until you open it; a link to a build opens the builds card. The old `/builds` address redirects there and keeps its query. diff --git a/frontend/packages/shared/src/jobq-graph/JobqGraph.tsx b/frontend/packages/shared/src/jobq-graph/JobqGraph.tsx index 7d2d8fc4..af03684f 100644 --- a/frontend/packages/shared/src/jobq-graph/JobqGraph.tsx +++ b/frontend/packages/shared/src/jobq-graph/JobqGraph.tsx @@ -28,6 +28,7 @@ // loader is `text` (for unrelated shadow-DOM components' CSS-as-string // needs), and that loader is global per call, not per-module. +import type { ComponentChildren } from "preact"; import { useState, useEffect, useCallback, useRef } from "preact/hooks"; // Mirrors `hive_jobq_wire::StateSchema` verbatim (variant names, no @@ -283,6 +284,9 @@ export interface JobqGraphProps { onUpdate?: (nodes: GraphNode[]) => void; onCancel?: (id: number) => void; refreshToken?: number; + /** What shows while the first answer loads. A host that has a placeholder in + * the shape of its graph passes it; the default is a line of text. */ + loading?: ComponentChildren; } // `refreshToken` is not read anywhere in the body — its only job is to @@ -295,6 +299,7 @@ export function JobqGraph({ onUpdate, onCancel, refreshToken = 0, + loading, }: JobqGraphProps) { const [selectedStates, setSelectedStates] = useState>( () => new Set(ALL_STATES.filter((s) => !DEFAULT_HIDDEN_STATES.has(s))), @@ -344,7 +349,7 @@ export function JobqGraph({ {error ? (

fetch failed: {error}

) : nodes === null ? ( -

loading…

+ (loading ??

loading…

) ) : !nodes.length ? (

empty

) : ( diff --git a/frontend/packages/swarm-ui/src/pages/JobsPage.tsx b/frontend/packages/swarm-ui/src/pages/JobsPage.tsx index 2fa59f33..2969f9e5 100644 --- a/frontend/packages/swarm-ui/src/pages/JobsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/JobsPage.tsx @@ -18,10 +18,12 @@ // fetch — both `JobqGraph`/`JobqRollup` already accept that prop // (bumping it is their documented "refetch now" lever) since the // dashboard's own rebuild-queue view needed the same thing first. +import type { ComponentChildren } from "preact"; import { useState } from "preact/hooks"; import { JobqGraph } from "@hive/shared/jobq-graph.js"; import { JobqRollup } from "@hive/shared/jobq-rollup.js"; import { LazyDisclosure } from "../ui/disclosure/LazyDisclosure.js"; +import { Skeleton, SkeletonRegion } from "../ui/skeleton/Skeleton.js"; import { Toolbar } from "../ui/toolbar/Toolbar.js"; import { BuildsCard } from "./builds/BuildsPage.js"; import { ReviewPage } from "./review/ReviewPage.js"; @@ -37,6 +39,39 @@ import "./JobsPage.css"; // just solve staleness rather than require an opt-in every visit. const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; +// The graph's twin while it loads: the graph's own node and row classes, so the +// rows and the indent of the children are the real ones, with a state glyph and a +// label as placeholders. Two roots, the first with two children. +function SkeletonNode({ + label, + children, +}: { + label: string; + children?: ComponentChildren; +}) { + return ( +
+
+ + +
+ {children} +
+ ); +} + +function JobsGraphSkeleton() { + return ( + + + + + + + + ); +} + function JobsBody() { const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); @@ -62,7 +97,11 @@ function JobsBody() { /> - + } + /> ); }