Skip to main content

umbral_admin/handlers/
dashboard.rs

1//! Dashboard API + the two built-in widgets.
2
3use axum::extract::State;
4use minijinja::context;
5use umbral::orm::DynQuerySet;
6use umbral::web::{HeaderMap, IntoResponse, Json, Path, Response, StatusCode};
7
8use crate::AdminState;
9use crate::auth::require_staff;
10use crate::discovery::find_model;
11use crate::engine::render;
12use crate::error::AdminError;
13use crate::models;
14use crate::util::is_htmx;
15use crate::widgets::{
16    BarPayload, CatalogEntry, ChartPoint, FeedItem, FeedPayload, Series, Span, Widget,
17    WidgetDataFn, WidgetKind, WidgetPayload,
18};
19
20// =========================================================================
21// Built-in widgets
22// =========================================================================
23
24/// `Models by plugin` bar chart — counts every model the migration
25/// registry knows about, grouped by plugin. Cheap to compute and
26/// always present.
27pub fn builtin_total_models_widget() -> Widget {
28    Widget {
29        key: "umbral_total_models",
30        title: "Models by Plugin".to_string(),
31        kind: WidgetKind::Bar,
32        default_span: Span { cols: 4, rows: 2 },
33        permission: None,
34        default_period: None,
35        filters: Vec::new(),
36        data: WidgetDataFn::new(|_user| async move {
37            let points = models_by_plugin_points();
38            WidgetPayload::Bar(BarPayload {
39                series: vec![Series {
40                    name: "models".to_string(),
41                    points,
42                }],
43                x_type: "plugin".to_string(),
44            })
45        }),
46    }
47}
48
49fn models_by_plugin_points() -> Vec<ChartPoint> {
50    let mut assigned = std::collections::HashSet::new();
51    let mut points: Vec<ChartPoint> = Vec::new();
52
53    for plugin in umbral::migrate::registered_plugins() {
54        let models = umbral::migrate::models_for_plugin(&plugin);
55        for model in &models {
56            assigned.insert(model.table.clone());
57        }
58        if !models.is_empty() {
59            points.push(ChartPoint {
60                x: plugin,
61                y: models.len() as f64,
62            });
63        }
64    }
65
66    let app_count = umbral::migrate::registered_models()
67        .into_iter()
68        .filter(|model| !assigned.contains(&model.table))
69        .count();
70    if app_count > 0 {
71        points.push(ChartPoint {
72            x: "app".to_string(),
73            y: app_count as f64,
74        });
75    }
76
77    points.sort_by(|a, b| match (a.x.as_str(), b.x.as_str()) {
78        ("app", "app") => std::cmp::Ordering::Equal,
79        ("app", _) => std::cmp::Ordering::Greater,
80        (_, "app") => std::cmp::Ordering::Less,
81        _ => a.x.cmp(&b.x),
82    });
83    points
84}
85
86/// `Recent signups` feed — last 5 `auth_user` rows ordered by
87/// `date_joined`. Gracefully degrades to an empty list if the table
88/// is absent (e.g. an admin-only install where `AuthPlugin` isn't
89/// registered), so this widget never breaks the dashboard.
90///
91/// Goes through [`DynQuerySet`] keyed off the `auth_user` `ModelMeta`
92/// — that way the widget works against any custom user model
93/// `AuthPlugin::<U>` registers, not just the built-in `AuthUser`. If
94/// the registry doesn't know about an `auth_user` table (the
95/// degraded-install case), the widget returns an empty feed.
96pub fn builtin_recent_users_widget() -> Widget {
97    Widget {
98        key: "umbral_recent_users",
99        title: "Recent Signups".to_string(),
100        kind: WidgetKind::Feed,
101        default_span: Span { cols: 4, rows: 2 },
102        permission: None,
103        default_period: None,
104        filters: Vec::new(),
105        data: WidgetDataFn::new(|_user| async move {
106            let items = match find_model("auth_user") {
107                Some((_, meta)) => {
108                    let rows = DynQuerySet::for_meta(&meta)
109                        .select_cols(&["username".to_string(), "date_joined".to_string()])
110                        .order_by_col("date_joined", true)
111                        .limit(5)
112                        .fetch_as_strings()
113                        .await;
114                    match rows {
115                        Ok(rows) => rows
116                            .into_iter()
117                            .map(|r| FeedItem {
118                                actor: r.get("username").cloned().unwrap_or_default(),
119                                verb: "signed".to_string(),
120                                object: "up".to_string(),
121                                object_link: None,
122                                at: r.get("date_joined").cloned().unwrap_or_default(),
123                            })
124                            .collect(),
125                        Err(e) => {
126                            tracing::debug!(error = %e, "umbral_recent_users: auth_user fetch failed; empty feed");
127                            vec![]
128                        }
129                    }
130                }
131                None => vec![],
132            };
133            // Auto-resolve "View all →" to the admin's auth_user
134            // changelist — works for any UserModel registered with
135            // AuthPlugin since the table name is read from the
136            // ModelMeta we already looked up.
137            let mut payload = FeedPayload::new(items);
138            if let Some((_, meta)) = find_model("auth_user") {
139                payload.view_all_url = Some(format!(
140                    "{}/{}/",
141                    crate::branding::current().base_path,
142                    meta.table,
143                ));
144            }
145            WidgetPayload::Feed(payload)
146        }),
147    }
148}
149
150// =========================================================================
151// API handlers
152// =========================================================================
153
154/// `GET /admin/api/dashboard/catalog` — list widgets the user may add to
155/// the dashboard.
156pub(crate) async fn dashboard_catalog(
157    State(state): State<AdminState>,
158    headers: HeaderMap,
159) -> Response {
160    let user = match require_staff(&headers, "/admin/api/dashboard/catalog").await {
161        Ok(u) => u,
162        Err(r) => return r,
163    };
164    // gaps3 #6: omit widgets the user can't load. Otherwise a user without a
165    // widget's codename sees it in the "add widget" catalog, adds it, then
166    // gets a 403 on the data fetch (the data endpoint IS gated). Same
167    // per-widget `permission` check `dashboard_widget_data` enforces.
168    let mut entries: Vec<CatalogEntry> = Vec::with_capacity(state.widget_catalog.len());
169    for w in state.widget_catalog.iter() {
170        if let Some(code) = w.permission {
171            if !crate::permcheck::has_codename(&user, code).await {
172                continue;
173            }
174        }
175        entries.push(CatalogEntry {
176            key: w.key,
177            title: w.title.clone(),
178            kind: w.kind.as_str().to_string(),
179            default_span: w.default_span.clone(),
180        });
181    }
182    Json(entries).into_response()
183}
184
185/// `GET /admin/api/dashboard/layout` — user's saved layout or default.
186/// The body is returned as raw JSON because we round-trip it through
187/// the prefs row as a string.
188pub(crate) async fn dashboard_layout_get(headers: HeaderMap) -> Response {
189    let user = match require_staff(&headers, "/admin/api/dashboard/layout").await {
190        Ok(u) => u,
191        Err(r) => return r,
192    };
193    let prefs = match models::fetch_or_default(user.id).await {
194        Ok(p) => p,
195        Err(e) => {
196            tracing::error!(error = %e, "admin: dashboard_layout_get failed");
197            return (StatusCode::INTERNAL_SERVER_ERROR, "layout error").into_response();
198        }
199    };
200    axum::response::Response::builder()
201        .status(StatusCode::OK)
202        .header("Content-Type", "application/json")
203        .body(axum::body::Body::from(prefs.dashboard_layout))
204        .unwrap_or_else(|_| (StatusCode::OK, "[]").into_response())
205}
206
207/// `PUT /admin/api/dashboard/layout` — save the user's layout. Body
208/// must be a JSON array of widget instances; non-JSON 400s. Validity
209/// of the array shape is the client's problem until we lock down a
210/// schema for it.
211pub(crate) async fn dashboard_layout_put(headers: HeaderMap, body: String) -> Response {
212    let user = match require_staff(&headers, "/admin/api/dashboard/layout").await {
213        Ok(u) => u,
214        Err(r) => return r,
215    };
216    if serde_json::from_str::<serde_json::Value>(&body).is_err() {
217        return (StatusCode::BAD_REQUEST, "invalid JSON layout").into_response();
218    }
219    let mut prefs = match models::fetch_or_default(user.id).await {
220        Ok(p) => p,
221        Err(e) => {
222            tracing::error!(error = %e, "admin: dashboard_layout_put fetch failed");
223            return (StatusCode::INTERNAL_SERVER_ERROR, "layout error").into_response();
224        }
225    };
226    prefs.dashboard_layout = body;
227    match models::upsert(prefs).await {
228        Ok(_) => Json(serde_json::json!({ "ok": true })).into_response(),
229        Err(e) => {
230            tracing::error!(error = %e, "admin: dashboard_layout_put save failed");
231            (StatusCode::INTERNAL_SERVER_ERROR, "layout save error").into_response()
232        }
233    }
234}
235
236/// `GET /admin/api/dashboard/widgets/{key}/data` — compute and return
237/// one widget's payload. Returns either JSON (API consumers) or an
238/// HTML fragment (HTMX swap).
239/// Both permission gates a widget fetch must pass, in one place.
240///
241/// The data endpoint and the CSV export endpoint MUST agree on who may see a
242/// widget. Duplicating these checks is how one of them quietly drifts and
243/// becomes the bypass — export the numbers you were forbidden to look at.
244async fn gate_widget(
245    state: &AdminState,
246    user: &umbral_auth::AuthUser,
247    key: &str,
248    widget: &Widget,
249) -> Option<Response> {
250    // If this widget belongs to a permission-gated custom view, the requesting
251    // user must hold the view's codename — the same check the page handler
252    // enforces. Without it, a staff user blocked from the page could bypass
253    // `.with_permission(...)` by calling the endpoint directly.
254    if let Some(code) = state.widget_gates.get(key) {
255        if let Err(r) = crate::permcheck::require_codename(user, code).await {
256            return Some(r);
257        }
258    }
259    // Per-widget gate, independent of any view-level gate above. Graceful no-op:
260    // `require_codename` allows all when PermissionsPlugin is absent.
261    if let Some(code) = widget.permission {
262        if let Err(r) = crate::permcheck::require_codename(user, code).await {
263            return Some(r);
264        }
265    }
266    None
267}
268
269/// `GET /admin/api/dashboard/widgets/{key}/export.csv` — the same payload the
270/// widget renders, as a CSV download.
271///
272/// It runs the widget's own data closure with the SAME resolved filters the
273/// dashboard is showing, so the file you download is the chart you are looking
274/// at. An export that silently ignored the filters would hand someone a
275/// spreadsheet that disagrees with the screen they exported it from.
276pub(crate) async fn dashboard_widget_export(
277    State(state): State<AdminState>,
278    headers: HeaderMap,
279    Path(key): Path<String>,
280    axum::extract::RawQuery(query): axum::extract::RawQuery,
281) -> Response {
282    let user = match require_staff(&headers, "/admin/api/dashboard/widgets/.../export.csv").await {
283        Ok(u) => u,
284        Err(r) => return r,
285    };
286    let Some(widget) = state.widget_catalog.iter().find(|w| w.key == key.as_str()) else {
287        return AdminError::NotFound(format!("no widget `{key}`")).into_response();
288    };
289    if let Some(denied) = gate_widget(&state, &user, &key, widget).await {
290        return denied;
291    }
292
293    let (params, _filters) =
294        resolve_widget_params(&user, &key, widget, query.as_deref().unwrap_or("")).await;
295    let payload = (widget.data.0.clone())(user, params).await;
296
297    let Some(csv) = payload.to_csv() else {
298        return AdminError::NotFound(format!(
299            "widget `{key}` renders a shape that has no rows to export"
300        ))
301        .into_response();
302    };
303
304    axum::response::Response::builder()
305        .status(StatusCode::OK)
306        .header("Content-Type", "text/csv; charset=utf-8")
307        .header(
308            "Content-Disposition",
309            format!("attachment; filename=\"{key}.csv\""),
310        )
311        .body(axum::body::Body::from(csv))
312        .unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "csv error").into_response())
313}
314
315pub(crate) async fn dashboard_widget_data(
316    State(state): State<AdminState>,
317    headers: HeaderMap,
318    Path(key): Path<String>,
319    axum::extract::RawQuery(query): axum::extract::RawQuery,
320) -> Response {
321    let user = match require_staff(&headers, "/admin/api/dashboard/widgets/.../data").await {
322        Ok(u) => u,
323        Err(r) => return r,
324    };
325    let Some(widget) = state.widget_catalog.iter().find(|w| w.key == key.as_str()) else {
326        return AdminError::NotFound(format!("no widget `{key}`")).into_response();
327    };
328    if let Some(denied) = gate_widget(&state, &user, &key, widget).await {
329        return denied;
330    }
331
332    let (params, filters) =
333        resolve_widget_params(&user, &key, widget, query.as_deref().unwrap_or("")).await;
334
335    let data_fn = widget.data.0.clone();
336    let payload = data_fn(user, params.clone()).await;
337    let exportable = payload.to_csv().is_some();
338
339    if is_htmx(&headers) {
340        let kind = widget.kind.as_str().to_string();
341        let title = widget.title.clone();
342        let payload_json = serde_json::to_value(&payload).unwrap_or(serde_json::Value::Null);
343        let active_period = params.period.clone().unwrap_or_default();
344        let widget_key = widget.key.to_string();
345        let filters_json = serde_json::to_value(&filters).unwrap_or(serde_json::Value::Null);
346        // Declared vs. synthesized: a line chart with no declared filters keeps
347        // its own inline chip strip (the historic behaviour). One that declares
348        // filters hands rendering to the generic strip and suppresses its chips,
349        // so a widget never shows two competing period strips.
350        let has_declared_filters = !widget.filters.is_empty();
351        // The export link must carry the filters currently in force, so the file
352        // matches the chart on screen.
353        let export_query = query.clone().unwrap_or_default();
354        return match render(
355            "admin/widget_data.html",
356            context!(
357                kind                 => kind,
358                title                => title,
359                payload              => payload_json,
360                widget_key           => widget_key,
361                active_period        => active_period,
362                filters              => filters_json,
363                has_declared_filters => has_declared_filters,
364                exportable           => exportable,
365                export_query         => export_query,
366            ),
367        ) {
368            Ok(html) => html.into_response(),
369            Err(e) => e.into_response(),
370        };
371    }
372    Json(serde_json::json!({
373        "key": key,
374        "kind": widget.kind.as_str(),
375        "title": widget.title,
376        "payload": serde_json::to_value(&payload).unwrap_or(serde_json::Value::Null),
377    }))
378    .into_response()
379}
380
381/// Resolve a widget's per-request params + filter states.
382///
383/// Shared by the data and export endpoints so a CSV can never be computed from
384/// different filters than the chart it came from.
385async fn resolve_widget_params(
386    user: &umbral_auth::AuthUser,
387    key: &str,
388    widget: &Widget,
389    raw_query: &str,
390) -> (
391    crate::widgets::WidgetParams,
392    Vec<crate::widgets::WidgetFilter>,
393) {
394    let user = user.clone();
395    let key = key.to_string();
396
397    // Per-request parameters parsed from the query string.
398    // Closures registered via `WidgetDataFn::with_params` read
399    // these to vary the response (`?period=7d`, etc.); closures
400    // registered via plain `::new` see them dropped.
401    let mut params = crate::widgets::WidgetParams::from_query(raw_query);
402
403    // gaps2 #11 round 2 — period resolution priority:
404    //
405    //   1. URL `?period=` (explicit user click on a chip THIS visit).
406    //   2. User's saved override at
407    //      `preferences.dashboard.widget_periods.<key>`.
408    //   3. Widget's registration-time `default_period`.
409    //
410    // When the URL carries an explicit `?period=`, we ALSO persist
411    // it as the user's new preference — chip clicks become sticky
412    // across reloads / tabs / devices without any extra UI surface
413    // or HTMX wiring.
414    if let Some(explicit) = params.period.clone() {
415        if let Err(e) = models::set_widget_period(user.id, &key, &explicit).await {
416            tracing::warn!(
417                user = user.id,
418                widget = %key,
419                period = %explicit,
420                error = %e,
421                "gaps2 #11: failed to persist widget period (continuing render)"
422            );
423        }
424    } else {
425        if let Ok(Some(saved)) = models::get_widget_period(user.id, &key).await {
426            params.period = Some(saved);
427        } else if let Some(default) = widget.default_period {
428            params.period = Some(default.to_string());
429        }
430    }
431
432    // Declarative filters resolve on the same ladder the period does:
433    //
434    //   1. an explicit value in THIS request's query string,
435    //   2. the value this user last picked (sticky, persisted),
436    //   3. the filter's registration-time default.
437    //
438    // An explicit pick is persisted so it survives a reload — the same deal
439    // period chips already got, extended to every control. The resolved value
440    // is written back into `params` so the data closure sees the user's choice
441    // even when the URL is bare, which is what makes a bookmarked /admin land
442    // on the dashboard you left rather than a reset one.
443    let mut filters = widget.effective_filters();
444    let saved = models::get_widget_filters(user.id, &key)
445        .await
446        .unwrap_or_default();
447
448    for filter in &mut filters {
449        match &filter.kind {
450            crate::widgets::WidgetFilterKind::DateRange => {
451                // A date range is two params, and a half-specified range is not
452                // a range — only persist and apply it when both ends are given.
453                if let (Some(s), Some(e)) = (params.start.clone(), params.end.clone()) {
454                    let _ = models::set_widget_filter(user.id, &key, "start", &s).await;
455                    let _ = models::set_widget_filter(user.id, &key, "end", &e).await;
456                } else {
457                    if params.start.is_none() {
458                        params.start = saved.get("start").cloned();
459                    }
460                    if params.end.is_none() {
461                        params.end = saved.get("end").cloned();
462                    }
463                }
464                filter.active_start = params.start.clone();
465                filter.active_end = params.end.clone();
466            }
467            crate::widgets::WidgetFilterKind::Period { .. } => {
468                // Resolved above; mirror it so the chip strip highlights.
469                filter.active = params.period.clone().or(filter.default.clone());
470            }
471            crate::widgets::WidgetFilterKind::Choice { .. } => {
472                let fk = filter.key.clone();
473                if let Some(explicit) = params.raw.get(&fk).cloned() {
474                    if let Err(e) = models::set_widget_filter(user.id, &key, &fk, &explicit).await {
475                        tracing::warn!(
476                            user = user.id, widget = %key, filter = %fk, error = %e,
477                            "admin: failed to persist widget filter (continuing render)"
478                        );
479                    }
480                    filter.active = Some(explicit);
481                } else {
482                    let resolved = saved.get(&fk).cloned().or_else(|| filter.default.clone());
483                    if let Some(v) = &resolved {
484                        params.raw.insert(fk, v.clone());
485                    }
486                    filter.active = resolved;
487                }
488            }
489        }
490    }
491
492    // Each control must carry the OTHER controls' current values in its URL.
493    // Without this, clicking "30d" on a widget filtered to status=paid would
494    // navigate to `?period=30d` alone and silently drop the status — the filter
495    // strip would lie about what the user is looking at.
496    let snapshot: Vec<(String, Option<String>, Option<String>, Option<String>)> = filters
497        .iter()
498        .map(|f| {
499            (
500                f.key.clone(),
501                f.active.clone(),
502                f.active_start.clone(),
503                f.active_end.clone(),
504            )
505        })
506        .collect();
507    for filter in &mut filters {
508        let mut carry = String::new();
509        for (other_key, active, start, end) in &snapshot {
510            if *other_key == filter.key {
511                continue;
512            }
513            if let (Some(s), Some(e)) = (start, end) {
514                carry.push_str(&format!(
515                    "&start={}&end={}",
516                    crate::util::urlencoding_simple(s),
517                    crate::util::urlencoding_simple(e)
518                ));
519            } else if let Some(v) = active {
520                carry.push_str(&format!(
521                    "&{}={}",
522                    crate::util::urlencoding_simple(other_key),
523                    crate::util::urlencoding_simple(v)
524                ));
525            }
526        }
527        filter.carry_lead = carry.trim_start_matches('&').to_string();
528        filter.carry = carry;
529    }
530
531    (params, filters)
532}