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,
filters: Vec::new(),
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,
filters: Vec::new(),
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()
}
}
}
async fn gate_widget(
state: &AdminState,
user: &umbral_auth::AuthUser,
key: &str,
widget: &Widget,
) -> Option<Response> {
if let Some(code) = state.widget_gates.get(key) {
if let Err(r) = crate::permcheck::require_codename(user, code).await {
return Some(r);
}
}
if let Some(code) = widget.permission {
if let Err(r) = crate::permcheck::require_codename(user, code).await {
return Some(r);
}
}
None
}
pub(crate) async fn dashboard_widget_export(
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/.../export.csv").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(denied) = gate_widget(&state, &user, &key, widget).await {
return denied;
}
let (params, _filters) =
resolve_widget_params(&user, &key, widget, query.as_deref().unwrap_or("")).await;
let payload = (widget.data.0.clone())(user, params).await;
let Some(csv) = payload.to_csv() else {
return AdminError::NotFound(format!(
"widget `{key}` renders a shape that has no rows to export"
))
.into_response();
};
axum::response::Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "text/csv; charset=utf-8")
.header(
"Content-Disposition",
format!("attachment; filename=\"{key}.csv\""),
)
.body(axum::body::Body::from(csv))
.unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "csv 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(denied) = gate_widget(&state, &user, &key, widget).await {
return denied;
}
let (params, filters) =
resolve_widget_params(&user, &key, widget, query.as_deref().unwrap_or("")).await;
let data_fn = widget.data.0.clone();
let payload = data_fn(user, params.clone()).await;
let exportable = payload.to_csv().is_some();
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();
let filters_json = serde_json::to_value(&filters).unwrap_or(serde_json::Value::Null);
let has_declared_filters = !widget.filters.is_empty();
let export_query = query.clone().unwrap_or_default();
return match render(
"admin/widget_data.html",
context!(
kind => kind,
title => title,
payload => payload_json,
widget_key => widget_key,
active_period => active_period,
filters => filters_json,
has_declared_filters => has_declared_filters,
exportable => exportable,
export_query => export_query,
),
) {
Ok(html) => html.into_response(),
Err(e) => e.into_response(),
};
}
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()
}
async fn resolve_widget_params(
user: &umbral_auth::AuthUser,
key: &str,
widget: &Widget,
raw_query: &str,
) -> (
crate::widgets::WidgetParams,
Vec<crate::widgets::WidgetFilter>,
) {
let user = user.clone();
let key = key.to_string();
let mut params = crate::widgets::WidgetParams::from_query(raw_query);
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 mut filters = widget.effective_filters();
let saved = models::get_widget_filters(user.id, &key)
.await
.unwrap_or_default();
for filter in &mut filters {
match &filter.kind {
crate::widgets::WidgetFilterKind::DateRange => {
if let (Some(s), Some(e)) = (params.start.clone(), params.end.clone()) {
let _ = models::set_widget_filter(user.id, &key, "start", &s).await;
let _ = models::set_widget_filter(user.id, &key, "end", &e).await;
} else {
if params.start.is_none() {
params.start = saved.get("start").cloned();
}
if params.end.is_none() {
params.end = saved.get("end").cloned();
}
}
filter.active_start = params.start.clone();
filter.active_end = params.end.clone();
}
crate::widgets::WidgetFilterKind::Period { .. } => {
filter.active = params.period.clone().or(filter.default.clone());
}
crate::widgets::WidgetFilterKind::Choice { .. } => {
let fk = filter.key.clone();
if let Some(explicit) = params.raw.get(&fk).cloned() {
if let Err(e) = models::set_widget_filter(user.id, &key, &fk, &explicit).await {
tracing::warn!(
user = user.id, widget = %key, filter = %fk, error = %e,
"admin: failed to persist widget filter (continuing render)"
);
}
filter.active = Some(explicit);
} else {
let resolved = saved.get(&fk).cloned().or_else(|| filter.default.clone());
if let Some(v) = &resolved {
params.raw.insert(fk, v.clone());
}
filter.active = resolved;
}
}
}
}
let snapshot: Vec<(String, Option<String>, Option<String>, Option<String>)> = filters
.iter()
.map(|f| {
(
f.key.clone(),
f.active.clone(),
f.active_start.clone(),
f.active_end.clone(),
)
})
.collect();
for filter in &mut filters {
let mut carry = String::new();
for (other_key, active, start, end) in &snapshot {
if *other_key == filter.key {
continue;
}
if let (Some(s), Some(e)) = (start, end) {
carry.push_str(&format!(
"&start={}&end={}",
crate::util::urlencoding_simple(s),
crate::util::urlencoding_simple(e)
));
} else if let Some(v) = active {
carry.push_str(&format!(
"&{}={}",
crate::util::urlencoding_simple(other_key),
crate::util::urlencoding_simple(v)
));
}
}
filter.carry_lead = carry.trim_start_matches('&').to_string();
filter.carry = carry;
}
(params, filters)
}