Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
2c270cc
feat(manifest): let a dashboard group its widgets into titled sections
crisfiorini-oss Sep 28, 2026
58b1881
feat(ui): SortableBoard, tiles in groups a viewer can rearrange
crisfiorini-oss Sep 28, 2026
f6af102
feat(frontend): draw a solution dashboard in its declared sections, a…
crisfiorini-oss Sep 28, 2026
fb30d0f
feat(frontend): say in a dashboard's + menu which removed tiles have …
crisfiorini-oss Sep 28, 2026
397f776
feat(manifest): let a metric say its value is a percentage
crisfiorini-oss Sep 28, 2026
d92434e
fix(sdk): put a 0 on each day of a per-day count that had no events
crisfiorini-oss Sep 29, 2026
3c7a90c
feat(frontend): make a chart tile two rows tall, so two numbers stack…
crisfiorini-oss Sep 29, 2026
ccf0978
fix(sdk): keep a derived metric complete over a day no input had even…
crisfiorini-oss Oct 2, 2026
0e84d23
fix(frontend): save nothing for an arrow key that moves no tile, and …
crisfiorini-oss Oct 2, 2026
ec17e36
fix(frontend): draw a solution dashboard with the metric charts the a…
crisfiorini-oss Oct 5, 2026
554217f
Merge main into feat/dashboard-sections
crisfiorini-oss Oct 6, 2026
7bf64b6
fix(frontend): regenerate the kit's preview stylesheet for the dashbo…
crisfiorini-oss Oct 6, 2026
f37fd6e
fix(sdk): keep a total unavailable when the only values are the fille…
crisfiorini-oss Oct 6, 2026
2cb45d8
fix(frontend): keep a viewer's removed widgets when every section the…
crisfiorini-oss Oct 6, 2026
e40e85f
fix(ui): let a dashboard's accent colour its line and area charts
crisfiorini-oss Oct 6, 2026
2e4a1d2
fix(frontend): title a number tile as the audit log labels its numbers
crisfiorini-oss Oct 6, 2026
dadb42e
Merge main into feat/dashboard-sections
crisfiorini-oss Oct 6, 2026
1660fb3
fix(ui): draw a lone chart series in the palette's last colour
crisfiorini-oss Oct 6, 2026
e6fe65c
fix(frontend): write a number tile's title in the foreground colour
crisfiorini-oss Oct 6, 2026
43f97c0
Merge main into feat/dashboard-sections
antoinetoussaint-byte Oct 12, 2026
c779cc9
refactor(ui): drop this branch's second renderer in favour of #1043's
antoinetoussaint-byte Oct 12, 2026
98564bf
feat(ui): group a dashboard into sections and write every mark in its…
antoinetoussaint-byte Oct 12, 2026
7e2b3fa
fix(sdk): scale a percent metric's share once, in runDashboard; carry…
antoinetoussaint-byte Oct 12, 2026
95766c1
feat(frontend): compose a viewer's sections through the kit's dashboa…
antoinetoussaint-byte Oct 12, 2026
31cfe26
docs: describe sections, the percent boundary and per-day zeros
antoinetoussaint-byte Oct 12, 2026
fe679cc
fix(accounts): key audit time buckets in UTC, whatever the session zone
antoinetoussaint-byte Oct 12, 2026
50322d0
fix(sdk): fill a derived metric's inputs, and every series through th…
antoinetoussaint-byte Oct 12, 2026
442896f
feat(ui): title a section's widgets one level under the section
antoinetoussaint-byte Oct 12, 2026
0afad20
fix(frontend): pass a section widget's heading on; read filled zeros …
antoinetoussaint-byte Oct 12, 2026
6c96c13
chore(manifest): @codefly/saas-plugin-manifest 0.1.2
antoinetoussaint-byte Oct 12, 2026
41766e2
docs: UTC buckets, the fill through the range end and across a rate's…
antoinetoussaint-byte Oct 12, 2026
d7b4901
test(frontend): build the stale-layout fixture without an unused binding
antoinetoussaint-byte Oct 12, 2026
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
53 changes: 48 additions & 5 deletions module/AUDIT_METRICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,35 @@ events means partial telemetry. Older servers without samples cannot establish
numeric completeness.

SDK series expose `coverage: complete | partial | empty` and nullable `total`.
Time buckets are **UTC** days, weeks and months, whatever zone the database
session runs in, and every key reads `YYYY-MM-DDT00:00:00+00`. Keyed in the
session's zone, a key's offset would move across daylight saving, so neither a
bucket the aggregate did not return nor the kit's UTC label could be placed.

A count, distinct count or sum grouped by **time** is zero-filled where the series
is produced, in the SDK's `runMetric`: every day (or week, or month) from the
first bucket the aggregate returned through the end of the range — the bucket
holding `to`, or today for an open-ended query — that it did not return gets a
`0`, because no event matched there. Without it a chart spaces points by
position, so a day with no events vanished and the line ran straight over it,
and the days since the last event vanished too, so the newest point and the
"vs previous bucket" delta read an old day as the latest. The current bucket is
in progress, so its value is so far. An average, minimum, maximum or percentile
has no value over no events and is not filled; a bucket the aggregate *did*
return whose value was dropped as unknown stays out and keeps the series
partial; days before the first bucket are not filled; returned keys that
disagree about their offset are left as they are rather than given a guessed
one; and a series made only of filled zeros has no total.

A derived metric over such series reads a day an input has no bucket for as
that input's `0`, wherever the day falls: a failure rate whose failures start
after the requests, or never happen, reads `0` on those days and stays
complete, rather than dropping them as missing telemetry. A day no input had
events on has no ratio (0/0) and keeps the series complete; a sum or difference
of such series is filled the same way and can feed another derived metric. An
input that is partial, or whose aggregation has no value over no events, keeps
a missing day unknown.

Missing derived operands and zero denominators stay unknown. Partial series keep
observed chart points with a visible label and withhold the total. Distinct
counts, averages, percentiles and ratios have no scalar total across multiple
Expand All @@ -228,11 +257,25 @@ does not have to ask which half of the kit its dashboard will get.
| `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.
A widget's `format` names how its values are written — a scalar's number, a
chart's axis, readout and data table, and a table's values all write through the
one function `StatTile` uses — and the view's `percent` takes a value that is
**already a percentage**: 45 renders "45%". It is not a share — 0.4 renders
"0.4%". A data graph's `format: "percent"` means the opposite, a share from 0 to
1, because that is what a ratio metric produces. The two meet at exactly one
place: `runDashboard` multiplies a percent metric's points and total by 100 on
the widget it resolves (`byMetric` keeps the shares), so a declared 0.4 is drawn
"40%" on every mark, and nothing after it scales again. A producer of its own
with share semantics must do the same before the view.

A view may group its widgets into **sections**: `sections` lists them in the
order they are drawn (`id`, `title`, an optional `description`) and a widget's
`section` names the one it is drawn in. Each section is headed by the kit's
`DashboardSectionHeader`, whose title is an `<h3>`, and a widget inside a
section is titled `<h4>`, so heading navigation reads it as the section's child;
a declared section with no widgets is still drawn and says so; a widget naming
no section is drawn first, untitled, and keeps its `<h3>`. A view without
sections draws as it always has.

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
Expand Down
51 changes: 39 additions & 12 deletions module/docs/plugin-manifest-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,29 +92,56 @@ has three node kinds:
- **`metrics`** — a `source` metric filters one event and compiles to an
`AuditService.AggregateAuditLog` query (its `groupBy`/`bucket`/`aggregation`
are exactly that RPC's dimensions); a `derived` metric combines other metrics
(`sum`, `ratio`, `difference`).
(`sum`, `ratio`, `difference`). Either kind may name a `format`: `number`
(the default) writes its value as it is, and `percent` reads it as a share
from 0 to 1 and writes it as a percentage (0.4 reads 40%) on every widget
that draws it. A ratio is not always a share, so the host never infers one.
The share is scaled to a percentage once, by the SDK's `runDashboard`, on the
widget it resolves; the dashboard kit's own `format: "percent"` takes the
scaled value (see `AUDIT_METRICS.md`, "How a resolved series is drawn").
A count, distinct count or sum grouped by `time` reads `0` on a day (week,
month) with no events, from its first bucket through today (or the end of
the range asked for), rather than skipping it; a derived metric over such
counts reads an input's day with no events as that input's `0`.
- **`dashboards`** — a `layout` of `widgets`, each binding a `visualization` to
one metric. These metric-bound widgets are distinct from `ui.widgets`, which
are presentation slots contributed to host surfaces; the two never mix.
A dashboard may group its widgets into `sections` — each an `id`, a `title`
and an optional `description`, drawn in declared order — in which case every
widget names its `section`. A section may hold no widgets. A dashboard that
declares no sections draws its widgets as one untitled group, and no widget
may name a section.

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
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
adds a metric the dashboard does not draw, so a metric declared only as a
derived metric's input stays off the page.

The schema owns the per-node field formats — including the two shape rules that
cross fields: a metric carries a `bucket` exactly when it groups by `time`, and
a `ratio`/`difference` takes exactly two inputs (`sum` takes two or more). The
them, and re-add the ones they removed. The declared widgets, in declared order
and in their declared sections, are where every viewer starts, and a widget
declared later reaches viewers who already rearranged, in its declared section.
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.

Sections are declared by the solution; a viewer only groups and arranges the
tiles. A viewer can move a tile into another section, rename, reorder and
remove sections, and add sections of their own, which start empty and are
filled by dragging tiles in. A removed section's tiles move to the section
above it (below, for the first), and a section's + puts a removed widget back
into that section. A section the solution declares later still appears for a
viewer who rearranged; one the viewer removed stays removed. None of this
reaches other viewers or the declaration.

The schema owns the per-node field formats — including the shape rules that
cross fields: a metric carries a `bucket` exactly when it groups by `time`, a
`ratio`/`difference` takes exactly two inputs (`sum` takes two or more), and a
widget names a `section` exactly when its dashboard declares `sections`. The
host validator adds only what a JSON Schema cannot express: referential
integrity — every metric filter names a declared event, every derived-metric
input and every widget names a declared metric — and that the derived-metric
reference graph is acyclic.
input and every widget names a declared metric, every widget's section is one
its dashboard declares, and section ids are unique within their dashboard — and
that the derived-metric reference graph is acyclic.

What neither layer checks is **dimensional coherence** — whether a metric's
`groupBy` is meaningful for its data, or whether a derived metric's inputs have
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -598,3 +598,50 @@ func TestPostgresAuditPolicyAsNonOwner(t *testing.T) {
require.Empty(t, rows)
}
}

// A time bucket's key is a UTC midnight with a "+00" offset in every session
// zone. Keyed in the session's zone, a bucket carries that zone's offset, which
// moves across daylight saving, and the SDK's zero fill, which has to name the
// days the aggregate did not return, cannot know which offset a missing day
// takes; the kit labels keys in UTC, so a "+02" midnight read as the day before.
func TestTimeBucketKeysAreUTCInAnySessionZone(t *testing.T) {
dsn := os.Getenv("AUDIT_METRICS_TEST_DSN")
if dsn == "" {
if os.Getenv("AUDIT_METRICS_REQUIRE_DB") == "1" {
t.Fatal("database gate requires AUDIT_METRICS_TEST_DSN")
}
t.Skip("set AUDIT_METRICS_TEST_DSN to a disposable PostgreSQL database")
}
ctx := context.Background()
conn, err := pgx.Connect(ctx, dsn)
require.NoError(t, err)
defer func() { require.NoError(t, conn.Close(ctx)) }()
tx, err := conn.Begin(ctx)
require.NoError(t, err)
defer func() { require.NoError(t, tx.Rollback(ctx)) }()
// Either side of the Central European change to summer time (29 March 2026,
// 01:00 UTC). 22:30 UTC on the 29th is already the 30th in Paris.
_, err = tx.Exec(ctx, `SET LOCAL TimeZone = 'Europe/Paris';
CREATE TEMP TABLE audit_events (org_id text, resource text, resource_id text, event_type text, actor_id text, created_at timestamptz, payload jsonb);
INSERT INTO audit_events VALUES
('org-a','collection','source-a','saas.document.ingested','reader','2026-03-28T12:00:00Z','{}'),
('org-a','collection','source-a','saas.document.ingested','reader','2026-03-29T22:30:00Z','{}'),
('org-a','collection','source-a','saas.document.ingested','reader','2026-04-01T12:00:00Z','{}');`)
require.NoError(t, err)
ctx = txbind.BindRequest(ctx, tx, "", "")
store := &infra.PostgresStore{}
q := business.AuditQuery{OrgID: "org-a", Resource: "collection", ResourceID: "source-a", EventType: "saas.document.ingested"}
for bucket, want := range map[string][]string{
"day": {"2026-03-28T00:00:00+00", "2026-03-29T00:00:00+00", "2026-04-01T00:00:00+00"},
"week": {"2026-03-23T00:00:00+00", "2026-03-30T00:00:00+00"},
"month": {"2026-03-01T00:00:00+00", "2026-04-01T00:00:00+00"},
} {
rows, err := store.AggregateAuditLog(ctx, q, business.AuditAggregationSpec{GroupBy: []string{"time"}, Bucket: bucket})
require.NoError(t, err, bucket)
keys := make([]string, 0, len(rows))
for _, row := range rows {
keys = append(keys, row.Key)
}
require.ElementsMatch(t, want, keys, bucket)
}
}
9 changes: 8 additions & 1 deletion module/services/accounts/code/pkg/infra/postgres_audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,14 @@ func auditDimensionExpr(dim, bucket string, addArg func(any) int) (string, error
case "week", "month", "day":
grain = bucket
}
return fmt.Sprintf("to_char(date_trunc('%s', created_at), 'YYYY-MM-DD\"T\"HH24:MI:SSOF')", grain), nil
// Buckets are UTC days, weeks and months, whatever zone the session
// runs in, so every key carries the same "+00" offset. In the session's
// own zone a key would carry that zone's offset, which changes across
// daylight saving: a client that has to name a bucket the aggregate did
// not return (the SDK's zero fill) could not know which offset it takes,
// and the kit labels keys in UTC, so a "+02" midnight already read as
// 22:00 the day before.
return fmt.Sprintf("to_char(date_trunc('%s', created_at AT TIME ZONE 'UTC'), 'YYYY-MM-DD\"T\"HH24:MI:SS\"+00\"')", grain), nil
default:
return "", fmt.Errorf("audit: unhandled group dimension %q", dim)
}
Expand Down
22 changes: 19 additions & 3 deletions module/services/frontend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,30 @@ renderer's slots:
`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 viewer's arrangement** — the declared sections, and within and between
them the tiles: reorder or move a tile by drag or arrow keys, remove one, add
it back from a +, rename, reorder or remove a section, add one of their own.
It is saved per viewer and organization (`dashboard-layout.ts`, version 2,
still the labelled browser-storage STOPGAP) and resolved here into the view
the kit draws — the viewer's sections, in the viewer's order, under the
viewer's titles, each widget in the section the viewer put it in. The
renderer's `layout` slot lays it out as the kit's `SortableBoard`, each
section headed by the kit's `DashboardSectionHeader` with the host's grip,
rename, + and options in it. Every + offers only the declared widgets the
viewer removed, asks each one's query only while it is open (the tile's own
query, so a tile added back draws from that answer), and says, muted, when
the tile would have nothing to show ("No data yet.", "Telemetry
incomplete."). A chart, bar or table tile spans two grid rows, so two number
tiles stack beside it;
- **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.

A widget's `format` comes from the same resolved answer as its series, because
`runDashboard` is where a declared share became percentage points; binding it
from the declaration instead would pair a percentage with a share's format.

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
Expand Down
4 changes: 2 additions & 2 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.

2 changes: 1 addition & 1 deletion module/services/frontend/code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"@codefly-dev/saas-sdk": "0.3.25",
"@codefly-dev/saas-ui": "0.12.7",
"@codefly-dev/ui": "0.12.7",
"@codefly/saas-plugin-manifest": "0.1.1",
"@codefly/saas-plugin-manifest": "0.1.2",
"@codefly/saas-settings": "0.1.0",
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-web": "^2.1.1",
Expand Down
33 changes: 30 additions & 3 deletions module/services/frontend/code/packages/codefly-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,15 @@ and the v1 subset — is [CATALOG.md](./CATALOG.md).
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`.
renderer rather than beside it. A view's `sections` group its widgets under
titled headings in the order listed (a widget's `section` names its own);
`DashboardSectionHeader` is that heading, exported for a layout of the
consumer's own — `SortableBoard`, say, which lets a viewer drag tiles between
sections. A widget's `format` reaches its tile, its chart's axis, readout and
data table, and its table alike; `percent` there is already a percentage, and
`runDashboard` is where a data graph's share is scaled to one. 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 All @@ -68,6 +75,26 @@ and the v1 subset — is [CATALOG.md](./CATALOG.md).
a menu. It commits nothing: a move is reported and the consumer confirms,
writes or refuses it. See *A board commits no move*.

### Dashboard changes a remote will see in 0.12.7

- **A widget's `format` now reaches every mark, not only a scalar.** A line,
area or bar chart writes its axis, crosshair readout and data table in it, and
a table writes its values in it. A remote that declared `compact` or
`currency` on a chart or table widget, which only scalars honoured before,
now reads "1.2K" or "$1.2K" there. `percent` there is percentage points, as
it always was for a scalar.
- **Sections.** `DashboardView.sections` and `DashboardWidgetView.section`
group widgets under `DashboardSectionHeader`. A widget inside a section is
titled `<h4>`, one level under the section's `<h3>`, and stays `<h3>` above
every section; a view without sections draws as before.
- **`renderWidget` gets a second argument**, `{ heading }`: the level the
widget's title takes where the dashboard draws it. Pass it on to
`DashboardWidget`'s new `heading` prop. A slot that ignores it keeps `<h3>`.
- `Card` takes `titleAs` (`"h3"` by default, or `"h4"`), and `StatTile`'s
`labelAs` also accepts `"h4"`.
- `SortableBoard` (several groups, tiles moved between them) is exported beside
`SortableGrid`, which is now a one-group board with unchanged props.

### Status, card headers, lists and empty states

A solution page is built from kit components, never from hand-laid utilities:
Expand Down Expand Up @@ -115,7 +142,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`, `DashboardWidget`, metric tiles + charts, `fromDashboardData` (React-only) |
| `@codefly-dev/ui/dashboard` | `Dashboard`, `DashboardWidget`, `DashboardSectionHeader`, metric tiles + charts, `SortableGrid`/`SortableBoard`, `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
Loading
Loading