| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | | use super::settings::csrf; |
| 7 | | use super::{filters, Error, Page, Shell}; |
| 8 | | use crate::auth::Viewer; |
| 9 | | use crate::db::runner_updates::{self as rdb, Group, Policy}; |
| 10 | | use crate::db::{accounts, runners}; |
| 11 | | use crate::runner_updates::{self as ru, Levels}; |
| 12 | | use crate::Shared; |
| 13 | | use askama::Template; |
| 14 | | use axum::extract::{Form, Path, State}; |
| 15 | | use flow_core::api::UpdatePolicy; |
| 16 | | use serde::Deserialize; |
| 17 | |
|
| 18 | 15× | fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> { |
| 19 | 15× | match viewer.account() { |
| 20 | 14× | Some(a) if a.is_admin => Ok(a.clone()), |
| 21 | 1× | _ => Err(Error::not_found(shell.clone())), |
| 22 | | } |
| 23 | 15× | } |
| 24 | |
|
| 25 | |
|
| 26 | |
|
| 27 | | pub struct PolicyView { |
| 28 | | pub scope: String, |
| 29 | | pub key: String, |
| 30 | | pub label: String, |
| 31 | | pub updates: &'static str, |
| 32 | | pub channel: String, |
| 33 | | pub window: String, |
| 34 | | pub dry_run: &'static str, |
| 35 | | } |
| 36 | |
|
| 37 | | impl PolicyView { |
| 38 | 144× | fn of(p: &Policy, label: String) -> PolicyView { |
| 39 | 288× | let tri = |b: Option<bool>| match b { |
| 40 | 40× | Some(true) => "on", |
| 41 | 14× | Some(false) => "off", |
| 42 | 234× | None => "inherit", |
| 43 | 288× | }; |
| 44 | | PolicyView { |
| 45 | 144× | scope: p.scope.clone(), |
| 46 | 144× | key: p.key.clone(), |
| 47 | 144× | label, |
| 48 | 144× | updates: tri(p.updates), |
| 49 | 144× | channel: p.channel.clone().unwrap_or_default(), |
| 50 | 144× | window: match (p.window_from, p.window_to) { |
| 51 | 7× | (Some(a), Some(b)) => format!("{a:02}-{b:02}"), |
| 52 | 137× | _ => String::new(), |
| 53 | | }, |
| 54 | 144× | dry_run: tri(p.dry_run), |
| 55 | | } |
| 56 | 144× | } |
| 57 | |
|
| 58 | | |
| 59 | 21× | pub fn summary(&self) -> String { |
| 60 | 21× | let mut s = vec![]; |
| 61 | 21× | if self.updates != "inherit" { |
| 62 | 15× | s.push(format!("updates {}", self.updates)); |
| 63 | 15× | } |
| 64 | 21× | if !self.channel.is_empty() { |
| 65 | 2× | s.push(format!("channel {}", self.channel)); |
| 66 | 19× | } |
| 67 | 21× | if !self.window.is_empty() { |
| 68 | 1× | s.push(format!("window {} UTC", self.window)); |
| 69 | 20× | } |
| 70 | 21× | if self.dry_run != "inherit" { |
| 71 | 10× | s.push(format!("dry run {}", self.dry_run)); |
| 72 | 11× | } |
| 73 | 21× | if s.is_empty() { |
| 74 | 5× | "inherits everything".into() |
| 75 | | } else { |
| 76 | 16× | s.join(", ") |
| 77 | | } |
| 78 | 21× | } |
| 79 | | } |
| 80 | |
|
| 81 | | pub struct GroupView { |
| 82 | | pub g: Group, |
| 83 | | |
| 84 | | pub reach: String, |
| 85 | | |
| 86 | | pub members: Vec<String>, |
| 87 | | pub chosen: Vec<i64>, |
| 88 | | pub policy: PolicyView, |
| 89 | | } |
| 90 | |
|
| 91 | | pub struct RunnerView { |
| 92 | | pub r: runners::Runner, |
| 93 | | pub groups: Vec<String>, |
| 94 | | pub effective: UpdatePolicy, |
| 95 | | |
| 96 | | pub offer: String, |
| 97 | | pub policy: PolicyView, |
| 98 | | } |
| 99 | |
|
| 100 | | impl RunnerView { |
| 101 | | |
| 102 | 46× | pub fn effective_words(&self) -> String { |
| 103 | 46× | let e = &self.effective; |
| 104 | 46× | let mut s = vec![format!("updates {} ({})", if e.updates { "on" } else { "off" }, e.from[0])]; |
| 105 | 46× | if e.updates { |
| 106 | 32× | s.push(format!("channel {} ({})", e.channel, e.from[1])); |
| 107 | 32× | if let Some((a, b)) = e.window { |
| 108 | 7× | s.push(format!("window {a:02}–{b:02} UTC ({})", e.from[2])); |
| 109 | 25× | } |
| 110 | 32× | if e.dry_run { |
| 111 | 16× | s.push(format!("dry run ({})", e.from[3])); |
| 112 | 16× | } |
| 113 | 14× | } |
| 114 | 46× | s.join(" · ") |
| 115 | 46× | } |
| 116 | | } |
| 117 | |
|
| 118 | | #[derive(Template)] |
| 119 | | #[template(path = "admin_runner_updates.html")] |
| 120 | | pub struct UpdatesPage { |
| 121 | | shell: Shell, |
| 122 | | repo: String, |
| 123 | | site: PolicyView, |
| 124 | | owners: Vec<PolicyView>, |
| 125 | | groups: Vec<GroupView>, |
| 126 | | runners: Vec<RunnerView>, |
| 127 | | notice: Option<String>, |
| 128 | | error: Option<String>, |
| 129 | | } |
| 130 | |
|
| 131 | |
|
| 132 | |
|
| 133 | |
|
| 134 | |
|
| 135 | | #[derive(Clone, Debug, PartialEq, Eq)] |
| 136 | | pub enum Reach { |
| 137 | | Site, |
| 138 | | Org(String), |
| 139 | | } |
| 140 | |
|
| 141 | | impl Reach { |
| 142 | 62× | fn holds_runner(&self, r: &runners::Runner) -> bool { |
| 143 | 62× | match self { |
| 144 | 27× | Reach::Site => true, |
| 145 | 35× | Reach::Org(o) => r.scope_owner == *o, |
| 146 | | } |
| 147 | 62× | } |
| 148 | |
|
| 149 | 23× | fn holds_group(&self, g: &Group) -> bool { |
| 150 | 23× | match self { |
| 151 | 14× | Reach::Site => true, |
| 152 | 9× | Reach::Org(o) => g.owner == *o, |
| 153 | | } |
| 154 | 23× | } |
| 155 | | } |
| 156 | |
|
| 157 | |
|
| 158 | | pub struct UpdateViews { |
| 159 | | pub site: PolicyView, |
| 160 | | pub owners: Vec<PolicyView>, |
| 161 | | pub groups: Vec<GroupView>, |
| 162 | | pub runners: Vec<RunnerView>, |
| 163 | | } |
| 164 | |
|
| 165 | 45× | pub async fn views(app: &Shared, reach: &Reach) -> sqlx::Result<UpdateViews> { |
| 166 | 45× | let levels = Levels::load(&app.db).await?; |
| 167 | 45× | let all = runners::list(&app.db).await?; |
| 168 | 137× | let policy = |scope: &str, key: &str| { |
| 169 | 137× | levels.policies.get(&(scope.to_string(), key.to_string())).cloned().unwrap_or(Policy { |
| 170 | 137× | scope: scope.into(), |
| 171 | 137× | key: key.into(), |
| 172 | 137× | ..Policy::default() |
| 173 | 137× | }) |
| 174 | 137× | }; |
| 175 | 45× | let mut owners: Vec<PolicyView> = match reach { |
| 176 | | Reach::Site => { |
| 177 | 26× | levels.policies.values().filter(|p| p.scope == "owner").map(|p| PolicyView::of(p, format!("owner {}", p.key))).collect() |
| 178 | | } |
| 179 | 31× | Reach::Org(o) => vec![PolicyView::of(&policy("owner", o), format!("owner {o}"))], |
| 180 | | }; |
| 181 | 45× | owners.sort_by(|a, b| a.key.cmp(&b.key)); |
| 182 | 45× | let groups = levels |
| 183 | 45× | .groups |
| 184 | 45× | .iter() |
| 185 | 45× | .filter(|g| reach.holds_group(g)) |
| 186 | 45× | .map(|g| GroupView { |
| 187 | 15× | g: g.clone(), |
| 188 | 15× | reach: if g.owner.is_empty() { "the site's runners".into() } else { format!("{}'s runners", g.owner) }, |
| 189 | 35× | members: all.iter().filter(|r| levels.member(g, r)).map(|r| r.name.clone()).collect(), |
| 190 | 15× | chosen: levels.chosen.iter().filter(|(gid, _)| *gid == g.id).map(|(_, r)| *r).collect(), |
| 191 | 15× | policy: PolicyView::of(&policy("group", &g.id.to_string()), format!("group {}", g.name)), |
| 192 | 15× | }) |
| 193 | 45× | .collect(); |
| 194 | 45× | let hour = time::OffsetDateTime::now_utc().hour(); |
| 195 | 45× | let mut out = vec![]; |
| 196 | 59× | for r in all.into_iter().filter(|r| reach.holds_runner(r)) { |
| 197 | 46× | let effective = levels.resolve(&r); |
| 198 | 46× | let (_, offer) = ru::offer(&app.db, &r, &effective, hour).await?; |
| 199 | 46× | out.push(RunnerView { |
| 200 | 46× | groups: levels.groups_of(&r).iter().map(|g| g.name.clone()).collect(), |
| 201 | 46× | policy: PolicyView::of(&policy("runner", &r.id.to_string()), format!("runner {}", r.name)), |
| 202 | 46× | effective, |
| 203 | 46× | offer, |
| 204 | 46× | r, |
| 205 | | }); |
| 206 | | } |
| 207 | 45× | Ok(UpdateViews { site: PolicyView::of(&policy("site", ""), "the site".into()), owners, groups, runners: out }) |
| 208 | 45× | } |
| 209 | |
|
| 210 | 14× | async fn page(app: &Shared, shell: Shell, notice: Option<String>, error: Option<String>) -> Result<Page<UpdatesPage>, Error> { |
| 211 | 14× | let e = |e| Error::from_db(shell.clone(), e); |
| 212 | 14× | let repo = accounts::setting(&app.db, ru::SETTING_REPO).await.map_err(e)?.unwrap_or_default(); |
| 213 | 14× | let v = views(app, &Reach::Site).await.map_err(e)?; |
| 214 | 14× | Ok(Page(UpdatesPage { shell, repo, site: v.site, owners: v.owners, groups: v.groups, runners: v.runners, notice, error })) |
| 215 | 14× | } |
| 216 | |
|
| 217 | |
|
| 218 | | pub type Outcome = Result<String, String>; |
| 219 | |
|
| 220 | |
|
| 221 | 10× | async fn page_after(app: &Shared, shell: Shell, outcome: Outcome) -> Result<Page<UpdatesPage>, Error> { |
| 222 | 10× | match outcome { |
| 223 | 7× | Ok(n) => page(app, shell, Some(n), None).await, |
| 224 | 3× | Err(why) => page(app, shell, None, Some(why)).await, |
| 225 | | } |
| 226 | 10× | } |
| 227 | |
|
| 228 | 3× | pub async fn show(State(app): State<Shared>, viewer: Viewer) -> Result<Page<UpdatesPage>, Error> { |
| 229 | 3× | let shell = Shell::new(&app, "/admin/runners/updates", &viewer); |
| 230 | 3× | require_admin(&viewer, &shell)?; |
| 231 | 2× | page(&app, shell, None, None).await |
| 232 | 3× | } |
| 233 | |
|
| 234 | | #[derive(Deserialize)] |
| 235 | | pub struct SourceForm { |
| 236 | | #[serde(rename = "_csrf")] |
| 237 | | csrf_token: String, |
| 238 | | repo: String, |
| 239 | | } |
| 240 | |
|
| 241 | |
|
| 242 | 2× | pub async fn save_source(State(app): State<Shared>, viewer: Viewer, Form(f): Form<SourceForm>) -> Result<Page<UpdatesPage>, Error> { |
| 243 | 2× | let shell = Shell::new(&app, "/admin/runners/updates", &viewer); |
| 244 | 2× | let me = require_admin(&viewer, &shell)?; |
| 245 | 2× | csrf(&viewer, &f.csrf_token, &shell)?; |
| 246 | 2× | let repo = f.repo.trim().trim_matches('/').to_string(); |
| 247 | 2× | if !repo.is_empty() { |
| 248 | 2× | let found = match repo.split_once('/') { |
| 249 | 2× | Some((o, n)) => crate::db::repos::get(&app.db, o, n).await.map_err(|e| Error::from_db(shell.clone(), e))?, |
| 250 | 0× | None => None, |
| 251 | | }; |
| 252 | 2× | if found.is_none() { |
| 253 | 1× | return page(&app, shell, None, Some(format!("There is no repository {repo:?} here; give it as owner/name."))).await; |
| 254 | 1× | } |
| 255 | 0× | } |
| 256 | 1× | accounts::set_setting(&app.db, ru::SETTING_REPO, &repo).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 257 | 1× | accounts::audit(&app.db, Some(&me), "runner.updates.source", &repo, "runner builds come from its releases").await.ok(); |
| 258 | 1× | let n = if repo.is_empty() { |
| 259 | 0× | "Saved: no repository, so no runner is offered a build.".into() |
| 260 | | } else { |
| 261 | 1× | format!("Saved: runner builds come from {repo}'s releases.") |
| 262 | | }; |
| 263 | 1× | page(&app, shell, Some(n), None).await |
| 264 | 2× | } |
| 265 | |
|
| 266 | | #[derive(Deserialize)] |
| 267 | | pub struct PolicyForm { |
| 268 | | #[serde(rename = "_csrf")] |
| 269 | | pub(crate) csrf_token: String, |
| 270 | | scope: String, |
| 271 | | #[serde(default)] |
| 272 | | key: String, |
| 273 | | #[serde(default)] |
| 274 | | updates: String, |
| 275 | | #[serde(default)] |
| 276 | | channel: String, |
| 277 | | #[serde(default)] |
| 278 | | window: String, |
| 279 | | #[serde(default)] |
| 280 | | dry_run: String, |
| 281 | | } |
| 282 | |
|
| 283 | |
|
| 284 | 12× | pub async fn apply_policy(app: &Shared, me: &accounts::Account, reach: &Reach, f: &PolicyForm) -> sqlx::Result<Outcome> { |
| 285 | 12× | let key = f.key.trim().to_string(); |
| 286 | 12× | let label = match (f.scope.as_str(), reach) { |
| 287 | 12× | ("site", Reach::Site) if key.is_empty() => "the site".to_string(), |
| 288 | 8× | ("owner", Reach::Org(o)) if key != *o => return Ok(Err("An organisation sets its own runners' updates only.".into())), |
| 289 | | ("owner", _) => { |
| 290 | 1× | let known = accounts::account_by_name(&app.db, &key).await?.is_some() || accounts::org_by_name(&app.db, &key).await?.is_some(); |
| 291 | 1× | if !known { |
| 292 | 0× | return Ok(Err(format!("There is no account or organisation {key:?}."))); |
| 293 | 1× | } |
| 294 | 1× | format!("owner {key}") |
| 295 | | } |
| 296 | 6× | ("group", _) => match key.parse().ok() { |
| 297 | 3× | Some(id) => match rdb::group(&app.db, id).await?.filter(|g| reach.holds_group(g)) { |
| 298 | 3× | Some(g) => format!("group {}", g.name), |
| 299 | 0× | None => return Ok(Err("That test group is gone.".into())), |
| 300 | | }, |
| 301 | 0× | None => return Ok(Err("That test group is gone.".into())), |
| 302 | | }, |
| 303 | 3× | ("runner", _) => match key.parse().ok() { |
| 304 | 3× | Some(id) => match runners::get(&app.db, id).await?.filter(|r| reach.holds_runner(r)) { |
| 305 | 2× | Some(r) => format!("runner {}", r.name), |
| 306 | 1× | None => return Ok(Err("That runner is gone.".into())), |
| 307 | | }, |
| 308 | 0× | None => return Ok(Err("That runner is gone.".into())), |
| 309 | | }, |
| 310 | 1× | _ => return Ok(Err("Choose the site, an owner, a test group or a runner.".into())), |
| 311 | | }; |
| 312 | 14× | let tri = |v: &str| match v { |
| 313 | 14× | "on" => Some(true), |
| 314 | 9× | "off" => Some(false), |
| 315 | 7× | _ => None, |
| 316 | 14× | }; |
| 317 | 9× | let channel = f.channel.trim().to_string(); |
| 318 | 9× | if !channel.is_empty() && !ru::valid_channel(&channel) { |
| 319 | 1× | return Ok(Err(format!("A channel is stable, canary or a version such as 0.3.4, not {channel:?}."))); |
| 320 | 8× | } |
| 321 | 8× | let window = match ru::parse_window(&f.window) { |
| 322 | 7× | Ok(w) => w, |
| 323 | 1× | Err(why) => return Ok(Err(format!("{why}."))), |
| 324 | | }; |
| 325 | 7× | let p = Policy { |
| 326 | 7× | scope: f.scope.clone(), |
| 327 | 7× | key, |
| 328 | 7× | updates: tri(&f.updates), |
| 329 | 7× | channel: Some(channel).filter(|c| !c.is_empty()), |
| 330 | 7× | window_from: window.map(|w| w.0 as i64), |
| 331 | 7× | window_to: window.map(|w| w.1 as i64), |
| 332 | 7× | dry_run: tri(&f.dry_run), |
| 333 | | }; |
| 334 | 7× | rdb::set_policy(&app.db, &p).await?; |
| 335 | 7× | let words = PolicyView::of(&p, label.clone()).summary(); |
| 336 | 7× | accounts::audit(&app.db, Some(me), "runner.updates.policy", &label, &words).await.ok(); |
| 337 | 7× | Ok(Ok(format!("Saved for {label}: {words}."))) |
| 338 | 12× | } |
| 339 | |
|
| 340 | |
|
| 341 | 6× | pub async fn save_policy(State(app): State<Shared>, viewer: Viewer, Form(f): Form<PolicyForm>) -> Result<Page<UpdatesPage>, Error> { |
| 342 | 6× | let shell = Shell::new(&app, "/admin/runners/updates", &viewer); |
| 343 | 6× | let me = require_admin(&viewer, &shell)?; |
| 344 | 6× | csrf(&viewer, &f.csrf_token, &shell)?; |
| 345 | 6× | let outcome = apply_policy(&app, &me, &Reach::Site, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 346 | 6× | page_after(&app, shell, outcome).await |
| 347 | 6× | } |
| 348 | |
|
| 349 | | #[derive(Deserialize)] |
| 350 | | pub struct GroupForm { |
| 351 | | #[serde(rename = "_csrf")] |
| 352 | | pub(crate) csrf_token: String, |
| 353 | | #[serde(default)] |
| 354 | | owner: String, |
| 355 | | #[serde(default)] |
| 356 | | name: String, |
| 357 | | |
| 358 | | #[serde(default)] |
| 359 | | mode: String, |
| 360 | | #[serde(default)] |
| 361 | | percent: String, |
| 362 | | #[serde(default)] |
| 363 | | runner: Vec<i64>, |
| 364 | | } |
| 365 | |
|
| 366 | |
|
| 367 | 4× | fn percent_of(f: &GroupForm) -> Result<Option<i64>, String> { |
| 368 | 4× | if f.mode != "percent" { |
| 369 | 2× | return Ok(None); |
| 370 | 2× | } |
| 371 | 2× | match f.percent.trim().trim_end_matches('%').parse::<i64>() { |
| 372 | 2× | Ok(p) if (1..=100).contains(&p) => Ok(Some(p)), |
| 373 | 1× | _ => Err("A percentage is a whole number from 1 to 100.".into()), |
| 374 | | } |
| 375 | 4× | } |
| 376 | |
|
| 377 | |
|
| 378 | 2× | async fn chosen_in_reach(app: &Shared, owner: &str, f: &GroupForm) -> sqlx::Result<Vec<i64>> { |
| 379 | 2× | let all = runners::list(&app.db).await?; |
| 380 | 6× | Ok(f.runner.iter().copied().filter(|id| all.iter().any(|r| r.id == *id && (owner.is_empty() || r.scope_owner == owner))).collect()) |
| 381 | 2× | } |
| 382 | |
|
| 383 | |
|
| 384 | 3× | pub async fn apply_create_group(app: &Shared, me: &accounts::Account, reach: &Reach, f: &GroupForm) -> sqlx::Result<Outcome> { |
| 385 | 3× | let name = f.name.trim().to_string(); |
| 386 | 3× | if name.is_empty() || name.len() > 60 { |
| 387 | 0× | return Ok(Err("A test group needs a name of up to 60 characters.".into())); |
| 388 | 3× | } |
| 389 | 3× | let owner = match reach { |
| 390 | 2× | Reach::Site => f.owner.trim().to_string(), |
| 391 | 1× | Reach::Org(o) => o.clone(), |
| 392 | | }; |
| 393 | 3× | if !owner.is_empty() |
| 394 | 1× | && accounts::account_by_name(&app.db, &owner).await?.is_none() |
| 395 | 1× | && accounts::org_by_name(&app.db, &owner).await?.is_none() |
| 396 | | { |
| 397 | 0× | return Ok(Err(format!("There is no account or organisation {owner:?}."))); |
| 398 | 3× | } |
| 399 | 3× | let percent = match percent_of(f) { |
| 400 | 2× | Ok(p) => p, |
| 401 | 1× | Err(why) => return Ok(Err(why)), |
| 402 | | }; |
| 403 | 2× | if rdb::groups(&app.db).await?.iter().any(|g| g.owner == owner && g.name == name) { |
| 404 | 0× | return Ok(Err(format!("There is already a test group {name:?} for those runners."))); |
| 405 | 2× | } |
| 406 | 2× | let chosen = chosen_in_reach(app, &owner, f).await?; |
| 407 | 2× | let g = rdb::create_group(&app.db, &owner, &name, percent).await?; |
| 408 | 2× | if percent.is_none() { |
| 409 | 2× | rdb::set_members(&app.db, g.id, &chosen).await?; |
| 410 | 0× | } |
| 411 | 2× | let what = match percent { |
| 412 | 0× | Some(p) => format!("{p}% of {}", if owner.is_empty() { "the site's runners".into() } else { format!("{owner}'s runners") }), |
| 413 | 2× | None => format!("{} chosen runners", chosen.len()), |
| 414 | | }; |
| 415 | 2× | accounts::audit(&app.db, Some(me), "runner.updates.group", &name, &format!("created: {what}")).await.ok(); |
| 416 | 2× | Ok(Ok(format!("Test group {name} made: {what}. Give it its settings below."))) |
| 417 | 3× | } |
| 418 | |
|
| 419 | |
|
| 420 | 2× | pub async fn create_group( |
| 421 | 2× | State(app): State<Shared>, |
| 422 | 2× | viewer: Viewer, |
| 423 | 2× | axum_extra::extract::Form(f): axum_extra::extract::Form<GroupForm>, |
| 424 | 2× | ) -> Result<Page<UpdatesPage>, Error> { |
| 425 | 2× | let shell = Shell::new(&app, "/admin/runners/updates", &viewer); |
| 426 | 2× | let me = require_admin(&viewer, &shell)?; |
| 427 | 2× | csrf(&viewer, &f.csrf_token, &shell)?; |
| 428 | 2× | let outcome = apply_create_group(&app, &me, &Reach::Site, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 429 | 2× | page_after(&app, shell, outcome).await |
| 430 | 2× | } |
| 431 | |
|
| 432 | |
|
| 433 | 1× | pub async fn apply_save_group(app: &Shared, me: &accounts::Account, reach: &Reach, id: i64, f: &GroupForm) -> sqlx::Result<Outcome> { |
| 434 | 1× | let Some(g) = rdb::group(&app.db, id).await?.filter(|g| reach.holds_group(g)) else { |
| 435 | 0× | return Ok(Err("That test group is gone.".into())); |
| 436 | | }; |
| 437 | 1× | let percent = match percent_of(f) { |
| 438 | 1× | Ok(p) => p, |
| 439 | 0× | Err(why) => return Ok(Err(why)), |
| 440 | | }; |
| 441 | 1× | let chosen = if percent.is_some() { vec![] } else { chosen_in_reach(app, &g.owner, f).await? }; |
| 442 | 1× | rdb::set_percent(&app.db, id, percent).await?; |
| 443 | 1× | rdb::set_members(&app.db, id, &chosen).await?; |
| 444 | 1× | let what = match percent { |
| 445 | 1× | Some(p) => format!("{p}% of its runners"), |
| 446 | 0× | None => format!("{} chosen runners", chosen.len()), |
| 447 | | }; |
| 448 | 1× | accounts::audit(&app.db, Some(me), "runner.updates.group", &g.name, &what).await.ok(); |
| 449 | 1× | Ok(Ok(format!("Test group {} is now {what}.", g.name))) |
| 450 | 1× | } |
| 451 | |
|
| 452 | |
|
| 453 | 1× | pub async fn save_group( |
| 454 | 1× | State(app): State<Shared>, |
| 455 | 1× | viewer: Viewer, |
| 456 | 1× | Path(id): Path<i64>, |
| 457 | 1× | axum_extra::extract::Form(f): axum_extra::extract::Form<GroupForm>, |
| 458 | 1× | ) -> Result<Page<UpdatesPage>, Error> { |
| 459 | 1× | let shell = Shell::new(&app, "/admin/runners/updates", &viewer); |
| 460 | 1× | let me = require_admin(&viewer, &shell)?; |
| 461 | 1× | csrf(&viewer, &f.csrf_token, &shell)?; |
| 462 | 1× | let outcome = apply_save_group(&app, &me, &Reach::Site, id, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 463 | 1× | page_after(&app, shell, outcome).await |
| 464 | 1× | } |
| 465 | |
|
| 466 | | #[derive(Deserialize)] |
| 467 | | pub struct CsrfOnly { |
| 468 | | #[serde(rename = "_csrf")] |
| 469 | | pub(crate) csrf_token: String, |
| 470 | | } |
| 471 | |
|
| 472 | |
|
| 473 | 2× | pub async fn apply_delete_group(app: &Shared, me: &accounts::Account, reach: &Reach, id: i64) -> sqlx::Result<Outcome> { |
| 474 | 2× | let Some(g) = rdb::group(&app.db, id).await?.filter(|g| reach.holds_group(g)) else { |
| 475 | 1× | return Ok(Err("That test group is gone.".into())); |
| 476 | | }; |
| 477 | 1× | rdb::delete_group(&app.db, id).await?; |
| 478 | 1× | accounts::audit(&app.db, Some(me), "runner.updates.group", &g.name, "removed").await.ok(); |
| 479 | 1× | Ok(Ok(format!("Test group {} removed; its runners follow the levels above it.", g.name))) |
| 480 | 2× | } |
| 481 | |
|
| 482 | |
|
| 483 | 1× | pub async fn delete_group( |
| 484 | 1× | State(app): State<Shared>, |
| 485 | 1× | viewer: Viewer, |
| 486 | 1× | Path(id): Path<i64>, |
| 487 | 1× | Form(f): Form<CsrfOnly>, |
| 488 | 1× | ) -> Result<Page<UpdatesPage>, Error> { |
| 489 | 1× | let shell = Shell::new(&app, "/admin/runners/updates", &viewer); |
| 490 | 1× | let me = require_admin(&viewer, &shell)?; |
| 491 | 1× | csrf(&viewer, &f.csrf_token, &shell)?; |
| 492 | 1× | let outcome = apply_delete_group(&app, &me, &Reach::Site, id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 493 | 1× | page_after(&app, shell, outcome).await |
| 494 | 1× | } |