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