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, Records, RemoveRecordsQuery, RemovedRecords,
RunningJob, Status,
};
use runnerd_core::client::{Body, Reply};
use serde::Deserialize;
use crate::App;
use crate::views::config::{self, Outcome};
use crate::views::{self, Tone, caches, 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,
)
})
}
async fn rows(app: &App) -> Result<Markup, Markup> {
let status: Status = app
.daemon
.get("/v1/status")
.await
.map_err(|error| views::notice(&error.to_string(), Tone::Failed))?;
Ok(overview::repositories(&status))
}
fn ask_to_force(reply: &Reply, force: &str, label: &str) -> Response {
let (message, them) = match serde_json::from_slice::<Busy>(&reply.body) {
Ok(busy) if busy.jobs.len() == 1 => (
format!("{}. Pausing now cancels its run on GitHub.", busy.message),
"it",
),
Ok(busy) if !busy.jobs.is_empty() => (
format!(
"{}. Pausing now cancels their runs on GitHub.",
busy.message
),
"them",
),
_ => (reply.message(), "them"),
};
html(&[views::question(
&message,
&format!("@post('{force}')"),
label,
&format!("Keep {them} running"),
)])
}
fn paused(reply: &Reply, region: Markup, what: &str) -> Response {
let stopped: Vec<RunningJob> = serde_json::from_slice::<Paused>(&reply.body)
.map(|paused| paused.stopped)
.unwrap_or_default();
if stopped.is_empty() {
html(&[region])
} else {
let names: Vec<String> = stopped.iter().map(ToString::to_string).collect();
html(&[
region,
views::notice(
&format!("Paused {what}; stopped {}", names.join(", ")),
Tone::Done,
),
])
}
}
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 {
return ask_to_force(
&reply,
&format!("/v1/repositories/{repository}/pause?force=true"),
"Cancel and pause",
);
}
if !reply.status.is_success() {
return refused(&reply.message());
}
match row(&app, &repository).await {
Ok(row) => paused(&reply, row, &repository),
Err(notice) => html(&[notice]),
}
}
pub(crate) async fn pause_all(State(app): State<App>, Query(query): Query<PauseQuery>) -> Response {
let path = if query.force {
"/v1/repositories/pause?force=true"
} else {
"/v1/repositories/pause"
};
let reply = match send(&app, path).await {
Ok(reply) => reply,
Err(notice) => return html(&[notice]),
};
if reply.status == StatusCode::CONFLICT && !query.force {
return ask_to_force(
&reply,
"/v1/repositories/pause?force=true",
"Cancel and pause all",
);
}
if !reply.status.is_success() {
return refused(&reply.message());
}
match rows(&app).await {
Ok(rows) => paused(&reply, rows, "every repository"),
Err(notice) => html(&[notice]),
}
}
pub(crate) async fn resume_all(State(app): State<App>) -> Response {
match send(&app, "/v1/repositories/resume").await {
Ok(reply) if reply.status.is_success() => match rows(&app).await {
Ok(rows) => html(&[rows]),
Err(notice) => html(&[notice]),
},
Ok(reply) => refused(&reply.message()),
Err(notice) => html(&[notice]),
}
}
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]),
}
}
pub(crate) async fn remove_record(State(app): State<App>, Path(job): Path<u64>) -> Response {
remove_records(&app, &format!("/v1/records/{job}")).await
}
pub(crate) async fn remove_all_records(
State(app): State<App>,
Query(query): Query<RemoveRecordsQuery>,
) -> Response {
if !query.all {
return refused("say which records: all=true");
}
remove_records(&app, "/v1/records?all=true").await
}
async fn remove_records(app: &App, path: &str) -> Response {
let reply = match app.daemon.request(Method::DELETE, path, None).await {
Ok(reply) if reply.status.is_success() => reply,
Ok(reply) => return refused(&reply.message()),
Err(error) => return refused(&error.to_string()),
};
let removed: RemovedRecords = serde_json::from_slice(&reply.body).unwrap_or_default();
let notice = views::notice(
&match removed.jobs.len() {
1 => format!(
"Removed 1 record, {} freed",
crate::format::bytes(removed.bytes)
),
count => format!(
"Removed {count} records, {} freed",
crate::format::bytes(removed.bytes)
),
},
Tone::Done,
);
let records = app.daemon.get::<Records>("/v1/records").await;
let status = app.daemon.get::<Status>("/v1/status").await;
match (status, records) {
(Ok(status), Ok(records)) => {
html(&[caches::records(&status, &records, crate::now()), notice])
}
_ => html(&[notice]),
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct Edit {
toml: String,
#[serde(default)]
revision: Option<String>,
}
pub(crate) async fn check_config(State(app): State<App>, Json(edit): Json<Edit>) -> Response {
match app
.daemon
.post_toml::<ConfigCheck>("/v1/config/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 path = match &edit.revision {
Some(revision) => format!(
"/v1/config?{}",
serde_urlencoded::to_string([("revision", revision)]).unwrap_or_default()
),
None => "/v1/config".to_owned(),
};
let reply = match app
.daemon
.request(Method::PUT, &path, 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);
let revision = check.revision.clone();
html(&[config::result(
&check,
&edit.toml,
&Outcome::Saved { path, revision },
)])
}
(StatusCode::UNPROCESSABLE_ENTITY, Ok(check)) => {
html(&[config::result(&check, &edit.toml, &Outcome::Refused)])
}
_ => refused(&format!("Not saved: {}", reply.message())),
}
}