The workspace dashboard at /workspaces/:id/insights. It ships the frame
only — route, range picker, the sections as tabs, the Needs attention
card and its drill-down panel, widget card shell, per-widget error boundary and
a few chart primitives. Every card comes from the plugin that owns its data,
through INSIGHTS_WIDGET_SLOT; every Needs-attention line through
INSIGHTS_SIGNAL_SLOT.
Install
npm install @orthacms/insights-adminRegister
// apps/admin/src/plugins.ts
import { InsightsPlugin } from '@orthacms/insights-admin';
export function buildPlugins(): AdminPlugin[] {
return [
IdentityPlugin(),
ShellPlugin(),
WorkspacesPlugin(),
InsightsPlugin(), // before plugins that rename or reorder its sections
ContentPlugin()
// …
];
}No server half, no environment variables. Sections merge by id with the
last contribution winning, so a plugin changing a band must come after it.
The widget slot is order-independent.
Configuration
One option, sections. It replaces the four default bands:
import { DEFAULT_INSIGHTS_SECTIONS, InsightsPlugin } from '@orthacms/insights-admin';
InsightsPlugin({
sections: [
...DEFAULT_INSIGHTS_SECTIONS,
{ id: 'sales', order: 25, titleId: 'insights.section.sales', defaultTitle: 'Sales' }
]
});sections: [] leaves every band to contributing plugins.
id | Title | order |
|---|---|---|
overview | Overview | 10 |
content | Content | 20 |
reach | Localisation & media | 30 |
team | Team | 40 |
- Since 0.13.0 each section is a tab. A section with no widget the reader may see has no tab; with only one visible section there is no tab strip at all.
- Only the open tab is mounted, so a tab nobody opens makes no request.
- A widget naming an unregistered section lands in a trailing More tab.
INSIGHTS_SECTION_IDSholds the four ids as constants.
Adding a widget
// my-plugin/admin/src/plugin.tsx
import type { AdminPlugin } from '@orthacms/bootstrap-admin';
import {
INSIGHTS_SECTION_IDS,
INSIGHTS_WIDGET_SLOT,
useInsightsRange
} from '@orthacms/insights-admin';
function OrdersCard() {
const { range, days } = useInsightsRange(); // e.g. range '30d', days 30
// Fetch your own figures for the last `days` days, then render them —
// WidgetCard, StatWidget and BarRows are exported for this.
return <p>Orders in the last {days} days: …</p>;
}
export function OrdersPlugin(): AdminPlugin {
return {
name: 'orders',
slots: [
{
slot: INSIGHTS_WIDGET_SLOT,
items: [
{
id: 'insights.orders.count',
section: INSIGHTS_SECTION_IDS.Overview,
order: 50,
size: 'xs', // 'xs' | 'sm' | 'md' | 'lg' | 'full' — of 12 columns: 3, 4, 6, 8, 12
permission: 'content:read',
titleId: 'orders.insights.title',
defaultTitle: 'Orders',
Component: OrdersCard
}
]
}
]
};
}permissionhides the widget from readers who lack it — match it to the key your route checks.Componenttakes no props; it reads what it needs.defaultTitleis what the error boundary shows if the component throws.xsis for stat tiles only: since 0.13.0 the Overview tab’sxswidgets sit side by side in one bordered strip, each a cell with no card of its own.- Chart primitives:
WidgetCard,WidgetChip,StatWidget,BarRows,AreaTrend,ColumnTrend(acompactcard form and afullheadline form with a table view),HeatGrid,RampLegend,Sparkline.
Adding a Needs-attention line
The first tab leads with Needs attention: one line per count that asks for somebody’s work, each opening the records behind it in a side panel. A line is contributed like a widget, with two components — the line, which renders always, and the panel’s body, which mounts only while it is open:
import {
INSIGHTS_SIGNAL_SLOT,
SignalDrill,
SignalRow,
type InsightsSignalRowProps
} from '@orthacms/insights-admin';
function LateOrdersSignal({ onOpen, isOpen }: InsightsSignalRowProps) {
const { data, isPending, isError } = useLateOrders(); // your own query
return (
<SignalRow
icon={Clock}
tone="warn" // 'warn' | 'info' | 'bad'
count={data?.total ?? 0}
title="Late orders"
hint="older than two days"
isPending={isPending}
isError={isError}
onOpen={onOpen}
isOpen={isOpen}
/>
);
}
// In the plugin's slots:
{
slot: INSIGHTS_SIGNAL_SLOT,
items: [
{
id: 'orders.late',
order: 50,
permission: 'content:read',
titleId: 'orders.insights.signal.late',
defaultTitle: 'Late orders',
Component: LateOrdersSignal,
Drill: LateOrdersDrill // renders a SignalDrill of DrillRecordRows
}
]
}SignalRowowns the states: a named skeleton while pending, no count and no button on an error — “nothing to do” and “we could not ask” must not look alike.SignalDrillis the panel: its title reads “Late orders · 12 records”, an optional filter,DrillRecordRows and anactionsslot for the way out to the full list. The page owns the sheet, its opening and focus.resolveInsightsSignalsis the permission filter and stable order, exported for a host or a test.
What fills it
And the Needs attention lines, in this order:
| Plugin | Line | Its panel reads |
|---|---|---|
| Content | Waiting to go live | GET /api/insights/content/unshipped/records?limit= |
| Protection | Waiting on review | the Reviews queue — GET /api/protection/queue |
| Content | Gone quiet | GET /api/insights/content/stale/records?age=&limit= — age is recent (30–90 days), months (90–365) or year (older, the default) |
| Media | Images missing alt text | GET /api/insights/media/alt/missing?limit= |
A drill-down lists 20 records by default, at most 50, oldest first. Each panel is gated by its own route’s permission and loads and fails on its own. Without one of those plugins, the page simply has fewer panels.
A shareable range
The range is in the URL: ?range=7d, 30d, 90d or 12m.
- The default,
30d, is left out of the URL. - An unknown value falls back to the default.
- Changing the range replaces the history entry instead of adding one.
The open tab and the open drill-down are in the URL too: ?tab=content (the
first tab is left out) and ?signal=content.stale. An unknown or forbidden tab
falls back to the first; a signal the reader may not see opens nothing.
Example: https://cms.example.com/workspaces/3f7a…/insights?range=90d&tab=team.
The Team tab’s punchcard groups saves in the reader’s time zone: the admin
sends ?tz= (an IANA name) to GET /api/insights/content/punchcard, and an
unknown zone reads as UTC.
See insights and activity for what each panel shows.