Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Insights plugin

The dashboard frame, its sections, and how to add a widget of your own.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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-admin

Register

// 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.

idTitleorder
overviewOverview10
contentContent20
reachLocalisation & media30
teamTeam40
  • 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_IDS holds 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
                    }
                ]
            }
        ]
    };
}
  • permission hides the widget from readers who lack it — match it to the key your route checks.
  • Component takes no props; it reads what it needs.
  • defaultTitle is what the error boundary shows if the component throws.
  • xs is for stat tiles only: since 0.13.0 the Overview tab’s xs widgets 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 (a compact card form and a full headline 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
        }
    ]
}
  • SignalRow owns 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.
  • SignalDrill is the panel: its title reads “Late orders · 12 records”, an optional filter, DrillRecordRows and an actions slot for the way out to the full list. The page owns the sheet, its opening and focus.
  • resolveInsightsSignals is the permission filter and stable order, exported for a host or a test.

What fills it

PluginTabPanels
ContentOverviewEntries, Published, Drafts; Publishing velocity; By type
ContentContentGone quiet; Waiting to go live
ContentTeamWhen the work happens
i18nLocalisation & mediaTranslation coverage
MediaOverviewMedia storage
MediaLocalisation & mediaStorage; Uploads

And the Needs attention lines, in this order:

PluginLineIts panel reads
ContentWaiting to go liveGET /api/insights/content/unshipped/records?limit=
ProtectionWaiting on reviewthe Reviews queue — GET /api/protection/queue
ContentGone quietGET /api/insights/content/stale/records?age=&limit= — age is recent (30–90 days), months (90–365) or year (older, the default)
MediaImages missing alt textGET /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.