Dashboard widgets
KPI cards, line + donut + bar charts, tables, feed widgets, declared in Rust, rendered via ApexCharts, grouped into named sections.
The admin dashboard is more than a list of tables. Cards summarise the day, charts trace trends, tables surface recent activity, donuts show distribution. Each one is a Widget: declared in Rust, registered against AdminPlugin, and rendered through ApexCharts (charts) or minijinja (cards / tables / feed).
The shape is the same across every kind: pick a WidgetKind, declare a default_span (grid cells wide by rows tall), and hand a WidgetDataFn async closure that returns a typed payload.
A widget in 10 lines
use umbral_admin::{ CardPayload, Span, Widget, WidgetDataFn, WidgetKind, WidgetPayload,}; pub fn shop_customers_card() -> Widget { Widget { key: "shop_customers", title: "Customers".to_string(), kind: WidgetKind::Card, default_span: Span { cols: 3, rows: 2 }, permission: None, default_period: None, data: WidgetDataFn::new(|_user| async move { let total = Customer::objects().count().await.unwrap_or(0); WidgetPayload::Card( CardPayload::new(umbral_admin::humanize_number(total as f64)) .unit("total") .icon("users") .subtitle("All time"), ) }), }}Then register it in the dashboard:
.plugin( AdminPlugin::default() .dashboard_section( umbral_admin::WidgetSection::new("Overview") .widget(shop_customers_card()), ))Widget kinds
| Kind | When to reach for it | Payload |
|---|---|---|
Card | KPI tiles: sales total, order count, AOV. Icon + headline value + optional growth-vs-previous-period + sparkline trail. | CardPayload |
Line | Time series. Single series (one metric over time) or multi (overlay 2-5 metrics on the same x-axis). | LinePayload |
Donut | Categorical breakdown: 3-6 buckets (status mix, top regions). Center label shows the total. | DonutPayload |
Bar | Categorical counts that exceed donut's 6-slice readability. | BarPayload |
Radial | Progress toward a goal: one or more 0-100% gauges (quota attainment, capacity used, completion rate). One big ring, or 2-4 to compare ratios. | RadialPayload |
Heatmap | Activity-by-time / cohort patterns: a 2-D grid of cells colored by magnitude (day by hour signups, retention by cohort). | HeatmapPayload |
Progress | "Top N by metric": a ranked list of labeled horizontal bars (revenue by product, traffic by source). Pure HTML, no chart lib. | ProgressPayload |
Table | "Recent orders / posts / comments." Three or four columns, 5-10 rows, optional "View all" link. | TablePayload |
Feed | Chronological activity: recent signups, comments, audit log entries. Avatar + title + timestamp per item. | FeedPayload |
Kpi | The original simple stat card. Superseded by Card for most cases; kept for backwards compat. | KpiPayload |
Span: sizing widgets on the grid
The dashboard is a 12-column grid; rows auto-size to 120px each. Span { cols, rows } says how many cells a widget occupies.
default_span: Span { cols: 3, rows: 2 }, // 1/4 width, 240px tall, standard carddefault_span: Span { cols: 8, rows: 3 }, // 2/3 width, 360px tall, line chartdefault_span: Span { cols: 12, rows: 4 }, // full width, 480px tall, big tableA user override (via Widget::with_span(cols, rows)) wins over default_span at registration time:
umbral_admin::builtin_total_models_widget().with_span(8, 2)Sections: grouping widgets
WidgetSection is a named group with a title + optional subtitle + its own widget grid:
.dashboard_section( WidgetSection::new("Sales overview") .subtitle("Daily KPIs across the storefront") .widget(shop_total_sales_widget()) .widget(shop_orders_widget()) .widget(shop_customers_widget()) .widget(shop_avg_order_value_widget()),).dashboard_section( WidgetSection::new("Trends") .subtitle("Daily sales + activity + status mix") .widget(shop_daily_sales_chart()) .widget(shop_activity_chart()) .widget(shop_order_status_donut()),)Sections render top-to-bottom in registration order with a heading band between each. Past roughly 3 widgets per section, sections beat a single mega-grid for visual scanability.
Card
The everyday KPI tile: icon top-left, optional growth-vs-prior pill top-right, label, big humanized value, subtitle, optional sparkline trail.
data: WidgetDataFn::new(|_user| async move { let now = chrono::Utc::now(); let month_ago = now - chrono::Duration::days(30); let two_months_ago = now - chrono::Duration::days(60); let current = sales_between(month_ago, now).await; let previous = sales_between(two_months_ago, month_ago).await; let trail = daily_sales_trail(30).await; WidgetPayload::Card( CardPayload::new(umbral_admin::humanize_number(current)) .unit("USD") .icon("dollar-sign") // lucide icon name .subtitle("Last 30 days") .growth(current, previous) // computes delta_percent .delta_label("vs prior 30d".to_string()) .sparkline(trail), // Vec<f64> drawn as area chart )}),humanize_number(12_438.0) returns "12.4K", giving the headline value room when the column gets narrow. Use format_thousands(12438.0) for "12,438" on tiles where the precise figure matters (AOV, invoice totals).
The sparkline goes through ApexCharts (not hand-rolled SVG). Pass any Vec<f64> and the renderer mounts a smoothed area chart in the card's trail area; keep it to 7-30 points. Span { cols: 3, rows: 2 } gives it 240px to fit icon row + body + trail without clipping.
Line
Two flavours: single-series (one metric over time, gradient area fill) and multi-series (2-5 metrics overlaid, solid colors). The same LinePayload shape covers both; the renderer picks the style based on how many series the payload carries.
data: WidgetDataFn::with_params(|_user, params| async move { let days = params.period_days().unwrap_or(30); let now = chrono::Utc::now(); let trail = daily_sales_trail(days).await; let points: Vec<ChartPoint> = trail .into_iter() .enumerate() .map(|(i, y)| { let back = (days - 1 - i as i64).max(0); let day = now - chrono::Duration::days(back); ChartPoint { x: day.format("%b %-d").to_string(), y } }) .collect(); WidgetPayload::Line(LinePayload { series: vec![Series { name: "USD".to_string(), points }], x_type: "date".to_string(), })}),For multi-series, pass two or more Series. The renderer drops the heavy gradient fill so overlaid lines stay readable and surfaces a legend strip in the header:
WidgetPayload::Line(LinePayload { series: vec![ Series { name: "Orders".into(), points: orders_points }, Series { name: "Items sold".into(), points: items_points }, Series { name: "New customers".into(), points: customers_points }, ], x_type: "date".to_string(),})Period chips
Line widgets can carry a [7d] [30d] [90d] chip strip in the header. Clicking re-fetches the widget data with ?period=X. The data closure reads via params.period_days():
data: WidgetDataFn::with_params(|_user, params| async move { let days = params.period_days().unwrap_or(30); // "7d" → 7 ...}),Pre-select the active chip on first load via .with_default_period("7d"):
.widget(shop_daily_sales_chart().with_default_period("7d"))When the URL has no ?period=, the handler stamps params.period = Some("7d") before calling the data fn, so the template renders the matching chip highlighted and the data closure sees the same period. One source of truth: the URL.
Donut
Categorical breakdown summing to 100%. Best for 3-6 slices; past that, switch to Bar.
Compute the per-bucket counts in SQL with a grouped annotate, one row per status, rather than pulling every order into memory to tally client-side:
use umbral::orm::Aggregate; data: WidgetDataFn::new(|_user| async move { // GROUP BY status with COUNT(*) per group: one row // per status, the count aliased as "count". let rows = Order::objects() .annotate(&["status"], &[("count", Aggregate::count())]) .await .unwrap_or_default(); let mut counts: HashMap<String, f64> = HashMap::new(); for row in &rows { if let (Some(label), Some(n)) = ( row.get("status").and_then(|v| v.as_str()), row.get("count").and_then(|v| v.as_f64()), ) { counts.insert(label.to_string(), n); } } // Canonical lifecycle order: the donut reads as a flow, // not random alphabetical buckets. let order = ["pending", "paid", "shipped", "delivered", "cancelled"]; let mut pairs: Vec<(String, f64)> = Vec::new(); for k in order { if let Some(v) = counts.remove(k) { pairs.push((k.to_string(), v)); } } WidgetPayload::Donut(DonutPayload::from_pairs(pairs))}),annotate(&["status"], &[("count", Aggregate::count())]) returns [{ "status": "paid", "count": 12 }, ...]. The count happens in the database, so the widget scales to millions of orders without loading them. The canonical-order reshuffle stays client-side (it's presentation, not aggregation). See Aggregates and annotate for Sum/Avg/multi-column grouping.
DonutPayload::from_pairs([(label, value), ...]) is the short constructor. For explicit colors per slice, build DonutSlice { label, value, color: Some("#34d399") } and pass to DonutPayload::new(slices).
The chart center shows the total automatically. Legend renders to the right (or below on narrow viewports).
Bar
Same Series shape as Line, rendered as vertical bars. Use for low-cardinality categorical counts that exceed donut's 6-slice readability.
WidgetPayload::Bar(BarPayload { series: vec![Series { name: "Models".into(), points: counts.into_iter() .map(|(plugin, n)| ChartPoint { x: plugin, y: n as f64 }) .collect(), }], x_type: "category".to_string(),})Radial
A gauge of one or more 0-100% tracks, rendered as concentric arcs (ApexCharts radialBar). Reach for it when a metric is a ratio against a target (quota attainment, capacity used, completion rate, SLA) where a card's bare number hides how close you are to "done".
The everyday case is a single ring built straight from the current value and its target; the percent lands in the centre:
use umbral_admin::widgets::{RadialPayload, WidgetPayload}; // 73% of the monthly sales goal.WidgetPayload::Radial(RadialPayload::goal("Monthly goal", sales, target))goal(label, current, target) computes current / target * 100 and clamps to [0, 100] (a non-positive target reads as 0%). For a percentage you already have, use RadialPayload::single(label, percent).
Pass two to four tracks to compare related ratios on one tile:
// Per-plan conversion, compared at a glance.WidgetPayload::Radial(RadialPayload::from_pairs([ ("Free", 8.0), ("Pro", 21.5), ("Team", 34.0),]))Every percent is clamped to [0, 100] (non-finite to 0), so an overshoot fills the ring rather than overrunning it. For an explicit arc color, build RadialTrack { label, value, color: Some("#34d399") } and pass to RadialPayload::new(tracks).
Heatmap
A 2-D grid of cells colored by magnitude (ApexCharts heatmap). Reach for it when the story is when or where something concentrates: signups by weekday and hour, retention by cohort and week, load by region and day.
The dense-grid constructor takes row labels, the shared column labels, and a values[row][col] matrix:
use umbral_admin::widgets::{HeatmapPayload, WidgetPayload}; WidgetPayload::Heatmap(HeatmapPayload::from_grid( ["Mon", "Tue", "Wed", "Thu", "Fri"], ["00-06", "06-12", "12-18", "18-24"], signups_by_day_and_bucket, // Vec<Vec<f64>>, one inner vec per row))from_grid is rectangular by construction: a short value row is padded with 0.0 and any extra values past the column labels are dropped, so ragged input never desyncs the grid. For full control build HeatmapRow { name, cells: vec![HeatmapCell { x, y }] } and pass to HeatmapPayload::new(rows).
Progress
A ranked list of labeled horizontal bars, "top N by metric": revenue by product, traffic by source, completion per category. It's the readable middle ground between a Donut (proportions, but circular and capped at roughly 6 slices) and a Table (exact numbers, but no visual weight). Rendered as pure HTML; no chart library mounts.
By default each bar is sized relative to the largest value, so the top item fills the bar:
use umbral_admin::widgets::{ProgressPayload, WidgetPayload}; WidgetPayload::Progress(ProgressPayload::from_pairs([ ("Pro", 48_200.0), ("Team", 31_000.0), ("Free", 9_400.0),]))When each row should be measured against a fixed target instead (a per-row "% of goal"), use from_pairs_of(pairs, target). Values over the target clamp to a full bar, and a non-positive target falls back to sizing against the max:
WidgetPayload::Progress(ProgressPayload::from_pairs_of( [("Web", 82.0), ("Mobile", 57.0)], 100.0, // 100-task target))display (the right-aligned value) is thousands-grouped for you; percent is the clamped [0, 100] bar width. For explicit per-bar colors or pre-formatted values, build ProgressItem { label, display, percent, color } and pass to ProgressPayload::new(items).
Table
Three to four columns, 5-10 rows, optional "View all" link in the header.
data: WidgetDataFn::new(|_user| async move { let columns = vec![ TableColumn { key: "number".into(), label: "Order".into() }, TableColumn { key: "status".into(), label: "Status".into() }, TableColumn { key: "grand_total".into(), label: "Total".into() }, ]; let rows: Vec<serde_json::Value> = Order::objects() .order_by(order::PLACED_AT.desc()) .limit(5) .fetch().await.unwrap_or_default() .into_iter() .map(|o| serde_json::json!({ "number": o.number, "status": format!("{:?}", o.status).to_lowercase(), "grand_total": format!("${}", o.grand_total), })) .collect(); WidgetPayload::Table( TablePayload::new(columns, rows) .view_all_for::<Order>(), // auto-resolves to {admin_base}/order/ )}),The view_all_for::<T>() helper takes any T: Model and resolves to the admin changelist URL for that table ({admin_base}/<table>/). No string-typing, rename-safe: a #[umbral(table = "...")] change to the model propagates without chasing strings in the widget code. For a non-managed target, set view_all_url(...) explicitly.
Feed
Chronological activity stream. Each FeedItem reads as a subject-verb-object sentence with a timestamp: actor did verb to object at some time. object_link makes the object clickable.
WidgetPayload::Feed(FeedPayload { items: AuthUser::objects() .order_by(auth_user::DATE_JOINED.desc()) .limit(5) .fetch().await.unwrap_or_default() .into_iter() .map(|u| FeedItem { actor: u.username.clone(), verb: "signed".into(), object: "up".into(), object_link: None, at: u.date_joined.to_rfc3339(), }) .collect(), view_all_url: Some("/admin/auth_user/".into()),})The at value is an RFC 3339 timestamp string; the feed template renders it through the admin's naturaltime filter ("2 hours ago") with the absolute date in a hover tooltip.
WidgetDataFn: closure shapes
Two constructors:
// Per-request params dropped: use for widgets that always render// the same shape (count tiles, registry-size cards, etc.)WidgetDataFn::new(|user| async move { ...}) // Per-request params passed in: use for widgets that read query// state like period chips, status filters, etc.WidgetDataFn::with_params(|user, params| async move { let days = params.period_days().unwrap_or(30); ...})WidgetParams carries period: Option<String>, start: Option<String>, end: Option<String>, plus a catch-all raw: HashMap<String, String> for widget-specific query params.
Built-ins
Two builtins ship with umbral-admin:
umbral_admin::builtin_total_models_widget(): a bar chart of model counts grouped by plugin.umbral_admin::builtin_recent_users_widget(): a feed of the 5 most-recent signups.
Both are regular Widget values you opt into via .widget(...) on a section. Register them where you want, override with_span(cols, rows) for a non-default size, and skip them if your dashboard doesn't need them.
.dashboard_section( WidgetSection::new("System") .subtitle("Framework-wide health") .widget(umbral_admin::builtin_total_models_widget().with_span(8, 2)) .widget(umbral_admin::builtin_recent_users_widget().with_span(4, 2)),)Model cards section
Below the widget sections, the dashboard renders one card per registered model, quick access to every CRUD table. Configure via:
AdminPlugin::default() .dashboard_models_title("Data") .dashboard_models_subtitle("Jump straight to a managed table") .dashboard_models_only(&umbral::models![ // type-safe table list ecommerce::models::Product, ecommerce::models::Order, ecommerce::models::Customer, content::models::Post, ])umbral::models![T, U, V] expands to [T::TABLE, U::TABLE, V::TABLE], rename-safe (a #[umbral(table = "...")] change propagates) and type-checked at compile time.
Three modes:
.dashboard_models_only(&[...]): show exactly these tables.dashboard_models_hidden(): hide the section entirely- (default): show every registered model card
How it wires together
The runtime contract:
AdminPlugin::dashboard_section(...)collectsWidgetSectionvalues during App build.- The dashboard handler (
GET /admin/) renders the section frame for each section + a placeholder skeleton per widget. - Each skeleton kicks off an HTMX
hx-get="/admin/api/dashboard/widgets/<key>/data"request. - The widget-data handler calls the widget's
data: WidgetDataFn, which returns aWidgetPayload. - The handler renders the per-kind macro (
card.html/line.html/donut.html/ etc.) and HTMX swaps it into the placeholder. - ApexCharts mounts on
[data-umbral-chart="<kind>"]divs that the macros emit.
The skeleton-first shape means the dashboard paints fast: every widget loads in parallel after the initial HTML lands.
Related
- Plugin trait:
AdminPluginIS a plugin; widgets are how it exposes a customisation surface. - Admin plugin: the broader admin features (CRUD, filters, search, permissions).
- user in templates: widget headers can branch on
user.is_stafffor staff-only widgets.