use http::{Response, StatusCode};
use crate::app_state::AppState;
use crate::i18n;
use crate::router::{Body, html_response};
use crate::routes::layout::{Nav, PageContext, html_escape, wrap_page};
use csaf_models::audit_log::AuditLogEntry;
use csaf_models::settings::Settings;
struct DashboardCounts {
documents: usize,
advisories: usize,
vex: usize,
informational: usize,
}
fn action_badge(lang: i18n::Lang, action: &str) -> String {
let (class, label) = match action {
"create" => ("badge-create", i18n::t(lang, "home.action_create")),
"update" => ("badge-update", i18n::t(lang, "home.action_update")),
"delete" => ("badge-delete", i18n::t(lang, "home.action_delete")),
"import" => ("badge-import", i18n::t(lang, "home.action_import")),
"export" => ("badge-export", i18n::t(lang, "home.action_export")),
_ => ("badge-create", action),
};
format!(r#"<span class="badge {class}">{label}</span>"#)
}
fn render_activity_rows(lang: i18n::Lang, recent_activity: &[AuditLogEntry]) -> String {
if recent_activity.is_empty() {
let empty_msg = i18n::t(lang, "home.no_activity");
return format!(
r#"<tr><td colspan="4" class="muted" style="text-align:center;">{empty_msg}</td></tr>"#
);
}
let mut rows = String::new();
for entry in recent_activity {
let details_display = entry.details.as_deref().unwrap_or("-");
rows.push_str(&format!(
"<tr><td>{ts}</td><td>{badge}</td><td><a href=\"/csaf/{tid}\">{tid}</a></td><td>{details}</td></tr>",
ts = html_escape(&entry.timestamp),
badge = action_badge(lang, &entry.action),
tid = html_escape(&entry.tracking_id),
details = html_escape(details_display),
));
}
rows
}
fn render_content(
lang: i18n::Lang,
counts: &DashboardCounts,
settings: &Settings,
activity_rows: &str,
) -> String {
format!(
r#"<h1>{title}</h1>
<div class="card stats">
<div class="stat"><div class="number">{doc_count}</div><div class="label">{stat_documents}</div></div>
<div class="stat"><div class="number">{advisory_count}</div><div class="label">{stat_advisories}</div></div>
<div class="stat"><div class="number">{vex_count}</div><div class="label">{stat_vex}</div></div>
<div class="stat"><div class="number">{informational_count}</div><div class="label">{stat_informational}</div></div>
</div>
<div class="card">
<h2>{config_title}</h2>
<p>{config_csaf_mode}: <strong>{csaf_mode}</strong> | {config_theme}: <strong>{theme}</strong> | {config_naming}: <strong>{naming}</strong></p>
</div>
<div class="card">
<h2>{activity_title}</h2>
<table>
<thead><tr><th>{col_timestamp}</th><th>{col_action}</th><th>{col_tracking_id}</th><th>{col_details}</th></tr></thead>
<tbody>{activity_rows}</tbody>
</table>
</div>"#,
title = i18n::t(lang, "nav.dashboard"),
doc_count = counts.documents,
stat_documents = i18n::t(lang, "home.stat_documents"),
advisory_count = counts.advisories,
stat_advisories = i18n::t(lang, "home.stat_advisories"),
vex_count = counts.vex,
stat_vex = i18n::t(lang, "home.stat_vex"),
informational_count = counts.informational,
stat_informational = i18n::t(lang, "home.stat_informational"),
config_title = i18n::t(lang, "home.config_title"),
config_csaf_mode = i18n::t(lang, "home.config_csaf_mode"),
csaf_mode = html_escape(&settings.csaf_mode),
config_theme = i18n::t(lang, "home.config_theme"),
theme = html_escape(&settings.theme),
config_naming = i18n::t(lang, "home.config_naming"),
naming = html_escape(&settings.naming_convention),
activity_title = i18n::t(lang, "home.activity_title"),
col_timestamp = i18n::t(lang, "home.col_timestamp"),
col_action = i18n::t(lang, "home.col_action"),
col_tracking_id = i18n::t(lang, "home.col_tracking_id"),
col_details = i18n::t(lang, "home.col_details"),
)
}
pub async fn dashboard(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let counts = DashboardCounts {
documents: state.csaf_storage().count_documents().unwrap_or(0),
advisories: state
.csaf_storage()
.list_by_category("csaf_security_advisory")
.map(|v| v.len())
.unwrap_or(0),
vex: state
.csaf_storage()
.list_by_category("csaf_vex")
.map(|v| v.len())
.unwrap_or(0),
informational: state
.csaf_storage()
.list_by_category("csaf_informational_advisory")
.map(|v| v.len())
.unwrap_or(0),
};
let recent_activity = state
.db_pool()
.with_conn(|conn| csaf_models::audit_log::list(conn, None, 20, 0))
.unwrap_or_else(|_| Vec::new());
let activity_rows = render_activity_rows(lang, &recent_activity);
let settings = state.settings();
let content = render_content(lang, &counts, &settings, &activity_rows);
html_response(
StatusCode::OK,
wrap_page(
i18n::t(lang, "nav.dashboard"),
PageContext {
theme: &settings.theme,
lang,
},
Nav::Home,
&content,
),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_action_badge_classes() {
assert!(action_badge(i18n::Lang::En, "create").contains("badge-create"));
assert!(action_badge(i18n::Lang::En, "delete").contains("badge-delete"));
assert!(action_badge(i18n::Lang::En, "import").contains("badge-import"));
}
#[test]
fn test_action_badge_translates_the_label_but_not_the_class() {
let badge = action_badge(i18n::Lang::De, "delete");
assert!(badge.contains("badge-delete"), "class stays literal ASCII");
assert!(badge.contains("löschen"), "label is translated");
assert!(
!badge.contains(">delete<"),
"English label must not leak through"
);
}
#[test]
fn test_action_badge_falls_back_to_the_raw_action_for_unknown_actions() {
let badge = action_badge(i18n::Lang::De, "settings_reset");
assert!(
badge.contains("badge-create"),
"unstyled actions get the default class"
);
assert!(
badge.contains(">settings_reset<"),
"unknown actions show their raw name"
);
}
#[test]
fn test_render_activity_rows_empty_state_is_translated() {
let rows = render_activity_rows(i18n::Lang::Fr, &[]);
assert!(rows.contains("Aucune activité enregistrée pour le moment."));
}
}