Dashboard & widgets
The admin panel homepage hosts a grid of widgets, small cards that surface live data from your database or runtime. Five preset widgets are enabled by default, and you can append custom widgets for anything else you want at a glance.
routes: createRouter({
...admin({
database: db,
dashboard: {
presets: {
totals: { only: ["users", "rooms"] },
recentUsers: { limit: 10 },
health: false, // disable
},
widgets: [{
title: "Active events",
icon: "thunderbolt",
render: "kpi",
data: async () => ({ live: 12, queued: 3 }),
}],
},
}),
}),Preset widgets
| Preset | Default render | Surfaces |
|---|---|---|
totals | KPI | Row counts per registered table, a useful “DB is healthy” snapshot. |
recentUsers | Table | Newest user registrations with deep-link to each user’s show page. |
liveRooms | Table | Currently-active rooms from the matchmaker driver with deep-link to the room inspector. |
health | KPI | Database connectivity check. Reports status and query latency. |
segments | KPI | Player segment definitions and their current sizes. |
All presets are enabled by default. Configure each via dashboard.presets.<name>:
| Value | Effect |
|---|---|
omitted or true | Enabled with defaults. |
false | Disabled: the preset is not rendered. |
| Options object | Enabled with customization. |
Preset options
Every preset shares three base options (title, icon, span) and adds preset-specific knobs.
totals
totals: {
title?: string;
icon?: AdminIconName;
span?: number; // 24-column grid span (antd convention)
only?: string[]; // restrict to a subset of table names
}totals: { only: ["users", "rooms", "guilds"] }recentUsers
recentUsers: {
title?: string;
icon?: AdminIconName;
span?: number;
limit?: number; // default 5
columns?: UserColumnName[]; // subset of users columns, in order
}recentUsers: { limit: 10, columns: ["id", "email", "createdAt"] }liveRooms
liveRooms: {
title?: string;
icon?: AdminIconName;
span?: number;
limit?: number; // default 10
columns?: LiveRoomColumnName[]; // default: roomId, name, clients, maxClients, locked, createdAt
}liveRooms: { limit: 20, columns: ["roomId", "name", "clients", "elapsedTime"] }health / segments
Both accept only the base options (title, icon, span). No preset-specific knobs.
Custom widgets
Append your own widgets via dashboard.widgets. Each widget runs server-side on the dashboard request and sends its output to the client:
widgets: [{
title: "Active events", // also the basis for the auto-derived id
icon: "thunderbolt", // AdminIconName
render: "kpi", // 'kpi' | 'table' | 'list' | 'json' (default 'json')
span: 12, // 24-column grid span (half width)
refreshIntervalMs: 5000, // poll for live updates
data: async ({ database, userId }) => ({ live: 12, queued: 3 }),
}]Widget shape
interface DashboardWidget {
id?: string; // defaults to slugify(title)
title: string;
icon?: AdminIconName;
render?: WidgetRender; // 'kpi' | 'table' | 'list' | 'json' (default 'json')
span?: number; // 24-column grid; default per render type
refreshIntervalMs?: number;
data: (ctx: { database: GameDatabase; userId: string }) => Promise<unknown>;
}| Field | Notes |
|---|---|
id | Stable identifier for data-testid selectors. Defaults to slugify(title) (e.g. "Live events" → "live-events"). Must not collide with a preset id. |
data | Server-side resolver. Receives the resolved GameDatabase and the requesting admin’s userId. Errors are caught and surfaced to the panel as { error }. |
refreshIntervalMs | When set, the client polls GET /admin-api/_dashboard/<id> at this cadence and refreshes the card in place (no full dashboard reload). |
span | 24-column grid span (antd convention). Defaults: kpi/table/json full width (24), list half (12). The grid collapses to a single column on mobile widths regardless of span. |
Render modes
The render field tells the panel how to interpret data’s return value.
kpi
data returns Record<string, number | string>. The panel renders each entry as a label/value card.
{
title: "Player counts",
render: "kpi",
data: async ({ database }) => {
const [total, online] = await Promise.all([
database.drizzle.$count(database.tables.users),
database.drizzle.$count(database.tables.users, eq(database.tables.users.online, true)),
]);
return { total, online };
},
}table
data returns TableWidgetData:
interface TableWidgetData {
columns: string[]; // header order; auto-humanized for display
rows: Array<Record<string, any>>;
linkTo?: {
resource: string; // admin resource name to link to
idColumn?: string; // row column with the FK value; default 'id'
};
}When linkTo is set, the panel makes each row clickable and navigates to that resource’s show page. This behavior is useful for “recent X” widgets that should deep-link back to the records.
{
title: "Top spenders",
render: "table",
data: async ({ database }) => ({
columns: ["id", "displayName", "totalSpent"],
rows: await database.drizzle.select(...).from(...).orderBy(...).limit(10),
linkTo: { resource: "users" },
}),
}list
data returns Array<{ title: string; description?: string }>. The panel renders a simple labeled list, good for status feeds or named items without tabular structure.
{
title: "Recent events",
render: "list",
data: async () => [
{ title: "Server deployed", description: "v1.4.2, 12 minutes ago" },
{ title: "Hotfix applied", description: "v1.4.1, 2 hours ago" },
],
}json
data returns anything. The panel renders it as a collapsible JSON viewer. Default render mode when render is omitted (useful for ad-hoc diagnostics).
{
title: "Cache stats",
render: "json",
data: async () => cache.stats(),
}Refresh and polling
By default, widgets resolve once when the dashboard loads. For live data, set refreshIntervalMs to enable client-side polling of GET /admin-api/_dashboard/<id>. Only the affected card re-renders, which is useful for a live rooms count or active sessions card without paying for a full dashboard refresh.
{
title: "Active sessions",
render: "kpi",
refreshIntervalMs: 3000, // every 3s
data: async ({ database }) => ({
sessions: await database.drizzle.$count(database.tables.users, eq(database.tables.users.online, true)),
}),
}Next steps
- Resources & CRUD: for the underlying tables that widgets pull from.
- Live rooms: the room inspector that
liveRoomsand your custom widgets can deep-link into.