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 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
85pub 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 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
148pub(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
174pub(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
196pub(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
225pub(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 let mut params = crate::widgets::WidgetParams::from_query(query.as_deref().unwrap_or(""));
247
248 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 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}