vyuh 0.2.8

Vyuh web framework for Axum and SQLx with handler-first APIs
Documentation
use axum::{http::StatusCode, response::Html};
use serde_json::json;

use crate::routes::{Path, Query};
use crate::{
    Site,
    console::{
        auth::ConsoleSessionUser,
        query::{OperationQuery, TaskQuery, filter_operations, is_console_operation},
        status::StatusOut,
        types::{ConfigOut, OperationOut, Page, TaskDetailOut, TaskOut},
    },
    templates::TemplateError,
};

pub async fn login(site: Site) -> Result<Html<String>, TemplateError> {
    render(
        &site,
        "console/login.html",
        json!({ "base_path": base_path(&site) }),
    )
}

pub async fn overview(
    site: Site,
    ConsoleSessionUser(_user): ConsoleSessionUser,
) -> Result<Html<String>, TemplateError> {
    let status = status_snapshot(&site);
    render_page(
        &site,
        "console/overview.html",
        "overview",
        "Overview",
        json!({ "status": status }),
    )
}

pub async fn runtime(
    site: Site,
    ConsoleSessionUser(_user): ConsoleSessionUser,
) -> Result<Html<String>, TemplateError> {
    let status = status_snapshot(&site);
    render_page(
        &site,
        "console/runtime.html",
        "runtime",
        "Runtime",
        runtime_context(status),
    )
}

pub async fn operations(
    site: Site,
    ConsoleSessionUser(_user): ConsoleSessionUser,
    Query(query): Query<OperationQuery>,
) -> Result<Html<String>, TemplateError> {
    let conf = &site.conf().console;
    let console_bundle_id = console_bundle_id(&site);
    let (items, next_cursor) = filter_operations(
        site.iter_operations(),
        &query,
        console_bundle_id,
        conf.page_size_default,
        conf.page_size_max,
    );
    let page = Page {
        items: items
            .into_iter()
            .map(OperationOut::from)
            .collect::<Vec<_>>(),
        next_cursor,
    };
    let selected_operation = selected_operation(&site, &query, console_bundle_id);
    render_page(
        &site,
        "console/operations.html",
        "operations",
        "Operations",
        json!({
            "page": page,
            "query": query,
            "kinds": operation_kinds(),
            "selected_operation": selected_operation,
        }),
    )
}

pub async fn operation_detail(
    site: Site,
    ConsoleSessionUser(_user): ConsoleSessionUser,
    Path(id): Path<String>,
) -> Result<Html<String>, StatusCode> {
    let id = uuid::Uuid::parse_str(&id).map_err(|_| StatusCode::NOT_FOUND)?;
    let operation = site
        .iter_operations()
        .find(|op| op.id == id)
        .map(OperationOut::from)
        .ok_or(StatusCode::NOT_FOUND)?;
    render_page(
        &site,
        "console/operation_detail.html",
        "operations",
        "Operation",
        json!({ "operation": operation }),
    )
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}

pub async fn tasks(
    site: Site,
    ConsoleSessionUser(_user): ConsoleSessionUser,
    Query(query): Query<TaskQuery>,
) -> Result<Html<String>, StatusCode> {
    if !site.console_has_tasks() {
        return render_tasks(&site, query, empty_tasks())
            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR);
    }

    let conf = &site.conf().console;
    let filter = query.to_filter(conf.page_size_default, conf.page_size_max);
    let page = site
        .tasks()
        .list(filter)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let page = Page {
        items: page.records.iter().map(TaskOut::from).collect::<Vec<_>>(),
        next_cursor: page.next_cursor,
    };
    render_tasks(&site, query, page).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}

pub async fn task_detail(
    site: Site,
    ConsoleSessionUser(_user): ConsoleSessionUser,
    Path(id): Path<String>,
) -> Result<Html<String>, StatusCode> {
    let id = uuid::Uuid::parse_str(&id).map_err(|_| StatusCode::NOT_FOUND)?;
    let record = site
        .tasks()
        .get(id)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
        .ok_or(StatusCode::NOT_FOUND)?;
    let detail = TaskDetailOut::from(&record);
    let payload =
        serde_json::to_string_pretty(&detail).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    render_page(
        &site,
        "console/task_detail.html",
        "tasks",
        "Task",
        json!({ "task": detail, "payload": payload }),
    )
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}

pub async fn conf(
    site: Site,
    ConsoleSessionUser(_user): ConsoleSessionUser,
) -> Result<Html<String>, StatusCode> {
    let conf = ConfigOut::from_site(&site);
    let payload =
        serde_json::to_string_pretty(&conf).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    render_page(
        &site,
        "console/conf.html",
        "conf",
        "Config",
        json!({ "conf": conf, "payload": payload }),
    )
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}

pub async fn openapi(
    site: Site,
    ConsoleSessionUser(_user): ConsoleSessionUser,
) -> Result<Html<String>, StatusCode> {
    render_page(
        &site,
        "console/openapi.html",
        "openapi",
        "OpenAPI",
        json!({ "blank_page": true }),
    )
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}

fn render_page(
    site: &Site,
    template: &str,
    active: &str,
    title: &str,
    mut context: serde_json::Value,
) -> Result<Html<String>, TemplateError> {
    context["active"] = json!(active);
    context["title"] = json!(title);
    context["base_path"] = json!(base_path(site));
    render(site, template, context)
}

fn status_snapshot(site: &Site) -> StatusOut {
    let ttl = std::time::Duration::from_secs(site.conf().console.status_cache_ttl_seconds);
    site.console_runtime()
        .map(|runtime| runtime.status(site, ttl))
        .unwrap_or_else(|| crate::console::status::collect(site))
}

fn runtime_context(status: StatusOut) -> serde_json::Value {
    let process_memory = format_optional_bytes(status.process.memory_bytes);
    let process_virtual = format_optional_bytes(status.process.virtual_memory_bytes);
    let total_memory = format_bytes(status.system.total_memory_bytes);
    let used_memory = format_bytes(status.system.used_memory_bytes);
    let available_memory = format_bytes(status.system.available_memory_bytes);
    let total_swap = format_bytes(status.system.total_swap_bytes);
    let used_swap = format_bytes(status.system.used_swap_bytes);
    let process_cpu = format_optional_percent(status.process.cpu_percent);
    let global_cpu = format_percent(status.system.global_cpu_percent);
    let load = format!(
        "{:.2} / {:.2} / {:.2}",
        status.system.load_average.one,
        status.system.load_average.five,
        status.system.load_average.fifteen
    );
    json!({
        "status": status,
        "memory": {
            "process": process_memory,
            "process_virtual": process_virtual,
            "total": total_memory,
            "used": used_memory,
            "available": available_memory,
            "swap_total": total_swap,
            "swap_used": used_swap,
        },
        "cpu": {
            "process": process_cpu,
            "global": global_cpu,
        },
        "load": load,
    })
}

fn format_optional_bytes(value: Option<u64>) -> String {
    value
        .map(format_bytes)
        .unwrap_or_else(|| "not available".to_string())
}

fn format_bytes(value: u64) -> String {
    const KIB: f64 = 1024.0;
    const MIB: f64 = KIB * 1024.0;
    const GIB: f64 = MIB * 1024.0;
    let value = value as f64;
    if value >= GIB {
        format!("{:.2} GiB", value / GIB)
    } else if value >= MIB {
        format!("{:.2} MiB", value / MIB)
    } else if value >= KIB {
        format!("{:.2} KiB", value / KIB)
    } else {
        format!("{value:.0} B")
    }
}

fn format_optional_percent(value: Option<f32>) -> String {
    value
        .map(format_percent)
        .unwrap_or_else(|| "not available".to_string())
}

fn format_percent(value: f32) -> String {
    format!("{value:.1}%")
}

fn render(
    site: &Site,
    template: &str,
    context: serde_json::Value,
) -> Result<Html<String>, TemplateError> {
    site.template_engine().html(template, &context)
}

fn render_tasks(
    site: &Site,
    query: TaskQuery,
    page: Page<TaskOut>,
) -> Result<Html<String>, TemplateError> {
    render_page(
        site,
        "console/tasks.html",
        "tasks",
        "Tasks",
        json!({ "page": page, "query": query, "statuses": task_statuses() }),
    )
}

fn selected_operation(
    site: &Site,
    query: &OperationQuery,
    console_bundle_id: Option<uuid::Uuid>,
) -> Option<OperationOut> {
    let id = query.selected.as_deref()?;
    let id = uuid::Uuid::parse_str(id).ok()?;
    site.iter_operations()
        .find(|op| op.id == id && !is_console_operation(op, console_bundle_id))
        .map(OperationOut::from)
}

fn console_bundle_id(site: &Site) -> Option<uuid::Uuid> {
    site.console_runtime().map(|runtime| runtime.bundle_id())
}

fn empty_tasks() -> Page<TaskOut> {
    Page {
        items: Vec::new(),
        next_cursor: None,
    }
}

fn base_path(site: &Site) -> &str {
    &site.conf().console.path
}

fn operation_kinds() -> [&'static str; 9] {
    [
        "route", "command", "task", "service", "signal", "cron", "periodic", "pgnotify", "api_doc",
    ]
}

fn task_statuses() -> [&'static str; 5] {
    ["pending", "running", "suspended", "succeeded", "failed"]
}