Browse documentation

Widgets

Every widget builder and the options it accepts.

Widgets are the contents of a dashboard section, of a detail page's widgets tab, and of a drawer's widget tab. Each builder returns a WidgetConfig; every query runs on the server with a WidgetContext. In a detail tab and in a drawer the record being viewed is ctx.row; a drawer renders its widgets as static cards (see Drawers).

import { dashboard, metric, statGroup, table } from "@flowpanel/kit";
import { areaChart } from "@flowpanel/kit/charts";

export const overview = dashboard({
  path: "/",
  label: "Overview",
  dateRange: { preset: "last30d" },
  sections: [
    {
      label: "Revenue",
      widgets: [
        metric("MRR", async ({ db, dateRange }) => mrr(db, dateRange), {
          format: "currency",
          span: 3,
          drilldown: "/admin/invoices",
        }),
        areaChart("Revenue over time", async ({ db }) => revenueSeries(db), {
          x: "day",
          y: ["subscriptions", "one-off"],
          stacked: true,
          span: 9,
        }),
      ],
    },
  ],
});

WidgetContext

What every widget query receives.

Prop

Type

href builds a link under the admin's own basePath, so a config never hand-types /admin/…. href("orders") is the list, href("orders", id) one record, and the third argument adds the list page's own query keys — { filter: { status: "paid" } } becomes ?f_status=paid and { tab: "activity" } becomes ?tab=activity.

query(key, fn) memoises for the request. Two widgets that need the same figure pass the same key and the query runs once; the memo is per page render, so the next request queries again. The memo holds the promise, so a failure is shared too — the request does not retry under that key.

labels is the admin's resolved LabelsConfig, which is where a widget's own wording should come from. row is present only when the widget sits in a detail tab, and holds that record's projected fields.

metric("Paid orders", async (ctx) => ({
  value: await ctx.query("paid-orders", () => countPaid(ctx.db, ctx.dateRange)),
  href: ctx.href("orders", undefined, { filter: { status: "paid" } }),
}));

Shared option types

Span is 1 | 2 | 3 | 4 | 6 | 8 | 12 — the widget's width in the section's 12-column grid.

NumericFormat is "number" | "currency" | "percent" | "bytes" | "duration".

Tone is "default" | "accent" | "ok" | "warn" | "err" | "info" | "muted".

Every widget accepts realtime, a channel name or list. When one fires, that widget re-runs its query — the rest of the dashboard stays put.

metric

A single big-number widget.

export function metric(label: string, query: (ctx: WidgetContext) => Promise<number | string | MetricResult>, options?: MetricOptions): MetricWidget;

Prop

Type

icon is handed to the metric card as a React child and rendered verbatim. Metric icons are deliberately free-form: pass an emoji or a ligature your own icon font resolves; a Lucide name renders as literal text. This differs from resource, action and command icons, which use the serializable IconName registry described under resource icons.

A metric() query may return a bare number or string, or a MetricResult carrying the presentation the value itself implies. A result field wins over the matching static option, which stays as the fallback: MetricResult.href renders as the card's drilldown, and MetricResult.sublabel as its caption.

MetricResult

Prop

Type

MetricDelta

Prop

Type

goodWhen: "down" colours a fall green — for churn, cost or latency a drop is the good news.

table

A list-of-rows widget on a dashboard.

export function table<R = unknown>(options: TableWidgetOptions<R>): TableWidget;

Point it at a registered resource to reuse that resource's columns, or supply your own query and columns.

R is inferred from query, so columns is checked against the row that query returns — a misspelled column is a compile error. A column entry is either a bare key, which is keyof R & string once R is known and plain string while it is not, or a WidgetColumn object naming and formatting a column the query produced itself. A bare key on the resource path keeps that resource's declared header and format; an object overrides them.

Prop

Type

WidgetColumn

Prop

Type

rowKey names the field that identifies a row. It defaults to the resource's own key on the resource path and to "id" for a query; a row without a value there still renders but never acts.

rowHref turns rows into links. It runs on the server, once per row, and returning null leaves that row inert. seeAll: true links the card heading to the resource's list through ctx.href; a string is used as the href verbatim.

emptyState replaces the table body when there are no rows — a string becomes the empty state's title, any other node is rendered in the card instead of the table. With none set the admin's labels.widget.empty is used. limit caps the rows: it defaults to 10 on the resource path and applies to a query's own rows only when you set it.

table({
  resource: "orders",
  label: "Recent orders",
  columns: ["id", { field: "total", label: "Total", format: "money", align: "right" }],
  rowHref: (row, ctx) => ctx.href("orders", row.id),
  seeAll: true,
  limit: 5,
});

statGroup

A row of small stats (count + label) — denser than a grid of `metric()` cards.

export function statGroup(options: StatGroupOptions): StatGroupWidget;

Prop

Type

StatItem

Prop

Type

StatValue is string | number | boolean | bigint | Date | null | undefined. Keeping the literal side explicit preserves full WidgetContext autocomplete inside async value resolvers. A Date is rendered through the admin's formatting — its locale and timeZone — like a table cell, so no resolver needs to format one by hand.

statGroup({
  label: "Account",
  span: 6,
  stats: [
    { label: "Plan", value: async (ctx) => planOf(ctx.row) },
    { label: "Joined", value: async (ctx) => joinedAt(ctx.row) },
    { label: "Open invoices", value: async (ctx) => ctx.count("invoices", { status: "open" }) },
  ],
});

Cards

Five small cards cover most of what a founder dashboard shows between its metrics and its tables. Each takes counts and labels only; the card derives the geometry, formats values through the admin's formatting — locale and currency, so one dashboard never prints two currencies for the same money — and marks a tone on the item it belongs to.

stat

One number and a label. A `metric()` without the delta and sparkline machinery.

export function stat(label: string, value: StatWidget["value"], options?: StatOptions): StatWidget;

One number with a label, without metric()'s delta and sparkline machinery. The value is a literal or a resolver.

Prop

Type

StatResult

A resolver may return a StatResult instead of a bare value, so a card computed from data carries its own tone, caption and link — the same arrangement MetricResult gives metric(). A result field wins over the matching static option, which stays as the fallback. A Date is still a value, not a result.

Prop

Type

stat("Open invoices", async (ctx) => {
  const filter = { customerId: ctx.row?.id, status: "open" };
  const value = await ctx.count("invoices", filter);
  return { value, href: ctx.href("invoices", undefined, { filter }), tone: value > 0 ? "warn" : "ok" };
});

kv

A label/value card — the facts about one thing rather than a table of many.

export function kv(options: KvOptions): KvWidget;

The facts about one thing. Each item's format accepts a ColumnFormat or a NumericFormat, resolved on the server so the card renders a finished string.

Prop

Type

KvItem

Prop

Type

bars

A ranked breakdown, sized against the largest value.

export function bars(options: BarsOptions): BarsWidget;

A ranked breakdown. The query returns labels and values; the card sizes every bar against the largest one.

Prop

Type

BarRow

Prop

Type

funnel

Stages with their share of the first step and the drop-off from the one before.

export function funnel(options: FunnelOptions): FunnelWidget;

Stages in order. Give counts only: the card computes each step's share of the first step and its drop-off from the one before.

Prop

Type

FunnelStep

Prop

Type

list

A short feed of lines, each with optional trailing detail.

export function list(options: ListOptions): ListWidget;

A short feed — latest signups, failing jobs — where a table would be too much.

Prop

Type

ListRow

Prop

Type

import { bars, funnel, kv, list, stat } from "@flowpanel/kit";

const widgets = [
  stat("Active trials", async (ctx) => countTrials(ctx.db), { hint: "ends within 7 days" }),
  kv({
    label: "This account",
    items: [
      { label: "Plan", value: "Pro" },
      { label: "Balance", value: async (ctx) => balance(ctx.db), format: "money" },
    ],
  }),
  bars({ label: "Revenue by plan", query: async (ctx) => revenueByPlan(ctx.db), format: "currency" }),
  funnel({ label: "Signup", query: async (ctx) => signupSteps(ctx.db) }),
  list({ label: "Latest signups", query: async (ctx) => latestSignups(ctx.db) }),
];

custom

Drop a fully-custom React component into a dashboard section.

export function custom<P>(Component: ComponentType<P>, props: P | ((ctx: WidgetContext) => Promise<P>), options?: CustomOptions): CustomWidget;

Renders your own component in the grid. props may be a value or a function resolved on the server per request, which is how you feed it query results.

Prop

Type

Charts

Chart builders live in @flowpanel/kit/charts, which is a separate entry point so the recharts bundle never loads for admins that use no charts.

import { areaChart, barChart, lineChart, pieChart } from "@flowpanel/kit/charts";

Each takes (label, query, options), where query returns an array of rows. The row type is inferred from query, so x / y (and category / value on a pie) must name keys of that row — a typo is a compile error. A query typed as unknown[] leaves them as plain strings.

ChartOptionsBase

Shared by area, bar and line charts.

Prop

Type

ChartBucket is "minute" | "hour" | "day" | "week" | "month" | "year" | "auto".

areaChart

export function areaChart<R = unknown>(label: string, query: (ctx: WidgetContext) => Promise<R[]>, options: AreaChartOptions<R>): AreaChartWidget;

Prop

Type

barChart

export function barChart<R = unknown>(label: string, query: (ctx: WidgetContext) => Promise<R[]>, options: BarChartOptions<R>): BarChartWidget;

Prop

Type

lineChart

export function lineChart<R = unknown>(label: string, query: (ctx: WidgetContext) => Promise<R[]>, options: LineChartOptions<R>): LineChartWidget;

Prop

Type

pieChart

export function pieChart<R = unknown>(label: string, query: (ctx: WidgetContext) => Promise<R[]>, options: PieChartOptions<R>): PieChartWidget;

Pie charts take category / value instead of x / y. The slice legend is on unless you pass showLegend: false.

Prop

Type

Series helpers

Two pure helpers for the shape a dashboard query usually needs. Bucketing itself stays in SQL, where the dialect belongs.

A range of the same length ending where `range` begins. The shift is elapsed time, not calendar days, so the comparison window for a week that crosses a DST boundary starts an hour off the wall clock.

export function previousRange(range: { from: Date; to: Date; }): { from: Date; to: Date; };

The range of the same length ending where the given one begins — the comparison period a MetricDelta is measured against. The shift is elapsed time, not calendar days: the window before a week that crosses a DST boundary starts an hour off the wall clock.

Turn sparse grouped rows into one point per day across `range`, so a chart draws a flat stretch where nothing happened instead of skipping the day. Bucketing is done in `timeZone`, which defaults to UTC. There is one bucket per calendar day the range touches, both ends included, so the `last7d` preset — `now - 7d … now` — yields eight buckets whose first is a partial day.

export function fillDays<R>(rows: R[], range: { from: Date; to: Date; }, opts: { dateField: keyof R & string; valueField: keyof R & string; timeZone?: string; }): Array<{ date: string; value: number; }>;

Turns sparse grouped rows into one point per day across the range, so a chart draws a flat stretch where nothing happened instead of skipping the day. Days are bucketed in timeZone, which defaults to UTC. There is one bucket per calendar day the range touches, both ends included, so the last7d preset — now - 7d … now — yields eight buckets and the first one is a partial day. Narrow the range, or slice the result, if a chart should show exactly seven bars.

import { fillDays, previousRange } from "@flowpanel/kit";

areaChart("Signups", async ({ db, dateRange }) => {
  const rows = await signupsByDay(db, dateRange);
  return fillDays(rows, dateRange, { dateField: "day", valueField: "total" });
}, { x: "date", y: "value" });

const before = previousRange(dateRange);

Dashboards

Register a dashboard route under `/admin<path>`.

export function dashboard(config: DashboardConfig): DashboardConfig;

Register an arbitrary page under `/admin<path>` that renders a custom React component.

export function page(config: PageConfig): PageConfig;

Both are identity functions — they exist so the object literal is checked against its type at the call site and reads as a builder alongside resource() and queue().

Prop

Type

refresh is "<n>s" or "<n>m". It re-renders the whole dashboard on that interval, pauses while the browser tab is hidden, and shows how old the numbers are next to the date picker from labels.widget.updated.

SectionConfig

Prop

Type

DateRangeConfig

Prop

Type

DateRangePreset is "today" | "yesterday" | "last7d" | "last30d" | "MTD" | "QTD" | "YTD".

ResolvedDateRange

Prop

Type

PageConfig

Prop

Type