Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion module/AUDIT_METRICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,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
Expand All @@ -213,6 +214,60 @@ 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, `<Dashboard>` 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 | — |

A scalar widget's `format` names how its value is written, and `percent` takes a
value that is **already a percentage**: 45 renders "45%". It is not a share —
0.4 renders "0.4%". A producer whose own `percent` means a 0-to-1 share scales it
before it reaches the view, because the field is bound structurally and nothing
on the wire distinguishes the two conventions.

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. Both read the series in **time**
order, taken from the parsed bucket keys rather than from the order the
aggregate returned them in, so a series delivered newest-first reports the same
change — a delta's sign is drawn as a colour, and getting it from an array
position would paint a decline as a green rise.

A widget whose resolution **failed** says so only when it has nothing else to
say. With a series still in hand — a refresh that did not land — the tile draws
that series and carries a **Stale** badge instead, because values that are old
are still better than a blank card. Only a failure with nothing behind it reads
as "Unable to load."

A widget whose resolution was **refused** is the other case, and it keeps
nothing. "No access" is drawn before the series is read, and the host that
resolved the widget deletes the values it had cached — every revision of that
widget's declaration, at the read that was refused rather than in the component
displaying it, so a refusal arriving after the viewer moved on still lands.
Numbers this viewer is no longer allowed to read are a wrong answer, not a stale
one, and only a read that succeeds puts any back.

A host composes its own features around that renderer — `<Dashboard>`'s slots
take header actions, a layout (the host's solution dashboard passes a
drag-to-reorder grid), 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.

## Independent validation

Run `go test -race ./pkg/infra/auditmetricstest` from accounts/code for pure
Expand Down
49 changes: 49 additions & 0 deletions module/services/frontend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<Dashboard>` (`@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
Expand Down Expand Up @@ -218,3 +252,18 @@ failures part company here, which is what `mayKeepRetainedRows` and
those very rows, so they go; a **transient failure** is not, so they stay with a
note that they may be out of date. Blanking a good table because one request did
not come back is worse than the staleness.

**Where a refusal acts is the read, not the component.** Withholding rows at
render answers for the error in hand and leaves the rows in the cache, so the
next transient failure serves them again — authority is a property of the read,
and so is what the read may leave behind. A refused read therefore deletes the
retained snapshots for every cached key of what it was reading, in the query
function where `readOutcome` classifies it, before the rejection reaches any
component. `src/solutions/SolutionDashboard.tsx` is the worked example: one
widget, several cached revisions of its declaration, and a refusal that may
arrive after the viewer has navigated away or the page has unmounted, where no
effect of theirs is left to run. Delete the data and nothing else: removing the
entry cancels the read reporting the refusal and strands recovery, clearing it
restarts every active read of it so a standing 403 never settles, and
`setQueryData(key, undefined)` is a documented no-op. Recovery is then exactly
one thing — a read that succeeds.
9 changes: 4 additions & 5 deletions module/services/frontend/code/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions module/services/frontend/code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@
"@codefly-dev/saas-plugin-contract": "2.4.2",
"@codefly-dev/saas-plugin-react": "0.4.3",
"@codefly-dev/saas-sdk": "0.3.23",
"@codefly-dev/saas-ui": "0.12.6",
"@codefly-dev/ui": "0.12.6",
"@codefly-dev/saas-ui": "0.12.7",
"@codefly-dev/ui": "0.12.7",
"@codefly/saas-plugin-manifest": "0.1.1",
"@codefly/saas-settings": "0.1.0",
"@connectrpc/connect": "^2.1.1",
Expand All @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion module/services/frontend/code/packages/codefly-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Dashboard>`, charts, `fromDashboardData`; `chat`
is `<Chat>`. React only: no plugin runtime, no host context.
`<Dashboard>` paints with the **metric tier** — `StatTile`/`KPIRow` 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. `<Chat>` 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` → `<Dashboard>`.
Expand Down Expand Up @@ -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) |
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,30 @@ import { describe, expect, it } from "vitest";
import { Axis, Gridline } from "../atoms.js";
import { linearScale, sparkPlot } from "../geometry.js";

type ElementLike = { type?: unknown; props?: { children?: unknown } & Record<string, unknown> };
type ElementLike = {
type?: unknown;
props?: { children?: unknown } & Record<string, unknown>;
};

// Collect every element of a given SVG tag (a rendered component is a plain
// object tree, so no DOM is needed) with its props and text child. Recurses into
// child arrays — an axis <g> holds one array of y labels and one of x labels.
function byTag(node: unknown, tag: string): { props: Record<string, unknown>; text: string }[] {
function byTag(
node: unknown,
tag: string,
): { props: Record<string, unknown>; text: string }[] {
if (Array.isArray(node)) return node.flatMap((child) => byTag(child, tag));
if (!node || typeof node !== "object") return [];
const el = node as ElementLike;
const self =
el.type === tag && el.props
? [{ props: el.props, text: typeof el.props.children === "string" ? el.props.children : "" }]
? [
{
props: el.props,
text:
typeof el.props.children === "string" ? el.props.children : "",
},
]
: [];
return self.concat(byTag(el.props?.children, tag));
}
Expand All @@ -34,13 +46,18 @@ describe("Gridline", () => {
describe("Axis", () => {
it("labels each y tick with the formatted value", () => {
const scale = linearScale(0, 10, plot.bottom, plot.top);
const labels = byTag(Axis({ plot, y: { ticks: [0, 5, 10], scale } }), "text").map((t) => t.text);
const labels = byTag(
Axis({ plot, y: { ticks: [0, 5, 10], scale } }),
"text",
).map((t) => t.text);
expect(labels).toEqual(["0", "5", "10"]);
});

it("time-formats ISO bucket keys on the x axis", () => {
const keys = ["2026-09-01T00:00:00+00", "2026-09-02T00:00:00+00"];
const labels = byTag(Axis({ plot, x: { keys } }), "text").map((t) => t.text);
const labels = byTag(Axis({ plot, x: { keys } }), "text").map(
(t) => t.text,
);
expect(labels).toEqual(["Sep 1", "Sep 2"]);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { Axis, Svg } from "../atoms.js";
import { AreaChart, LineChart, StatChart } from "../charts.js";
import type { SeriesPoint } from "../types.js";

type ElementLike = { type?: unknown; props?: { children?: unknown } & Record<string, unknown> };
type ElementLike = {
type?: unknown;
props?: { children?: unknown } & Record<string, unknown>;
};

// Walk a React element tree (no DOM needed — a rendered component is a plain
// object) collecting every <path>'s props, so a test can assert what color the
Expand Down Expand Up @@ -36,7 +39,9 @@ const pts: SeriesPoint[] = [
// a text-color class (and the Dashboard accent behind `text-primary`) theme it.
describe("chart color inherits via currentColor", () => {
it("LineChart strokes with currentColor", () => {
const strokes = pathProps(LineChart({ points: pts })).filter((p) => p.stroke && p.stroke !== "none");
const strokes = pathProps(LineChart({ points: pts })).filter(
(p) => p.stroke && p.stroke !== "none",
);
expect(strokes.length).toBeGreaterThan(0);
for (const p of strokes) expect(p.stroke).toBe("currentColor");
});
Expand Down Expand Up @@ -69,7 +74,9 @@ describe("axes gating", () => {
expect(tree.props.stretch).toBe(false);
expect(byType(tree, Axis)).toHaveLength(1);
// The data path still colors via currentColor, not a hardcoded hue.
const strokes = pathProps(tree).filter((p) => p.stroke && p.stroke !== "none");
const strokes = pathProps(tree).filter(
(p) => p.stroke && p.stroke !== "none",
);
expect(strokes.length).toBeGreaterThan(0);
for (const p of strokes) expect(p.stroke).toBe("currentColor");
});
Expand Down
Loading
Loading