Per-resource dashboard widgets — v3.31.44

Auto-generated Total + 30-day sparkline + Latest N per resource, scoped by DateFilter.

9 minmedium

The dashboard widgets lesson covered the dashboard shell — stat cards, charts, activity feed — wired by hand. That pattern doesn't scale: every new resource you generate deserves its own snapshot on the dashboard, but copy-pasting a stat card per resource is a chore. Per-resource dashboard widgets (shipped in v3.31.44) make that automatic: every registered resource gets a Total + 30-day sparkline tile and a Latest N preview, all scoped by the dashboard's DateFilter.

What the dashboard looks like now

Below the existing Quick Access section a new By resource band renders one row per registered resource. Each row is two widgets: a stat card on the left (Total + 30-day sparkline) and a Latest 5 list on the right (the resource's most recent rows, columns auto-picked). A single DateFilter at the top of the dashboard scopes every row in lockstep.

┌─ Dashboard ────────────────────────────────────────────────┐
│ Good morning, Alex. [Last 7 days▾] │
│ │
│ [Users] [Events 24h] [Notifications] [Resources] │
│ [── Activity chart ──] [── Severity ──] │
│ [── Recent activity feed ──] │
│ [Quick access: Users][Categories][Products][System hub] │
│ │
│ BY RESOURCE sparkline always last 30 days │
│ ┌────────────────┐ ┌──────────────────────────────────┐ │
│ │ Total Products│ │ Latest Products │ │
│ │ 42 │ │ Name: Widget A · Status: active │ │
│ │ ▁▂▃▆▇▆▇▆▇▆▇▆ │ │ Name: Widget B · Status: draft │ │
│ │ Last 30 days │ │ ... │ │
│ └────────────────┘ └──────────────────────────────────┘ │
│ (one row per resource that opts in) │
└────────────────────────────────────────────────────────────┘

How a request flows

One endpoint per resource, dispatched server-side. The web widget calls GET /api/admin/dashboard/resource-stats/:resource with whichever date params the DateFilter is active for. The handler delegates to a service function that switches on the resource name and calls a single reflective helper:

apps/api/internal/services/resource_stats_dispatch.go
func ComputeResourceStats(db *gorm.DB, resourceName string, filter ResourceStatsFilter) (*ResourceStats, error) {
if filter.LatestLimit <= 0 {
filter.LatestLimit = 10
}
switch resourceName {
case "users":
return reflectiveResourceStats(db, resourceName, &models.User{}, filter)
case "blogs":
return reflectiveResourceStats(db, resourceName, &models.Blog{}, filter)
// grit:resource-stats:dispatch
default:
return nil, fmt.Errorf("dashboard stats not registered for %q", resourceName)
}
}

The dispatcher is also the security boundary: only resources registered here are reachable. A compromised admin token can't dump arbitrary tables by guessing resource names in the URL — anything not in the switch returns a 400.

Each grit generate resource Foo injects a new case at the marker. You never edit this file by hand for a normal resource; the generator owns it. If you ever want to opt a resource out of dashboard widgets, do it on the admin side (see “Opting out” below) rather than removing the dispatch case — the case is also used by the system-wide stats endpoint.

Three pieces of data per resource

The helper computes three things in one round-trip:

  • Total — row count within the active date range. No range = all-time count.
  • 30-day sparkline — one bucket per calendar day for the last 30 days. Always 30 days regardless of the active filter (see next section for why).
  • Latest N — up to 10 newest rows in the active range. Returned via JSON round-trip so json:"-" tags are honoured — PasswordHash on User never reaches the response.
{
"data": {
"resource": "products",
"total": 42,
"series": [
{ "date": "2026-05-27", "count": 0 },
{ "date": "2026-05-28", "count": 1 },
... 30 entries ...
],
"latest": [
{ "id": "01H...", "name": "Widget A", "status": "active", "created_at": "..." },
...
]
}
}

Why the sparkline ignores the DateFilter

The first instinct is “everything should scope to the filter.” The Total + Latest list do — but the sparkline is fixed at 30 days on purpose. Under the “Today” preset a filter-scoped sparkline would collapse to a single bar with no trend information. The sparkline is meant to answer “is this resource growing?” not “what's inside the filter?” — those are different questions, and one widget shouldn't pretend to answer both.

Opting out per resource

Most resources are worth showing on the dashboard. A few aren't — internal-only tables, audit-log rows, sync scratch data. Hide a resource's widgets by setting dashboard.enabled = false in the resource definition:

apps/admin/resources/internal-note.ts
import { defineResource } from "@/lib/resource";
export const internalNoteResource = defineResource({
name: "Internal Note",
slug: "internal-notes",
endpoint: "/api/internal-notes",
icon: "FileText",
table: { /* ... */ },
form: { /* ... */ },
dashboard: { enabled: false }, // hide widgets from main dashboard
});

Default is enabled — a newly generated resource gets the widgets without any extra config. The flag is opt-out, not opt-in, because the cost of forgetting to opt in is higher than the cost of occasionally opting out: a brand-new resource not showing up on the dashboard looks like a bug, while an extra row on the dashboard is easily ignored.

The Latest table's column heuristics

The Latest widget shows at most three columns per row — it's a glance, not a full data table. The column picker prefers name, title, subject, email, status, and price in that order, then falls back to the first non-id text columns from the resource's table.columns config. Resources with no recognisable columns fall through to showing the row id in a monospace font.

Columns are picked from the resource's declared table.columns, not from whatever the API returns. If you add a new field on the Go model and want it to surface in the dashboard preview, you also need to add it to the table.columns array (or regenerate the resource so the column lands automatically).

Backward compatibility

Pre-v3.31.44 projects don't have the new dispatch file, the handler, or the marker. The generator detects this and prints a one-line warning per generate instead of failing — the dashboard widget for that resource will render “not registered” until the file is added. To upgrade an existing project:

  1. Copy apps/api/internal/services/resource_stats_dispatch.go and apps/api/internal/handlers/resource_stats.go from a fresh scaffold (or the framework repo's internal/scaffold/api_resource_stats_files.go).
  2. Register the handler + route in your routes file: resourceStatsHandler := &handlers.ResourceStatsHandler{DB: db} and admin.GET("/admin/dashboard/resource-stats/:resource", resourceStatsHandler.Get).
  3. Copy apps/admin/components/dashboard/*.tsx and the By resource section in the dashboard page.
  4. Re-run grit generate on each existing resource (or hand-add cases to ComputeResourceStats).

Quick check

You set the dashboard DateFilter to 'Last 7 days', but the Products sparkline still shows 30 bars. Bug?

Try it

In the project from the last exercise, see the new widgets end-to-end:

  1. Open the admin dashboard. Scroll past Quick Access — you should see the By resource band with one row per resource. Hover the sparkline; the tooltip should show “N new” per day.
  2. Toggle the dashboard DateFilter to "Last 7 days". The Total numbers should update; the sparkline should not. Confirm in the Network tab: resource-stats/products?created_since=7d fires per resource.
  3. In the admin, create one new Category. Wait the React Query refetch interval (60 s) or hard-refresh. Both the Total and the Latest list for Category should update.
  4. Add dashboard: { enabled: false } to one resource definition. Reload the dashboard — that resource's row should disappear from the band.

Screenshot the dashboard with the "By resource" band visible and paste it into notes.md.

What's next

The dashboard now scales linearly with your resource count — every new grit generate adds one row to the By resource band automatically. The next lesson ( web-nextjs/admin-panel/define-resource) goes deeper on the resource definition itself, including the table + form halves and how they feed back into both the resource page and the dashboard widgets you just saw.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub