On this page

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#

modules/my_module/panel/index.tsx
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>
  );
}
ImportUse it for
CardShell, CardSection, CardEmpty, CardError, InfoHintThe frame: loading, empty, and error states, plus scrolling. Never add your own scroll container inside it.
KpiTile, KpiGridHeadline figures with consistent typography.
seriesColor(i), sequentialScale(t), divergingScale, statusColor(role), CHART_CHROMEColour by job, not by look.
variantHref(logId, id), activityHref(logId, name), useDrillParams(), DRILL_PARAMSLinks into the process page, encoded once.
api, ApiError, subscribeBus, subscribeJobFetching with the session attached, and live updates.
useWidget(moduleId, widgetId)Embed another module's widget, lazily.
Links and live updates
<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#

A widget declaration
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.

A card body
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:

A dataset declaration
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 } } }
Serving it
@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 whenUse a widget when
The result is tabular, a graph, or figuresThe visual is the point
You want the platform's table, pagination, and exportYou need bespoke layout or interaction
The same data should be readable by MCP toolsYou are embedding another module's widget