use axum::extract::State;
use minijinja::context;
use umbral::orm::DynQuerySet;
use umbral::web::{HeaderMap, IntoResponse, Json, Path, Response, StatusCode};
use crate::AdminState;
use crate::auth::require_staff;
use crate::discovery::find_model;
use crate::engine::render;
use crate::error::AdminError;
use crate::models;
use crate::util::is_htmx;
use crate::widgets::{
BarPayload, CatalogEntry, ChartPoint, FeedItem, FeedPayload, Series, Span, Widget,
WidgetDataFn, WidgetKind, WidgetPayload,
};
pub fn builtin_total_models_widget() -> Widget {
Widget {
key: "umbral_total_models",
title: "Models by Plugin".to_string(),
kind: WidgetKind::Bar,
default_span: Span { cols: 4, rows: 2 },
permission: None,
default_period: None,
data: WidgetDataFn::new(|_user| async move {
let points = models_by_plugin_points();
WidgetPayload::Bar(BarPayload {
series: vec![Series {
name: "models".to_string(),
points,
}],
x_type: "plugin".to_string(),
})
}),
}
}
fn models_by_plugin_points() -> Vec<ChartPoint> {
let mut assigned = std::collections::HashSet::new();
let mut points: Vec<ChartPoint> = Vec::new();
for plugin in umbral::migrate::registered_plugins() {
let models = umbral::migrate::models_for_plugin(&plugin);
for model in &models {
assigned.insert(model.table.clone());
}
if !models.is_empty() {
points.push(ChartPoint {
x: plugin,
y: models.len() as f64,
});
}
}
let app_count = umbral::migrate::registered_models()
.into_iter()
.filter(|model| !assigned.contains(&model.table))
.count();
if app_count > 0 {
points.push(ChartPoint {
x: "app".to_string(),
y: app_count as f64,
});
}
points.sort_by(|a, b| match (a.x.as_str(), b.x.as_str()) {
("app", "app") => std::cmp::Ordering::Equal,
("app", _) => std::cmp::Ordering::Greater,
(_, "app") => std::cmp::Ordering::Less,
_ => a.x.cmp(&b.x),
});
points
}
pub fn builtin_recent_users_widget() -> Widget {
Widget {
key: "umbral_recent_users",
title: "Recent Signups".to_string(),
kind: WidgetKind::Feed,
default_span: Span { cols: 4, rows: 2 },
permission: None,
default_period: None,
data: WidgetDataFn::new(|_user| async move {
let items = match find_model("auth_user") {
Some((_, meta)) => {
let rows = DynQuerySet::for_meta(&meta)
.select_cols(&["username".to_string(), "date_joined".to_string()])
.order_by_col("date_joined", true)
.limit(5)
.fetch_as_strings()
.await;
match rows {
Ok(rows) => rows
.into_iter()
.map(|r| FeedItem {
actor: r.get("username").cloned().unwrap_or_default(),
verb: "signed".to_string(),
object: "up".to_string(),
object_link: None,
at: r.get("date_joined").cloned().unwrap_or_default(),
})
.collect(),
Err(e) => {
tracing::debug!(error = %e, "umbral_recent_users: auth_user fetch failed; empty feed");
vec![]
}
}
}
None => vec![],
};
let mut payload = FeedPayload::new(items);
if let Some((_, meta)) = find_model("auth_user") {
payload.view_all_url = Some(format!(
"{}/{}/",
crate::branding::current().base_path,
meta.table,
));
}
WidgetPayload::Feed(payload)
}),
}
}
pub(crate) async fn dashboard_catalog(
State(state): State<AdminState>,
headers: HeaderMap,
) -> Response {
let user = match require_staff(&headers, "/admin/api/dashboard/catalog").await {
Ok(u) => u,
Err(r) => return r,
};
let mut entries: Vec<CatalogEntry> = Vec::with_capacity(state.widget_catalog.len());
for w in state.widget_catalog.iter() {
if let Some(code) = w.permission {
if !crate::permcheck::has_codename(&user, code).await {
continue;
}
}
entries.push(CatalogEntry {
key: w.key,
title: w.title.clone(),
kind: w.kind.as_str().to_string(),
default_span: w.default_span.clone(),
});
}
Json(entries).into_response()
}
pub(crate) async fn dashboard_layout_get(headers: HeaderMap) -> Response {
let user = match require_staff(&headers, "/admin/api/dashboard/layout").await {
Ok(u) => u,
Err(r) => return r,
};
let prefs = match models::fetch_or_default(user.id).await {
Ok(p) => p,
Err(e) => {
tracing::error!(error = %e, "admin: dashboard_layout_get failed");
return (StatusCode::INTERNAL_SERVER_ERROR, "layout error").into_response();
}
};
axum::response::Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(axum::body::Body::from(prefs.dashboard_layout))
.unwrap_or_else(|_| (StatusCode::OK, "[]").into_response())
}
pub(crate) async fn dashboard_layout_put(headers: HeaderMap, body: String) -> Response {
let user = match require_staff(&headers, "/admin/api/dashboard/layout").await {
Ok(u) => u,
Err(r) => return r,
};
if serde_json::from_str::<serde_json::Value>(&body).is_err() {
return (StatusCode::BAD_REQUEST, "invalid JSON layout").into_response();
}
let mut prefs = match models::fetch_or_default(user.id).await {
Ok(p) => p,
Err(e) => {
tracing::error!(error = %e, "admin: dashboard_layout_put fetch failed");
return (StatusCode::INTERNAL_SERVER_ERROR, "layout error").into_response();
}
};
prefs.dashboard_layout = body;
match models::upsert(prefs).await {
Ok(_) => Json(serde_json::json!({ "ok": true })).into_response(),
Err(e) => {
tracing::error!(error = %e, "admin: dashboard_layout_put save failed");
(StatusCode::INTERNAL_SERVER_ERROR, "layout save error").into_response()
}
}
}
pub(crate) async fn dashboard_widget_data(
State(state): State<AdminState>,
headers: HeaderMap,
Path(key): Path<String>,
axum::extract::RawQuery(query): axum::extract::RawQuery,
) -> Response {
let user = match require_staff(&headers, "/admin/api/dashboard/widgets/.../data").await {
Ok(u) => u,
Err(r) => return r,
};
let Some(widget) = state.widget_catalog.iter().find(|w| w.key == key.as_str()) else {
return AdminError::NotFound(format!("no widget `{key}`")).into_response();
};
if let Some(code) = state.widget_gates.get(key.as_str()) {
if let Err(r) = crate::permcheck::require_codename(&user, code).await {
return r;
}
}
if let Some(code) = widget.permission {
if let Err(r) = crate::permcheck::require_codename(&user, code).await {
return r;
}
}
let mut params = crate::widgets::WidgetParams::from_query(query.as_deref().unwrap_or(""));
if let Some(explicit) = params.period.clone() {
if let Err(e) = models::set_widget_period(user.id, &key, &explicit).await {
tracing::warn!(
user = user.id,
widget = %key,
period = %explicit,
error = %e,
"gaps2 #11: failed to persist widget period (continuing render)"
);
}
} else {
if let Ok(Some(saved)) = models::get_widget_period(user.id, &key).await {
params.period = Some(saved);
} else if let Some(default) = widget.default_period {
params.period = Some(default.to_string());
}
}
let data_fn = widget.data.0.clone();
let payload = data_fn(user, params.clone()).await;
if is_htmx(&headers) {
let kind = widget.kind.as_str().to_string();
let title = widget.title.clone();
let payload_json = serde_json::to_value(&payload).unwrap_or(serde_json::Value::Null);
let active_period = params.period.clone().unwrap_or_default();
let widget_key = widget.key.to_string();
match render(
"admin/widget_data.html",
context!(
kind => kind,
title => title,
payload => payload_json,
widget_key => widget_key,
active_period => active_period,
),
) {
Ok(html) => html.into_response(),
Err(e) => e.into_response(),
}
} else {
Json(serde_json::json!({
"key": key,
"kind": widget.kind.as_str(),
"title": widget.title,
"payload": serde_json::to_value(&payload).unwrap_or(serde_json::Value::Null),
}))
.into_response()
}
}