diff --git a/module/AUDIT_METRICS.md b/module/AUDIT_METRICS.md
index 473dec252..0b0adfbad 100644
--- a/module/AUDIT_METRICS.md
+++ b/module/AUDIT_METRICS.md
@@ -194,7 +194,8 @@ never the SDK binding input. Regenerate the export before generating the SDK.
## Unknown and partial results
-Empty queries have no points and render **No data yet**. Numeric sums with no
+Empty queries have no points and render as no data, in the form the widget's
+visualization has for it (see *How a resolved series is drawn*). Numeric sums with no
numeric observations omit their value, just like averages/percentiles; an emitted
zero stays zero. `samples[alias]` reports observations before reduction (so
repeated logical IDs are not mistaken for missing telemetry). Fewer samples than
@@ -212,6 +213,44 @@ The host DSL also withholds totals after top-N truncation. Authorization/RPC
errors remain errors, never empty successful results. Existing consumers of the
SDK must handle `total: null` when adopting this contract.
+## How a resolved series is drawn
+
+Every dashboard built from these series — the host's own pages, a solution's
+declared dashboard, and a solution remote that mounts the kit itself — is drawn
+by one renderer, `` from `@codefly-dev/ui/dashboard`, with the kit's
+**metric tier**. There is no second, plainer rendering: a consuming solution
+does not have to ask which half of the kit its dashboard will get.
+
+| Widget | Drawn as | Empty series | `coverage: "partial"` | `total: null` |
+| --- | --- | --- | --- | --- |
+| `number` | `StatTile`: the series total, in the widget's `format` and `unit`; a sparkline and a delta against the previous bucket when the metric is bucketed over **time** | `—` with a **No data** badge, never `0` | `—` with a **Partial data** badge | `—` with a **No total** badge when coverage is complete |
+| `line` / `area` / `bar` | the kit's metric chart over a `ChartSeries` — axes from the appearance palette, a crosshair readout, a legend for two or more series, and every exact value in a visually-hidden data table | the chart's own **No data** plot, plus a **No data** badge on the card | the observed points still plot, with a **Partial data** badge on the card | nothing: a series widget shows no total |
+| `table` | key/value rows | **No data yet.** | a **Partial data** badge on the card | — |
+
+Two of those cells are the contract, not presentation. A `null` total renders as
+a dash and never as `0`, because "cannot say" and "nothing happened" are
+different answers and only one of them is in the data; and a series with no
+points is no data rather than a zero, because the aggregate omits a bucket
+instead of emitting an empty one. A sparkline and a bucket delta are drawn
+**only** for a time-bucketed series: a line through counts grouped by event type
+or region would assert an order, and a delta between two of them a change over
+time, that a categorical grouping does not have.
+
+A host composes its own features around that renderer — ``'s slots
+take header actions, a layout (the host's solution dashboard passes a
+drag-to-reorder grid using the kit's shared layout), a per-widget renderer for a host that resolves each
+widget in its own query, and an empty state. A consumer re-implementing any part
+of the drawing is refused by a gate: `src/components/__tests__/kit.test.ts` in
+the frontend.
+
+The declaration can add dashboard `columns`, widget `span`, and named
+`sections` with their own heading and column count. The defaults need none of
+these: adjacent scalar widgets form compact four-column rows, series use two
+columns, and cards keep their natural height. A stack stays single-column.
+Section and widget order come from the declaration until a viewer customizes
+within a section. See [the layout contract](docs/plugin-manifest-schema.md#dashboard-layout)
+for responsive spans, persistence and the compatibility limit of legacy hosts.
+
## Independent validation
Run `go test -race ./pkg/infra/auditmetricstest` from accounts/code for pure
diff --git a/module/docs/plugin-manifest-schema.md b/module/docs/plugin-manifest-schema.md
index f6a75e0b0..63ad0d313 100644
--- a/module/docs/plugin-manifest-schema.md
+++ b/module/docs/plugin-manifest-schema.md
@@ -97,6 +97,54 @@ has three node kinds:
one metric. These metric-bound widgets are distinct from `ui.widgets`, which
are presentation slots contributed to host surfaces; the two never mix.
+### Dashboard layout
+
+Layout fields are **additive**. Existing declarations need no edits. A grid
+packs consecutive `number` widgets into four-column rows and series into
+two-column rows, collapsing to one column on narrow screens. Runs retain widget
+order; a scalar is never moved ahead of a preceding chart. Cards align at their
+natural height, so a short or empty card does not stretch to its neighbour.
+`stack` keeps one widget per row and ignores column/span hints.
+
+- `Dashboard.columns?: 1 | 2 | 3 | 4` overrides the default for both kinds of row.
+- `MetricWidget.span?: 1 | 2 | 3 | 4` applies to scalars and series, clamped to
+ the containing row's columns and the current responsive breakpoint.
+- `Dashboard.sections?: { id, title, description?, columns? }[]` declares named
+ bands in section order. Each widget may name one with `section`; the id must
+ exist. A section inherits the dashboard column count unless it overrides it.
+ Widgets without a section appear first; within each section, widget order is
+ preserved. Empty sections draw nothing.
+
+For example, an existing dashboard can add:
+
+```json
+{
+ "id": "overview",
+ "layout": "grid",
+ "columns": 2,
+ "sections": [
+ { "id": "headline", "title": "Headline numbers", "columns": 4 },
+ { "id": "trends", "title": "Over time" }
+ ],
+ "widgets": [
+ { "id": "total", "metric": "total_events", "visualization": "number", "section": "headline" },
+ { "id": "trend", "metric": "events_per_day", "visualization": "line", "section": "trends", "span": 2 }
+ ]
+}
+```
+
+`runDashboard` and `fromDashboardData` carry these fields to the kit unchanged.
+The host's sortable view uses the same geometry. Dragging swaps widget IDs;
+keyboard arrows move one place within the widget's declared section. A viewer's
+preference does not change declared section membership.
+
+**Independent rollout:** hosts with this validator ignore unknown presentation
+fields on dashboards, sections and widgets. Unknown metric/query fields still
+fail validation. Previously released hosts used exact-key validation and will
+reject the new fields: upgrade those hosts before adding layout fields, or keep
+sending the unchanged declaration to them. Additive types cannot retrofit an
+already-deployed validator.
+
On a registered solution's page the host renders every declared dashboard in a
**Dashboard** tab beside the solution's own **App** tab, never stacked above
the solution; the solution stays mounted while the dashboard is open. A
@@ -104,7 +152,10 @@ solution that declares no dashboard gets no tab bar. Each viewer can arrange a
dashboard for themselves: reorder its declared widgets, remove
them, and re-add the ones they removed. The declared widgets, in declared order,
are where every viewer starts, and a widget declared later reaches viewers who
-already rearranged. A viewer's arrangement is a preference (ADR 0007): it never
+already rearranged. Saved layouts use widget IDs, not positions, and record the
+IDs known at save time. An unchanged saved default follows a new declaration;
+a customized order keeps surviving IDs, appends newly declared IDs once, and
+keeps intentionally removed widgets hidden. A viewer's arrangement is a preference (ADR 0007): it never
adds a metric the dashboard does not draw, so a metric declared only as a
derived metric's input stays off the page.
diff --git a/module/services/frontend/AGENTS.md b/module/services/frontend/AGENTS.md
index 00759fb49..bf0ba54e6 100644
--- a/module/services/frontend/AGENTS.md
+++ b/module/services/frontend/AGENTS.md
@@ -115,6 +115,40 @@ whether `/s/{id}` renders, and it stays installation-blind.
observation speaks for the present is the host's to decide, and a copy of that
window here would drift from it.
+## Drawing a solution's declared dashboard
+
+`src/solutions/SolutionDashboard.tsx` **mounts** the shared kit's
+`` (`@codefly-dev/ui/dashboard`); it draws nothing itself. Which
+tier each widget and each coverage state is drawn with is the module's
+[AUDIT_METRICS.md](../../AUDIT_METRICS.md) ("How a resolved series is drawn") —
+one renderer for the host's pages, a declared solution dashboard and a solution
+remote alike.
+
+What this file owns is only what is the host's, composed through that
+renderer's slots:
+
+- **the audit queries**, one per widget, so a widget whose metric errors fails
+ alone instead of blanking its siblings. Each lives in its own component
+ (`SolutionWidget`) that resolves the series and then mounts the kit's
+ `DashboardWidget`, because React fixes the number of hooks a component may
+ call and a renderer taking the whole view cannot hold a varying number of
+ queries;
+- **the viewer's arrangement** — reorder by drag or arrow keys, remove, add
+ back, saved per viewer and organization (`dashboard-layout.ts`), handed to
+ the renderer's `layout` slot as the kit's `SortableGrid`;
+- **the ⓘ** that says what a tile counts, from which audit events, over what
+ range and who caused the newest ones (`metric-info.ts`, the principal
+ directory, the recent-events drill-down), handed to each widget as its
+ header actions.
+
+Re-implementing any part of the drawing is refused by
+`src/components/__tests__/kit.test.ts`: a declaration taking a name
+`@codefly-dev/ui` exports must render the kit's component of that name or carry
+a reviewed entry, `class-variance-authority` is not a dependency of this app so
+a kit-shaped primitive cannot be built here, and the two chart surfaces
+(`src/components/charts/chart.tsx`,
+`src/features/dashboard/ui/charts/line-chart.tsx`) stay re-exports.
+
## Loading a remote, and its CSP
`/s/[solutionId]` (`src/app/(dashboard)/s/[solutionId]/page.tsx`) loads the
diff --git a/module/services/frontend/code/next-env.d.ts b/module/services/frontend/code/next-env.d.ts
index a419cbe4e..ce4e94a6b 100644
--- a/module/services/frontend/code/next-env.d.ts
+++ b/module/services/frontend/code/next-env.d.ts
@@ -1,7 +1,7 @@
///
///
-import "./.next/dev/types/routes.d.ts";
-import "./.next/dev/types/root-params.d.ts";
+import "./.next/types/routes.d.ts";
+import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json
index b10edc9c7..1bc610f5c 100644
--- a/module/services/frontend/code/package-lock.json
+++ b/module/services/frontend/code/package-lock.json
@@ -15,10 +15,10 @@
"@bufbuild/protobuf": "^2.11.0",
"@codefly-dev/saas-plugin-contract": "2.4.2",
"@codefly-dev/saas-plugin-react": "0.4.3",
- "@codefly-dev/saas-sdk": "0.3.17",
- "@codefly-dev/saas-ui": "0.12.6",
- "@codefly-dev/ui": "0.12.6",
- "@codefly/saas-plugin-manifest": "0.1.1",
+ "@codefly-dev/saas-sdk": "0.3.18",
+ "@codefly-dev/saas-ui": "0.12.7",
+ "@codefly-dev/ui": "0.12.7",
+ "@codefly/saas-plugin-manifest": "0.1.2",
"@codefly/saas-settings": "0.1.0",
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-web": "^2.1.1",
@@ -34,7 +34,6 @@
"@simplewebauthn/browser": "^13.3.0",
"@tanstack/react-query": "^5.60.0",
"@tanstack/react-table": "^8.21.3",
- "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codefly": "https://github.com/codefly-dev/sdk-js/archive/9f3bb78682a0c2b9a70631d35c2ae0094c281ac5.tar.gz",
"js-yaml": "^4.3.2",
@@ -15234,7 +15233,7 @@
},
"packages/codefly-ui": {
"name": "@codefly-dev/ui",
- "version": "0.12.6",
+ "version": "0.12.7",
"license": "Apache-2.0",
"dependencies": {
"@base-ui/react": "^1.3.0",
@@ -15315,7 +15314,7 @@
},
"packages/saas-plugin-manifest": {
"name": "@codefly/saas-plugin-manifest",
- "version": "0.1.1",
+ "version": "0.1.2",
"license": "Apache-2.0",
"dependencies": {
"@codefly-dev/saas-plugin-contract": "2.4.2"
@@ -15348,7 +15347,7 @@
},
"packages/saas-sdk": {
"name": "@codefly-dev/saas-sdk",
- "version": "0.3.17",
+ "version": "0.3.18",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^2.11.0",
@@ -15382,7 +15381,7 @@
},
"packages/saas-ui": {
"name": "@codefly-dev/saas-ui",
- "version": "0.12.6",
+ "version": "0.12.7",
"license": "Apache-2.0",
"devDependencies": {
"@bufbuild/protobuf": "^2.11.0",
diff --git a/module/services/frontend/code/package.json b/module/services/frontend/code/package.json
index 7f3f13676..475f3589a 100644
--- a/module/services/frontend/code/package.json
+++ b/module/services/frontend/code/package.json
@@ -38,10 +38,10 @@
"@bufbuild/protobuf": "^2.11.0",
"@codefly-dev/saas-plugin-contract": "2.4.2",
"@codefly-dev/saas-plugin-react": "0.4.3",
- "@codefly-dev/saas-sdk": "0.3.17",
- "@codefly-dev/saas-ui": "0.12.6",
- "@codefly-dev/ui": "0.12.6",
- "@codefly/saas-plugin-manifest": "0.1.1",
+ "@codefly-dev/saas-sdk": "0.3.18",
+ "@codefly-dev/saas-ui": "0.12.7",
+ "@codefly-dev/ui": "0.12.7",
+ "@codefly/saas-plugin-manifest": "0.1.2",
"@codefly/saas-settings": "0.1.0",
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-web": "^2.1.1",
@@ -57,7 +57,6 @@
"@simplewebauthn/browser": "^13.3.0",
"@tanstack/react-query": "^5.60.0",
"@tanstack/react-table": "^8.21.3",
- "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codefly": "https://github.com/codefly-dev/sdk-js/archive/9f3bb78682a0c2b9a70631d35c2ae0094c281ac5.tar.gz",
"js-yaml": "^4.3.2",
diff --git a/module/services/frontend/code/packages/codefly-ui/README.md b/module/services/frontend/code/packages/codefly-ui/README.md
index 070eee474..0ff7b2105 100644
--- a/module/services/frontend/code/packages/codefly-ui/README.md
+++ b/module/services/frontend/code/packages/codefly-ui/README.md
@@ -36,6 +36,15 @@ and the v1 subset — is [CATALOG.md](./CATALOG.md).
(`Banner`, `EmptyState`, `ErrorState`) and overlays (`Dialog`, `AlertDialog`, `Notice`, `Tooltip`,
`DropdownMenu`); `dashboard` is ``, charts, `fromDashboardData`; `chat`
is ``. React only: no plugin runtime, no host context.
+ `` paints with the **metric tier** — `StatTile` in compact rows for scalar
+ widgets, `metric-chart`'s line/area/bar over `ChartSeries` for series ones —
+ which is the tier the host's own operations pages use, so a solution's
+ dashboard and the host's own are the same picture. `DashboardWidget` is that
+ renderer for one widget, exported for a consumer that resolves each widget in
+ its own query; `slots` take header actions, a layout, a per-widget renderer
+ and an empty state, so a consumer composes its own features around the
+ renderer rather than beside it. What each widget and each coverage state
+ draws is tabulated in the module's `AUDIT_METRICS.md`.
This is the surface a solution fe-remote consumes. `` is fed by
`@codefly-dev/saas-sdk`'s `useChatStream` — the hook owns the SSE/WS transport, the
component stays pure, the same split as `runDashboard` → ``.
@@ -106,7 +115,7 @@ solution measured where it had to reach past the kit.
| `@codefly-dev/ui/plugin-host/ui` | Client UI adapters (`PluginErrorBoundary`) |
| `@codefly-dev/ui/skin` | `resolveSkin`, skin types |
| `@codefly-dev/ui/layout` | `Card`/`Section`/`Tabs` + shadcn primitives (React-only) |
-| `@codefly-dev/ui/dashboard` | `Dashboard`, charts, `fromDashboardData` (React-only) |
+| `@codefly-dev/ui/dashboard` | `Dashboard`, `DashboardWidget`, metric tiles + charts, `fromDashboardData` (React-only) |
| `@codefly-dev/ui/chat` | `Chat` (React-only) |
| `@codefly-dev/ui/content` | `Content`, `Markdown`, `JsonView`, `CodeBlock`, `TextBlock` (React-only) |
| `@codefly-dev/ui/board` | `Board` (React-only) |
@@ -633,3 +642,18 @@ Version 0.12.3 refreshes the default preview palette from appearance contract 2.
Version 0.12.4 also makes destructive/danger Badge text mix 30% toward the active
foreground, retaining its status hue while meeting small-text contrast on its
tinted background in the tested default and supplied light/dark skins.
+
+### Declared dashboard geometry
+
+`DashboardView.sections` names bands with `id`, `title`, optional `description`
+and `columns`; a widget's `section` refers to one. Ungrouped widgets appear first,
+then sections in declared order. Consecutive scalars default to four columns,
+series to two, with natural-height cards. `columns` overrides both defaults and
+`span` applies to every widget, clamped responsively. `stack` ignores both hints.
+`fromDashboardData` preserves these fields from the SDK.
+
+The `layout` slot receives the tiles and a `renderLayout(nodesById)` callback.
+Pass that callback to `SortableGrid.renderLayout` to retain exactly the default
+geometry under one drag context, including drops across scalar/chart bands.
+`SortableGrid.canSwap` can constrain drops to declared sections. Keep widget IDs
+stable across declaration changes; never persist positional indexes.
diff --git a/module/services/frontend/code/packages/codefly-ui/package.json b/module/services/frontend/code/packages/codefly-ui/package.json
index 18f4fc37c..056f89478 100644
--- a/module/services/frontend/code/packages/codefly-ui/package.json
+++ b/module/services/frontend/code/packages/codefly-ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@codefly-dev/ui",
- "version": "0.12.6",
+ "version": "0.12.7",
"description": "The shared, versioned Codefly SaaS frontend kit: the plugin host and the tokens-as-data skin mechanism, standalone so the host and Module-Federation remotes import and dedupe one copy.",
"repository": {
"type": "git",
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/dashboard.test.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/dashboard.test.tsx
index cd9ffb4a2..a148c8144 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/dashboard.test.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/dashboard.test.tsx
@@ -1,77 +1,446 @@
// @vitest-environment happy-dom
-import { cleanup, render, screen } from "@testing-library/react";
+import { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { Dashboard } from "../dashboard.js";
-import type { DashboardView } from "../types.js";
+import type {
+ DashboardView,
+ DashboardWidgetView,
+ WidgetSeries,
+} from "../types.js";
afterEach(cleanup);
-const emptySeries = { points: [], total: 0 };
+// Two daily buckets: a time-bucketed series, so it earns a trend.
+const daily: WidgetSeries = {
+ points: [
+ { key: "2026-09-01T00:00:00Z", value: 3 },
+ { key: "2026-09-02T00:00:00Z", value: 5 },
+ ],
+ total: 8,
+};
+
+// The same counts grouped by a category: an ordered line through these would
+// assert a direction the data does not have.
+const byCategory: WidgetSeries = {
+ points: [
+ { key: "identity", value: 3 },
+ { key: "security", value: 5 },
+ ],
+ total: 8,
+};
+
+const empty: WidgetSeries = { points: [], total: 0, coverage: "empty" };
+
+function view(...widgets: DashboardWidgetView[]): DashboardView {
+ return { widgets };
+}
+
+function show(...widgets: DashboardWidgetView[]) {
+ return render();
+}
describe("Dashboard", () => {
- it("renders its header through Section and each widget through Card", () => {
- const data: DashboardView = {
- title: "Traffic",
- description: "last 7 days",
- widgets: [
- {
- id: "w1",
- title: "Visits",
- visualization: "number",
- series: emptySeries,
- },
- ],
- };
- const { container } = render();
-
- // Section owns the header markup — its root and heading classes.
- const section = container.querySelector("section");
- expect(section).not.toBeNull();
+ it("renders its header through Section and each series widget through Card", () => {
+ const { container } = render(
+ ,
+ );
+
+ // Section owns the header markup — its root and heading.
+ expect(container.querySelector("section")).not.toBeNull();
expect(
screen.getByRole("heading", { name: "Traffic", level: 2 }),
).toBeTruthy();
expect(screen.getByText("last 7 days")).toBeTruthy();
- // Card owns the surface — one card per widget, painted by the primitive.
- const card = container.querySelector(
- ".rounded-lg.border.bg-card.p-4.text-card-foreground.shadow-sm",
- );
- expect(card).not.toBeNull();
+ // Card owns the surface — one card per series widget, painted by the
+ // primitive, with the widget's title as its heading.
+ expect(
+ container.querySelector(
+ ".rounded-lg.border.bg-card.p-4.text-card-foreground.shadow-sm",
+ ),
+ ).not.toBeNull();
expect(
screen.getByRole("heading", { name: "Visits", level: 3 }),
).toBeTruthy();
});
it("scopes the accent override onto the Section root", () => {
- const data: DashboardView = {
- accent: "hotpink",
- widgets: [{ id: "w1", visualization: "number", series: emptySeries }],
- };
- const { container } = render();
+ const { container } = render(
+ ,
+ );
const section = container.querySelector("section") as HTMLElement;
expect(section.style.getPropertyValue("--primary")).toBe("hotpink");
});
+
+ it("says so, rather than drawing an empty grid, when the view has no widgets", () => {
+ show();
+ expect(screen.getByText("No widgets.")).toBeTruthy();
+ });
});
-it("preserves partial telemetry and unavailable totals from the SDK view", () => {
- render(
- {
+ it("draws a metric chart with an accessible data table", () => {
+ const { container } = render(
+ ,
+ );
+ const table = screen.getByRole("table", { name: "Events" });
+ expect(within(table).getByRole("row", { name: /identity/ })).toBeTruthy();
+ expect(within(table).getByText("3")).toBeTruthy();
+ expect(within(table).getByText("5")).toBeTruthy();
+ expect(container.querySelector("svg")?.getAttribute("aria-hidden")).toBe(
+ "true",
+ );
+ });
+
+ it("draws the chart's own empty state instead of an axis with nothing on it", () => {
+ const { container } = render(
+ ,
+ );
+ expect(
+ container.querySelector("figure")?.getAttribute("aria-label"),
+ ).toBe("Events: no data");
+ expect(container.querySelector("svg")).toBeNull();
+ // Said twice on purpose, and in two registers: the card badges the
+ // coverage beside its title, and the plot says it where the geometry
+ // would have been.
+ expect(screen.getAllByText("No data")).toHaveLength(2);
+ });
+
+ it("badges a partial series on the card, from `series.coverage`", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Partial data")).toBeTruthy();
+ // A partial series keeps its observed points: the chart still draws.
+ expect(screen.getByRole("table", { name: "Events" })).toBeTruthy();
+ });
+ },
+);
+
+describe("a number widget", () => {
+ it("is a stat tile showing the series total in the widget's format", () => {
+ show({
+ id: "w",
+ title: "Revenue",
+ visualization: "number",
+ series: { points: [{ key: "all", value: 4_200_000 }], total: 4_200_000 },
+ format: "currency",
+ });
+ expect(screen.getByText("Revenue")).toBeTruthy();
+ expect(screen.getByText("$4.2M")).toBeTruthy();
+ });
+
+ it("appends the widget's unit", () => {
+ show({
+ id: "w",
+ title: "Throughput",
+ visualization: "number",
+ series: { points: [{ key: "all", value: 42 }], total: 42 },
+ unit: "req/s",
+ });
+ expect(screen.getByText("req/s")).toBeTruthy();
+ });
+
+ it("sparklines a time-bucketed metric and deltas its last bucket", () => {
+ const { container } = show({
+ id: "w",
+ title: "Logins",
+ visualization: "number",
+ series: daily,
+ });
+ expect(screen.getByText("8")).toBeTruthy();
+ expect(container.querySelector("svg")).not.toBeNull();
+ // 3 → 5 between the last two buckets.
+ expect(screen.getByText(/66\.7%/).textContent).toContain(
+ "vs previous bucket",
+ );
+ });
+
+ it("draws neither a trend nor a delta for a metric grouped by category", () => {
+ // A sparkline over "identity, security" would read as a direction, and a
+ // delta between them as a change over time. Neither is true of a
+ // categorical grouping, whose order is the aggregate's, not time's.
+ const { container } = show({
+ id: "w",
+ title: "Logins",
+ visualization: "number",
+ series: byCategory,
+ });
+ expect(screen.getByText("8")).toBeTruthy();
+ expect(container.querySelector("svg")).toBeNull();
+ expect(screen.queryByText(/%/)).toBeNull();
+ });
+
+ it("gives no delta for a rise from zero, which is not a percentage", () => {
+ show({
+ id: "w",
+ visualization: "number",
+ series: {
+ points: [
+ { key: "2026-09-01T00:00:00Z", value: 0 },
+ { key: "2026-09-02T00:00:00Z", value: 5 },
],
- }}
- />,
- );
- expect(screen.getByRole("status").textContent).toBe("Partial telemetry");
- expect(screen.getByText("Total unavailable")).toBeTruthy();
- expect(screen.queryByText("0")).toBeNull();
+ total: 5,
+ },
+ });
+ expect(screen.queryByText(/%/)).toBeNull();
+ });
+
+ it("shows a dash and says no total, never a 0, when the total is withheld", () => {
+ // `total: null` means the pipeline cannot name one number — a distinct
+ // count or an average across groups. Rendering it as 0 would report
+ // "nothing happened" where the answer is "cannot say".
+ show({
+ id: "w",
+ title: "Distinct actors",
+ visualization: "number",
+ series: { ...byCategory, total: null },
+ });
+ expect(screen.getByText("—")).toBeTruthy();
+ expect(screen.getByText("No total")).toBeTruthy();
+ expect(screen.queryByText("0")).toBeNull();
+ });
+
+ it("badges a partial series, which is why its total is withheld", () => {
+ show({
+ id: "w",
+ title: "Logins",
+ visualization: "number",
+ series: { ...byCategory, total: null, coverage: "partial" },
+ });
+ expect(screen.getByText("Partial data")).toBeTruthy();
+ expect(screen.getByText("—")).toBeTruthy();
+ expect(screen.queryByText("No total")).toBeNull();
+ });
+
+ it("reads an empty series as no data, not as a zero", () => {
+ // The audit aggregate omits a bucket rather than emitting a zero one, so
+ // a series with no points means nothing matched.
+ show({ id: "w", title: "Logins", visualization: "number", series: empty });
+ expect(screen.getByText("No data")).toBeTruthy();
+ expect(screen.getByText("—")).toBeTruthy();
+ expect(screen.queryByText("0")).toBeNull();
+ });
+
+ it("keeps a real zero a zero", () => {
+ show({
+ id: "w",
+ title: "Failures",
+ visualization: "number",
+ series: { points: [{ key: "all", value: 0 }], total: 0 },
+ });
+ expect(screen.getByText("0")).toBeTruthy();
+ expect(screen.queryByText("—")).toBeNull();
+ });
+});
+
+describe("a table widget", () => {
+ it("lists each point's key and value", () => {
+ show({
+ id: "w",
+ title: "By type",
+ visualization: "table",
+ series: byCategory,
+ });
+ const table = screen.getByRole("table", { name: "By type" });
+ expect(within(table).getByText("identity")).toBeTruthy();
+ expect(within(table).getByText("5")).toBeTruthy();
+ });
+
+ it("says there is nothing yet for an empty series", () => {
+ show({ id: "w", title: "By type", visualization: "table", series: empty });
+ expect(screen.getByText("No data yet.")).toBeTruthy();
+ });
+});
+
+describe("a widget still being resolved, or that failed", () => {
+ it("waits on a null series rather than drawing it as empty", () => {
+ const { container } = show(
+ { id: "chart", title: "Logins", visualization: "line", series: null },
+ { id: "tile", title: "Total", visualization: "number", series: null },
+ );
+ expect(container.querySelectorAll('[data-slot="skeleton"]')).toHaveLength(
+ 2,
+ );
+ expect(screen.queryByText("No data")).toBeNull();
+ });
+
+ it("says a failed widget failed, and does not wait forever on its missing series", () => {
+ // A failed resolution leaves no series behind, so a renderer that tested
+ // for the series first would draw every failure as an endless wait.
+ const { container } = show(
+ {
+ id: "chart",
+ title: "Logins",
+ visualization: "line",
+ series: null,
+ failed: true,
+ },
+ {
+ id: "tile",
+ title: "Total",
+ visualization: "number",
+ series: null,
+ failed: true,
+ },
+ );
+ expect(screen.getByText("Unable to load.")).toBeTruthy();
+ expect(screen.getByText("Provider unavailable")).toBeTruthy();
+ expect(container.querySelector('[data-slot="skeleton"]')).toBeNull();
+ });
+});
+
+describe("the default layout", () => {
+ it("gathers adjacent scalars into one KPI row and keeps declared order", () => {
+ const { container } = show(
+ { id: "a", title: "A", visualization: "number", series: daily },
+ { id: "b", title: "B", visualization: "number", series: daily },
+ { id: "c", title: "C", visualization: "line", series: daily },
+ { id: "d", title: "D", visualization: "number", series: daily },
+ );
+ const grid = container.querySelector(".grid.items-start");
+ expect(grid?.className).toContain("lg:grid-cols-4");
+ const tiles = [...container.querySelectorAll("[data-dashboard-tile]")];
+ expect(tiles).toHaveLength(4);
+ expect(tiles[2].className).toContain("sm:col-span-2");
+ expect(tiles[2].className).toContain("col-start-1");
+ expect(tiles[3].className).toContain("col-start-1");
+ expect(
+ [...container.querySelectorAll('[data-slot="metric-label"], h3')].map(
+ (node) => node.textContent,
+ ),
+ ).toEqual(["A", "B", "C", "D"]);
+ });
+
+ it("stacks every widget in a row of its own for a stack layout", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector(".flex.flex-col")?.className).toContain(
+ "flex flex-col",
+ );
+ expect(container.querySelector('[class*="grid-cols-4"]')).toBeNull();
+ });
+});
+
+// The seams a host composes its own features through. They are what let the
+// host's solution dashboard keep its reorderable grid, its + menu and its
+// per-widget queries while this renderer still does the drawing.
+describe("slots", () => {
+ const widgets: DashboardWidgetView[] = [
+ { id: "a", title: "A", visualization: "line", series: daily },
+ { id: "b", title: "B", visualization: "number", series: daily },
+ ];
+
+ it("puts actions in the dashboard's own header", () => {
+ render(
+ Add }}
+ />,
+ );
+ expect(screen.getByRole("button", { name: "Add" })).toBeTruthy();
+ });
+
+ it("hands every widget to renderWidget, in the view's order", () => {
+ render(
+
{widget.id}
}}
+ />,
+ );
+ expect(
+ screen.getAllByRole("listitem").map((item) => item.textContent),
+ ).toEqual(["a", "b"]);
+ });
+
+ it("hands the tiles, each with its widget, to a layout of the host's own", () => {
+ render(
+ (
+
+ {tiles.map((tile) => (
+
+ {tile.node}
+
+ ))}
+
+ ),
+ }}
+ />,
+ );
+ const items = screen.getAllByRole("listitem");
+ expect(items.map((item) => item.dataset.visualization)).toEqual([
+ "line",
+ "number",
+ ]);
+ // The nodes are the kit's own cards: the chart's data table is in them.
+ expect(within(items[0]).getByRole("table", { name: "A" })).toBeTruthy();
+ });
+
+ it("shows the host's own empty state in place of the layout", () => {
+ render(
+ Add one back with +.
}}
+ />,
+ );
+ expect(screen.getByText("Add one back with +.")).toBeTruthy();
+ expect(screen.queryByText("No widgets.")).toBeNull();
+ });
});
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/sortable-grid.test.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/sortable-grid.test.tsx
index 04b972c53..2dac84832 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/sortable-grid.test.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/sortable-grid.test.tsx
@@ -252,3 +252,17 @@ describe("SortableGrid", () => {
expect(animate).not.toHaveBeenCalled();
});
});
+
+it("does not turn a drop on another section into a swap with the nearest allowed tile", () => {
+ const onSwap = vi.fn();
+ render(
+ id}
+ canSwap={(a, b) => ["a", "b"].includes(a) === ["a", "b"].includes(b)}
+ />,
+ );
+ dragTo("a", center("d"))();
+ expect(onSwap).not.toHaveBeenCalled();
+});
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/types.test.ts b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/types.test.ts
index 02f7d839d..57cd03399 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/types.test.ts
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/types.test.ts
@@ -25,8 +25,14 @@ describe("fromDashboardData", () => {
expect(view.columns).toBe(3);
expect(view.accent).toBe("#7c3aed");
expect(view.widgets).toHaveLength(1);
- expect(view.widgets[0]).toMatchObject({ id: "trend", visualization: "line" });
- expect(view.widgets[0].series.total).toBe(3);
+ expect(view.widgets[0]).toMatchObject({
+ id: "trend",
+ visualization: "line",
+ });
+ // `fromDashboardData` bridges an ALREADY-resolved result, so the series
+ // it produces is never the `null` the view model allows for a widget
+ // still being resolved.
+ expect(view.widgets[0].series?.total).toBe(3);
});
it("defaults layout to grid when the source omits it", () => {
@@ -35,3 +41,24 @@ describe("fromDashboardData", () => {
expect(view.widgets).toEqual([]);
});
});
+
+it("preserves sections, columns and widget spans from the SDK", () => {
+ const data = {
+ columns: 3 as const,
+ sections: [{ id: "summary", title: "Summary", columns: 4 as const }],
+ widgets: [
+ {
+ id: "total",
+ visualization: "number" as const,
+ span: 2 as const,
+ section: "summary",
+ series: { points: [], total: 0 },
+ },
+ ],
+ };
+ const view = fromDashboardData(data);
+ expect(view.columns).toBe(3);
+ expect(view.sections).toEqual(data.sections);
+ expect(view.widgets[0]).toMatchObject({ span: 2, section: "summary" });
+ expect(fromDashboardData(data, { columns: 1 }).columns).toBe(1);
+});
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/dashboard.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/dashboard.tsx
index 978723a30..f284268c6 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/dashboard.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/dashboard.tsx
@@ -6,13 +6,35 @@
// solution's Module-Federation remote render identical dashboards from the same
// package instance. Data resolution (metric → audit query) is the job of
// `@codefly-dev/saas-sdk`'s `runDashboard`; use `fromDashboardData` to bridge.
+//
+// It paints with the kit's METRIC tier — `StatTile` rows for scalars,
+// `metric-chart`'s line/area/bar over `ChartSeries` for series — which is the
+// tier the host's own operations pages are drawn with. Before, this renderer
+// and the host's solution dashboard both used the older `charts.tsx` tier, so a
+// solution's Dashboard tab read plainer than the host's own pages from the same
+// kit, and there were two renderers to keep in step rather than one.
import type * as React from "react";
+import type { ReactNode } from "react";
import { Card } from "../layout/card.js";
import { Section } from "../layout/page.js";
-import { AreaChart, BarList, LineChart, StatChart } from "./charts.js";
+import { Skeleton } from "../layout/skeleton.js";
import { cn } from "./cn.js";
-import type { DashboardView, DashboardWidgetView } from "./types.js";
+import { parseTimeKey } from "./format.js";
+import {
+ AreaChart as MetricAreaChart,
+ BarChart as MetricBarChart,
+ LineChart as MetricLineChart,
+} from "./metric-chart.js";
+import type { ChartSeries } from "./metric-geometry.js";
+import { type MetricState, MetricStateBadge } from "./metric-state.js";
+import { type Metric, StatTile } from "./metric-tiles.js";
+import type {
+ DashboardView,
+ DashboardWidgetView,
+ SeriesPoint,
+ WidgetSeries,
+} from "./types.js";
// Column-span utility classes, matching the responsive grid below so a spanning
// card widens in step with it. A span is clamped to the grid's column count by
@@ -31,33 +53,129 @@ const GRID_COLS: Record<1 | 2 | 3 | 4, string> = {
4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
};
-function WidgetBody({ widget }: { widget: DashboardWidgetView }) {
- const { series, visualization } = widget;
- if (series.points.length === 0) {
- return
No data yet.
;
+/** The widget's own title, or its id when the declaration gave none. */
+function widgetTitle(widget: DashboardWidgetView): string {
+ return widget.title ?? widget.id;
+}
+
+/**
+ * Whether the series is bucketed over time, so its points are a trend rather
+ * than a set of categories. Only a time-bucketed series earns a sparkline and
+ * a bucket-over-bucket delta: drawing a line through counts grouped by event
+ * type or region would assert an order and a direction the data does not have.
+ */
+function isTimeBucketed(points: SeriesPoint[]): boolean {
+ return points.length > 1 && points.every((p) => parseTimeKey(p.key) !== null);
+}
+
+/**
+ * The badge a resolved series earns, or `undefined` when it is simply ready.
+ *
+ * `partial` outranks a withheld total because it is the *reason* the total is
+ * withheld (a partial series keeps its observed points and gives up its total),
+ * and a reader who sees "No total" alone learns less than one who sees
+ * "Partial data". An empty series is `no_data`, never a zero: the audit
+ * aggregate omits a bucket rather than emitting one, so no points means nothing
+ * matched.
+ */
+function seriesState(series: WidgetSeries): MetricState | undefined {
+ if (series.coverage === "partial") return "partial";
+ if (series.coverage === "empty" || series.points.length === 0) {
+ return "no_data";
}
- switch (visualization) {
+ if (series.total === null) return "no_total";
+ return undefined;
+}
+
+/**
+ * Change from the second-to-last bucket to the last, as the signed fraction
+ * `StatTile` renders. A change from zero is not a percentage, so it is no
+ * delta at all rather than an infinite one.
+ */
+function lastBucketDelta(points: SeriesPoint[]): number | undefined {
+ if (points.length < 2) return undefined;
+ const previous = points[points.length - 2].value;
+ const latest = points[points.length - 1].value;
+ if (previous === 0) return undefined;
+ return (latest - previous) / Math.abs(previous);
+}
+
+/** A resolved scalar widget as the `Metric` a tile binds to. */
+function widgetMetric(
+ widget: DashboardWidgetView,
+ series: WidgetSeries,
+): Metric {
+ const trend = isTimeBucketed(series.points);
+ const delta = trend ? lastBucketDelta(series.points) : undefined;
+ return {
+ id: widget.id,
+ label: widgetTitle(widget),
+ // A withheld total is passed through as null, so the tile shows a dash
+ // and its badge says what withheld it. Coercing it to 0 here would
+ // report "nothing happened" where the pipeline said "cannot say".
+ value: series.total,
+ format: widget.format,
+ unit: widget.unit,
+ higherIsBetter: widget.higherIsBetter,
+ series: trend ? series.points.map((p) => p.value) : undefined,
+ delta,
+ // Only named when there is a delta: `MetricDelta` reads a label with no
+ // delta as "no data