Dashboard
The panel’s home page is a list of widgets declared in Go. Each one owns a data callback and a column span; the grid arranges them.
app.Dashboard(func(d *steward.Dashboard) {
d.Metric("Users", func(c *steward.Context) (any, error) {
var n int64
return n, c.Admin.DB().Model(&User{}).Count(&n).Error
}).Span(1).Hint("all time")
d.Metric("Revenue", monthRevenue).Span(1)
d.Template("Signups this week", "widgets/signups.html", weeklySignups).
Span(2).Lazy()
})Call it before Build. Without it, the built-in overview page is served.
Widgets
Metric(label, load) is a KPI tile. Whatever load returns is stringified,
so an int, a string, or a fmt.Stringer all work. Hint adds a line of
secondary text under the value.
Template(title, tmpl, load) renders a template of your own, receiving
load’s return value as its data. Pass a nil load for a static tile. The
template resolves through the normal overlay, so it can live in your project
rather than the framework.
Span
Span(n) sets how many of the grid’s three columns a tile occupies, clamped
to 1–3. Metrics default to 1, templates to 3. The grid narrows to two columns
on tablets and one on phones, and spans clamp with it, so a Span(3) tile
never overflows a narrow screen.
Lazy widgets
Lazy() defers the data callback to a follow-up request. The tile renders a
skeleton, then fetches {Prefix}/_widget/{index} and replaces itself with the
result.
Use it for anything slow. Without it, every callback runs before the page is written, so one expensive aggregate delays the whole dashboard; with it, the page paints immediately and each tile fills in as its query finishes.
The fragment route runs through the same middleware chain as everything else, so authentication and permissions apply to it normally.
Charts
Chart(title, load) draws with Basecoat’s Chart component, so charts inherit
the panel’s --chart-1…5 theme colours, external tooltips, and legend
styling rather than introducing a second visual language.
d.Chart("Visitors", func(c *steward.Context) (*steward.ChartData, error) {
return &steward.ChartData{
Type: steward.ChartBar,
Labels: []string{"Jan", "Feb", "Mar"},
Series: []steward.ChartSeries{
{Label: "Desktop", Values: []float64{186, 305, 237}},
{Label: "Mobile", Values: []float64{80, 200, 120}},
},
Legend: true,
}, nil
}).Span(2)The API is column-oriented on purpose: you give Labels once and one
ChartSeries per line or bar set, so nothing in your code deals in
map[string]any. Steward converts that to the row-shaped payload the
component expects.
Type is ChartBar (the default), ChartLine, ChartPie, ChartDoughnut,
or ChartRadar. Stacked stacks bar series on both axes. Per series, Color
is any CSS colour and defaults to the next theme palette entry, and Fill
shades the area under a line.
A series whose Values length does not match Labels is a bug, so the tile
reports it instead of emitting a broken chart.
Setup
Chart.js is Basecoat’s peer dependency and is not redistributed with Steward, so fetch it once:
make vendor-chart # downloads Chart.js, stages the chart runtime
make assets # picks up the chart component CSSThe runtime is served per page, not bundled into app.js — Chart.js is
larger than the whole current bundle and most pages have no chart, so the
scripts load only on a dashboard that declares one.
Until those commands run, a chart tile renders a note explaining what is missing rather than a blank box.
Aggregates
Widgets need counts and sums, so those come with the framework rather than as hand-written SQL in every callback.
d.Metric("Users", func(c *steward.Context) (any, error) {
return steward.Count[User](c)
})
d.Metric("Revenue", func(c *steward.Context) (any, error) {
return steward.Sum[Order](c, "Total")
})
d.Chart("Signups", func(c *steward.Context) (*steward.ChartData, error) {
rows, err := steward.PeriodCount[User](c, "CreatedAt", steward.PeriodMonth)
if err != nil {
return nil, err
}
return rows.Chart(steward.ChartLine, "Signups"), nil
})
d.Chart("By status", func(c *steward.Context) (*steward.ChartData, error) {
rows, err := steward.GroupCount[Order](c, "Status", 5)
if err != nil {
return nil, err
}
return rows.Chart(steward.ChartDoughnut, "Orders"), nil
})| Helper | Returns |
|---|---|
Count[T](c, conds…) | total row count |
Sum[T](c, path, conds…) | total of a numeric field |
GroupCount[T](c, path, limit, conds…) | counts per distinct value, largest first |
PeriodCount[T](c, path, period, conds…) | counts per time bucket, oldest first |
PeriodSum[T](c, sumPath, datePath, period, conds…) | sums per time bucket |
Aggregate[T](c, *AggQuery) | anything the above don’t cover |
conds are the same Cond values a grid filter uses, so
steward.Cond{Path: "Status", Op: steward.OpEq, Val: "paid"} narrows any
helper.
AggRows carries the result. .Chart(type, label) turns it straight into a
single-series chart and .Total() sums it, so the common paths need no
reshaping.
Periods
PeriodDay, PeriodMonth, and PeriodYear bucket a date column. Keys come
back as 2026-01-15, 2026-01, and 2026 — formats that sort
lexicographically in chronological order, which is why buckets need no
re-sorting.
Two things to know. Empty buckets are absent, not zero: the query only sees rows that exist, so fill gaps yourself if the axis must be continuous. And there is no week bucket — the supported engines disagree on where a week starts and on week numbering, so rather than ship three subtly different definitions it is left out for now.
Which repository runs the query
Aggregates go through the registered resource’s Repository[T], so a custom
repository’s query refinements and preloads still apply. For a model with no
resource, a GORM repository is built on demand — so Count[AuditEntry](c)
works whether or not the model is in the panel.
A custom repository that does not implement the optional Aggregator
interface reports that plainly rather than silently querying around it.
Aggregator is separate from Repository on purpose: folding it in would have
broken every existing implementation.
Failures are isolated
If a callback returns an error, that tile renders “Could not load.” and the rest of the page still works — one broken query does not blank the dashboard. The underlying error goes to the logger, not the page, so internal detail is not exposed to whoever is looking at it.
Layout beyond widgets
There is no Row/Column/Layout builder, deliberately. A widget declares
what it shows and how wide it is; arranging anything more elaborate is a
job for a template, where the overlay lets you override
pages/widgets.html — or any other view — by dropping a file in your project.
Column spans are applied through a data-span attribute and plain CSS rather
than Tailwind utilities, because the span is chosen at runtime and the shipped
CSS bundle carries no col-span classes. If you would rather use utilities,
override pages/widgets.html, add the classes to frontend/src/safelist.txt,
and rebuild with make assets.