csaf-crud 0.2.0

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)
Documentation
// 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::router::{Body, html_response};
use crate::routes::layout::{Nav, html_escape, wrap_page};

/// Format an audit action as an HTML badge.
fn action_badge(action: &str) -> String {
    let class = match action {
        "create" => "badge-create",
        "update" => "badge-update",
        "delete" => "badge-delete",
        "import" => "badge-import",
        "export" => "badge-export",
        _ => "badge-create",
    };
    format!(r#"<span class="badge {class}">{action}</span>"#)
}

/// `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 doc_count = state.csaf_storage().count_documents().unwrap_or(0);

    let advisory_count = state
        .csaf_storage()
        .list_by_category("csaf_security_advisory")
        .map(|v| v.len())
        .unwrap_or(0);

    let vex_count = state
        .csaf_storage()
        .list_by_category("csaf_vex")
        .map(|v| v.len())
        .unwrap_or(0);

    let informational_count = 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 mut activity_rows = String::new();
    if recent_activity.is_empty() {
        activity_rows.push_str(
            r#"<tr><td colspan="4" class="muted" style="text-align:center;">No activity recorded yet.</td></tr>"#,
        );
    } else {
        for entry in &recent_activity {
            let details_display = entry.details.as_deref().unwrap_or("-");
            activity_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(&entry.action),
                tid = html_escape(&entry.tracking_id),
                details = html_escape(details_display),
            ));
        }
    }

    let settings = state.settings();
    let content = format!(
        r#"<h1>Dashboard</h1>
<div class="card stats">
  <div class="stat"><div class="number">{doc_count}</div><div class="label">Total Documents</div></div>
  <div class="stat"><div class="number">{advisory_count}</div><div class="label">Security Advisories</div></div>
  <div class="stat"><div class="number">{vex_count}</div><div class="label">VEX Documents</div></div>
  <div class="stat"><div class="number">{informational_count}</div><div class="label">Informational</div></div>
</div>
<div class="card">
  <h2>Configuration</h2>
  <p>CSAF Mode: <strong>{csaf_mode}</strong> | Theme: <strong>{theme}</strong> | Naming: <strong>{naming}</strong></p>
</div>
<div class="card">
  <h2>Recent Activity</h2>
  <table>
    <thead><tr><th>Timestamp</th><th>Action</th><th>Tracking ID</th><th>Details</th></tr></thead>
    <tbody>{activity_rows}</tbody>
  </table>
</div>"#,
        csaf_mode = html_escape(&settings.csaf_mode),
        theme = html_escape(&settings.theme),
        naming = html_escape(&settings.naming_convention),
    );

    html_response(
        StatusCode::OK,
        wrap_page("Dashboard", &settings.theme, Nav::Home, &content),
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_action_badge_classes() {
        assert!(action_badge("create").contains("badge-create"));
        assert!(action_badge("delete").contains("badge-delete"));
        assert!(action_badge("import").contains("badge-import"));
    }
}