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. Filtered by per-widget permission once those land.
154pub(crate) async fn dashboard_catalog(
155    State(state): State<AdminState>,
156    headers: HeaderMap,
157) -> Response {
158    if let Err(r) = require_staff(&headers, "/admin/api/dashboard/catalog").await {
159        return r;
160    }
161    let entries: Vec<CatalogEntry> = state
162        .widget_catalog
163        .iter()
164        .map(|w| CatalogEntry {
165            key: w.key,
166            title: w.title.clone(),
167            kind: w.kind.as_str().to_string(),
168            default_span: w.default_span.clone(),
169        })
170        .collect();
171    Json(entries).into_response()
172}
173
174/// `GET /admin/api/dashboard/layout` — user's saved layout or default.
175/// The body is returned as raw JSON because we round-trip it through
176/// the prefs row as a string.
177pub(crate) async fn dashboard_layout_get(headers: HeaderMap) -> Response {
178    let user = match require_staff(&headers, "/admin/api/dashboard/layout").await {
179        Ok(u) => u,
180        Err(r) => return r,
181    };
182    let prefs = match models::fetch_or_default(user.id).await {
183        Ok(p) => p,
184        Err(e) => {
185            tracing::error!(error = %e, "admin: dashboard_layout_get failed");
186            return (StatusCode::INTERNAL_SERVER_ERROR, "layout error").into_response();
187        }
188    };
189    axum::response::Response::builder()
190        .status(StatusCode::OK)
191        .header("Content-Type", "application/json")
192        .body(axum::body::Body::from(prefs.dashboard_layout))
193        .unwrap_or_else(|_| (StatusCode::OK, "[]").into_response())
194}
195
196/// `PUT /admin/api/dashboard/layout` — save the user's layout. Body
197/// must be a JSON array of widget instances; non-JSON 400s. Validity
198/// of the array shape is the client's problem until we lock down a
199/// schema for it.
200pub(crate) async fn dashboard_layout_put(headers: HeaderMap, body: String) -> Response {
201    let user = match require_staff(&headers, "/admin/api/dashboard/layout").await {
202        Ok(u) => u,
203        Err(r) => return r,
204    };
205    if serde_json::from_str::<serde_json::Value>(&body).is_err() {
206        return (StatusCode::BAD_REQUEST, "invalid JSON layout").into_response();
207    }
208    let mut prefs = match models::fetch_or_default(user.id).await {
209        Ok(p) => p,
210        Err(e) => {
211            tracing::error!(error = %e, "admin: dashboard_layout_put fetch failed");
212            return (StatusCode::INTERNAL_SERVER_ERROR, "layout error").into_response();
213        }
214    };
215    prefs.dashboard_layout = body;
216    match models::upsert(prefs).await {
217        Ok(_) => Json(serde_json::json!({ "ok": true })).into_response(),
218        Err(e) => {
219            tracing::error!(error = %e, "admin: dashboard_layout_put save failed");
220            (StatusCode::INTERNAL_SERVER_ERROR, "layout save error").into_response()
221        }
222    }
223}
224
225/// `GET /admin/api/dashboard/widgets/{key}/data` — compute and return
226/// one widget's payload. Returns either JSON (API consumers) or an
227/// HTML fragment (HTMX swap).
228pub(crate) async fn dashboard_widget_data(
229    State(state): State<AdminState>,
230    headers: HeaderMap,
231    Path(key): Path<String>,
232    axum::extract::RawQuery(query): axum::extract::RawQuery,
233) -> Response {
234    let user = match require_staff(&headers, "/admin/api/dashboard/widgets/.../data").await {
235        Ok(u) => u,
236        Err(r) => return r,
237    };
238    let Some(widget) = state.widget_catalog.iter().find(|w| w.key == key.as_str()) else {
239        return AdminError::NotFound(format!("no widget `{key}`")).into_response();
240    };
241
242    // Per-request parameters parsed from the query string.
243    // Closures registered via `WidgetDataFn::with_params` read
244    // these to vary the response (`?period=7d`, etc.); closures
245    // registered via plain `::new` see them dropped.
246    let mut params = crate::widgets::WidgetParams::from_query(query.as_deref().unwrap_or(""));
247
248    // gaps2 #11 round 2 — period resolution priority:
249    //
250    //   1. URL `?period=` (explicit user click on a chip THIS visit).
251    //   2. User's saved override at
252    //      `preferences.dashboard.widget_periods.<key>`.
253    //   3. Widget's registration-time `default_period`.
254    //
255    // When the URL carries an explicit `?period=`, we ALSO persist
256    // it as the user's new preference — chip clicks become sticky
257    // across reloads / tabs / devices without any extra UI surface
258    // or HTMX wiring.
259    if let Some(explicit) = params.period.clone() {
260        if let Err(e) = models::set_widget_period(user.id, &key, &explicit).await {
261            tracing::warn!(
262                user = user.id,
263                widget = %key,
264                period = %explicit,
265                error = %e,
266                "gaps2 #11: failed to persist widget period (continuing render)"
267            );
268        }
269    } else {
270        if let Ok(Some(saved)) = models::get_widget_period(user.id, &key).await {
271            params.period = Some(saved);
272        } else if let Some(default) = widget.default_period {
273            params.period = Some(default.to_string());
274        }
275    }
276    let data_fn = widget.data.0.clone();
277    let payload = data_fn(user, params.clone()).await;
278
279    if is_htmx(&headers) {
280        let kind = widget.kind.as_str().to_string();
281        let title = widget.title.clone();
282        let payload_json = serde_json::to_value(&payload).unwrap_or(serde_json::Value::Null);
283        // Pass the active period through to the template so the
284        // chip strip can highlight the current selection.
285        let active_period = params.period.clone().unwrap_or_default();
286        let widget_key = widget.key.to_string();
287        match render(
288            "admin/widget_data.html",
289            context!(
290                kind          => kind,
291                title         => title,
292                payload       => payload_json,
293                widget_key    => widget_key,
294                active_period => active_period,
295            ),
296        ) {
297            Ok(html) => html.into_response(),
298            Err(e) => e.into_response(),
299        }
300    } else {
301        Json(serde_json::json!({
302            "key": key,
303            "kind": widget.kind.as_str(),
304            "title": widget.title,
305            "payload": serde_json::to_value(&payload).unwrap_or(serde_json::Value::Null),
306        }))
307        .into_response()
308    }
309}