use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::{Method, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use maud::Markup;
use runnerd_core::api::{Busy, ConfigCheck, ConfigFile, PauseQuery, Paused, Status};
use serde::Deserialize;
use crate::App;
use crate::daemon::{Body, Reply};
use crate::views::config::{self, Outcome};
use crate::views::{self, Tone, overview};
fn html(markup: &[Markup]) -> Response {
Html(
markup
.iter()
.map(|markup| markup.clone().into_string())
.collect::<String>(),
)
.into_response()
}
fn refused(message: &str) -> Response {
html(&[views::notice(message, Tone::Failed)])
}
async fn send(app: &App, path: &str) -> Result<Reply, Markup> {
app.daemon
.request(Method::POST, path, None)
.await
.map_err(|error| views::notice(&error.to_string(), Tone::Failed))
}
async fn row(app: &App, repository: &str) -> Result<Markup, Markup> {
let status: Status = app
.daemon
.get("/v1/status")
.await
.map_err(|error| views::notice(&error.to_string(), Tone::Failed))?;
status
.repositories
.iter()
.find(|candidate| candidate.name == repository)
.map(|found| overview::repository_row(&status, found))
.ok_or_else(|| {
views::notice(
&format!("{repository} is not a configured repository"),
Tone::Failed,
)
})
}
pub(crate) async fn pause(
State(app): State<App>,
Path((owner, name)): Path<(String, String)>,
Query(query): Query<PauseQuery>,
) -> Response {
let repository = format!("{owner}/{name}");
let path = format!(
"/v1/repositories/{repository}/pause{}",
if query.force { "?force=true" } else { "" }
);
let reply = match send(&app, &path).await {
Ok(reply) => reply,
Err(notice) => return html(&[notice]),
};
if reply.status == StatusCode::CONFLICT && !query.force {
let message = match serde_json::from_slice::<Busy>(&reply.body) {
Ok(busy) if !busy.jobs.is_empty() => {
let jobs: Vec<String> = busy.jobs.iter().map(ToString::to_string).collect();
format!(
"{repository} runs {}. Pausing now cancels it on GitHub.",
jobs.join(", ")
)
}
_ => reply.message(),
};
return html(&[views::question(
&message,
&format!("@post('/v1/repositories/{repository}/pause?force=true')"),
"Cancel and pause",
)]);
}
if !reply.status.is_success() {
return refused(&reply.message());
}
let stopped = serde_json::from_slice::<Paused>(&reply.body)
.map(|paused| paused.stopped)
.unwrap_or_default();
let row = match row(&app, &repository).await {
Ok(row) => row,
Err(notice) => return html(&[notice]),
};
if stopped.is_empty() {
html(&[row])
} else {
let names: Vec<String> = stopped.iter().map(ToString::to_string).collect();
html(&[
row,
views::notice(
&format!("Paused {repository}; stopped {}", names.join(", ")),
Tone::Done,
),
])
}
}
pub(crate) async fn resume(
State(app): State<App>,
Path((owner, name)): Path<(String, String)>,
) -> Response {
let repository = format!("{owner}/{name}");
match send(&app, &format!("/v1/repositories/{repository}/resume")).await {
Ok(reply) if reply.status.is_success() => match row(&app, &repository).await {
Ok(row) => html(&[row]),
Err(notice) => html(&[notice]),
},
Ok(reply) => refused(&reply.message()),
Err(notice) => html(&[notice]),
}
}
pub(crate) async fn reseed(State(app): State<App>, Path(profile): Path<String>) -> Response {
match send(&app, &format!("/v1/profiles/{profile}/reseed")).await {
Ok(reply) if reply.status.is_success() => html(&[views::notice(
&format!(
"Reseeding {profile}'s snapshot from slot 1; the overview shows when it is done"
),
Tone::Done,
)]),
Ok(reply) => refused(&reply.message()),
Err(notice) => html(&[notice]),
}
}
pub(crate) async fn clean(State(app): State<App>, Path(volume): Path<String>) -> Response {
match send(&app, &format!("/v1/caches/{volume}/clean")).await {
Ok(reply) if reply.status.is_success() => html(&[views::notice(
&format!("Emptied {volume}; its size shows at the next measurement"),
Tone::Done,
)]),
Ok(reply) => refused(&reply.message()),
Err(notice) => html(&[notice]),
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct Edit {
toml: String,
}
pub(crate) async fn check_config(State(app): State<App>, Json(edit): Json<Edit>) -> Response {
match app.daemon.check(edit.toml.clone()).await {
Ok(check) => html(&[config::result(&check, &edit.toml, &Outcome::Checked)]),
Err(error) => refused(&error.to_string()),
}
}
pub(crate) async fn save_config(State(app): State<App>, Json(edit): Json<Edit>) -> Response {
let reply = match app
.daemon
.request(
Method::PUT,
"/v1/config",
Some(Body::toml(edit.toml.clone())),
)
.await
{
Ok(reply) => reply,
Err(error) => return refused(&error.to_string()),
};
let check = serde_json::from_slice::<ConfigCheck>(&reply.body);
match (reply.status, check) {
(status, Ok(check)) if status.is_success() => {
let path = app
.daemon
.get::<ConfigFile>("/v1/config")
.await
.map_or_else(|_| "runnerd.toml".to_owned(), |file| file.path);
html(&[config::result(&check, &edit.toml, &Outcome::Saved(path))])
}
(StatusCode::UNPROCESSABLE_ENTITY, Ok(check)) => {
html(&[config::result(&check, &edit.toml, &Outcome::Refused)])
}
_ => refused(&format!("Not saved: {}", reply.message())),
}
}