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 let user = match require_staff(&headers, "/admin/api/dashboard/catalog").await {
159 Ok(u) => u,
160 Err(r) => return r,
161 };
162 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
183pub(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
205pub(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
234pub(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 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 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 let mut params = crate::widgets::WidgetParams::from_query(query.as_deref().unwrap_or(""));
276
277 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 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}