use serde_json::Value;
use crate::cloudflare::{self, health};
use crate::plane::Plane;
#[derive(Clone)]
pub struct PlaneView {
pub plane: Plane,
pub online: bool,
pub github: bool,
pub installed: bool,
pub aws: bool,
pub runners: bool,
pub gitlab: bool,
pub moved_to: Option<String>,
pub standby: bool,
pub version: Option<String>,
pub status: Option<Value>,
}
impl PlaneView {
pub fn outdated(&self) -> bool { self.online && older(self.version.as_deref(), DASHBOARD_VERSION) }
pub fn version_name(&self) -> String { self.version.clone().unwrap_or_default() }
pub fn ready(&self) -> bool { self.online && ((self.github && self.installed) || self.gitlab) && self.runners }
pub fn gitlab_url(&self) -> Option<String> { self.status.as_ref().and_then(|s| s["gitlab"]["url"].as_str().map(str::to_string)) }
pub fn gitlabs(&self) -> Vec<(String, String, Option<Vec<String>>)> {
let Some(st) = &self.status else { return vec![] };
let scopes = |v: &Value| v.as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_string)).collect::<Vec<_>>());
match st["gitlabs"].as_array() {
Some(list) => list.iter().filter_map(|g| Some((g["id"].as_str().unwrap_or_default().to_string(), g["url"].as_str()?.to_string(), scopes(&g["scopes"])))).collect(),
None => self.gitlab_url().map(|u| vec![(String::new(), u, scopes(&st["permissions"]["gitlab_scopes"]))]).unwrap_or_default(),
}
}
pub fn labels(&self) -> Vec<String> {
let said: Vec<String> = self.status.as_ref().and_then(|s| s["labels"].as_array().cloned()).unwrap_or_default().iter().filter_map(|l| l.as_str().map(str::to_string)).collect();
if said.is_empty() { vec![self.plane.label().to_string()] } else { said }
}
pub fn label(&self) -> String { self.labels().into_iter().next().unwrap_or_else(|| "superci".into()) }
pub fn name(&self) -> String { self.status.as_ref().and_then(|s| s["name"].as_str()).filter(|n| !n.is_empty()).unwrap_or("SuperCI").to_string() }
pub fn jobs(&self) -> Vec<Value> {
let mut jobs = self.status.as_ref().and_then(|s| s["jobs"].as_array().cloned()).unwrap_or_default();
let own: std::collections::HashMap<u64, Value> = jobs.iter().filter_map(|j| Some((j["job_id"].as_u64()?, j.clone()))).collect();
for j in jobs.iter_mut() {
let Some(ran) = j["ran"].as_u64().and_then(|id| own.get(&id)) else { continue };
j["started_for"] = j["job_id"].clone();
for field in ["job_id", "name", "workflow", "run_id", "attempt", "repo", "conclusion"] { j[field] = ran[field].clone() }
}
jobs
}
pub fn app_slug(&self) -> Option<String> { self.status.as_ref().and_then(|s| s["app"]["slug"].as_str().map(str::to_string)) }
pub fn app_host(&self) -> Option<String> { self.status.as_ref().and_then(|s| s["app"]["host"].as_str().map(str::to_string)) }
pub fn app_owner(&self) -> Option<String> { self.status.as_ref().and_then(|s| s["app"]["owner"].as_str().map(str::to_string)) }
pub fn agents(&self) -> Vec<superci_core::plane::Agent> {
self.status.as_ref().and_then(|s| serde_json::from_value(s["agents"].clone()).ok()).unwrap_or_default()
}
pub fn routing(&self) -> superci_core::plane::Routing {
self.status.as_ref().and_then(|s| serde_json::from_value(s["routing"].clone()).ok()).unwrap_or_default()
}
pub fn machine(&self) -> superci_core::spec::Spec {
self.status.as_ref().and_then(|s| serde_json::from_value(s["machine"].clone()).ok()).unwrap_or_default()
}
pub fn spend(&self, cloud: &str) -> f64 { self.status.as_ref().and_then(|s| s["spend"][cloud].as_f64()).unwrap_or(0.0) }
pub fn spend_total(&self) -> f64 { self.status.as_ref().and_then(|s| s["spend"].as_object().map(|m| m.values().filter_map(|v| v.as_f64()).sum())).unwrap_or(0.0) }
pub fn spend_account(&self, account: &str) -> f64 {
self.status.as_ref().and_then(|s| s["spend"].as_object().map(|m| m.iter().filter(|(c, _)| superci_core::plane::account_of(c) == account).filter_map(|(_, v)| v.as_f64()).sum())).unwrap_or(0.0)
}
pub fn spend_today_total(&self) -> f64 { self.status.as_ref().and_then(|s| s["spend_today"].as_object().map(|m| m.values().filter_map(|v| v.as_f64()).sum())).unwrap_or(0.0) }
pub fn spend_today_account(&self, account: &str) -> f64 {
self.status.as_ref().and_then(|s| s["spend_today"].as_object().map(|m| m.iter().filter(|(c, _)| superci_core::plane::account_of(c) == account).filter_map(|(_, v)| v.as_f64()).sum())).unwrap_or(0.0)
}
pub fn daily_limits(&self) -> bool { self.status.as_ref().is_some_and(|s| s["daily_limits"] == true) }
pub fn budget_stopped_ms(&self) -> Option<u64> { self.status.as_ref().and_then(|s| s["aws_budget_stopped_ms"].as_u64()) }
pub fn account_limits(&self) -> bool { self.status.as_ref().is_some_and(|s| s["account_limits"] == true) }
pub fn own_containers(&self) -> bool { self.status.as_ref().is_some_and(|s| s["containers"] == true) }
pub fn clouds(&self) -> Vec<String> {
let own = self.status.as_ref().and_then(|s| s["own_cloud"].as_str().map(str::to_string)).unwrap_or_else(|| "cloudflare".into());
let mut c: Vec<String> = self.aws.then(|| "aws".to_string()).into_iter().chain(self.own_containers().then_some(own)).chain(self.agents().into_iter().map(|a| a.cloud)).collect();
c.dedup();
c
}
pub fn order(&self) -> Vec<superci_core::plane::Pool> {
use superci_core::plane::{Pool, AWS_ON_DEMAND};
let routing = self.routing();
let mut places = self.clouds();
if let Some(at) = places.iter().position(|c| c == "aws") { places.insert(at + 1, AWS_ON_DEMAND.to_string()) }
let mut order: Vec<_> = routing.order.into_iter().filter(|p| places.contains(&p.cloud)).collect();
for c in places {
if order.iter().any(|p| p.cloud == c) { continue }
let new = Pool { off: false, cloud: c.clone(), max_jobs: None, monthly_usd: None };
match order.iter().position(|p| p.cloud == "aws") { Some(at) if c == AWS_ON_DEMAND => order.insert(at + 1, new), _ => order.push(new) }
}
order
}
pub fn aws_regions(&self) -> Vec<String> {
let listed: Vec<String> = self.status.as_ref().and_then(|s| s["aws_regions"].as_array().cloned()).unwrap_or_default().iter().filter_map(|r| r.as_str().map(str::to_string)).collect();
if listed.is_empty() { self.aws_account().map(|(_, r)| vec![r]).unwrap_or_default() } else { listed }
}
pub fn aws_account(&self) -> Option<(String, String)> {
let a = &self.status.as_ref()?["aws"];
Some((a["account_id"].as_str()?.to_string(), a["region"].as_str()?.to_string()))
}
}
pub const DASHBOARD_VERSION: &str = env!("CARGO_PKG_VERSION");
impl PlaneView {
pub fn serves_its_own(&self) -> bool { (self.github || self.gitlab) && self.moved_to.is_none() && !self.standby }
pub fn takes_a_move_from(&self, from: &str) -> bool { !(self.github || self.gitlab) || self.standby || self.moved_to.as_deref() == Some(from) }
pub fn goes_with(&self, used: &Plane, another_ci: bool) -> std::result::Result<bool, String> {
let (place, id) = (self.plane.place(), self.plane.plane_id());
if self.moved_to.as_deref() == Some(used.url()) { return Ok(true) }
if self.serves_its_own() { return Err(format!("{place} serves a code host of its own and goes on as before (`superci leave --plane={id} --confirm` stops that one)")) }
if !self.online { return Err(format!("{place} did not answer and was left as it is (`superci planes delete {id} --confirm` deletes it)")) }
Ok(!another_ci)
}
}
pub const MOVE_VERSION: &str = "0.9.3";
thread_local! {
static NAMED: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
}
pub fn name(named: Option<&str>) { NAMED.with(|n| *n.borrow_mut() = named.map(str::trim).filter(|s| !s.is_empty()).map(str::to_string)) }
pub fn named() -> Option<String> { NAMED.with(|n| n.borrow().clone()) }
pub fn is(plane: &Plane, named: &str) -> bool { plane.plane_id() == named || plane.url().trim_end_matches('/') == named.trim_end_matches('/') }
pub fn several(views: &[PlaneView]) -> Option<Vec<&PlaneView>> {
if named().is_some_and(|n| views.iter().any(|v| is(&v.plane, &n))) { return None }
let live: Vec<&PlaneView> = views.iter().filter(|v| v.serves_its_own()).collect();
(live.len() > 1).then_some(live)
}
pub fn name_one(live: &[&PlaneView]) -> String {
let rows: Vec<String> = live.iter().map(|v| format!(" {} {}", v.plane.plane_id(), v.plane.place())).collect();
format!("{} control planes are live here. Name one: --plane=ID (or SUPERCI_PLANE=ID). Nothing was done.\n{}", live.len(), rows.join("\n"))
}
pub fn in_use(views: &[PlaneView]) -> usize {
if let Some(named) = named() {
if let Some(i) = views.iter().position(|v| is(&v.plane, &named)) { return i }
}
let staying: Vec<usize> = (0..views.len()).filter(|i| views[*i].moved_to.is_none()).collect();
let active: Vec<usize> = staying.iter().copied().filter(|i| !views[*i].standby).collect();
active.iter().copied().find(|i| views[*i].online && (views[*i].github || views[*i].gitlab))
.or_else(|| active.first().copied()).or_else(|| staying.first().copied()).unwrap_or(0)
}
pub fn older(a: Option<&str>, b: &str) -> bool {
let parse = |v: &str| v.split('.').map(|n| n.parse::<u32>().unwrap_or(0)).collect::<Vec<_>>();
a.is_none_or(|a| parse(a) < parse(b))
}
pub fn plane_view(plane: &Plane, key: Option<&str>) -> PlaneView {
let t = std::time::Instant::now();
let (h, health_ms, said) = std::thread::scope(|scope| {
let said = key.map(|k| scope.spawn(move || { let t = std::time::Instant::now(); (cloudflare::status(plane.url(), k), t.elapsed().as_millis()) }));
let h = health(plane.url());
let health_ms = t.elapsed().as_millis();
(h, health_ms, said.and_then(|s| s.join().ok()))
});
let flag = |k: &str| h.as_ref().is_some_and(|h| h[k] == true);
PlaneView { plane: plane.clone(), online: h.is_some(), github: flag("github"), installed: flag("installed"), aws: flag("aws"), runners: flag("runners"), gitlab: flag("gitlab"),
moved_to: h.as_ref().and_then(|h| h["moved_to"].as_str().map(str::to_string)), standby: flag("standby"),
version: h.as_ref().map(|h| h["version"].as_str().unwrap_or("0.1.0").to_string()),
status: said.and_then(|(s, status_ms)| {
if std::env::var_os("SUPERCI_TIMING").is_some() { eprintln!(" {:>6} ms {}: health ยท {:>6} ms status{}", health_ms, plane.place(), status_ms, if s.is_some() { "" } else { " (not read)" }) }
s
}) }
}