pgtask-web 0.2.0

Optional web interface for pgtask
Documentation
use std::fmt::Write;

use chrono::{DateTime, Utc};
use html_escape::{encode_double_quoted_attribute, encode_text};

use crate::model::{
    Dashboard, QueueSummary, ScheduleDetail, ScheduleSummary, TaskDetail, TaskSummary, WorkerDetail, WorkerSummary,
};

const STYLE: &str = "body{font:15px system-ui;max-width:1200px;margin:0 auto;padding:24px;color:#17202a}\
nav{display:flex;gap:16px;margin-bottom:24px}a{color:#075985}table{width:100%;border-collapse:collapse;margin:12px 0 28px}\
th,td{text-align:left;padding:8px;border-bottom:1px solid #d5d8dc;vertical-align:top}th{font-size:13px;color:#566573}\
code,pre{font:13px ui-monospace,monospace}pre{white-space:pre-wrap;background:#f4f6f7;padding:12px;border-radius:6px}\
.state{font-weight:600}.muted{color:#707b7c}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px}\
.card{border:1px solid #d5d8dc;border-radius:8px;padding:16px}.card h2{margin-top:0}input{padding:8px;width:min(420px,80%)}\
button{padding:8px;margin-right:8px}.actions{display:flex;gap:8px}";

fn layout(title: &str, body: &str) -> String {
    format!(
        "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" \
         content=\"width=device-width,initial-scale=1\"><title>{}</title><style>{STYLE}</style></head><body>\
         <nav><strong>pgtask</strong><a href=\"/\">Queues</a><a href=\"/tasks\">Tasks</a>\
         <a href=\"/schedules\">Schedules</a><a href=\"/workers\">Workers</a></nav>{body}</body></html>",
        encode_text(title),
    )
}

fn time(value: DateTime<Utc>) -> String {
    value.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
}

fn optional_time(value: Option<DateTime<Utc>>) -> String {
    value.map_or_else(|| "-".to_owned(), time)
}

fn queue_rows(queues: &[QueueSummary]) -> String {
    let mut rows = String::new();
    for queue in queues {
        let status = if queue.paused_at.is_some() { "paused" } else { "active" };
        let capacity = queue.max_outstanding_tasks.map_or_else(
            || format!("{}/unlimited", queue.outstanding_count),
            |maximum| format!("{}/{maximum}", queue.outstanding_count),
        );
        write!(
            rows,
            "<tr><td>{}</td><td class=\"state\">{status}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td>\
             <td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}s</td></tr>",
            encode_text(&queue.name),
            queue.pending_count,
            queue.ready_count,
            queue.routable_count,
            queue.unroutable_count,
            queue.running_count,
            queue.waiting_count,
            queue.terminal_count,
            capacity,
            queue.starvation_timeout_seconds,
        )
        .unwrap();
    }
    rows
}

fn task_rows(tasks: &[TaskSummary]) -> String {
    let mut rows = String::new();
    for task in tasks {
        write!(
            rows,
            "<tr><td><a href=\"/tasks/{}\"><code>{}</code></a></td><td>{}</td><td>{}</td>\
             <td class=\"state\">{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
            task.id,
            task.id,
            encode_text(&task.queue_name),
            encode_text(&task.task_name),
            encode_text(&task.state),
            task.attempt,
            time(task.created_at),
            optional_time(task.completed_at),
        )
        .unwrap();
    }
    rows
}

fn schedule_rows(schedules: &[ScheduleSummary]) -> String {
    let mut rows = String::new();
    for schedule in schedules {
        let status = if schedule.paused_at.is_some() {
            "paused"
        } else {
            "active"
        };
        write!(
            rows,
            "<tr><td><a href=\"/schedules/{}\">{}</a></td><td>{}</td><td>{}</td><td>{}</td>\
             <td>{}</td><td class=\"state\">{status}</td></tr>",
            schedule.id,
            encode_text(&schedule.name),
            encode_text(&schedule.kind),
            encode_text(&schedule.queue_name),
            encode_text(&schedule.task_name),
            time(schedule.next_run_at),
        )
        .unwrap();
    }
    rows
}

fn worker_rows(workers: &[WorkerSummary]) -> String {
    let mut rows = String::new();
    for worker in workers {
        let status = if worker.live { "live" } else { "expired" };
        write!(
            rows,
            "<tr><td><a href=\"/workers/{}\"><code>{}</code></a></td><td>{}</td><td>{}</td>\
             <td class=\"state\">{status}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
            worker.id,
            worker.id,
            encode_text(&worker.queue_name),
            encode_text(&worker.version),
            worker.draining,
            time(worker.heartbeat_at),
            time(worker.expires_at),
        )
        .unwrap();
    }
    rows
}

pub fn dashboard(data: &Dashboard) -> String {
    let body = format!(
        "<h1>Queues</h1><table><thead><tr><th>Queue</th><th>Status</th><th>Pending</th><th>Ready</th>\
         <th>Routable</th><th>Unroutable</th><th>Running</th><th>Waiting</th><th>Terminal</th>\
         <th>Capacity</th><th>Starvation rescue</th></tr></thead>\
         <tbody>{}</tbody></table>\
         <div class=\"grid\"><div class=\"card\"><h2>Recent tasks</h2><p>{} visible</p><a href=\"/tasks\">Inspect tasks</a></div>\
         <div class=\"card\"><h2>Schedules</h2><p>{} configured</p><a href=\"/schedules\">Inspect schedules</a></div>\
         <div class=\"card\"><h2>Workers</h2><p>{} registered</p><a href=\"/workers\">Inspect workers</a></div></div>",
        queue_rows(&data.queues),
        data.tasks.len(),
        data.schedules.len(),
        data.workers.len(),
    );
    layout("pgtask queues", &body)
}

pub fn tasks(tasks: &[TaskSummary], query: Option<&str>) -> String {
    let query = encode_double_quoted_attribute(query.unwrap_or_default());
    let body = format!(
        "<h1>Tasks</h1><form method=\"get\"><input name=\"query\" value=\"{query}\" \
         placeholder=\"Task name or task ID\"><button type=\"submit\">Search</button></form>\
         <table><thead><tr><th>ID</th><th>Queue</th><th>Task</th><th>State</th><th>Attempt</th>\
         <th>Created</th><th>Completed</th></tr></thead><tbody>{}</tbody></table>",
        task_rows(tasks),
    );
    layout("pgtask tasks", &body)
}

pub fn task(task: &TaskDetail, administrator: bool) -> String {
    let parent = task.parent_task_id.map_or_else(
        || "root task".to_owned(),
        |parent_task_id| format!("child of <a href=\"/tasks/{parent_task_id}\"><code>{parent_task_id}</code></a>"),
    );
    let mut attempt_rows = String::new();
    for attempt in &task.attempts {
        write!(
            attempt_rows,
            "<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td><pre>{}</pre></td></tr>",
            attempt.number,
            encode_text(&attempt.state),
            time(attempt.started_at),
            optional_time(attempt.finished_at),
            encode_text(attempt.error.as_deref().unwrap_or("")),
        )
        .unwrap();
    }
    let mut checkpoint_rows = String::new();
    for checkpoint in &task.checkpoints {
        write!(
            checkpoint_rows,
            "<tr><td>{}</td><td>{}</td><td><pre>{}</pre></td><td>{}</td></tr>",
            encode_text(&checkpoint.step_name),
            checkpoint.occurrence,
            encode_text(&checkpoint.value),
            time(checkpoint.created_at),
        )
        .unwrap();
    }
    let mut signal_rows = String::new();
    for signal in &task.signals {
        write!(
            signal_rows,
            "<tr><td>{}</td><td>{}</td><td><pre>{}</pre></td><td>{}</td></tr>",
            encode_text(&signal.name),
            signal.occurrence,
            encode_text(&signal.value),
            time(signal.created_at),
        )
        .unwrap();
    }
    let mut audit_rows = String::new();
    for event in &task.administrator_audit {
        write!(
            audit_rows,
            "<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
            encode_text(&event.action),
            encode_text(&event.actor),
            time(event.occurred_at),
        )
        .unwrap();
    }
    let actions = if administrator {
        format!(
            "<h2>Administrator actions</h2><div class=\"actions\">\
             <form method=\"post\" action=\"/admin/tasks/{}/cancel\"><button type=\"submit\">Cancel</button></form>\
             <form method=\"post\" action=\"/admin/tasks/{}/retry\"><button type=\"submit\">Retry</button></form></div>",
            task.id, task.id,
        )
    } else {
        String::new()
    };
    let body = format!(
        "<h1><code>{}</code></h1><p><strong>{}</strong> on {} - {} v{} - attempt {}/{} - {parent}</p>\
         <p class=\"muted\">Run {} - created {} - completed {}</p><h2>Payload</h2><pre>{}</pre>\
         <h2>Result</h2><pre>{}</pre><h2>Error</h2><pre>{}</pre>\
         <h2>Attempts</h2><table><tr><th>Attempt</th><th>State</th><th>Started</th><th>Finished</th><th>Error</th></tr>{attempt_rows}</table>\
         <h2>Checkpoints</h2><table><tr><th>Step</th><th>Occurrence</th><th>Value</th><th>Created</th></tr>{checkpoint_rows}</table>\
         <h2>Signals</h2><table><tr><th>Name</th><th>Occurrence</th><th>Value</th><th>Created</th></tr>{signal_rows}</table>\
         <h2>Administrator audit</h2><table><tr><th>Action</th><th>Actor</th><th>Time</th></tr>{audit_rows}</table>{actions}",
        task.id,
        encode_text(&task.state),
        encode_text(&task.queue_name),
        encode_text(&task.task_name),
        task.handler_version,
        task.attempt,
        task.max_attempts,
        time(task.run_at),
        time(task.created_at),
        optional_time(task.completed_at),
        encode_text(&task.payload),
        encode_text(task.result.as_deref().unwrap_or("")),
        encode_text(task.error.as_deref().unwrap_or("")),
    );
    layout("pgtask task", &body)
}

pub fn schedules(schedules: &[ScheduleSummary]) -> String {
    let body = format!(
        "<h1>Schedules</h1><table><tr><th>Name</th><th>Kind</th><th>Queue</th><th>Task</th><th>Next run</th>\
         <th>Status</th></tr>{}</table>",
        schedule_rows(schedules),
    );
    layout("pgtask schedules", &body)
}

pub fn schedule(detail: &ScheduleDetail, administrator: bool) -> String {
    let mut rows = String::new();
    for occurrence in &detail.occurrences {
        write!(
            rows,
            "<tr><td>{}</td><td><a href=\"/tasks/{}\"><code>{}</code></a></td><td>{}</td><td>{}</td><td>{}</td></tr>",
            time(occurrence.scheduled_for),
            occurrence.task_id,
            occurrence.task_id,
            encode_text(&occurrence.state),
            time(occurrence.created_at),
            optional_time(occurrence.completed_at),
        )
        .unwrap();
    }
    let mut audit_rows = String::new();
    for event in &detail.administrator_audit {
        write!(
            audit_rows,
            "<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
            encode_text(&event.action),
            encode_text(&event.actor),
            time(event.occurred_at),
        )
        .unwrap();
    }
    let schedule = &detail.schedule;
    let actions = if administrator {
        format!(
            "<h2>Administrator actions</h2><div class=\"actions\">\
             <form method=\"post\" action=\"/admin/schedules/{}/pause\"><button type=\"submit\">Pause</button></form>\
             <form method=\"post\" action=\"/admin/schedules/{}/resume\"><button type=\"submit\">Resume</button></form></div>",
            schedule.id, schedule.id,
        )
    } else {
        String::new()
    };
    let body = format!(
        "<h1>{}</h1><p>{} schedule for {} on {}. Next run: {}.</p><h2>Occurrences</h2>\
         <table><tr><th>Scheduled for</th><th>Task</th><th>State</th><th>Created</th><th>Completed</th></tr>{rows}</table>\
         <h2>Administrator audit</h2><table><tr><th>Action</th><th>Actor</th><th>Time</th></tr>{audit_rows}</table>{actions}",
        encode_text(&schedule.name),
        encode_text(&schedule.kind),
        encode_text(&schedule.task_name),
        encode_text(&schedule.queue_name),
        time(schedule.next_run_at),
    );
    layout("pgtask schedule", &body)
}

pub fn workers(workers: &[WorkerSummary]) -> String {
    let body = format!(
        "<h1>Workers</h1><table><tr><th>ID</th><th>Queue</th><th>Version</th><th>Status</th><th>Draining</th>\
         <th>Heartbeat</th><th>Expires</th></tr>{}</table>",
        worker_rows(workers),
    );
    layout("pgtask workers", &body)
}

pub fn worker(detail: &WorkerDetail) -> String {
    let mut rows = String::new();
    for capability in &detail.capabilities {
        write!(
            rows,
            "<tr><td>{}</td><td>{}</td></tr>",
            encode_text(&capability.task_name),
            capability.handler_version,
        )
        .unwrap();
    }
    let worker = &detail.worker;
    let body = format!(
        "<h1><code>{}</code></h1><p>{} worker on {}. Version {}. Draining: {}.</p>\
         <p class=\"muted\">Heartbeat {} - expires {}</p><h2>Capabilities</h2>\
         <table><tr><th>Task</th><th>Version</th></tr>{rows}</table>",
        worker.id,
        if worker.live { "Live" } else { "Expired" },
        encode_text(&worker.queue_name),
        encode_text(&worker.version),
        worker.draining,
        time(worker.heartbeat_at),
        time(worker.expires_at),
    );
    layout("pgtask worker", &body)
}

pub fn error(status: u16, message: &str) -> String {
    layout(
        "pgtask error",
        &format!("<h1>{status}</h1><p>{}</p>", encode_text(message)),
    )
}