pgtask-web 0.2.0

Optional web interface for pgtask
Documentation
#![doc = "Read-only web interface for pgtask."]

mod model;
mod pages;

use axum::{
    Router,
    extract::{Path, Query, State},
    http::{HeaderMap, HeaderName, StatusCode},
    response::{Html, IntoResponse, Redirect, Response},
    routing::{get, post},
};
use serde::Deserialize;
use sqlx::PgPool;
use thiserror::Error;
use uuid::Uuid;

use crate::model::{Dashboard, ScheduleDetail, TaskDetail, WorkerDetail};

#[derive(Clone)]
struct AppState {
    pool: PgPool,
    administrator: Option<AdministratorConfig>,
}

#[derive(Clone, Debug)]
pub struct AdministratorConfig {
    pub actor_header: HeaderName,
}

impl Default for AdministratorConfig {
    fn default() -> Self {
        Self {
            actor_header: HeaderName::from_static("x-pgtask-actor"),
        }
    }
}

#[derive(Debug, Error)]
enum WebError {
    #[error("database query failed")]
    Database(#[from] sqlx::Error),
    #[error("resource not found")]
    NotFound,
    #[error("administrator identity is required")]
    Unauthorized,
}

impl IntoResponse for WebError {
    fn into_response(self) -> Response {
        let status = match self {
            Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
            Self::NotFound => StatusCode::NOT_FOUND,
            Self::Unauthorized => StatusCode::UNAUTHORIZED,
        };
        (status, Html(pages::error(status.as_u16(), &self.to_string()))).into_response()
    }
}

#[derive(Deserialize)]
struct TaskSearch {
    query: Option<String>,
}

pub fn application(pool: PgPool) -> Router {
    build_application(pool, None)
}

pub fn application_with_administrator(pool: PgPool, config: AdministratorConfig) -> Router {
    build_application(pool, Some(config))
}

fn build_application(pool: PgPool, administrator: Option<AdministratorConfig>) -> Router {
    let mut router = Router::new()
        .route("/", get(dashboard))
        .route("/healthz", get(health))
        .route("/tasks", get(tasks))
        .route("/tasks/{task_id}", get(task))
        .route("/schedules", get(schedules))
        .route("/schedules/{schedule_id}", get(schedule))
        .route("/workers", get(workers))
        .route("/workers/{worker_id}", get(worker));
    if administrator.is_some() {
        router = router
            .route("/admin/tasks/{task_id}/cancel", post(cancel_task))
            .route("/admin/tasks/{task_id}/retry", post(retry_task))
            .route("/admin/schedules/{schedule_id}/pause", post(pause_schedule))
            .route("/admin/schedules/{schedule_id}/resume", post(resume_schedule));
    }
    router.with_state(AppState { pool, administrator })
}

async fn health(State(state): State<AppState>) -> Result<&'static str, WebError> {
    sqlx::query("SELECT 1").execute(&state.pool).await?;
    Ok("healthy")
}

async fn dashboard(State(state): State<AppState>) -> Result<Html<String>, WebError> {
    Ok(Html(pages::dashboard(&Dashboard::load(&state.pool).await?)))
}

async fn tasks(State(state): State<AppState>, Query(search): Query<TaskSearch>) -> Result<Html<String>, WebError> {
    Ok(Html(pages::tasks(
        &model::TaskSummary::search(&state.pool, search.query.as_deref()).await?,
        search.query.as_deref(),
    )))
}

async fn task(State(state): State<AppState>, Path(task_id): Path<Uuid>) -> Result<Html<String>, WebError> {
    let detail = TaskDetail::load(&state.pool, task_id)
        .await?
        .ok_or(WebError::NotFound)?;
    Ok(Html(pages::task(&detail, state.administrator.is_some())))
}

async fn schedules(State(state): State<AppState>) -> Result<Html<String>, WebError> {
    Ok(Html(pages::schedules(&model::ScheduleSummary::all(&state.pool).await?)))
}

async fn schedule(State(state): State<AppState>, Path(schedule_id): Path<Uuid>) -> Result<Html<String>, WebError> {
    let detail = ScheduleDetail::load(&state.pool, schedule_id)
        .await?
        .ok_or(WebError::NotFound)?;
    Ok(Html(pages::schedule(&detail, state.administrator.is_some())))
}

async fn workers(State(state): State<AppState>) -> Result<Html<String>, WebError> {
    Ok(Html(pages::workers(&model::WorkerSummary::all(&state.pool).await?)))
}

async fn worker(State(state): State<AppState>, Path(worker_id): Path<Uuid>) -> Result<Html<String>, WebError> {
    let detail = WorkerDetail::load(&state.pool, worker_id)
        .await?
        .ok_or(WebError::NotFound)?;
    Ok(Html(pages::worker(&detail)))
}

fn administrator_actor<'a>(state: &AppState, headers: &'a HeaderMap) -> Result<&'a str, WebError> {
    let config = state.administrator.as_ref().ok_or(WebError::NotFound)?;
    headers
        .get(&config.actor_header)
        .and_then(|value| value.to_str().ok())
        .filter(|value| !value.is_empty())
        .ok_or(WebError::Unauthorized)
}

async fn cancel_task(
    State(state): State<AppState>,
    Path(task_id): Path<Uuid>,
    headers: HeaderMap,
) -> Result<Redirect, WebError> {
    let actor = administrator_actor(&state, &headers)?;
    let changed: bool = sqlx::query_scalar("SELECT pgtask.admin_cancel_task($1, $2)")
        .bind(task_id)
        .bind(actor)
        .fetch_one(&state.pool)
        .await?;
    if !changed {
        return Err(WebError::NotFound);
    }
    Ok(Redirect::to(&format!("/tasks/{task_id}")))
}

async fn retry_task(
    State(state): State<AppState>,
    Path(task_id): Path<Uuid>,
    headers: HeaderMap,
) -> Result<Redirect, WebError> {
    let actor = administrator_actor(&state, &headers)?;
    let changed: bool = sqlx::query_scalar("SELECT pgtask.admin_retry_task($1, $2)")
        .bind(task_id)
        .bind(actor)
        .fetch_one(&state.pool)
        .await?;
    if !changed {
        return Err(WebError::NotFound);
    }
    Ok(Redirect::to(&format!("/tasks/{task_id}")))
}

async fn pause_schedule(
    State(state): State<AppState>,
    Path(schedule_id): Path<Uuid>,
    headers: HeaderMap,
) -> Result<Redirect, WebError> {
    set_schedule_paused(&state, schedule_id, &headers, true).await
}

async fn resume_schedule(
    State(state): State<AppState>,
    Path(schedule_id): Path<Uuid>,
    headers: HeaderMap,
) -> Result<Redirect, WebError> {
    set_schedule_paused(&state, schedule_id, &headers, false).await
}

async fn set_schedule_paused(
    state: &AppState,
    schedule_id: Uuid,
    headers: &HeaderMap,
    paused: bool,
) -> Result<Redirect, WebError> {
    let actor = administrator_actor(state, headers)?;
    let changed: bool = sqlx::query_scalar("SELECT pgtask.admin_set_schedule_paused($1, $2, $3)")
        .bind(schedule_id)
        .bind(paused)
        .bind(actor)
        .fetch_one(&state.pool)
        .await?;
    if !changed {
        return Err(WebError::NotFound);
    }
    Ok(Redirect::to(&format!("/schedules/{schedule_id}")))
}