Insights
A dashboard per workspace, at Insights in the sidebar, with a range picker.
The page itself ships no widgets. It provides the frame — the route, the range picker, the card shell, a per-widget error boundary and the chart primitives — and every card arrives from the plugin that owns the data behind it. A dashboard is the module most likely to slowly become the one that knows about every other one, and this is what prevents that.
The content widgets
| Widget | Answers |
|---|---|
| Totals | Entry, published and draft counts, the change over the range, and a sparkline |
| Pipeline | Draft against published, per content type |
| Velocity | Entries published per time bucket across the range |
| Stale | Published entries bucketed by time since last edit — 30, 90, 180, 365 days and older |
| Unshipped | Live entries carrying unpublished edits — the Modified state |
| Punchcard | Edits by weekday and hour |
Media and translation widgets
Media contributes storage, uploads over time, and alt coverage — how many images have no text alternative. Localization contributes translation coverage per language and content type.
Each widget owns its own request
Six content endpoints rather than one combined payload, so a slow or failing aggregate degrades one card instead of blanking the dashboard. A widget that throws shows its own error inside its own card, named after the widget so the failure is identifiable.
The activity log
An append-only audit trail, at Activity. There is also a per-user view on each member’s page, and a recent-activity panel on the home page.
What it records
Each row is an event with a time, an actor, a kind, and a subject.
| Source | Kinds it records |
|---|---|
| Identity | Sign-in, API token lifecycle, workspace lifecycle |
| Users | Member invitation, edit, disable, enable |
| Content | Publish lifecycle |
| Media | Asset and folder lifecycle |
It is written in the same transaction
An audit row is not written by the code that performs the action. The action appends a domain event to the transactional outbox using its own transaction, so the event commits if and only if the change does — and never without it. After commit, the event is delivered to the activity plugin’s subscriber, which writes the row.
Delivery is at-least-once and the insert is keyed on the event id, so a redelivery never double-records.
The practical consequence: there is no window in which a change is applied but unrecorded, and no way for an audit row to describe something that was rolled back.
Filtering
The log filters by subject type, subject id, actor, actor email, event kind and a date range; it pages and sorts by time or kind.
What it does not record
An unmapped event kind is audited by nobody, silently
The audit is derived from the outbox downstream of the write. An event kind that has no mapper in the activity plugin is not an error anywhere: the dispatcher finds no subscriber, marks the event delivered, and the action is simply never audited.
That failure is completely silent, and it has happened three times in this
codebase — API tokens, entry publishes, and the whole media library each shipped
unaudited for a period. All three are mapped as of 0.5.0, which also pinned the
comparison as a test, so the next gap fails a suite rather than waiting for
somebody to notice. If you add a plugin that emits events you expect to see
here, you must also add its mapper, and the check is one query: compare the
distinct kinds in outbox_events against the audited set.
Reads are not audited — only changes. Nothing records that somebody looked at a record, and there is no access log beyond whatever your reverse proxy keeps.