use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use kanade_shared::manifest::Schedule;
use tracing::info;
#[derive(Args, Debug)]
pub struct ScheduleArgs {
#[command(subcommand)]
pub sub: ScheduleSub,
}
#[derive(Subcommand, Debug)]
pub enum ScheduleSub {
Create {
yaml: PathBuf,
},
List,
Delete { id: String },
Disable {
id: String,
#[arg(long)]
cascade: bool,
#[arg(long)]
cascade_kill: bool,
},
Preview {
id: String,
#[arg(long, default_value_t = 5, value_parser = clap::value_parser!(u8).range(1..=50))]
count: u8,
},
Status { id: String },
Coverage {
id: String,
#[arg(long)]
all: bool,
},
}
pub async fn execute(backend_url: &str, args: ScheduleArgs) -> Result<()> {
let base = backend_url.trim_end_matches('/');
match args.sub {
ScheduleSub::Create { yaml } => create(base, &yaml).await,
ScheduleSub::List => list(base).await,
ScheduleSub::Delete { id } => delete(base, &id).await,
ScheduleSub::Disable {
id,
cascade,
cascade_kill,
} => disable(base, &id, cascade, cascade_kill).await,
ScheduleSub::Preview { id, count } => preview(base, &id, count).await,
ScheduleSub::Status { id } => status(base, &id).await,
ScheduleSub::Coverage { id, all } => coverage(base, &id, all).await,
}
}
async fn preview(base: &str, id: &str, count: u8) -> Result<()> {
let url = format!("{base}/api/schedules/{id}/preview?count={count}");
let resp = crate::http_client::authed_client()?
.get(&url)
.send()
.await
.with_context(|| format!("GET {url}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("preview failed: {status} — {body}");
}
let p: serde_json::Value = resp.json().await?;
let when = p.get("when").and_then(|v| v.as_str()).unwrap_or("?");
let tz = p.get("tz").and_then(|v| v.as_str()).unwrap_or("?");
let disabled = if p.get("enabled").and_then(|v| v.as_bool()) == Some(false) {
" [DISABLED]"
} else {
""
};
println!("{id} — {when} (tz: {tz}){disabled}");
match p.get("fires").and_then(|v| v.as_array()) {
Some(f) if !f.is_empty() => {
for (i, t) in f.iter().enumerate() {
println!(" {:>2}. {}", i + 1, t.as_str().unwrap_or("?"));
}
}
_ => {
let note = p
.get("note")
.and_then(|v| v.as_str())
.unwrap_or("no upcoming fires");
println!(" (none) {note}");
}
}
Ok(())
}
async fn status(base: &str, id: &str) -> Result<()> {
let url = format!("{base}/api/schedules/{id}/status");
let resp = crate::http_client::authed_client()?
.get(&url)
.send()
.await
.with_context(|| format!("GET {url}"))?;
if !resp.status().is_success() {
let s = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("status failed: {s} — {body}");
}
let p: serde_json::Value = resp.json().await?;
let when = p.get("when").and_then(|v| v.as_str()).unwrap_or("?");
let tz = p.get("tz").and_then(|v| v.as_str()).unwrap_or("?");
let disabled = if p.get("enabled").and_then(|v| v.as_bool()) == Some(false) {
" [DISABLED]"
} else {
""
};
println!("{id} — {when} (tz: {tz}){disabled}");
println!(
" next run : {}",
p.get("next_run").and_then(|v| v.as_str()).unwrap_or("—")
);
match p.get("last_run") {
Some(lr) if !lr.is_null() => {
let pc = lr.get("pc_id").and_then(|v| v.as_str()).unwrap_or("?");
let fin = lr.get("finished_at").and_then(|v| v.as_str());
let outcome = match (fin, lr.get("exit_code").and_then(|v| v.as_i64())) {
(None, _) => "running".to_string(),
(Some(_), Some(0)) => "ok".to_string(),
(Some(_), Some(code)) => format!("exit {code}"),
(Some(_), None) => "done".to_string(),
};
let at = lr
.get("finished_at")
.and_then(|v| v.as_str())
.or_else(|| lr.get("started_at").and_then(|v| v.as_str()))
.unwrap_or("?");
println!(" last run : {at} on {pc} ({outcome})");
}
_ => println!(" last run : (never)"),
}
if let Some(rec) = p.get("recent") {
let h = rec
.get("window_hours")
.and_then(|v| v.as_i64())
.unwrap_or(24);
let ok = rec.get("ok").and_then(|v| v.as_i64()).unwrap_or(0);
let fail = rec.get("fail").and_then(|v| v.as_i64()).unwrap_or(0);
println!(" last {h}h : {ok} ok / {fail} fail");
}
Ok(())
}
async fn coverage(base: &str, id: &str, all: bool) -> Result<()> {
let url = format!("{base}/api/schedules/{id}/coverage");
let resp = crate::http_client::authed_client()?
.get(&url)
.send()
.await
.with_context(|| format!("GET {url}"))?;
if !resp.status().is_success() {
let s = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("coverage failed: {s} — {body}");
}
let p: serde_json::Value = resp.json().await?;
let when = p.get("when").and_then(|v| v.as_str()).unwrap_or("?");
let job = p.get("job_id").and_then(|v| v.as_str()).unwrap_or("?");
let runs_on = p.get("runs_on").and_then(|v| v.as_str()).unwrap_or("?");
let n = |k: &str| p.get(k).and_then(|v| v.as_u64()).unwrap_or(0);
let (total, ok, fail, running, pending) =
(n("total"), n("ok"), n("fail"), n("running"), n("pending"));
println!("{id} — {when} (job: {job}, runs_on: {runs_on})");
println!(" rollout : {ok}/{total} ok · {fail} fail · {running} running · {pending} pending");
let show = |state: &str| all || state != "ok";
if let Some(agents) = p.get("agents").and_then(|v| v.as_array()) {
let mut shown = 0usize;
for a in agents {
let state = a.get("state").and_then(|v| v.as_str()).unwrap_or("?");
if !show(state) {
continue;
}
let pc = a.get("pc_id").and_then(|v| v.as_str()).unwrap_or("?");
let ver = a.get("version").and_then(|v| v.as_str()).unwrap_or("—");
println!(" {pc:<24} {state:<8} {ver}");
shown += 1;
}
if shown == 0 {
println!(
" {}",
if all {
"(no targeted agents)"
} else {
"(all targeted agents done)"
}
);
}
}
Ok(())
}
async fn create(base: &str, yaml: &PathBuf) -> Result<()> {
let body = std::fs::read_to_string(yaml).with_context(|| format!("read {yaml:?}"))?;
let schedule: Schedule = kanade_shared::strict::from_yaml_str(&body)
.map_err(|e| anyhow::anyhow!("parse {yaml:?}: {e}"))?;
schedule
.validate()
.map_err(|e| anyhow::anyhow!("invalid schedule {yaml:?}: {e}"))?;
info!(
schedule_id = %schedule.id,
when = %schedule.when,
job_id = %schedule.job_id,
"upserting schedule",
);
let url = format!("{base}/api/schedules");
let resp = crate::http_client::authed_client()?
.post(&url)
.header(reqwest::header::CONTENT_TYPE, "application/yaml")
.body(body)
.send()
.await
.with_context(|| format!("POST {url}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("create rejected: {status} — {body}");
}
let payload: serde_json::Value = resp.json().await?;
println!("{}", serde_json::to_string_pretty(&payload)?);
Ok(())
}
async fn list(base: &str) -> Result<()> {
let url = format!("{base}/api/schedules");
let resp = crate::http_client::authed_client()?
.get(&url)
.send()
.await
.with_context(|| format!("GET {url}"))?;
if !resp.status().is_success() {
anyhow::bail!("list failed: {}", resp.status());
}
let payload: serde_json::Value = resp.json().await?;
println!("{}", serde_json::to_string_pretty(&payload)?);
Ok(())
}
async fn disable(base: &str, id: &str, cascade: bool, cascade_kill: bool) -> Result<()> {
let url =
format!("{base}/api/schedules/{id}/disable?cascade={cascade}&cascade_kill={cascade_kill}");
let resp = crate::http_client::authed_client()?
.post(&url)
.send()
.await
.with_context(|| format!("POST {url}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("disable failed: {status} — {body}");
}
match (cascade, cascade_kill) {
(true, true) => println!("disabled (cascade revoke + kill in-flight): {id}"),
(true, false) => println!("disabled (with cascade revoke): {id}"),
(false, true) => println!("disabled (kill in-flight only): {id}"),
(false, false) => println!("disabled: {id}"),
}
Ok(())
}
async fn delete(base: &str, id: &str) -> Result<()> {
let url = format!("{base}/api/schedules/{id}");
let resp = crate::http_client::authed_client()?
.delete(&url)
.send()
.await
.with_context(|| format!("DELETE {url}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("delete failed: {status} — {body}");
}
println!("deleted: {id}");
Ok(())
}