27.0 KiB · text History e832e77
//! Web UI: an axum + minijinja + htmx app for viewing and editing the
//! flat-file desired state, publishing it into the document, reporting agent
//! status, and handing out join tickets. Structured after ../dms.

use std::time::{SystemTime, UNIX_EPOCH};

use axum::{
    Form, Router,
    extract::{Path, State},
    http::{StatusCode, header},
    response::{Html, IntoResponse, Redirect, Response},
    routing::{get, post},
};
use minijinja::context;
use serde::{Deserialize, Serialize};

use devendra_proto::{Status, keys};

use crate::{AppState, adopt, agent_ticket, publish, store};

// Fonts are embedded in the binary (B612 Mono + FontAwesome solid, from ../dms)
// and served from `/assets/fonts/*`, so the controller stays a single
// self-contained binary.
const FONT_B612_400: &[u8] = include_bytes!("../assets/fonts/b612-mono-400.woff2");
const FONT_B612_700: &[u8] = include_bytes!("../assets/fonts/b612-mono-700.woff2");
const FONT_FA_SOLID: &[u8] = include_bytes!("../assets/fonts/fa-solid-900.woff2");

/// Build the router for the controller web app.
pub fn router(state: AppState) -> Router {
    Router::new()
        .route("/", get(index))
        .route("/hosts", post(create_host))
        .route("/hosts/{id}", get(show_host).post(save_host))
        .route("/hosts/{id}/edit", get(edit_host))
        .route("/hosts/{id}/includes", post(update_includes))
        .route("/hosts/{id}/adopt", get(adopt_page).post(accept_adopt))
        .route("/fragments", get(fragments_index).post(create_fragment))
        .route("/fragments/{id}", get(show_fragment).post(save_fragment))
        .route("/fragments/{id}/edit", get(edit_fragment))
        .route("/publish", post(publish_all))
        .route("/ticket", get(ticket))
        .route("/assets/fonts/{name}", get(font_asset))
        .with_state(state)
}

/// Serve an embedded font with a long immutable cache.
async fn font_asset(Path(name): Path<String>) -> Response {
    let bytes: &'static [u8] = match name.as_str() {
        "b612-mono-400.woff2" => FONT_B612_400,
        "b612-mono-700.woff2" => FONT_B612_700,
        "fa-solid-900.woff2" => FONT_FA_SOLID,
        _ => return StatusCode::NOT_FOUND.into_response(),
    };
    (
        [
            (header::CONTENT_TYPE, "font/woff2"),
            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
        ],
        bytes,
    )
        .into_response()
}

// ---- views passed to templates ---------------------------------------------

#[derive(Serialize)]
struct HostRow {
    id: String,
    has_adoption: bool,
    status: Option<StatusView>,
}

/// One proposed file in an adoption report, rendered for review.
#[derive(Serialize)]
struct AdoptFileView {
    path: String,
    rel_path: String,
    mode: String,
    size: u64,
    /// A text preview of the body, truncated. `None` if it looks binary.
    preview: Option<String>,
    truncated: bool,
}

/// A file resource with fields pre-rendered for display.
#[derive(Serialize)]
struct FileView {
    path: String,
    state: String,
    mode: String,
    owner: String,
}

/// A package resource with fields pre-rendered for display.
#[derive(Serialize)]
struct PackageView {
    name: String,
    state: String,
}

/// A directory resource with fields pre-rendered for display.
#[derive(Serialize)]
struct DirView {
    path: String,
    state: String,
    mode: String,
    owner: String,
}

/// A service resource with fields pre-rendered for display.
#[derive(Serialize)]
struct ServiceView {
    name: String,
    enabled: String,
    state: String,
    /// Live traffic-light health from the agent, or `None` when the service
    /// isn't monitored / no report yet. Serializes to `ok`/`pending`/`bad`/
    /// `unknown` for use as a CSS class.
    health: Option<devendra_proto::ServiceHealth>,
    /// Raw systemd state string, shown as a tooltip.
    health_detail: String,
}

/// Host system metrics, pre-formatted for the title badge.
#[derive(Serialize)]
struct MetricsView {
    uptime: String,
    load: String,
    age: String,
}

impl MetricsView {
    fn from_metrics(m: &devendra_proto::HostMetrics) -> Self {
        MetricsView {
            uptime: humanize_uptime(m.uptime_secs),
            load: format!("{:.2} {:.2} {:.2}", m.load1, m.load5, m.load15),
            age: humanize_age(m.reported_at_ms),
        }
    }
}

#[derive(Serialize)]
struct StatusView {
    generation: u64,
    result: String,
    message: String,
    age: String,
    changed: usize,
    failed: usize,
}

impl StatusView {
    fn from_status(s: &Status) -> Self {
        let changed = s.changes.iter().filter(|c| c.action.is_change()).count();
        let failed = s
            .changes
            .iter()
            .filter(|c| c.action == devendra_proto::ChangeAction::Failed)
            .count();
        StatusView {
            generation: s.generation,
            result: format!("{:?}", s.result).to_lowercase(),
            message: s.message.clone(),
            age: humanize_age(s.applied_at_ms),
            changed,
            failed,
        }
    }
}

// ---- handlers --------------------------------------------------------------

async fn index(State(state): State<AppState>) -> Result<Html<String>, AppError> {
    let ids = store::list_host_ids(&state.config.hosts_dir())?;
    let pending = adopt::list_pending(&state.node, &state.doc).await?;
    let pending_set: std::collections::HashSet<&str> = pending.iter().map(String::as_str).collect();

    let mut rows = Vec::with_capacity(ids.len());
    for id in &ids {
        let status = read_status(&state, id).await?;
        rows.push(HostRow {
            id: id.clone(),
            has_adoption: pending_set.contains(id.as_str()),
            status: status.as_ref().map(StatusView::from_status),
        });
    }

    // Hosts that have a proposal but no config file yet — pure adoption targets.
    let unmanaged: Vec<String> = pending
        .iter()
        .filter(|p| !ids.contains(p))
        .cloned()
        .collect();

    // Offer the available fragments as checkboxes in the "new host" form.
    let fragments = store::list_fragment_ids(&state.config.include_dir())?;

    render(
        &state,
        "index.html",
        context! { hosts => rows, unmanaged => unmanaged, fragments => fragments },
    )
}

async fn show_host(
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> Result<Html<String>, AppError> {
    store::validate_host_id(&id)?;
    let raw = store::read_raw(&state.config.hosts_dir(), &id)?;

    // Best-effort compile so the page can show a parsed summary and surface
    // errors, without blocking the view of the raw text.
    let (packages, directories, files, services, compile_error) =
        match store::load_host(&state.config.hosts_dir(), &id)
            .and_then(|cfg| cfg.resolve(&state.config.config_dir))
            .and_then(|cfg| cfg.compile(&state.config.config_dir))
        {
            Ok(compiled) => {
                let (packages, dirs, files, mut services) = manifest_views(&compiled.manifest);
                // Overlay the agent's live service health onto the desired-state
                // services, so each row gets a green/yellow/red indicator.
                if let Some(report) = read_service_report(&state, &id).await? {
                    let live: std::collections::HashMap<&str, &devendra_proto::ServiceRuntime> =
                        report
                            .services
                            .iter()
                            .map(|s| (s.name.as_str(), s))
                            .collect();
                    for sv in &mut services {
                        if let Some(rt) = live.get(sv.name.as_str()) {
                            sv.health = Some(rt.health);
                            sv.health_detail = rt.detail.clone();
                        }
                    }
                }
                (
                    minijinja::Value::from_serialize(&packages),
                    minijinja::Value::from_serialize(&dirs),
                    minijinja::Value::from_serialize(&files),
                    minijinja::Value::from_serialize(&services),
                    None,
                )
            }
            Err(e) => (
                minijinja::Value::UNDEFINED,
                minijinja::Value::UNDEFINED,
                minijinja::Value::UNDEFINED,
                minijinja::Value::UNDEFINED,
                Some(format!("{e:#}")),
            ),
        };

    // Includes management: current includes (best-effort if the file parses)
    // and the fragments not yet included, offered in the "add include" dropdown.
    let current_includes = store::load_host(&state.config.hosts_dir(), &id)
        .map(|c| c.include)
        .unwrap_or_default();
    let includes: Vec<_> = current_includes
        .iter()
        .map(|reference| {
            context! {
                reference => reference,
                fragment => store::fragment_id_from_ref(reference),
            }
        })
        .collect();
    let addable: Vec<String> = store::list_fragment_ids(&state.config.include_dir())?
        .into_iter()
        .filter(|frag| !current_includes.contains(&store::fragment_include_ref(frag)))
        .collect();

    let status = read_status(&state, &id).await?;
    let metrics = read_metrics(&state, &id).await?;
    let has_adoption = adopt::read_report(&state.node, &state.doc, &id)
        .await?
        .is_some();
    render(
        &state,
        "host.html",
        context! {
            id => id,
            raw => raw,
            includes => includes,
            addable => addable,
            packages => packages,
            directories => directories,
            files => files,
            services => services,
            compile_error => compile_error,
            status => status.as_ref().map(StatusView::from_status),
            metrics => metrics.as_ref().map(MetricsView::from_metrics),
            has_adoption => has_adoption,
        },
    )
}

/// Add or remove a host's fragment includes, then republish.
#[derive(Deserialize)]
struct IncludesForm {
    /// Fragment id to add.
    add: Option<String>,
    /// Include reference (`include/<id>.toml`) to remove.
    remove: Option<String>,
}

async fn update_includes(
    State(state): State<AppState>,
    Path(id): Path<String>,
    Form(form): Form<IncludesForm>,
) -> Result<Response, AppError> {
    store::validate_host_id(&id)?;
    if let Some(fragment) = form.add.as_deref().filter(|s| !s.is_empty()) {
        store::add_include(&state.config.hosts_dir(), &id, fragment)?;
    } else if let Some(reference) = form.remove.as_deref().filter(|s| !s.is_empty()) {
        store::remove_include(&state.config.hosts_dir(), &id, reference)?;
    }

    // Republish so the include change takes effect on the agent.
    let host = store::LoadedHost {
        id: id.clone(),
        config: store::load_host(&state.config.hosts_dir(), &id)?,
    };
    publish::publish_host(&state.node, &state.doc, &state.config.config_dir, &host).await?;

    Ok(Redirect::to(&format!("/hosts/{id}")).into_response())
}

/// Show a host's pending adoption proposal for review.
async fn adopt_page(
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> Result<Html<String>, AppError> {
    store::validate_host_id(&id)?;
    let report = adopt::read_report(&state.node, &state.doc, &id).await?;

    let Some(report) = report else {
        return render(
            &state,
            "adopt.html",
            context! { id => id, has_report => false },
        );
    };

    // Build a reviewable view of each file, fetching a small text preview.
    let mut files = Vec::with_capacity(report.files.len());
    for f in &report.files {
        let (preview, truncated) = match adopt::read_body(
            &state.node,
            &state.doc,
            &id,
            &f.path,
            &f.content_blake3,
        )
        .await
        {
            Ok(body) => text_preview(&body),
            Err(_) => (None, false), // not synced yet / mismatch — no preview
        };
        files.push(AdoptFileView {
            path: f.path.clone(),
            rel_path: f.rel_path.clone(),
            mode: format!("{:04o}", f.mode),
            size: f.size,
            preview,
            truncated,
        });
    }

    render(
        &state,
        "adopt.html",
        context! {
            id => id,
            has_report => true,
            scan_dir => report.scan_dir,
            age => humanize_age(report.discovered_at_ms),
            files => files,
        },
    )
}

#[derive(Deserialize)]
struct AcceptForm {
    /// A single file path to adopt, or absent when `all` is set.
    path: Option<String>,
    /// Present (any value) to adopt every proposed file.
    all: Option<String>,
}

async fn accept_adopt(
    State(state): State<AppState>,
    Path(id): Path<String>,
    Form(form): Form<AcceptForm>,
) -> Result<Response, AppError> {
    store::validate_host_id(&id)?;

    let selected: Vec<String> = if form.all.is_some() {
        match adopt::read_report(&state.node, &state.doc, &id).await? {
            Some(r) => r.files.iter().map(|f| f.path.clone()).collect(),
            None => Vec::new(),
        }
    } else {
        form.path.into_iter().collect()
    };

    if selected.is_empty() {
        return Ok(Redirect::to(&format!("/hosts/{id}/adopt")).into_response());
    }

    adopt::accept(&state.node, &state.doc, &state.config, &id, &selected).await?;
    // After accepting, the host is managed — send the operator to its page.
    Ok(Redirect::to(&format!("/hosts/{id}")).into_response())
}

async fn edit_host(
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> Result<Html<String>, AppError> {
    store::validate_host_id(&id)?;
    let raw = store::read_raw(&state.config.hosts_dir(), &id)?;
    render(
        &state,
        "edit.html",
        context! { id => id, raw => raw, is_new => false },
    )
}

#[derive(Deserialize)]
struct SaveForm {
    toml: String,
}

async fn save_host(
    State(state): State<AppState>,
    Path(id): Path<String>,
    Form(form): Form<SaveForm>,
) -> Result<Response, AppError> {
    store::validate_host_id(&id)?;
    // write_raw validates the TOML parses before persisting.
    store::write_raw(&state.config.hosts_dir(), &id, &form.toml)?;

    // Publish just this host immediately so a save takes effect.
    let host = store::LoadedHost {
        id: id.clone(),
        config: store::load_host(&state.config.hosts_dir(), &id)?,
    };
    publish::publish_host(&state.node, &state.doc, &state.config.config_dir, &host).await?;

    Ok(Redirect::to(&format!("/hosts/{id}")).into_response())
}

#[derive(Deserialize)]
struct CreateForm {
    id: String,
    toml: String,
}

/// The new-host form has repeated `fragments` checkboxes, which don't map to a
/// struct field, so we take the raw key/value pairs. Fields: `id`, zero or more
/// `fragments`, and an optional `extra` TOML block.
async fn create_host(
    State(state): State<AppState>,
    Form(pairs): Form<Vec<(String, String)>>,
) -> Result<Response, AppError> {
    let mut id = String::new();
    let mut fragments = Vec::new();
    let mut extra = String::new();
    for (key, value) in pairs {
        match key.as_str() {
            "id" => id = value.trim().to_string(),
            "fragments" => fragments.push(value),
            "extra" => extra = value,
            _ => {}
        }
    }
    store::validate_host_id(&id)?;

    // Validate each checked fragment exists before writing anything.
    let known = store::list_fragment_ids(&state.config.include_dir())?;
    for f in &fragments {
        if !known.contains(f) {
            return Err(anyhow::anyhow!("unknown fragment {f:?}").into());
        }
    }

    let toml = build_host_toml(&fragments, &extra);
    store::write_raw(&state.config.hosts_dir(), &id, &toml)?;

    // Publish immediately so the new host goes live.
    let host = store::LoadedHost {
        id: id.clone(),
        config: store::load_host(&state.config.hosts_dir(), &id)?,
    };
    publish::publish_host(&state.node, &state.doc, &state.config.config_dir, &host).await?;

    Ok(Redirect::to(&format!("/hosts/{id}")).into_response())
}

/// Assemble a host TOML from selected fragment ids and an optional extra block.
fn build_host_toml(fragments: &[String], extra: &str) -> String {
    let mut out = String::new();
    if !fragments.is_empty() {
        let refs: Vec<String> = fragments
            .iter()
            .map(|f| format!("\"{}\"", store::fragment_include_ref(f)))
            .collect();
        out.push_str(&format!("include = [{}]\n", refs.join(", ")));
    }
    let extra = extra.trim();
    if !extra.is_empty() {
        if !out.is_empty() {
            out.push('\n');
        }
        out.push_str(extra);
        out.push('\n');
    }
    out
}

// ---- fragments -------------------------------------------------------------

/// Starter content for a new fragment.
const SAMPLE_FRAGMENT_TOML: &str = "\
# A reusable fragment. Hosts pull it in with:
#   include = [\"include/<this-id>.toml\"]

[[files]]
path = \"/etc/containers/systemd/app.container\"
mode = \"0644\"
content = \"\"\"
[Container]
Image=docker.io/library/nginx:latest
\"\"\"

[[services]]
name = \"app.service\"
enabled = true
state = \"started\"
";

async fn fragments_index(State(state): State<AppState>) -> Result<Html<String>, AppError> {
    let ids = store::list_fragment_ids(&state.config.include_dir())?;
    render(
        &state,
        "fragments.html",
        context! { fragments => ids, sample_toml => SAMPLE_FRAGMENT_TOML },
    )
}

async fn show_fragment(
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> Result<Html<String>, AppError> {
    store::validate_fragment_id(&id)?;
    let raw = store::read_fragment_raw(&state.config.include_dir(), &id)?;

    // Best-effort compile (fragments may themselves include others) for a
    // parsed summary; surface errors without hiding the raw text.
    let (packages, directories, files, services, compile_error) =
        match store::read_fragment_raw(&state.config.include_dir(), &id)
            .and_then(|raw| toml::from_str::<store::HostConfig>(&raw).map_err(anyhow::Error::from))
            .and_then(|cfg| cfg.resolve(&state.config.config_dir))
            .and_then(|cfg| cfg.compile(&state.config.config_dir))
        {
            Ok(compiled) => {
                let (packages, dirs, files, services) = manifest_views(&compiled.manifest);
                (
                    minijinja::Value::from_serialize(&packages),
                    minijinja::Value::from_serialize(&dirs),
                    minijinja::Value::from_serialize(&files),
                    minijinja::Value::from_serialize(&services),
                    None,
                )
            }
            Err(e) => (
                minijinja::Value::UNDEFINED,
                minijinja::Value::UNDEFINED,
                minijinja::Value::UNDEFINED,
                minijinja::Value::UNDEFINED,
                Some(format!("{e:#}")),
            ),
        };

    let used_by = store::hosts_using_fragment(&state.config.hosts_dir(), &id)?;
    render(
        &state,
        "fragment.html",
        context! {
            id => id,
            raw => raw,
            include_ref => store::fragment_include_ref(&id),
            packages => packages,
            directories => directories,
            files => files,
            services => services,
            compile_error => compile_error,
            used_by => used_by,
        },
    )
}

async fn edit_fragment(
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> Result<Html<String>, AppError> {
    store::validate_fragment_id(&id)?;
    let raw = store::read_fragment_raw(&state.config.include_dir(), &id)?;
    render(
        &state,
        "fragment_edit.html",
        context! { id => id, raw => raw },
    )
}

async fn save_fragment(
    State(state): State<AppState>,
    Path(id): Path<String>,
    Form(form): Form<SaveForm>,
) -> Result<Response, AppError> {
    store::validate_fragment_id(&id)?;
    store::write_fragment_raw(&state.config.include_dir(), &id, &form.toml)?;
    // A fragment change affects every host that includes it (transitively), so
    // republish all — publishing only bumps a host's generation if it changed.
    publish::publish_all(&state.node, &state.doc, &state.config.config_dir).await?;
    Ok(Redirect::to(&format!("/fragments/{id}")).into_response())
}

async fn create_fragment(
    State(state): State<AppState>,
    Form(form): Form<CreateForm>,
) -> Result<Response, AppError> {
    let id = form.id.trim().to_string();
    store::validate_fragment_id(&id)?;
    store::write_fragment_raw(&state.config.include_dir(), &id, &form.toml)?;
    Ok(Redirect::to(&format!("/fragments/{id}")).into_response())
}

async fn publish_all(State(state): State<AppState>) -> Result<Html<String>, AppError> {
    let outcomes = publish::publish_all(&state.node, &state.doc, &state.config.config_dir).await?;
    let rows: Vec<_> = outcomes
        .iter()
        .map(|o| {
            context! {
                host_id => o.host_id,
                generation => o.generation,
                changed => o.changed,
            }
        })
        .collect();
    render(&state, "published.html", context! { outcomes => rows })
}

async fn ticket(State(state): State<AppState>) -> Result<Html<String>, AppError> {
    let ticket = agent_ticket(&state.node, &state.doc).await?;
    render(
        &state,
        "ticket.html",
        context! {
            ticket => ticket.to_string(),
            node_id => state.node.node_id().to_string(),
            doc => state.doc.id().to_string(),
        },
    )
}

// ---- helpers ---------------------------------------------------------------

async fn read_status(state: &AppState, host_id: &str) -> Result<Option<Status>, AppError> {
    let Some(bytes) = state
        .node
        .read(&state.doc, &keys::status(&host_id.to_string()))
        .await?
    else {
        return Ok(None);
    };
    Ok(serde_json::from_slice(&bytes).ok())
}

/// Read the host's live service-state report, if the agent has published one.
async fn read_service_report(
    state: &AppState,
    host_id: &str,
) -> Result<Option<devendra_proto::ServiceReport>, AppError> {
    let Some(bytes) = state
        .node
        .read(&state.doc, &keys::service_report(&host_id.to_string()))
        .await?
    else {
        return Ok(None);
    };
    Ok(serde_json::from_slice(&bytes).ok())
}

/// Read the host's latest system metrics, if the agent has published any.
async fn read_metrics(
    state: &AppState,
    host_id: &str,
) -> Result<Option<devendra_proto::HostMetrics>, AppError> {
    let Some(bytes) = state
        .node
        .read(&state.doc, &keys::metrics(&host_id.to_string()))
        .await?
    else {
        return Ok(None);
    };
    Ok(serde_json::from_slice(&bytes).ok())
}

fn render(
    state: &AppState,
    template: &str,
    ctx: minijinja::Value,
) -> Result<Html<String>, AppError> {
    let tmpl = state.env.get_template(template)?;
    Ok(Html(tmpl.render(ctx)?))
}

/// Produce a truncated UTF-8 preview of a file body for review, or `None` if it
/// contains NUL bytes (looks binary). Returns `(preview, truncated)`.
fn text_preview(body: &[u8]) -> (Option<String>, bool) {
    const MAX: usize = 4000;
    if body.contains(&0) {
        return (None, false);
    }
    let text = String::from_utf8_lossy(body);
    if text.len() > MAX {
        let mut end = MAX;
        while !text.is_char_boundary(end) {
            end -= 1;
        }
        (Some(text[..end].to_string()), true)
    } else {
        (Some(text.into_owned()), false)
    }
}

/// Build display views of a compiled manifest's packages, directories, files,
/// and services, shared by the host and fragment pages.
fn manifest_views(
    manifest: &devendra_proto::Manifest,
) -> (
    Vec<PackageView>,
    Vec<DirView>,
    Vec<FileView>,
    Vec<ServiceView>,
) {
    let packages = manifest
        .packages
        .iter()
        .map(|p| PackageView {
            name: p.name.clone(),
            state: format!("{:?}", p.state).to_lowercase(),
        })
        .collect();
    let directories = manifest
        .directories
        .iter()
        .map(|d| DirView {
            path: d.path.clone(),
            state: format!("{:?}", d.state).to_lowercase(),
            mode: d
                .mode
                .map(|m| format!("{m:04o}"))
                .unwrap_or_else(|| "—".into()),
            owner: match (&d.owner, &d.group) {
                (Some(o), Some(g)) => format!("{o}:{g}"),
                (Some(o), None) => o.clone(),
                _ => "—".into(),
            },
        })
        .collect();
    let files = manifest
        .files
        .iter()
        .map(|f| FileView {
            path: f.path.clone(),
            state: format!("{:?}", f.state).to_lowercase(),
            mode: f
                .mode
                .map(|m| format!("{m:04o}"))
                .unwrap_or_else(|| "—".into()),
            owner: match (&f.owner, &f.group) {
                (Some(o), Some(g)) => format!("{o}:{g}"),
                (Some(o), None) => o.clone(),
                _ => "—".into(),
            },
        })
        .collect();
    let services = manifest
        .services
        .iter()
        .map(|s| ServiceView {
            name: s.name.clone(),
            enabled: match s.enabled {
                Some(true) => "yes".into(),
                Some(false) => "no".into(),
                None => "—".into(),
            },
            state: s
                .state
                .map(|st| format!("{st:?}").to_lowercase())
                .unwrap_or_else(|| "—".into()),
            // Overlaid later from the agent's live report, if any.
            health: None,
            health_detail: String::new(),
        })
        .collect();
    (packages, directories, files, services)
}

fn humanize_age(applied_at_ms: u64) -> String {
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0);
    let secs = now.saturating_sub(applied_at_ms) / 1000;
    match secs {
        0..=59 => format!("{secs}s ago"),
        60..=3599 => format!("{}m ago", secs / 60),
        3600..=86399 => format!("{}h ago", secs / 3600),
        _ => format!("{}d ago", secs / 86400),
    }
}

/// Compact uptime, e.g. `3d 4h`, `5h 12m`, `42m`, `30s`.
fn humanize_uptime(secs: u64) -> String {
    let (d, h, m) = (secs / 86400, (secs % 86400) / 3600, (secs % 3600) / 60);
    if d > 0 {
        format!("{d}d {h}h")
    } else if h > 0 {
        format!("{h}h {m}m")
    } else if m > 0 {
        format!("{m}m")
    } else {
        format!("{secs}s")
    }
}

/// Wraps any error as a 500 response with the message in the body — adequate
/// for a small operator-facing tool.
pub struct AppError(anyhow::Error);

impl<E: Into<anyhow::Error>> From<E> for AppError {
    fn from(err: E) -> Self {
        AppError(err.into())
    }
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        tracing::error!("request error: {:#}", self.0);
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("error: {:#}", self.0),
        )
            .into_response()
    }
}