seaplane_cli/ops/
formation.rs

1mod endpoint;
2use std::{
3    collections::HashSet,
4    io::Write,
5    path::{Path, PathBuf},
6};
7
8pub use endpoint::*;
9use seaplane::api::compute::v1::{
10    Container as ContainerModel, ContainerStatus, Flight as FlightModel,
11    FormationConfiguration as FormationConfigurationModel,
12};
13use serde::{Deserialize, Serialize};
14use tabwriter::TabWriter;
15use uuid::Uuid;
16
17use crate::{
18    context::Ctx,
19    error::{CliError, Result},
20    fs::{FromDisk, ToDisk},
21    ops::Id,
22    printer::Output,
23};
24
25// TODO: Change out the Vecs for HashMaps where the key is an ID
26/// This struct represents a Local Formation. I.e. one the user can interact with on the CLI and can
27/// be (de)serialized locally.
28///
29/// A somewhat counter-intuitive thing about "Formations" and their models is the there is no
30/// "Formation Model" only a "Formation Configuration Model" This is because a "Formation" so to
31/// speak is really just a named collection of configurations and info about their traffic
32/// weights/activation statuses.
33#[derive(Debug, Deserialize, Serialize, Default, Clone)]
34pub struct Formations {
35    // Where was this "DB" loaded from on disk, so we can persist it back later
36    #[serde(skip)]
37    loaded_from: Option<PathBuf>,
38
39    /// A list of "Formation"s
40    #[serde(default)]
41    pub formations: Vec<Formation>,
42
43    /// A list of "Formation Configuration"s
44    ///
45    /// We keep these separate from the Formation themselves because multiple formations can
46    /// reference the same configuration.
47    #[serde(default)]
48    pub configurations: Vec<FormationConfiguration>,
49}
50
51impl Formations {
52    pub fn get_configuration_by_uuid(&self, uuid: Uuid) -> Option<&FormationConfiguration> {
53        self.configurations
54            .iter()
55            .find(|fc| fc.remote_id == Some(uuid))
56    }
57
58    pub fn remote_names(&self) -> Vec<&str> {
59        self.formations
60            .iter()
61            .filter(|f| !f.in_air.is_empty() || !f.grounded.is_empty())
62            .filter_map(|f| f.name.as_deref())
63            .collect()
64    }
65
66    pub fn has_flight(&self, flight: &str) -> bool {
67        self.configurations
68            .iter()
69            .any(|fc| fc.model.flights().iter().any(|f| f.name() == flight))
70    }
71
72    pub fn formations(&self) -> impl Iterator<Item = &Formation> { self.formations.iter() }
73    pub fn configurations(&self) -> impl Iterator<Item = &FormationConfiguration> {
74        self.configurations.iter()
75    }
76
77    pub fn get_configuration(&self, id: &Id) -> Option<&FormationConfiguration> {
78        self.configurations.iter().find(|fc| &fc.id == id)
79    }
80
81    /// Returns the removed FormationConfiguration by ID or None if there was no match
82    ///
83    /// DANGER: this will invalidate any previously held indices after the removed item
84    pub fn remove_configuration(&mut self, id: &Id) -> Option<FormationConfiguration> {
85        if let Some(idx) = self.configuration_index_of_id(id) {
86            return Some(self.configurations.swap_remove(idx));
87        }
88        None
89    }
90
91    // TODO: this should go away once we're not working with indices anymore
92    pub fn get_formation(&self, idx: usize) -> Option<&Formation> { self.formations.get(idx) }
93
94    // TODO: this should go away once we're not working with indices anymore
95    pub fn get_formation_mut(&mut self, idx: usize) -> Option<&mut Formation> {
96        self.formations.get_mut(idx)
97    }
98
99    /// Either updates a matching local Formation Configurations, or creates a new one. Returns the
100    /// existing ID of the config that was updated if any
101    pub fn update_or_create_configuration(&mut self, cfg: FormationConfiguration) -> Option<Id> {
102        let has_matching_uuid = self
103            .configurations
104            .iter()
105            .any(|c| c.remote_id == cfg.remote_id);
106        if let Some(old_cfg) = self
107            .configurations
108            .iter_mut()
109            .find(|c| c.model == cfg.model && (c.remote_id.is_none() && !has_matching_uuid))
110        {
111            // This should have come from the API and thus requires a UUID
112            old_cfg.remote_id = Some(cfg.remote_id.unwrap());
113            Some(old_cfg.id)
114        } else if self.configurations.iter().any(|c| c.eq_without_id(&cfg)) {
115            None
116        } else {
117            self.configurations.push(cfg);
118            None
119        }
120    }
121
122    /// Either updates a matching local Formations by replacing the local IDs, or creates a new
123    /// one. Returns NEW Formations IDs
124    pub fn update_or_create_formation(&mut self, formation: Formation) -> Option<Id> {
125        if let Some(f) = self
126            .formations
127            .iter_mut()
128            .find(|f| f.name == formation.name)
129        {
130            f.in_air = formation.in_air;
131            f.grounded = formation.grounded;
132            f.local = formation.local;
133            None
134        } else {
135            let id = formation.id;
136            self.formations.push(formation);
137            Some(id)
138        }
139    }
140
141    // TODO: add success indicator
142    pub fn add_uuid(&mut self, id: &Id, uuid: Uuid) {
143        for cfg in self.configurations.iter_mut() {
144            if &cfg.id == id {
145                cfg.remote_id = Some(uuid);
146                break;
147            }
148        }
149    }
150
151    // TODO: add success indicator
152    pub fn add_in_air_by_name(&mut self, name: &str, id: Id) {
153        cli_traceln!(
154            "Adding Cfg ID {} for Formation {name} as In Air to local state",
155            &id.to_string()[..8]
156        );
157        for f in self.formations.iter_mut() {
158            if f.name.as_deref() == Some(name) {
159                f.in_air.insert(id);
160                break;
161            }
162        }
163    }
164
165    // TODO: add success indicator
166    pub fn add_grounded_by_name(&mut self, name: &str, id: Id) {
167        cli_traceln!(
168            "Adding Cfg ID {} for Formation {name} as Grounded to local state",
169            &id.to_string()[..8]
170        );
171        for f in self.formations.iter_mut() {
172            if f.name.as_deref() == Some(name) {
173                f.grounded.insert(id);
174                f.in_air.remove(&id);
175                break;
176            }
177        }
178    }
179
180    /// Returns true if there is a Formation with the given name
181    pub fn contains_name(&self, name: &str) -> bool {
182        self.formations
183            .iter()
184            .any(|f| f.name.as_deref() == Some(name))
185    }
186
187    /// Removes an exact name match, returning the removed Formation or None if nothing matched.
188    ///
189    /// DANGER: this will invalidate any previously held indices after the removed item
190    pub fn remove_name(&mut self, name: &str) -> Option<Formation> {
191        cli_traceln!("Removing Formation {name} from local state");
192        if let Some(idx) = self.formation_index_of_name(name) {
193            return Some(self.formations.swap_remove(idx));
194        }
195
196        None
197    }
198
199    // TODO: this should go away once we're not working with indices anymore
200    /// Returns the index of an exact name match
201    pub fn formation_index_of_name(&self, name: &str) -> Option<usize> {
202        cli_traceln!("Searching local DB for index of Formation Plan {name}");
203        self.formations
204            .iter()
205            .enumerate()
206            .find(|(_, f)| f.name.as_deref() == Some(name))
207            .map(|(i, _)| i)
208    }
209
210    pub fn configuration_index_of_id(&self, id: &Id) -> Option<usize> {
211        cli_traceln!("Searching for index of Configuration ID {id}");
212        self.configurations
213            .iter()
214            .enumerate()
215            .find(|(_, c)| &c.id == id)
216            .map(|(i, _)| i)
217    }
218
219    // TODO: this should go away once we're not working with indices anymore
220    /// Returns all indices of an exact name or partial ID match
221    pub fn formation_indices_of_matches(&self, name: &str) -> Vec<usize> {
222        cli_traceln!("Searching local DB for exact matches of Formation Plan {name}");
223        self.formations
224            .iter()
225            .enumerate()
226            .filter(|(_, f)| f.name.as_deref() == Some(name) || f.id.to_string().starts_with(name))
227            .map(|(i, _)| i)
228            .collect()
229    }
230
231    // TODO: this should go away once we're not working with indices anymore
232    /// Returns all indices of a partial name or ID match
233    pub fn formation_indices_of_left_matches(&self, name: &str) -> Vec<usize> {
234        cli_traceln!("Searching local DB for partial matches of Formation Plan {name}");
235        self.formations
236            .iter()
237            .enumerate()
238            .filter(|(_, f)| {
239                f.name
240                    .as_deref()
241                    .map(|n| n.starts_with(name))
242                    .unwrap_or(false)
243                    || f.id.to_string().starts_with(name)
244            })
245            .map(|(i, _)| i)
246            .collect()
247    }
248
249    // TODO: this should go away once we're not working with indices anymore
250    /// Removes all indices
251    pub fn remove_formation_indices(&mut self, indices: &[usize]) -> Vec<Formation> {
252        cli_traceln!("Removing indexes {indices:?} from local state");
253        // TODO: There is probably a much more performant way to remove a bunch of times from a Vec
254        // but we're talking such a small number of items this should never matter.
255
256        indices
257            .iter()
258            .enumerate()
259            .map(|(i, idx)| self.formations.remove(idx - i))
260            .collect()
261    }
262
263    /// Removes the given flight from all formations that reference it
264    pub fn remove_flight(&mut self, flight: &str) {
265        self.configurations.iter_mut().for_each(|cfg| {
266            cfg.model.remove_flight(flight);
267        });
268    }
269}
270
271impl FromDisk for Formations {
272    fn set_loaded_from<P: AsRef<Path>>(&mut self, p: P) {
273        self.loaded_from = Some(p.as_ref().into());
274    }
275
276    fn loaded_from(&self) -> Option<&Path> { self.loaded_from.as_deref() }
277}
278
279impl ToDisk for Formations {}
280
281impl Output for Formations {
282    fn print_json(&self, _ctx: &Ctx) -> Result<()> {
283        cli_println!("{}", serde_json::to_string(self)?);
284
285        Ok(())
286    }
287
288    fn print_table(&self, _ctx: &Ctx) -> Result<()> {
289        let buf = Vec::new();
290        let mut tw = TabWriter::new(buf);
291        writeln!(
292            tw,
293            "LOCAL ID\tNAME\tLOCAL\tDEPLOYED (GROUNDED)\t DEPLOYED (IN AIR)\t TOTAL CONFIGURATIONS"
294        )?;
295        for formation in &self.formations {
296            let local = formation.local.len();
297            let in_air = formation.in_air.len();
298            let grounded = formation.grounded.len();
299            let total = formation
300                .in_air
301                .union(
302                    &formation
303                        .grounded
304                        .union(&formation.local)
305                        .copied()
306                        .collect(),
307                )
308                .count();
309
310            writeln!(
311                tw,
312                "{}\t{}\t{}\t{}\t{}\t{}",
313                &formation.id.to_string()[..8], // TODO: make sure length is not ambiguous
314                formation.name.as_deref().unwrap_or_default(),
315                local,
316                grounded,
317                in_air,
318                total
319            )?;
320        }
321        tw.flush()?;
322
323        cli_println!(
324            "{}",
325            String::from_utf8_lossy(
326                &tw.into_inner()
327                    .map_err(|_| CliError::bail("IO flush error"))?
328            )
329        );
330
331        Ok(())
332    }
333}
334
335// TODO: move ID to the key of a HashMap
336#[derive(Debug, Deserialize, Serialize, Clone)]
337pub struct Formation {
338    pub id: Id,
339    pub name: Option<String>,
340    pub local: HashSet<Id>,
341    pub in_air: HashSet<Id>,
342    pub grounded: HashSet<Id>,
343}
344
345impl Formation {
346    pub fn new<S: Into<String>>(name: S) -> Self {
347        Self {
348            id: Id::new(),
349            name: Some(name.into()),
350            local: HashSet::new(),
351            in_air: HashSet::new(),
352            grounded: HashSet::new(),
353        }
354    }
355
356    pub fn is_empty(&self) -> bool {
357        self.local.is_empty() && self.in_air.is_empty() && self.grounded.is_empty()
358    }
359
360    /// Replaces all occurrences of `old_id` with `new_id`
361    pub fn replace_id(&mut self, old_id: &Id, new_id: Id) {
362        if self.local.remove(old_id) {
363            self.local.insert(new_id);
364        }
365        if self.in_air.remove(old_id) {
366            self.in_air.insert(new_id);
367        }
368        if self.grounded.remove(old_id) {
369            self.grounded.insert(new_id);
370        }
371    }
372
373    /// Returns the Formation Configuration IDs that are neither Grounded (Inactive) or In Air
374    /// (active)
375    pub fn local_only_configs(&self) -> Vec<Id> {
376        self.local
377            .difference(&self.in_air.union(&self.grounded).copied().collect())
378            .copied()
379            .collect()
380    }
381
382    /// Returns the Formation Configuration IDs that are either Grounded (Inactive) or Local
383    pub fn local_or_grounded_configs(&self) -> Vec<Id> {
384        self.local
385            .iter()
386            .chain(self.grounded.iter())
387            .copied()
388            .collect::<HashSet<_>>()
389            .difference(&self.in_air)
390            .copied()
391            .collect()
392    }
393
394    /// Returns a deduplicated union of all the configuration IDs.
395    pub fn configs(&self) -> Vec<Id> {
396        self.local
397            .iter()
398            .chain(self.in_air.iter().chain(self.grounded.iter()))
399            .copied()
400            .collect()
401    }
402}
403
404/// Wraps the [`FormationConfiguration`] model adding a local ID and the UUID associated
405#[derive(Debug, Serialize, Deserialize, Clone)]
406pub struct FormationConfiguration {
407    pub id: Id,
408    pub remote_id: Option<Uuid>,
409    pub model: FormationConfigurationModel,
410}
411
412impl FormationConfiguration {
413    pub fn new(model: FormationConfigurationModel) -> Self {
414        Self { id: Id::new(), remote_id: None, model }
415    }
416
417    pub fn with_uuid(uuid: Uuid, model: FormationConfigurationModel) -> Self {
418        Self { id: Id::new(), remote_id: Some(uuid), model }
419    }
420
421    pub fn get_flight(&self, flight: &str) -> Option<&FlightModel> {
422        self.model.flights().iter().find(|f| f.name() == flight)
423    }
424
425    /// Performs equality check without consider the local ID
426    pub fn eq_without_id(&self, other: &Self) -> bool {
427        self.remote_id == other.remote_id && self.model == other.model
428    }
429}
430
431// Possible Symbols?: ◯ ◉ ◍ ◐ ● ○ ◯
432const SYM: char = '◉';
433
434#[derive(Debug, Eq, PartialEq, Clone, Serialize)]
435pub struct FormationStatus {
436    name: String,
437    status: OpStatus,
438    configurations: FormationConfigStatuses,
439}
440
441#[derive(Debug, Default, Eq, PartialEq, Clone, Serialize)]
442#[serde(transparent)]
443pub struct FormationConfigStatuses {
444    inner: Vec<FormationConfigStatus>,
445}
446
447#[derive(Debug, Default, Eq, PartialEq, Clone, Serialize)]
448pub struct FormationConfigStatus {
449    status: OpStatus,
450    uuid: Uuid,
451    flights: FlightStatuses,
452}
453
454#[derive(Debug, Default, PartialEq, Eq, Clone, Serialize)]
455#[serde(transparent)]
456pub struct FlightStatuses {
457    inner: Vec<FlightStatus>,
458}
459
460#[derive(Debug, PartialEq, Eq, Clone, Serialize)]
461pub struct FlightStatus {
462    name: String,
463    running: u64,
464    minimum: u64,
465    exited: u64,
466    errored: u64,
467    starting: u64,
468    #[serde(skip_serializing_if = "Option::is_none")]
469    maximum: Option<u64>,
470}
471
472#[derive(Debug, Copy, Clone, PartialEq, Eq, strum::EnumString, Serialize)]
473#[strum(ascii_case_insensitive, serialize_all = "lowercase")]
474pub enum OpStatus {
475    Up,
476    Down,
477    Degraded,
478    Starting,
479}
480
481impl OpStatus {
482    pub fn worse_only(&mut self, other: Self) {
483        use OpStatus::*;
484        match self {
485            Up => match other {
486                Down => *self = Down,
487                Degraded => *self = Degraded,
488                Starting => *self = Starting,
489                _ => (),
490            },
491            Down => (),
492            Degraded => {
493                if other == Down {
494                    *self = Down;
495                }
496            }
497            Starting => match other {
498                Down => *self = Down,
499                Degraded => *self = Degraded,
500                _ => (),
501            },
502        }
503    }
504
505    /// Prints the SYM character color coded to the current status
506    pub fn print_sym(self) {
507        match self {
508            OpStatus::Up => cli_print!(@Green, "{SYM}"),
509            OpStatus::Down => cli_print!(@Red, "{SYM}"),
510            OpStatus::Degraded | OpStatus::Starting => cli_print!(@Yellow, "{SYM}"),
511        }
512    }
513
514    /// Prints string version of self color coded to the current status
515    pub fn print(self) {
516        match self {
517            OpStatus::Up => cli_print!(@Green, "UP"),
518            OpStatus::Down => cli_print!(@Red, "DOWN"),
519            OpStatus::Degraded => cli_print!(@Yellow, "DEGRADED"),
520            OpStatus::Starting => cli_print!(@Yellow, "STARTING"),
521        }
522    }
523
524    /// Prints a string color coded to the current status
525    pub fn print_msg(self, msg: &str) {
526        match self {
527            OpStatus::Up => cli_print!(@Green, "{msg}"),
528            OpStatus::Down => cli_print!(@Red, "{msg}"),
529            OpStatus::Degraded | OpStatus::Starting => cli_print!(@Yellow, "{msg}"),
530        }
531    }
532}
533
534impl Default for OpStatus {
535    fn default() -> Self { OpStatus::Starting }
536}
537
538impl FormationStatus {
539    /// Create a new FormationStatus from a given Formation name.
540    pub fn new<S: Into<String>>(name: S) -> Self {
541        Self {
542            name: name.into(),
543            status: OpStatus::default(),
544            configurations: FormationConfigStatuses::default(),
545        }
546    }
547
548    /// Add the appropriate Flights and Configurations from a given Container Instance
549    pub fn add_container(&mut self, c: &ContainerModel, min: u64, max: Option<u64>) {
550        match c.status {
551            ContainerStatus::Running => self.configurations.add_running_flight(
552                c.configuration_id,
553                c.flight_name.clone(),
554                min,
555                max,
556            ),
557            ContainerStatus::Started => self.configurations.add_starting_flight(
558                c.configuration_id,
559                c.flight_name.clone(),
560                min,
561                max,
562            ),
563            ContainerStatus::Stopped => self.configurations.add_stopped_flight(
564                c.configuration_id,
565                c.flight_name.clone(),
566                c.exit_status.is_none() || c.exit_status == Some(0),
567                min,
568                max,
569            ),
570        }
571    }
572
573    pub fn update_status(&mut self) {
574        let mut status = OpStatus::Up;
575        for cfg in self.configurations.inner.iter_mut() {
576            cfg.update_status();
577            status.worse_only(cfg.status);
578        }
579        self.status = status;
580    }
581}
582
583impl FlightStatus {
584    pub fn new<S: Into<String>>(name: S) -> Self {
585        Self {
586            name: name.into(),
587            running: 0,
588            minimum: 0,
589            exited: 0,
590            starting: 0,
591            errored: 0,
592            maximum: None,
593        }
594    }
595
596    #[allow(unused_assignments)]
597    pub fn get_status(&self) -> OpStatus {
598        let mut status = OpStatus::Up;
599        if self.running >= self.minimum {
600            status = OpStatus::Up;
601        } else {
602            status = OpStatus::Degraded;
603        }
604        if self.running == 0 && self.starting > 0 {
605            status = OpStatus::Degraded;
606        }
607        if self.errored > 0 {
608            // TODO even if running > minimum?
609            status = OpStatus::Degraded;
610        }
611        status
612    }
613}
614
615impl FormationConfigStatuses {
616    pub fn add_running_flight<S: Into<String>>(
617        &mut self,
618        uuid: Uuid,
619        name: S,
620        min: u64,
621        max: Option<u64>,
622    ) {
623        if let Some(cfg) = self.inner.iter_mut().find(|cfg| cfg.uuid == uuid) {
624            cfg.flights.add_running(name, min, max)
625        } else {
626            let mut fs = FlightStatuses::default();
627
628            fs.add_running(name, min, max);
629            self.inner
630                .push(FormationConfigStatus { status: OpStatus::Starting, uuid, flights: fs })
631        }
632    }
633    pub fn add_starting_flight<S: Into<String>>(
634        &mut self,
635        uuid: Uuid,
636        name: S,
637        min: u64,
638        max: Option<u64>,
639    ) {
640        if let Some(cfg) = self.inner.iter_mut().find(|cfg| cfg.uuid == uuid) {
641            cfg.flights.add_starting(name, min, max)
642        } else {
643            let mut fs = FlightStatuses::default();
644
645            fs.add_starting(name, min, max);
646            self.inner
647                .push(FormationConfigStatus { status: OpStatus::Starting, uuid, flights: fs })
648        }
649    }
650    pub fn add_stopped_flight<S: Into<String>>(
651        &mut self,
652        uuid: Uuid,
653        name: S,
654        error: bool,
655        min: u64,
656        max: Option<u64>,
657    ) {
658        if let Some(cfg) = self.inner.iter_mut().find(|cfg| cfg.uuid == uuid) {
659            cfg.flights.add_stopped(name, error, min, max)
660        } else {
661            let mut fs = FlightStatuses::default();
662
663            fs.add_stopped(name, error, min, max);
664            self.inner
665                .push(FormationConfigStatus { status: OpStatus::Starting, uuid, flights: fs })
666        }
667    }
668
669    #[inline]
670    pub fn is_empty(&self) -> bool { self.inner.is_empty() }
671
672    #[inline]
673    pub fn len(&self) -> usize { self.inner.len() }
674}
675
676impl FormationConfigStatus {
677    pub fn update_status(&mut self) {
678        let mut status = OpStatus::Up;
679        for flight in self.flights.inner.iter() {
680            status.worse_only(flight.get_status());
681        }
682        self.status = status;
683    }
684
685    pub fn print_pretty(&self, last: bool) {
686        // Chars we'll need: │ ├ ─ └
687        if self.flights.is_empty() {
688            return;
689        }
690        if last {
691            cli_print!("└─");
692        } else {
693            cli_print!("├─");
694        }
695        self.status.print_sym();
696        cli_print!(" Configuration {}: ", self.uuid);
697        self.status.print();
698        cli_println!("");
699
700        if self.flights.is_empty() {
701            return;
702        }
703        let prefix = if last { "  " } else { "│ " };
704        cli_println!("{prefix}│");
705        // Unfortunately we can't use tabwriter here as we can't color the symbol with that. So we
706        // just manually calculate the spaces since it's only a few fields anyways. We also assume
707        // the numbered fields aren't going to be higher than 99999999999 and if they are we most
708        // likely have other problems.
709        macro_rules! nspaces {
710            ($n:expr, $w:expr) => {{
711                nspaces!(($w.chars().count() + 4) - $n.to_string().chars().count())
712            }};
713            ($n:expr) => {{
714                let mut spaces = String::with_capacity($n);
715                for _ in 0..$n {
716                    spaces.push(' ');
717                }
718                spaces
719            }};
720        }
721        let longest_flight_name = self
722            .flights
723            .inner
724            .iter()
725            .map(|f| f.name.len())
726            .max()
727            .unwrap();
728        let total_slot_size = std::cmp::max(longest_flight_name, 10);
729        let spaces_after_flight = total_slot_size - 6; // 6 = FLIGHT
730        cli_println!(
731            "{prefix}│   FLIGHT{}RUNNING    EXITED    ERRORED    STARTING    MIN / MAX",
732            nspaces!(spaces_after_flight)
733        );
734        for (i, flight) in self.flights.inner.iter().enumerate() {
735            if i == self.flights.inner.len() - 1 {
736                cli_print!("{prefix}└─");
737            } else {
738                cli_print!("{prefix}├─");
739            }
740            self.status.print_sym();
741
742            let name = &flight.name;
743            let running = flight.running;
744            let exited = flight.exited;
745            let errored = flight.errored;
746            let starting = flight.starting;
747            let minimum = flight.minimum;
748            let maximum = if let Some(maximum) = flight.maximum {
749                format!("{maximum}")
750            } else {
751                "AUTO".to_string()
752            };
753
754            let s_after_name = nspaces!(total_slot_size - name.len());
755            let s_after_running = nspaces!(running, "RUNNING");
756            let s_after_exited = nspaces!(exited, "EXITED");
757            let s_after_errored = nspaces!(errored, "ERRORED");
758            let s_after_starting = nspaces!(starting, "STARTING");
759
760            cli_println!(" {name}{s_after_name}{running}{s_after_running}{exited}{s_after_exited}{errored}{s_after_errored}{starting}{s_after_starting}{minimum} / {maximum}");
761        }
762        if last {
763            cli_println!("");
764        } else {
765            cli_println!("│");
766        }
767    }
768}
769
770impl FlightStatuses {
771    pub fn add_running<S: Into<String>>(&mut self, name: S, minimum: u64, maximum: Option<u64>) {
772        let name = name.into();
773        if let Some(f) = self.inner.iter_mut().find(|f| f.name == name) {
774            f.running += 1;
775        } else {
776            self.inner.push(FlightStatus {
777                name,
778                running: 1,
779                exited: 0,
780                errored: 0,
781                starting: 0,
782                minimum,
783                maximum,
784            })
785        }
786    }
787
788    pub fn add_stopped<S: Into<String>>(
789        &mut self,
790        name: S,
791        error: bool,
792        minimum: u64,
793        maximum: Option<u64>,
794    ) {
795        let name = name.into();
796        if let Some(f) = self.inner.iter_mut().find(|f| f.name == name) {
797            if error {
798                f.errored += 1;
799            } else {
800                f.exited += 1;
801            }
802        } else {
803            self.inner.push(FlightStatus {
804                name,
805                running: 0,
806                starting: 0,
807                exited: if error { 0 } else { 1 },
808                errored: if error { 1 } else { 0 },
809                minimum,
810                maximum,
811            })
812        }
813    }
814
815    pub fn add_starting<S: Into<String>>(&mut self, name: S, minimum: u64, maximum: Option<u64>) {
816        let name = name.into();
817        if let Some(f) = self.inner.iter_mut().find(|f| f.name == name) {
818            f.starting += 1;
819        } else {
820            self.inner.push(FlightStatus {
821                name,
822                running: 0,
823                starting: 1,
824                exited: 0,
825                errored: 0,
826                minimum,
827                maximum,
828            })
829        }
830    }
831
832    #[inline]
833    pub fn is_empty(&self) -> bool { self.inner.is_empty() }
834}
835
836impl Output for FormationStatus {
837    fn print_json(&self, _ctx: &Ctx) -> Result<()> {
838        cli_println!("{}", serde_json::to_string(self)?);
839
840        Ok(())
841    }
842
843    fn print_table(&self, _ctx: &Ctx) -> Result<()> {
844        // Chars we'll need: │ ├ ─ └
845        if !self.configurations.is_empty() {
846            self.status.print_sym();
847            cli_print!(" Formation {}: ", self.name);
848            self.status.print();
849            cli_println!("");
850            cli_println!("│");
851
852            for (i, cfg) in self.configurations.inner.iter().enumerate() {
853                cfg.print_pretty(i == self.configurations.len() - 1)
854            }
855        } else {
856            // If we have no configurations to display we assume the Formation is down
857            // We have to make a new status though because we're behind a & reference. Luckily,
858            // we're making an empty status struct so it's cheap.
859            let mut fs = FormationStatus::new(&self.name);
860            fs.status = OpStatus::Down;
861            fs.status.print_sym();
862            cli_print!(" Formation {}: ", fs.name);
863            fs.status.print();
864            cli_println!("");
865        }
866
867        Ok(())
868    }
869}
870
871impl Output for Vec<FormationStatus> {
872    fn print_json(&self, _ctx: &Ctx) -> Result<()> {
873        cli_println!("{}", serde_json::to_string(self)?);
874
875        Ok(())
876    }
877
878    fn print_table(&self, ctx: &Ctx) -> Result<()> {
879        for fstatus in self.iter() {
880            fstatus.print_table(ctx)?;
881        }
882
883        Ok(())
884    }
885}
886
887// impl From<Formations> for Vec<FormationStatus> {
888//     fn from(fs: Formations) -> Vec<FormationStatus> {
889//         let mut statuses = Vec::new();
890//         for formation in fs.formations.iter() {
891//             let mut f_status = FormationStatus::new(formation.name.as_ref().unwrap());
892
893//             // Loop through all the Formation Configurations defined in this Formation
894//             for cfg in formation.configs().iter().map(|id| {
895//                 // Map a config ID to an actual Config. We have to use these long chained calls
896// so                 // Rust can tell that `formations` itself isn't being borrowed, just it's
897// fields.                 fs.configurations.swap_remove(
898//                     // get the index of the Config where the ID matches
899//                     fs.configurations
900//                         .iter()
901//                         .enumerate()
902//                         .find_map(|(i, cfg)| if &cfg.id == id { Some(i) } else { None })
903//                         .unwrap(),
904//                 )
905//             }) {
906//                 let mut fc_status = FormationConfigStatus::default();
907//                 // Add or update all flights this configuration references
908//                 for flight in cfg.model.flights() {
909//                     fc_status.flights.push(FlightStatus::new(flight.name()));
910//                 }
911
912//                 f_status.configurations.push(fc_status);
913//             }
914//             statuses.push(f_status);
915//         }
916
917//         statuses
918//     }
919// }