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        data: WidgetDataFn::new(|_user| async move {
36            let points = models_by_plugin_points();
37            WidgetPayload::Bar(BarPayload {
38                series: vec![Series {
39                    name: "models".to_string(),
40                    points,
41                }],
42                x_type: "plugin".to_string(),
43            })
44        }),
45    }
46}
47
48fn models_by_plugin_points() -> Vec<ChartPoint> {
49    let mut assigned = std::collections::HashSet::new();
50    let mut points: Vec<ChartPoint> = Vec::new();
51
52    for plugin in umbral::migrate::registered_plugins() {
53        let models = umbral::migrate::models_for_plugin(&plugin);
54        for model in &models {
55            assigned.insert(model.table.clone());
56        }
57        if !models.is_empty() {
58            points.push(ChartPoint {
59                x: plugin,
60                y: models.len() as f64,
61            });
62        }
63    }
64
65    let app_count = umbral::migrate::registered_models()
66        .into_iter()
67        .filter(|model| !assigned.contains(&model.table))
68        .count();
69    if app_count > 0 {
70        points.push(ChartPoint {
71            x: "app".to_string(),
72            y: app_count as f64,
73        });
74    }
75
76    points.sort_by(|a, b| match (a.x.as_str(), b.x.as_str()) {
77        ("app", "app") => std::cmp::Ordering::Equal,
78        ("app", _) => std::cmp::Ordering::Greater,
79        (_, "app") => std::cmp::Ordering::Less,
80        _ => a.x.cmp(&b.x),
81    });
82    points
83}
84
85/// `Recent signups` feed — last 5 `auth_user` rows ordered by
86/// `date_joined`. Gracefully degrades to an empty list if the table
87/// is absent (e.g. an admin-only install where `AuthPlugin` isn't
88/// registered), so this widget never breaks the dashboard.
89///
90/// Goes through [`DynQuerySet`] keyed off the `auth_user` `ModelMeta`
91/// — that way the widget works against any custom user model
92/// `AuthPlugin::<U>` registers, not just the built-in `AuthUser`. If
93/// the registry doesn't know about an `auth_user` table (the
94/// degraded-install case), the widget returns an empty feed.
95pub fn builtin_recent_users_widget() -> Widget {
96    Widget {
97        key: "umbral_recent_users",
98        title: "Recent Signups".to_string(),
99        kind: WidgetKind::Feed,
100        default_span: Span { cols: 4, rows: 2 },
101        permission: None,
102        default_period: None,
103        data: WidgetDataFn::new(|_user| async move {
104            let items = match find_model("auth_user") {
105                Some((_, meta)) => {
106                    let rows = DynQuerySet::for_meta(&meta)
107                        .select_cols(&["username".to_string(), "date_joined".to_string()])
108                        .order_by_col("date_joined", true)
109                        .limit(5)
110                        .fetch_as_strings()
111                        .await;
112                    match rows {
113                        Ok(rows) => rows
114                            .into_iter()
115                            .map(|r| FeedItem {
116                                actor: r.get("username").cloned().unwrap_or_default(),
117                                verb: "signed".to_string(),
118                                object: "up".to_string(),
119                                object_link: None,
120                                at: r.get("date_joined").cloned().unwrap_or_default(),
121                            })
122                            .collect(),
123                        Err(e) => {
124                            tracing::debug!(error = %e, "umbral_recent_users: auth_user fetch failed; empty feed");
125                            vec![]
126                        }
127                    }
128                }
129                None => vec![],
130            };
131            // Auto-resolve "View all →" to the admin's auth_user
132            // changelist — works for any UserModel registered with
133            // AuthPlugin since the table name is read from the
134            // ModelMeta we already looked up.
135            let mut payload = FeedPayload::new(items);
136            if let Some((_, meta)) = find_model("auth_user") {
137                payload.view_all_url = Some(format!(
138                    "{}/{}/",
139                    crate::branding::current().base_path,
140                    meta.table,
141                ));
142            }
143            WidgetPayload::Feed(payload)
144        }),
145    }
146}
147
148// =========================================================================
149// API handlers
150// =========================================================================
151
152/// `GET /admin/api/dashboard/catalog` — list widgets the user may add to
153/// the dashboard.
154pub(crate) async fn dashboard_catalog(
155    State(state): State<AdminState>,
156    headers: HeaderMap,
157) -> Response {
158    let user = match require_staff(&headers, "/admin/api/dashboard/catalog").await {
159        Ok(u) => u,
160        Err(r) => return r,
161    };
162    // gaps3 #6: omit widgets the user can't load. Otherwise a user without a
163    // widget's codename sees it in the "add widget" catalog, adds it, then
164    // gets a 403 on the data fetch (the data endpoint IS gated). Same
165    // per-widget `permission` check `dashboard_widget_data` enforces.
166    let mut entries: Vec<CatalogEntry> = Vec::with_capacity(state.widget_catalog.len());
167    for w in state.widget_catalog.iter() {
168        if let Some(code) = w.permission {
169            if !crate::permcheck::has_codename(&user, code).await {
170                continue;
171            }
172        }
173        entries.push(CatalogEntry {
174            key: w.key,
175            title: w.title.clone(),
176            kind: w.kind.as_str().to_string(),
177            default_span: w.default_span.clone(),
178        });
179    }
180    Json(entries).into_response()
181}
182
183/// `GET /admin/api/dashboard/layout` — user's saved layout or default.
184/// The body is returned as raw JSON because we round-trip it through
185/// the prefs row as a string.
186pub(crate) async fn dashboard_layout_get(headers: HeaderMap) -> Response {
187    let user = match require_staff(&headers, "/admin/api/dashboard/layout").await {
188        Ok(u) => u,
189        Err(r) => return r,
190    };
191    let prefs = match models::fetch_or_default(user.id).await {
192        Ok(p) => p,
193        Err(e) => {
194            tracing::error!(error = %e, "admin: dashboard_layout_get failed");
195            return (StatusCode::INTERNAL_SERVER_ERROR, "layout error").into_response();
196        }
197    };
198    axum::response::Response::builder()
199        .status(StatusCode::OK)
200        .header("Content-Type", "application/json")
201        .body(axum::body::Body::from(prefs.dashboard_layout))
202        .unwrap_or_else(|_| (StatusCode::OK, "[]").into_response())
203}
204
205/// `PUT /admin/api/dashboard/layout` — save the user's layout. Body
206/// must be a JSON array of widget instances; non-JSON 400s. Validity
207/// of the array shape is the client's problem until we lock down a
208/// schema for it.
209pub(crate) async fn dashboard_layout_put(headers: HeaderMap, body: String) -> Response {
210    let user = match require_staff(&headers, "/admin/api/dashboard/layout").await {
211        Ok(u) => u,
212        Err(r) => return r,
213    };
214    if serde_json::from_str::<serde_json::Value>(&body).is_err() {
215        return (StatusCode::BAD_REQUEST, "invalid JSON layout").into_response();
216    }
217    let mut prefs = match models::fetch_or_default(user.id).await {
218        Ok(p) => p,
219        Err(e) => {
220            tracing::error!(error = %e, "admin: dashboard_layout_put fetch failed");
221            return (StatusCode::INTERNAL_SERVER_ERROR, "layout error").into_response();
222        }
223    };
224    prefs.dashboard_layout = body;
225    match models::upsert(prefs).await {
226        Ok(_) => Json(serde_json::json!({ "ok": true })).into_response(),
227        Err(e) => {
228            tracing::error!(error = %e, "admin: dashboard_layout_put save failed");
229            (StatusCode::INTERNAL_SERVER_ERROR, "layout save error").into_response()
230        }
231    }
232}
233
234/// `GET /admin/api/dashboard/widgets/{key}/data` — compute and return
235/// one widget's payload. Returns either JSON (API consumers) or an
236/// HTML fragment (HTMX swap).
237pub(crate) async fn dashboard_widget_data(
238    State(state): State<AdminState>,
239    headers: HeaderMap,
240    Path(key): Path<String>,
241    axum::extract::RawQuery(query): axum::extract::RawQuery,
242) -> Response {
243    let user = match require_staff(&headers, "/admin/api/dashboard/widgets/.../data").await {
244        Ok(u) => u,
245        Err(r) => return r,
246    };
247    let Some(widget) = state.widget_catalog.iter().find(|w| w.key == key.as_str()) else {
248        return AdminError::NotFound(format!("no widget `{key}`")).into_response();
249    };
250
251    // Security gate: if this widget belongs to a permission-gated custom view,
252    // the requesting user must hold the view's codename — the same check the
253    // page handler enforces. Without this, a staff user blocked from the page
254    // could bypass `.with_permission(...)` by calling the data endpoint directly.
255    if let Some(code) = state.widget_gates.get(key.as_str()) {
256        if let Err(r) = crate::permcheck::require_codename(&user, code).await {
257            return r;
258        }
259    }
260
261    // Per-widget permission gate (independent of any view-level gate above).
262    // A widget with `permission: Some(codename)` may only be fetched by a
263    // user holding that codename, regardless of which page the widget lives on.
264    // Graceful no-op: `require_codename` allows all when PermissionsPlugin is absent.
265    if let Some(code) = widget.permission {
266        if let Err(r) = crate::permcheck::require_codename(&user, code).await {
267            return r;
268        }
269    }
270
271    // Per-request parameters parsed from the query string.
272    // Closures registered via `WidgetDataFn::with_params` read
273    // these to vary the response (`?period=7d`, etc.); closures
274    // registered via plain `::new` see them dropped.
275    let mut params = crate::widgets::WidgetParams::from_query(query.as_deref().unwrap_or(""));
276
277    // gaps2 #11 round 2 — period resolution priority:
278    //
279    //   1. URL `?period=` (explicit user click on a chip THIS visit).
280    //   2. User's saved override at
281    //      `preferences.dashboard.widget_periods.<key>`.
282    //   3. Widget's registration-time `default_period`.
283    //
284    // When the URL carries an explicit `?period=`, we ALSO persist
285    // it as the user's new preference — chip clicks become sticky
286    // across reloads / tabs / devices without any extra UI surface
287    // or HTMX wiring.
288    if let Some(explicit) = params.period.clone() {
289        if let Err(e) = models::set_widget_period(user.id, &key, &explicit).await {
290            tracing::warn!(
291                user = user.id,
292                widget = %key,
293                period = %explicit,
294                error = %e,
295                "gaps2 #11: failed to persist widget period (continuing render)"
296            );
297        }
298    } else {
299        if let Ok(Some(saved)) = models::get_widget_period(user.id, &key).await {
300            params.period = Some(saved);
301        } else if let Some(default) = widget.default_period {
302            params.period = Some(default.to_string());
303        }
304    }
305    let data_fn = widget.data.0.clone();
306    let payload = data_fn(user, params.clone()).await;
307
308    if is_htmx(&headers) {
309        let kind = widget.kind.as_str().to_string();
310        let title = widget.title.clone();
311        let payload_json = serde_json::to_value(&payload).unwrap_or(serde_json::Value::Null);
312        // Pass the active period through to the template so the
313        // chip strip can highlight the current selection.
314        let active_period = params.period.clone().unwrap_or_default();
315        let widget_key = widget.key.to_string();
316        match render(
317            "admin/widget_data.html",
318            context!(
319                kind          => kind,
320                title         => title,
321                payload       => payload_json,
322                widget_key    => widget_key,
323                active_period => active_period,
324            ),
325        ) {
326            Ok(html) => html.into_response(),
327            Err(e) => e.into_response(),
328        }
329    } else {
330        Json(serde_json::json!({
331            "key": key,
332            "kind": widget.kind.as_str(),
333            "title": widget.title,
334            "payload": serde_json::to_value(&payload).unwrap_or(serde_json::Value::Null),
335        }))
336        .into_response()
337    }
338}