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#[derive(Debug, Deserialize, Serialize, Default, Clone)]
34pub struct Formations {
35 #[serde(skip)]
37 loaded_from: Option<PathBuf>,
38
39 #[serde(default)]
41 pub formations: Vec<Formation>,
42
43 #[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 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 pub fn get_formation(&self, idx: usize) -> Option<&Formation> { self.formations.get(idx) }
93
94 pub fn get_formation_mut(&mut self, idx: usize) -> Option<&mut Formation> {
96 self.formations.get_mut(idx)
97 }
98
99 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 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 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 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 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 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 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 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 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 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 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 pub fn remove_formation_indices(&mut self, indices: &[usize]) -> Vec<Formation> {
252 cli_traceln!("Removing indexes {indices:?} from local state");
253 indices
257 .iter()
258 .enumerate()
259 .map(|(i, idx)| self.formations.remove(idx - i))
260 .collect()
261 }
262
263 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], 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#[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 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 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 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 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#[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 pub fn eq_without_id(&self, other: &Self) -> bool {
427 self.remote_id == other.remote_id && self.model == other.model
428 }
429}
430
431const 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 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 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 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 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 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 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 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 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; 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 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 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