umbral_admin/widgets.rs
1//! Dashboard widget system for umbral-admin.
2//!
3//! Plugins register widgets via `AdminPlugin::register_widget`. Each widget
4//! has a `key`, `title`, `kind`, `default_span`, optional `permission`, and
5//! an async data function. The admin dashboard renders a 12-column grid of
6//! the user's saved layout (defaulting to all permitted widgets).
7//!
8//! ## Registration shape
9//!
10//! ```rust,ignore
11//! admin.register_widget(Widget {
12//! key: "umbral_total_models",
13//! title: "Total Models".to_string(),
14//! kind: WidgetKind::Kpi,
15//! default_span: Span { cols: 3, rows: 1 },
16//! permission: None,
17//! data: WidgetDataFn::new(|_user| async move {
18//! WidgetPayload::Kpi(KpiPayload {
19//! value: "42".to_string(),
20//! unit: None,
21//! delta: None,
22//! sparkline: None,
23//! })
24//! }),
25//! });
26//! ```
27//!
28//! ## Endpoint contract
29//!
30//! - `GET /admin/api/dashboard/catalog` — `[{key, title, kind, default_span}]`
31//! - `GET /admin/api/dashboard/layout` — user's saved layout or default
32//! - `PUT /admin/api/dashboard/layout` — save user's layout
33//! - `GET /admin/api/dashboard/widgets/{key}/data` — typed payload JSON
34
35use std::future::Future;
36use std::pin::Pin;
37use std::sync::Arc;
38
39use serde::{Deserialize, Serialize};
40use umbral_auth::AuthUser;
41
42// =========================================================================
43// Span
44// =========================================================================
45
46/// Grid span in the 12-column dashboard grid.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Span {
49 /// Number of columns to occupy (1–12).
50 pub cols: u8,
51 /// Number of rows to occupy (1–N).
52 pub rows: u8,
53}
54
55impl Default for Span {
56 fn default() -> Self {
57 Self { cols: 3, rows: 1 }
58 }
59}
60
61// =========================================================================
62// WidgetKind
63// =========================================================================
64
65/// The visual kind of a dashboard widget. Drives how the payload is rendered.
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
67#[serde(rename_all = "lowercase")]
68pub enum WidgetKind {
69 /// Simple single-value KPI (legacy, kept for backwards compat).
70 Kpi,
71 /// Shop-style summary card: title + icon + small unit / subtitle
72 /// + large humanized value + optional growth-vs-previous-period.
73 /// The everyday "Total sales / Orders / Customers" tile.
74 Card,
75 Line,
76 Bar,
77 /// Donut chart — labeled slices summing to 100%. Best for
78 /// low-cardinality breakdowns (status distribution, top N
79 /// regions, mode share) where a bar chart's axes are
80 /// overkill. 3-6 slices reads cleanly; past that switch
81 /// to a bar.
82 Donut,
83 /// Radial gauge — one or more 0–100% tracks rendered as
84 /// concentric arcs (ApexCharts `radialBar`). The everyday
85 /// "progress toward a goal" tile: quota attainment, capacity
86 /// used, completion rate, SLA. A single track reads as one big
87 /// ring with the percent in the centre; 2–4 tracks compare
88 /// related ratios (e.g. per-plan conversion).
89 Radial,
90 /// Heatmap — a 2-D grid of cells colored by magnitude (ApexCharts
91 /// `heatmap`). Best for "activity by time" patterns: day-of-week ×
92 /// hour-of-day signups, cohort retention, per-region load. Each
93 /// row is a series; each cell an `(x, value)` pair.
94 Heatmap,
95 /// Progress bars — a ranked list of labeled horizontal bars, each
96 /// filled relative to the largest value (or an explicit target).
97 /// The "top N by metric" tile: revenue by product, traffic by
98 /// source, completion per category. Pure HTML; no chart library.
99 Progress,
100 Table,
101 Feed,
102}
103
104impl WidgetKind {
105 pub fn as_str(&self) -> &'static str {
106 match self {
107 WidgetKind::Kpi => "kpi",
108 WidgetKind::Card => "card",
109 WidgetKind::Line => "line",
110 WidgetKind::Bar => "bar",
111 WidgetKind::Donut => "donut",
112 WidgetKind::Radial => "radial",
113 WidgetKind::Heatmap => "heatmap",
114 WidgetKind::Progress => "progress",
115 WidgetKind::Table => "table",
116 WidgetKind::Feed => "feed",
117 }
118 }
119}
120
121// =========================================================================
122// Typed payloads
123// =========================================================================
124
125/// KPI card payload.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct KpiPayload {
128 /// The primary metric value (displayed large).
129 pub value: String,
130 /// Optional unit label, e.g. "rows" or "MB".
131 pub unit: Option<String>,
132 /// Optional delta percentage; positive = up, negative = down.
133 pub delta: Option<f64>,
134 /// Optional sparkline data points (values only; x is implicit index).
135 pub sparkline: Option<Vec<f64>>,
136}
137
138// =========================================================================
139// Card payload — the everyday "summary tile" widget.
140// =========================================================================
141
142/// Summary card payload. Renders as:
143///
144/// ```text
145/// ┌──────────────────────────────────────────┐
146/// │ TITLE [icon] │ ← title row (from Widget)
147/// │ │
148/// │ USD 12,438.20 │ ← unit (sm, left) + value (lg, right)
149/// │ │
150/// │ This month ↑ 12.3% vs last month │ ← subtitle + growth
151/// └──────────────────────────────────────────┘
152/// ```
153///
154/// Build with [`CardPayload::new`] + the chained setters; pass to
155/// [`WidgetPayload::Card`].
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct CardPayload {
158 /// Formatted primary value, e.g. "12,438.20" or "12.4K". Use
159 /// [`humanize_number`] for the K/M/B/T compaction.
160 pub value: String,
161 /// Optional unit / context label shown on the left side of the
162 /// value row, e.g. "USD", "rows", "today".
163 pub unit: Option<String>,
164 /// Optional Lucide icon name (e.g. "dollar-sign", "shopping-cart").
165 /// Rendered via the data-lucide attribute the wrapper already
166 /// initializes.
167 pub icon: Option<String>,
168 /// Optional caption below the value, e.g. "This month".
169 pub subtitle: Option<String>,
170 /// Percentage delta vs. the previous period, signed:
171 /// `+12.3` = up 12.3%, `-4.1` = down 4.1%. The renderer picks
172 /// the arrow + color from the sign.
173 pub delta_percent: Option<f64>,
174 /// Optional comparison label, e.g. "vs last month".
175 pub delta_label: Option<String>,
176 /// Optional trend trail — a flat series of N points the
177 /// renderer plots as a fade-right sparkline under the value.
178 /// X is implicit (evenly spaced); Y autoscales between
179 /// min/max. Pair with `growth(...)` so the pill matches the
180 /// trail visually. Keep the series small (7–30 points) —
181 /// anything denser turns into noise at sparkline scale.
182 pub sparkline: Option<Vec<f64>>,
183}
184
185impl CardPayload {
186 /// New card with just a primary value. Caller picks the format
187 /// — strings stay as-is, numbers should be pre-humanized with
188 /// [`humanize_number`] / [`format_thousands`].
189 pub fn new(value: impl Into<String>) -> Self {
190 Self {
191 value: value.into(),
192 unit: None,
193 icon: None,
194 subtitle: None,
195 delta_percent: None,
196 delta_label: None,
197 sparkline: None,
198 }
199 }
200
201 pub fn unit(mut self, unit: impl Into<String>) -> Self {
202 self.unit = Some(unit.into());
203 self
204 }
205
206 pub fn icon(mut self, icon: impl Into<String>) -> Self {
207 self.icon = Some(icon.into());
208 self
209 }
210
211 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
212 self.subtitle = Some(subtitle.into());
213 self
214 }
215
216 /// Compute the delta automatically from current + previous raw
217 /// numbers. Skips the delta when `previous` is zero (no baseline
218 /// to grow from) or non-finite — the renderer just won't show
219 /// the growth row in that case.
220 pub fn growth(mut self, current: f64, previous: f64) -> Self {
221 if previous.is_finite() && previous != 0.0 && current.is_finite() {
222 self.delta_percent = Some(((current - previous) / previous.abs()) * 100.0);
223 }
224 self
225 }
226
227 /// Explicit delta percent (signed) + label. Use when you've
228 /// computed the percentage yourself or want a custom label.
229 pub fn delta(mut self, percent: f64, label: impl Into<String>) -> Self {
230 self.delta_percent = Some(percent);
231 self.delta_label = Some(label.into());
232 self
233 }
234
235 /// Standalone label for the delta — pairs with [`Self::growth`]
236 /// for the common case "auto-compute the percent but customize
237 /// the comparison label" (e.g. `"vs prior 30d"`).
238 pub fn delta_label(mut self, label: impl Into<String>) -> Self {
239 self.delta_label = Some(label.into());
240 self
241 }
242
243 /// Attach a trend trail rendered as a fade-right sparkline
244 /// under the value. Pass 7–30 raw numbers (daily totals,
245 /// hourly counts, etc.); the renderer autoscales and colors
246 /// the stroke to match [`Self::delta_percent`]'s sign.
247 pub fn sparkline(mut self, points: impl IntoIterator<Item = f64>) -> Self {
248 self.sparkline = Some(points.into_iter().collect());
249 self
250 }
251}
252
253/// Humanize a number into a compact display string:
254///
255/// | input | output |
256/// |------------------|------------|
257/// | `42.0` | `"42"` |
258/// | `1_234.5` | `"1,234.50"` |
259/// | `12_438.2` | `"12.4K"` |
260/// | `1_500_000.0` | `"1.50M"` |
261/// | `2_700_000_000.` | `"2.70B"` |
262///
263/// Suitable for card values where horizontal space is scarce.
264pub fn humanize_number(n: f64) -> String {
265 if !n.is_finite() {
266 return "—".to_string();
267 }
268 let abs = n.abs();
269 let sign = if n < 0.0 { "-" } else { "" };
270 if abs < 1000.0 {
271 // Two decimals when there's a fractional part; integer otherwise.
272 if (abs.fract() - 0.0).abs() < f64::EPSILON {
273 return format!("{sign}{}", abs as i64);
274 }
275 return format!("{sign}{:.2}", abs);
276 }
277 if abs < 1_000_000.0 {
278 if abs < 10_000.0 {
279 // Keep the thousands separator at the low end of the K
280 // range — "9,876" reads better than "9.9K" for amounts a
281 // user is likely to mentally verify against the data.
282 return format_thousands(n);
283 }
284 return format!("{sign}{:.1}K", abs / 1_000.0);
285 }
286 if abs < 1_000_000_000.0 {
287 return format!("{sign}{:.2}M", abs / 1_000_000.0);
288 }
289 if abs < 1_000_000_000_000.0 {
290 return format!("{sign}{:.2}B", abs / 1_000_000_000.0);
291 }
292 format!("{sign}{:.2}T", abs / 1_000_000_000_000.0)
293}
294
295/// Format a number with thousands separators and (when fractional)
296/// two decimal places. Use for values where the full digits matter
297/// (currency totals, audit counts) — for compact display use
298/// [`humanize_number`].
299pub fn format_thousands(n: f64) -> String {
300 if !n.is_finite() {
301 return "—".to_string();
302 }
303 let sign = if n < 0.0 { "-" } else { "" };
304 let abs = n.abs();
305 let int_part = abs.trunc() as u128;
306 let frac_part = abs - abs.trunc();
307
308 // Insert commas every 3 digits, right-to-left.
309 let int_str = int_part.to_string();
310 let bytes = int_str.as_bytes();
311 let mut grouped = String::with_capacity(int_str.len() + int_str.len() / 3);
312 for (i, b) in bytes.iter().enumerate() {
313 if i > 0 && (bytes.len() - i) % 3 == 0 {
314 grouped.push(',');
315 }
316 grouped.push(*b as char);
317 }
318
319 if frac_part > 0.0 {
320 format!("{sign}{grouped}.{:02}", (frac_part * 100.0).round() as u64)
321 } else {
322 format!("{sign}{grouped}")
323 }
324}
325
326/// One data series for Line or Bar charts.
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct Series {
329 pub name: String,
330 pub points: Vec<ChartPoint>,
331}
332
333/// X/Y data point. X is a string for flexible labeling.
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct ChartPoint {
336 pub x: String,
337 pub y: f64,
338}
339
340/// Line chart payload.
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct LinePayload {
343 pub series: Vec<Series>,
344 /// Describes what `x` represents; e.g. "date", "category".
345 pub x_type: String,
346}
347
348/// Bar chart payload (same shape as Line).
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct BarPayload {
351 pub series: Vec<Series>,
352 pub x_type: String,
353}
354
355/// One slice of a donut chart.
356#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct DonutSlice {
358 pub label: String,
359 pub value: f64,
360 /// Optional explicit color (CSS hex / rgb / token name).
361 /// `None` falls back to the chart's default palette.
362 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub color: Option<String>,
364}
365
366/// Donut chart payload — categorical breakdown summing to 100%.
367#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct DonutPayload {
369 pub slices: Vec<DonutSlice>,
370}
371
372impl DonutPayload {
373 pub fn new(slices: Vec<DonutSlice>) -> Self {
374 Self { slices }
375 }
376
377 /// Build slices from `(label, value)` tuples; the chart
378 /// picks colors from its default palette.
379 pub fn from_pairs<L: Into<String>>(pairs: impl IntoIterator<Item = (L, f64)>) -> Self {
380 Self::new(
381 pairs
382 .into_iter()
383 .map(|(label, value)| DonutSlice {
384 label: label.into(),
385 value,
386 color: None,
387 })
388 .collect(),
389 )
390 }
391}
392
393/// One arc of a [`RadialPayload`] gauge — a labeled 0–100% value.
394#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct RadialTrack {
396 pub label: String,
397 /// Percent in `[0, 100]`. The `RadialPayload` constructors clamp
398 /// this so the arc never overruns the ring.
399 pub value: f64,
400 /// Optional explicit arc color (CSS hex / rgb / token name).
401 /// `None` falls back to the chart's default palette.
402 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub color: Option<String>,
404}
405
406/// Radial gauge payload — one or more 0–100% tracks rendered as
407/// concentric arcs (ApexCharts `radialBar`). Use for "progress toward
408/// a goal" metrics: quota attainment, capacity used, completion rate.
409///
410/// ```ignore
411/// // One ring: 73% of the monthly sales goal.
412/// RadialPayload::goal("Monthly goal", sales, target)
413/// // Compare conversion across plans.
414/// RadialPayload::from_pairs([("Free", 8.0), ("Pro", 21.5), ("Team", 34.0)])
415/// ```
416#[derive(Debug, Clone, Serialize, Deserialize)]
417pub struct RadialPayload {
418 pub tracks: Vec<RadialTrack>,
419}
420
421impl RadialPayload {
422 /// New payload from explicit tracks; each `value` is clamped to
423 /// `[0, 100]` (non-finite -> 0).
424 pub fn new(tracks: Vec<RadialTrack>) -> Self {
425 Self {
426 tracks: tracks
427 .into_iter()
428 .map(|t| RadialTrack {
429 value: clamp_percent(t.value),
430 ..t
431 })
432 .collect(),
433 }
434 }
435
436 /// A single-track gauge — the common case (one big ring with the
437 /// percent in the centre).
438 pub fn single(label: impl Into<String>, percent: f64) -> Self {
439 Self::new(vec![RadialTrack {
440 label: label.into(),
441 value: percent,
442 color: None,
443 }])
444 }
445
446 /// A single-track gauge whose percent is `current / target * 100`
447 /// — the literal "progress toward a goal" shape. A non-positive
448 /// `target` yields 0% (nothing to measure against).
449 pub fn goal(label: impl Into<String>, current: f64, target: f64) -> Self {
450 let pct = if target > 0.0 {
451 current / target * 100.0
452 } else {
453 0.0
454 };
455 Self::single(label, pct)
456 }
457
458 /// Build tracks from `(label, percent)` tuples; the chart picks
459 /// colors from its default palette. Each percent is clamped.
460 pub fn from_pairs<L: Into<String>>(pairs: impl IntoIterator<Item = (L, f64)>) -> Self {
461 Self::new(
462 pairs
463 .into_iter()
464 .map(|(label, value)| RadialTrack {
465 label: label.into(),
466 value,
467 color: None,
468 })
469 .collect(),
470 )
471 }
472}
473
474/// Clamp a percentage into `[0, 100]`; non-finite -> 0.
475fn clamp_percent(v: f64) -> f64 {
476 if v.is_finite() {
477 v.clamp(0.0, 100.0)
478 } else {
479 0.0
480 }
481}
482
483/// One cell in a [`HeatmapRow`] — an x-axis bucket and its magnitude.
484#[derive(Debug, Clone, Serialize, Deserialize)]
485pub struct HeatmapCell {
486 /// X-axis label for this cell (e.g. an hour `"09"`, a month `"Mar"`).
487 pub x: String,
488 /// The value that colors the cell. Higher = hotter.
489 pub y: f64,
490}
491
492/// One row (series) of a [`HeatmapPayload`] — a label plus its cells
493/// across the shared x-axis.
494#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct HeatmapRow {
496 pub name: String,
497 pub cells: Vec<HeatmapCell>,
498}
499
500/// Heatmap payload — a 2-D grid of cells colored by magnitude
501/// (ApexCharts `heatmap`). Every row shares the same ordered x-axis.
502/// Use for "activity by time" patterns (day-of-week × hour), cohort
503/// retention, or per-region load.
504///
505/// ```ignore
506/// HeatmapPayload::from_grid(
507/// ["Mon", "Tue", "Wed"],
508/// ["00-06", "06-12", "12-18", "18-24"],
509/// vec![
510/// vec![2.0, 9.0, 14.0, 6.0],
511/// vec![1.0, 11.0, 17.0, 8.0],
512/// vec![3.0, 13.0, 19.0, 7.0],
513/// ],
514/// )
515/// ```
516#[derive(Debug, Clone, Serialize, Deserialize)]
517pub struct HeatmapPayload {
518 pub rows: Vec<HeatmapRow>,
519}
520
521impl HeatmapPayload {
522 /// New payload from explicit rows.
523 pub fn new(rows: Vec<HeatmapRow>) -> Self {
524 Self { rows }
525 }
526
527 /// Build a rectangular grid from row labels, shared column (x)
528 /// labels, and a `values[row][col]` matrix. A short value row is
529 /// padded with `0.0` and extra values past the columns are dropped,
530 /// so the grid is always rectangular regardless of ragged input.
531 pub fn from_grid<R, C>(
532 row_labels: impl IntoIterator<Item = R>,
533 col_labels: impl IntoIterator<Item = C>,
534 values: Vec<Vec<f64>>,
535 ) -> Self
536 where
537 R: Into<String>,
538 C: Into<String>,
539 {
540 let cols: Vec<String> = col_labels.into_iter().map(Into::into).collect();
541 let rows = row_labels
542 .into_iter()
543 .enumerate()
544 .map(|(r, label)| {
545 let row_vals = values.get(r);
546 let cells = cols
547 .iter()
548 .enumerate()
549 .map(|(c, x)| HeatmapCell {
550 x: x.clone(),
551 y: row_vals.and_then(|v| v.get(c)).copied().unwrap_or(0.0),
552 })
553 .collect();
554 HeatmapRow {
555 name: label.into(),
556 cells,
557 }
558 })
559 .collect();
560 Self { rows }
561 }
562}
563
564/// One row of a [`ProgressPayload`] — a labeled horizontal bar.
565#[derive(Debug, Clone, Serialize, Deserialize)]
566pub struct ProgressItem {
567 pub label: String,
568 /// Pre-formatted value shown at the right of the row (the
569 /// `from_pairs` constructors thousands-group it; `new` takes it
570 /// verbatim).
571 pub display: String,
572 /// Bar fill width, `0–100`, relative to the payload's reference
573 /// (the largest value, or an explicit target).
574 pub percent: f64,
575 /// Optional explicit bar color (CSS hex / rgb / token name).
576 #[serde(default, skip_serializing_if = "Option::is_none")]
577 pub color: Option<String>,
578}
579
580/// Progress-bar list payload — a ranked set of labeled horizontal bars,
581/// each filled relative to the largest value (or an explicit target).
582/// The "top N by metric" tile: revenue by product, traffic by source,
583/// completion per category. Rendered as pure HTML — no chart library.
584///
585/// ```ignore
586/// // Revenue by product; the top product fills the bar.
587/// ProgressPayload::from_pairs([("Pro", 48200.0), ("Team", 31000.0), ("Free", 9400.0)])
588/// // Completion per team, each measured against a 100-task target.
589/// ProgressPayload::from_pairs_of([("Web", 82.0), ("Mobile", 57.0)], 100.0)
590/// ```
591#[derive(Debug, Clone, Serialize, Deserialize)]
592pub struct ProgressPayload {
593 pub items: Vec<ProgressItem>,
594}
595
596impl ProgressPayload {
597 /// New payload from explicit items (you set `display` + `percent`).
598 pub fn new(items: Vec<ProgressItem>) -> Self {
599 Self { items }
600 }
601
602 /// From `(label, value)` pairs, with each bar sized relative to the
603 /// LARGEST value (the top item fills the bar). `display` is the
604 /// thousands-grouped value.
605 pub fn from_pairs<L: Into<String>>(pairs: impl IntoIterator<Item = (L, f64)>) -> Self {
606 let items: Vec<(String, f64)> = pairs.into_iter().map(|(l, v)| (l.into(), v)).collect();
607 let reference = items
608 .iter()
609 .map(|(_, v)| *v)
610 .filter(|v| v.is_finite())
611 .fold(0.0_f64, f64::max);
612 Self::build(items, reference)
613 }
614
615 /// From `(label, value)` pairs, with each bar sized against an
616 /// explicit `target` (e.g. a per-row "% of goal"). A non-positive
617 /// target falls back to sizing against the largest value.
618 pub fn from_pairs_of<L: Into<String>>(
619 pairs: impl IntoIterator<Item = (L, f64)>,
620 target: f64,
621 ) -> Self {
622 let items: Vec<(String, f64)> = pairs.into_iter().map(|(l, v)| (l.into(), v)).collect();
623 let reference = if target > 0.0 {
624 target
625 } else {
626 items
627 .iter()
628 .map(|(_, v)| *v)
629 .filter(|v| v.is_finite())
630 .fold(0.0_f64, f64::max)
631 };
632 Self::build(items, reference)
633 }
634
635 /// Shared: turn `(label, value)` + a reference max into rendered
636 /// items. `percent = value / reference * 100`, clamped to
637 /// `[0, 100]`; a zero/non-finite reference yields empty bars.
638 fn build(items: Vec<(String, f64)>, reference: f64) -> Self {
639 let items = items
640 .into_iter()
641 .map(|(label, value)| {
642 let percent = if reference > 0.0 && value.is_finite() {
643 (value / reference * 100.0).clamp(0.0, 100.0)
644 } else {
645 0.0
646 };
647 ProgressItem {
648 label,
649 display: format_thousands(value),
650 percent,
651 color: None,
652 }
653 })
654 .collect();
655 Self { items }
656 }
657}
658
659/// Table widget column descriptor.
660#[derive(Debug, Clone, Serialize, Deserialize)]
661pub struct TableColumn {
662 pub key: String,
663 pub label: String,
664}
665
666/// Table widget payload.
667#[derive(Debug, Clone, Serialize, Deserialize)]
668pub struct TablePayload {
669 pub columns: Vec<TableColumn>,
670 pub rows: Vec<serde_json::Value>,
671 /// Optional "View all →" link in the widget header. Populated
672 /// via [`Self::view_all_for`] (auto-resolves the admin URL
673 /// from a `Model` type) or set explicitly when the target
674 /// isn't a managed admin model.
675 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub view_all_url: Option<String>,
677}
678
679impl TablePayload {
680 /// New payload from columns + rows; no `view_all` link.
681 pub fn new(columns: Vec<TableColumn>, rows: Vec<serde_json::Value>) -> Self {
682 Self {
683 columns,
684 rows,
685 view_all_url: None,
686 }
687 }
688
689 /// Auto-resolve the "View all" link from a `Model` type — the
690 /// admin's changelist URL for that table. Mirrors the pattern
691 /// used by `models![T, U, V]`: rename the struct's
692 /// `#[umbral(table = "...")]` and the link follows automatically.
693 ///
694 /// ```rust,ignore
695 /// WidgetPayload::Table(
696 /// TablePayload::new(columns, rows)
697 /// .view_all_for::<Order>()
698 /// )
699 /// // → "View all →" links to {admin_base}/order/
700 /// ```
701 pub fn view_all_for<T: umbral::orm::Model>(mut self) -> Self {
702 self.view_all_url = Some(format!(
703 "{}/{}/",
704 crate::branding::current().base_path,
705 T::TABLE,
706 ));
707 self
708 }
709
710 /// Explicit URL override — use when the link target isn't a
711 /// managed admin model (an external dashboard, a custom route).
712 pub fn view_all_url(mut self, url: impl Into<String>) -> Self {
713 self.view_all_url = Some(url.into());
714 self
715 }
716}
717
718/// One item in an activity feed.
719#[derive(Debug, Clone, Serialize, Deserialize)]
720pub struct FeedItem {
721 pub actor: String,
722 pub verb: String,
723 pub object: String,
724 pub object_link: Option<String>,
725 pub at: String,
726}
727
728/// Activity feed payload.
729#[derive(Debug, Clone, Serialize, Deserialize)]
730pub struct FeedPayload {
731 pub items: Vec<FeedItem>,
732 /// Optional "View all →" link in the widget header. Same
733 /// shape as [`TablePayload::view_all_url`] — auto-resolve
734 /// from a `Model` via [`Self::view_all_for`].
735 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub view_all_url: Option<String>,
737}
738
739impl FeedPayload {
740 /// New payload from items; no `view_all` link.
741 pub fn new(items: Vec<FeedItem>) -> Self {
742 Self {
743 items,
744 view_all_url: None,
745 }
746 }
747
748 /// Auto-resolve the "View all" link from a `Model` type. The
749 /// recent-signups feed for instance:
750 ///
751 /// ```rust,ignore
752 /// WidgetPayload::Feed(
753 /// FeedPayload::new(items).view_all_for::<AuthUser>()
754 /// )
755 /// // → "View all →" links to {admin_base}/auth_user/
756 /// ```
757 pub fn view_all_for<T: umbral::orm::Model>(mut self) -> Self {
758 self.view_all_url = Some(format!(
759 "{}/{}/",
760 crate::branding::current().base_path,
761 T::TABLE,
762 ));
763 self
764 }
765
766 pub fn view_all_url(mut self, url: impl Into<String>) -> Self {
767 self.view_all_url = Some(url.into());
768 self
769 }
770}
771
772/// Union of all widget payloads. The JSON discriminant is the variant name.
773#[derive(Debug, Clone, Serialize, Deserialize)]
774#[serde(tag = "kind", rename_all = "lowercase")]
775pub enum WidgetPayload {
776 Kpi(KpiPayload),
777 Card(CardPayload),
778 Line(LinePayload),
779 Bar(BarPayload),
780 Donut(DonutPayload),
781 Radial(RadialPayload),
782 Heatmap(HeatmapPayload),
783 Progress(ProgressPayload),
784 Table(TablePayload),
785 Feed(FeedPayload),
786}
787
788// =========================================================================
789// WidgetDataFn
790// =========================================================================
791
792/// Per-request parameters a widget's data closure can read.
793/// Sourced from the query string on
794/// `GET /admin/api/dashboard/widgets/<key>/data?<params>`.
795///
796/// Defaults are all `None` — closures that don't care can use
797/// `WidgetDataFn::new(|user| ...)` and ignore params entirely.
798/// Closures that DO care use `WidgetDataFn::with_params` and
799/// branch on `params.period` / `params.start` / `params.end`.
800#[derive(Debug, Clone, Default)]
801pub struct WidgetParams {
802 /// Period preset like `"7d"`, `"30d"`, `"90d"`. The
803 /// rendering side emits chips that pass this through.
804 pub period: Option<String>,
805 /// Explicit ISO start date (`YYYY-MM-DD`) — overrides
806 /// `period` when both are present.
807 pub start: Option<String>,
808 /// Explicit ISO end date (`YYYY-MM-DD`).
809 pub end: Option<String>,
810 /// Catch-all for any other widget-specific query params
811 /// — `?model=order` for a future per-model filter, etc.
812 /// Closures read by `params.raw.get("...")`.
813 pub raw: std::collections::HashMap<String, String>,
814}
815
816impl WidgetParams {
817 /// Build from a `?key=value&...` query string. Recognised
818 /// keys (`period`, `start`, `end`) populate the typed
819 /// fields; the rest land in `raw`.
820 pub fn from_query<S: AsRef<str>>(query: S) -> Self {
821 let mut out = Self::default();
822 for pair in query.as_ref().split('&').filter(|s| !s.is_empty()) {
823 let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
824 let value = urlencoding_decode(v);
825 match k {
826 "period" => out.period = Some(value),
827 "start" => out.start = Some(value),
828 "end" => out.end = Some(value),
829 _ => {
830 out.raw.insert(k.to_string(), value);
831 }
832 }
833 }
834 out
835 }
836
837 /// Number of days the `period` preset represents. `"7d"`
838 /// → 7, `"30d"` → 30, `"90d"` → 90. None for unrecognised /
839 /// missing values so callers fall back to a default.
840 pub fn period_days(&self) -> Option<i64> {
841 let p = self.period.as_deref()?;
842 let digits: String = p.chars().take_while(|c| c.is_ascii_digit()).collect();
843 digits.parse().ok()
844 }
845}
846
847/// Minimal `%XX` → byte decoder; avoids pulling a query-string
848/// crate just for the four chars we need (`+` → space, `%2F` → `/`,
849/// etc.). Anything malformed passes through unchanged.
850fn urlencoding_decode(raw: &str) -> String {
851 let mut out = String::with_capacity(raw.len());
852 let bytes = raw.as_bytes();
853 let mut i = 0;
854 while i < bytes.len() {
855 match bytes[i] {
856 b'+' => {
857 out.push(' ');
858 i += 1;
859 }
860 b'%' if i + 2 < bytes.len() => {
861 let hi = (bytes[i + 1] as char).to_digit(16);
862 let lo = (bytes[i + 2] as char).to_digit(16);
863 if let (Some(h), Some(l)) = (hi, lo) {
864 out.push(char::from((h as u8) * 16 + l as u8));
865 i += 3;
866 } else {
867 out.push(bytes[i] as char);
868 i += 1;
869 }
870 }
871 b => {
872 out.push(b as char);
873 i += 1;
874 }
875 }
876 }
877 out
878}
879
880pub(crate) type DataFuture = Pin<Box<dyn Future<Output = WidgetPayload> + Send + 'static>>;
881pub(crate) type DataFnInner =
882 Arc<dyn Fn(AuthUser, WidgetParams) -> DataFuture + Send + Sync + 'static>;
883
884/// Wrapper around the async data closure. Build via
885/// [`WidgetDataFn::new`] (closure ignores per-request params) or
886/// [`WidgetDataFn::with_params`] (closure reads `WidgetParams` to
887/// honour period / date-range filters from the request URL).
888#[derive(Clone)]
889pub struct WidgetDataFn(pub(crate) DataFnInner);
890
891impl WidgetDataFn {
892 /// Create from any `async fn(AuthUser) -> WidgetPayload` —
893 /// per-request params are dropped on the floor. Use when the
894 /// widget renders the same thing regardless of UI controls
895 /// (KPI counts, registry sizes, etc.).
896 pub fn new<F, Fut>(f: F) -> Self
897 where
898 F: Fn(AuthUser) -> Fut + Send + Sync + 'static,
899 Fut: Future<Output = WidgetPayload> + Send + 'static,
900 {
901 Self(Arc::new(move |user, _params| Box::pin(f(user))))
902 }
903
904 /// Create from `async fn(AuthUser, WidgetParams) ->
905 /// WidgetPayload`. Use for filterable widgets — the line
906 /// chart reads `params.period` to switch between 7d / 30d /
907 /// 90d views, a future table widget might read
908 /// `params.raw.get("status")` for status filtering, etc.
909 pub fn with_params<F, Fut>(f: F) -> Self
910 where
911 F: Fn(AuthUser, WidgetParams) -> Fut + Send + Sync + 'static,
912 Fut: Future<Output = WidgetPayload> + Send + 'static,
913 {
914 Self(Arc::new(move |user, params| Box::pin(f(user, params))))
915 }
916}
917
918impl std::fmt::Debug for WidgetDataFn {
919 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
920 f.write_str("WidgetDataFn(<fn>)")
921 }
922}
923
924// =========================================================================
925// Widget
926// =========================================================================
927
928/// A registered dashboard widget.
929///
930/// Register via `AdminPlugin::register_widget(...)`.
931#[derive(Debug, Clone)]
932pub struct Widget {
933 /// URL-safe unique key, e.g. `"umbral_total_models"`.
934 pub key: &'static str,
935 /// Human-readable title shown in the widget card header.
936 pub title: String,
937 /// Determines which renderer (KPI card, chart, table, feed).
938 pub kind: WidgetKind,
939 /// Default grid span when the user hasn't customized.
940 pub default_span: Span,
941 /// Optional permission codename. `None` = any staff user may see.
942 pub permission: Option<&'static str>,
943 /// Async function that computes and returns the payload.
944 pub data: WidgetDataFn,
945 /// Default period preset used by line/bar/etc. widgets that
946 /// carry a period-chip strip — `"7d"`, `"30d"`, `"90d"`. When
947 /// `Some`, the handler pre-fills `WidgetParams.period` from
948 /// this value on first load (no `?period=` in the URL), so
949 /// the matching chip renders highlighted AND the data
950 /// closure receives the same period via `params.period_days()`.
951 /// `None` falls back to whatever the template / data closure
952 /// chooses as its fallback.
953 pub default_period: Option<&'static str>,
954}
955
956impl Widget {
957 /// Override the default grid span. Lets a caller resize a
958 /// builtin (or any pre-built widget) at registration time
959 /// without having to re-construct the whole struct literal:
960 ///
961 /// ```rust,ignore
962 /// .register_widget(builtin_total_models_widget().with_span(6, 2))
963 /// .register_widget(builtin_recent_users_widget().with_span(6, 2))
964 /// ```
965 ///
966 /// `cols` is clamped at the 12-col grid; `rows` is whatever
967 /// the dashboard's `auto-rows-[...]` accepts (1 = 120px).
968 pub fn with_span(mut self, cols: u8, rows: u8) -> Self {
969 self.default_span = Span { cols, rows };
970 self
971 }
972
973 /// Pre-select a period chip on the widget — `"7d"`, `"30d"`,
974 /// `"90d"`. On first load (no `?period=` in the URL), the
975 /// handler stamps this into `WidgetParams.period` before
976 /// calling the data closure, so the chip strip highlights
977 /// the right preset AND the data fn computes the right
978 /// window. Override on a per-request basis happens via the
979 /// chip clicks (which send their own `?period=` query).
980 ///
981 /// ```ignore
982 /// shop_daily_sales_chart().with_default_period("7d")
983 /// // → first paint shows 7d highlighted, 7 days of data;
984 /// // clicking "30d" hands control to the URL state.
985 /// ```
986 pub fn with_default_period(mut self, period: &'static str) -> Self {
987 self.default_period = Some(period);
988 self
989 }
990}
991
992// =========================================================================
993// WidgetInstance (user's saved layout entry)
994// =========================================================================
995
996/// One entry in a user's saved layout JSON.
997#[derive(Debug, Clone, Serialize, Deserialize)]
998pub struct WidgetInstance {
999 pub key: String,
1000 pub span: Span,
1001}
1002
1003// =========================================================================
1004// Widget catalog entry (API response shape)
1005// =========================================================================
1006
1007/// Serialized catalog entry returned by `GET /admin/api/dashboard/catalog`.
1008#[derive(Debug, Clone, Serialize)]
1009pub struct CatalogEntry {
1010 pub key: &'static str,
1011 pub title: String,
1012 pub kind: String,
1013 pub default_span: Span,
1014}
1015
1016// =========================================================================
1017// Sections — grouped widgets
1018// =========================================================================
1019
1020/// A named group of widgets on the dashboard. Each section renders
1021/// as its own heading + (optional) subtitle + widget grid, so a
1022/// dashboard with 20 widgets reads as themed clusters rather than
1023/// one mega-grid.
1024///
1025/// Build with the chainable API:
1026///
1027/// ```rust,ignore
1028/// use umbral_admin::WidgetSection;
1029///
1030/// let sales = WidgetSection::new("Sales overview")
1031/// .subtitle("Daily KPIs across the storefront")
1032/// .widget(shop_total_sales_widget())
1033/// .widget(shop_orders_widget())
1034/// .widget(shop_avg_order_value_widget());
1035///
1036/// AdminPlugin::default().dashboard_section(sales);
1037/// ```
1038///
1039/// Register multiple sections by chaining `.dashboard_section(...)`.
1040/// Widgets registered via the legacy `.register_widget(...)` end up
1041/// in an implicit final section titled "Widgets" — so existing apps
1042/// keep working without code changes.
1043#[derive(Debug, Clone)]
1044pub struct WidgetSection {
1045 /// Heading shown above the section (e.g. "Sales overview").
1046 pub title: String,
1047 /// Optional descriptive line under the title — keep it short,
1048 /// it's not a paragraph.
1049 pub subtitle: Option<String>,
1050 /// Widgets in this section, rendered in registration order.
1051 pub widgets: Vec<Widget>,
1052}
1053
1054impl WidgetSection {
1055 /// New empty section with just a title.
1056 pub fn new(title: impl Into<String>) -> Self {
1057 Self {
1058 title: title.into(),
1059 subtitle: None,
1060 widgets: Vec::new(),
1061 }
1062 }
1063
1064 /// Add a one-line subtitle under the heading.
1065 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
1066 self.subtitle = Some(subtitle.into());
1067 self
1068 }
1069
1070 /// Append one widget to the section.
1071 pub fn widget(mut self, w: Widget) -> Self {
1072 self.widgets.push(w);
1073 self
1074 }
1075
1076 /// Append many widgets at once (handy for splatting a Vec).
1077 pub fn widgets(mut self, ws: impl IntoIterator<Item = Widget>) -> Self {
1078 self.widgets.extend(ws);
1079 self
1080 }
1081}
1082
1083#[cfg(test)]
1084mod tests {
1085 use super::*;
1086
1087 #[test]
1088 fn radial_kind_serializes_as_radial() {
1089 assert_eq!(WidgetKind::Radial.as_str(), "radial");
1090 }
1091
1092 #[test]
1093 fn radial_single_builds_one_track() {
1094 let p = RadialPayload::single("Monthly goal", 73.0);
1095 assert_eq!(p.tracks.len(), 1);
1096 assert_eq!(p.tracks[0].label, "Monthly goal");
1097 assert_eq!(p.tracks[0].value, 73.0);
1098 assert!(p.tracks[0].color.is_none());
1099 }
1100
1101 #[test]
1102 fn radial_clamps_out_of_range_and_non_finite_percents() {
1103 // Over 100, under 0, and non-finite all clamp into [0, 100].
1104 assert_eq!(RadialPayload::single("over", 150.0).tracks[0].value, 100.0);
1105 assert_eq!(RadialPayload::single("under", -20.0).tracks[0].value, 0.0);
1106 // Non-finite (NaN, ±∞) is meaningless as a percent -> 0.
1107 assert_eq!(RadialPayload::single("nan", f64::NAN).tracks[0].value, 0.0);
1108 assert_eq!(
1109 RadialPayload::single("inf", f64::INFINITY).tracks[0].value,
1110 0.0,
1111 );
1112 }
1113
1114 #[test]
1115 fn radial_goal_is_current_over_target() {
1116 assert_eq!(RadialPayload::goal("g", 73.0, 100.0).tracks[0].value, 73.0);
1117 // current > target clamps to 100 (overachieved, full ring).
1118 assert_eq!(
1119 RadialPayload::goal("g", 120.0, 100.0).tracks[0].value,
1120 100.0
1121 );
1122 // A non-positive target has nothing to measure against -> 0%.
1123 assert_eq!(RadialPayload::goal("g", 5.0, 0.0).tracks[0].value, 0.0);
1124 }
1125
1126 #[test]
1127 fn radial_from_pairs_keeps_order_and_clamps() {
1128 let p = RadialPayload::from_pairs([("Free", 8.0), ("Pro", 150.0), ("Team", 34.0)]);
1129 assert_eq!(p.tracks.len(), 3);
1130 assert_eq!(p.tracks[0].label, "Free");
1131 assert_eq!(p.tracks[1].value, 100.0); // clamped
1132 assert_eq!(p.tracks[2].label, "Team");
1133 }
1134
1135 #[test]
1136 fn radial_payload_serializes_with_kind_tag() {
1137 let payload = WidgetPayload::Radial(RadialPayload::single("Quota", 42.0));
1138 let json = serde_json::to_value(&payload).expect("serialize");
1139 assert_eq!(json["kind"], "radial");
1140 assert_eq!(json["tracks"][0]["label"], "Quota");
1141 assert_eq!(json["tracks"][0]["value"], 42.0);
1142 // No explicit color -> the field is skipped entirely.
1143 assert!(json["tracks"][0].get("color").is_none());
1144 }
1145
1146 #[test]
1147 fn heatmap_kind_serializes_as_heatmap() {
1148 assert_eq!(WidgetKind::Heatmap.as_str(), "heatmap");
1149 }
1150
1151 #[test]
1152 fn heatmap_from_grid_is_rectangular_and_padded() {
1153 // A ragged matrix: row 0 short (padded with 0), row 1 long
1154 // (extra dropped), row 2 exact.
1155 let p = HeatmapPayload::from_grid(
1156 ["Mon", "Tue", "Wed"],
1157 ["AM", "PM"],
1158 vec![vec![3.0], vec![1.0, 2.0, 99.0], vec![4.0, 5.0]],
1159 );
1160 assert_eq!(p.rows.len(), 3);
1161 // Every row has exactly one cell per column label.
1162 for row in &p.rows {
1163 assert_eq!(row.cells.len(), 2, "row `{}` must be rectangular", row.name);
1164 assert_eq!(row.cells[0].x, "AM");
1165 assert_eq!(row.cells[1].x, "PM");
1166 }
1167 assert_eq!(p.rows[0].name, "Mon");
1168 assert_eq!(p.rows[0].cells[1].y, 0.0); // short row padded
1169 assert_eq!(p.rows[1].cells[1].y, 2.0); // extra `99.0` dropped
1170 assert_eq!(p.rows[2].cells[0].y, 4.0);
1171 }
1172
1173 #[test]
1174 fn heatmap_payload_serializes_with_kind_tag() {
1175 let payload = WidgetPayload::Heatmap(HeatmapPayload::from_grid(
1176 ["Row"],
1177 ["a", "b"],
1178 vec![vec![7.0, 8.0]],
1179 ));
1180 let json = serde_json::to_value(&payload).expect("serialize");
1181 assert_eq!(json["kind"], "heatmap");
1182 assert_eq!(json["rows"][0]["name"], "Row");
1183 assert_eq!(json["rows"][0]["cells"][0]["x"], "a");
1184 assert_eq!(json["rows"][0]["cells"][1]["y"], 8.0);
1185 }
1186
1187 #[test]
1188 fn progress_kind_serializes_as_progress() {
1189 assert_eq!(WidgetKind::Progress.as_str(), "progress");
1190 }
1191
1192 #[test]
1193 fn progress_from_pairs_sizes_against_largest_value() {
1194 let p = ProgressPayload::from_pairs([("A", 100.0), ("B", 50.0), ("C", 25.0)]);
1195 assert_eq!(p.items.len(), 3);
1196 // The largest value fills the bar; the rest are proportional.
1197 assert_eq!(p.items[0].percent, 100.0);
1198 assert_eq!(p.items[1].percent, 50.0);
1199 assert_eq!(p.items[2].percent, 25.0);
1200 // `display` is the thousands-grouped value, order preserved.
1201 assert_eq!(p.items[0].label, "A");
1202 assert_eq!(p.items[0].display, "100");
1203 }
1204
1205 #[test]
1206 fn progress_from_pairs_of_sizes_against_target_and_clamps() {
1207 let p = ProgressPayload::from_pairs_of([("Web", 82.0), ("Mobile", 150.0)], 100.0);
1208 assert_eq!(p.items[0].percent, 82.0);
1209 // Over target fills the bar rather than overrunning it.
1210 assert_eq!(p.items[1].percent, 100.0);
1211 }
1212
1213 #[test]
1214 fn progress_non_positive_target_falls_back_to_max() {
1215 // target = 0 -> size against the largest value (40 -> 100%).
1216 let p = ProgressPayload::from_pairs_of([("A", 40.0), ("B", 10.0)], 0.0);
1217 assert_eq!(p.items[0].percent, 100.0);
1218 assert_eq!(p.items[1].percent, 25.0);
1219 }
1220
1221 #[test]
1222 fn progress_payload_serializes_with_kind_tag() {
1223 let payload = WidgetPayload::Progress(ProgressPayload::from_pairs([("Pro", 48200.0)]));
1224 let json = serde_json::to_value(&payload).expect("serialize");
1225 assert_eq!(json["kind"], "progress");
1226 assert_eq!(json["items"][0]["label"], "Pro");
1227 assert_eq!(json["items"][0]["display"], "48,200");
1228 assert_eq!(json["items"][0]["percent"], 100.0);
1229 // No explicit color -> the field is skipped entirely.
1230 assert!(json["items"][0].get("color").is_none());
1231 }
1232}