csaf-crud 1.4.15

CSAF 2.0 / 2.1 advisory CRUD server with HATEOAS JSON API and HTML UI (TLS 1.3, HTTP/1.1 + HTTP/2 + HTTP/3)
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne

//! Dashboard route handler.
//!
//! Displays document count, category breakdown, and recent audit log activity.

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;

/// Per-category document counts shown on the dashboard's stat cards.
struct DashboardCounts {
    documents: usize,
    advisories: usize,
    vex: usize,
    informational: usize,
}

/// Format an audit action as a translated HTML badge.
///
/// The CSS class always uses the literal ASCII action name (styling and
/// tests key off it); the visible label is translated for the five known
/// actions and falls back to the raw action string for anything else (e.g.
/// `settings_reset`, which has no dedicated badge style or translation).
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>"#)
}

/// Render the recent-activity table's `<tbody>` rows, or its translated
/// empty-state row when there is no activity yet.
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
}

/// Assemble the dashboard page body: stat cards, configuration summary, and
/// the recent-activity table. `settings.csaf_mode` / `.theme` /
/// `.naming_convention` are configuration *values*, not labels, so they are
/// escaped but never translated.
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"),
    )
}

/// `GET /` -- Dashboard page showing document count and recent activity.
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."));
    }
}