Admin PanelDashboard & widgets

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.

src/app.config.ts
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

PresetDefault renderSurfaces
totalsKPIRow counts per registered table, a useful “DB is healthy” snapshot.
recentUsersTableNewest user registrations with deep-link to each user’s show page.
liveRoomsTableCurrently-active rooms from the matchmaker driver with deep-link to the room inspector.
healthKPIDatabase connectivity check. Reports status and query latency.
segmentsKPIPlayer segment definitions and their current sizes.

All presets are enabled by default. Configure each via dashboard.presets.<name>:

ValueEffect
omitted or trueEnabled with defaults.
falseDisabled: the preset is not rendered.
Options objectEnabled 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>;
}
FieldNotes
idStable identifier for data-testid selectors. Defaults to slugify(title) (e.g. "Live events""live-events"). Must not collide with a preset id.
dataServer-side resolver. Receives the resolved GameDatabase and the requesting admin’s userId. Errors are caught and surfaced to the panel as { error }.
refreshIntervalMsWhen set, the client polls GET /admin-api/_dashboard/<id> at this cadence and refreshes the card in place (no full dashboard reload).
span24-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 liveRooms and your custom widgets can deep-link into.