1use 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
20pub 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
86pub 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 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
150pub(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 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
185pub(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
207pub(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
236async fn gate_widget(
245 state: &AdminState,
246 user: &umbral_auth::AuthUser,
247 key: &str,
248 widget: &Widget,
249) -> Option<Response> {
250 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 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
269pub(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 let has_declared_filters = !widget.filters.is_empty();
351 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
381async 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 let mut params = crate::widgets::WidgetParams::from_query(raw_query);
402
403 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 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 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 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 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}