Frontend: panels, widgets and datasets
Frontends are TypeScript regardless of the backend language. The platform bundles them with esbuild into .dist/ at startup; the Next.js build never sees your sources, and a panel may only import the packages listed in apps/web/lib/runtime-externals.json.
Panels#
import type { ModulePanelProps } from "@mate/module-sdk-ts";
import { CardShell, KpiGrid, KpiTile, seriesColor } from "@mate/module-sdk-ts";
export default function Panel({ logId, moduleId }: ModulePanelProps) {
const { data, isLoading, isError } = useModuleData(moduleId, logId);
return (
<CardShell loading={isLoading} error={isError} empty={!data}>
<KpiGrid>
{data?.kpis.map((kpi, i) => (
<KpiTile key={kpi.id} title={kpi.title} value={kpi.value} accent={seriesColor(i)} />
))}
</KpiGrid>
</CardShell>
);
}
| Import | Use it for |
|---|---|
CardShell, CardSection, CardEmpty, CardError, InfoHint | The frame: loading, empty, and error states, plus scrolling. Never add your own scroll container inside it. |
KpiTile, KpiGrid | Headline figures with consistent typography. |
seriesColor(i), sequentialScale(t), divergingScale, statusColor(role), CHART_CHROME | Colour by job, not by look. |
variantHref(logId, id), activityHref(logId, name), useDrillParams(), DRILL_PARAMS | Links into the process page, encoded once. |
api, ApiError, subscribeBus, subscribeJob | Fetching with the session attached, and live updates. |
useWidget(moduleId, widgetId) | Embed another module's widget, lazily. |
<Link href={variantHref(logId, variant.id)}>{variant.label}</Link>
const kpis = await api.get(`/api/v1/modules/${moduleId}/kpis?log_id=${logId}`);
subscribeBus(["my_module.analysis.completed"], () => refetch());
Widgets#
frontend:
widgets:
- id: bottlenecks
entry: ./widgets/Bottlenecks.tsx
title: Bottlenecks
description: Slowest activities by median duration. # palette blurb
icon: Timer
default_w: 4 # grid units on the 12-column board
default_h: 9
min_w: 3
min_h: 6
min_px_w: 320 # pixel floors – the real minimums
min_px_h: 240
resizable: true # false locks the card to default_w/default_h
log_models: [case_centric]
help: { what: …, read: …, computed: … }
config_schema: { properties: { top_n: { type: integer, default: 8 } } }
kpis: [{ id: median, title: Median duration }]
views: [{ id: bars, title: Bar chart, exposes: [top_n] }]
drill: { module_id: performance, params: { view: bottlenecks } }
Sizing is the field to get right. Grid units keep the layout tidy but are relative to the board's width; the pixel floors are what actually keep a card readable. Measure them in the browser at exactly that size.
export default function Bottlenecks({ logId, config, onDrill }: WidgetProps) {
const scope = useCardScope(); // board filters and time range
const { data, isLoading, isError } = useBottlenecks(logId, {
topN: config?.top_n ?? 8,
scope,
});
return (
<CardShell loading={isLoading} error={isError} empty={!data?.items.length}>
<BarChart
data={data?.items ?? []}
fill={seriesColor(0)}
onClick={(bar) => onDrill?.({ params: { activity: bar.activity } })}
/>
</CardShell>
);
}
onDrill is undefined when a panel embeds the widget, so always call it optionally. Colour rules: one series means one colour; never shade a bar by its own length; fold a long tail into "Other"; use solid hairline gridlines; never build a dual-axis chart.
Canvases#
Graphs, maps, and diagrams render through CanvasShell, which provides the dotted grid, the minimap, the initial fit, the drag guard, and pseudo-fullscreen. Every control belongs in its settings popover (built from the CanvasSetting* primitives), expensive settings commit on release (onCommit), and a busy canvas keeps its last result instead of unmounting to show a spinner.
Datasets#
When the output is data rather than pixels, declare a dataset and skip React entirely:
datasets:
- id: ranking
title: Activity ranking
shape: table # table | graph | kpi | tree | blob
route: /ranking # one of your @route paths
params_schema: { properties: { top_n: { type: integer, default: 10 } } }
@route.get("/ranking")
async def ranking(self, ctx: ModuleContext, top_n: int = 10) -> dict:
payload = {
"shape": "table",
"columns": [
{"key": "activity", "title": "Activity", "type": "string"},
{"key": "events", "title": "Events", "type": "number"},
],
"rows": [...],
}
await ctx.cache.set(f"ranking:{top_n}", payload)
return payload
| Use a dataset when | Use a widget when |
|---|---|
| The result is tabular, a graph, or figures | The visual is the point |
| You want the platform's table, pagination, and export | You need bespoke layout or interaction |
| The same data should be readable by MCP tools | You are embedding another module's widget |