Skip to content
Dashboard

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 CSS

The 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.

Basecoat’s Chart component is documented as a beta API — option names and generated markup may change before its 1.0. Steward pins the shape it sends, so a breaking change there surfaces here as a version bump rather than in your code.

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
})
HelperReturns
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.