1use std::time::{Duration, Instant};
15
16use tern_sdk::{
17 Font, Input, Options, Session, SurfaceOptions, nodes, spans,
18 ui::{
19 self, BadgeSpec, Bound, Column, Extent, MeterCell, Node, SparkCell, StatItem, TableRow, Text,
20 TextAlign, TextCell, Tone, View,
21 },
22};
23
24const TICK: Duration = Duration::from_secs(1);
26
27const UPTIME: u64 = 39 * 60;
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32enum Page {
33 Services,
35 Deploys,
37 Incidents,
39}
40
41impl Page {
42 const ALL: [Self; 3] = [Self::Services, Self::Deploys, Self::Incidents];
44
45 const fn id(self) -> &'static str {
47 match self {
48 Self::Services => "services",
49 Self::Deploys => "deploys",
50 Self::Incidents => "incidents",
51 }
52 }
53
54 fn from_id(id: &str) -> Option<Self> {
56 Self::ALL.into_iter().find(|p| p.id() == id)
57 }
58
59 const fn index(self) -> usize {
61 match self {
62 Self::Services => 0,
63 Self::Deploys => 1,
64 Self::Incidents => 2,
65 }
66 }
67
68 const fn turn(self, back: bool) -> Self {
70 let n = Self::ALL.len();
71 Self::ALL[(self.index() + if back { n - 1 } else { 1 }) % n]
72 }
73}
74
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77enum Health {
78 Healthy,
80 Degraded,
82 Down,
84}
85
86impl Health {
87 const fn look(self) -> (&'static str, Tone) {
89 match self {
90 Self::Healthy => ("Healthy", Tone::Success),
91 Self::Degraded => ("Degraded", Tone::Warning),
92 Self::Down => ("Down", Tone::Error),
93 }
94 }
95}
96
97#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99enum State {
100 Running,
102 Waiting,
104 Failed,
106 Done,
108}
109
110impl State {
111 const fn look(self) -> (&'static str, Tone) {
113 match self {
114 Self::Running => ("Rolling out", Tone::Accent),
115 Self::Waiting => ("Needs approval", Tone::Pending),
116 Self::Failed => ("Failed", Tone::Error),
117 Self::Done => ("Live", Tone::Success),
118 }
119 }
120}
121
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124enum Severity {
125 Critical,
127 Major,
129 Minor,
131}
132
133impl Severity {
134 const fn look(self) -> (&'static str, Tone) {
136 match self {
137 Self::Critical => ("SEV1", Tone::Error),
138 Self::Major => ("SEV2", Tone::Warning),
139 Self::Minor => ("SEV3", Tone::Neutral),
140 }
141 }
142}
143
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146enum Phase {
147 Open,
149 Acknowledged,
151 Resolved,
153}
154
155impl Phase {
156 const fn look(self) -> (&'static str, Tone) {
158 match self {
159 Self::Open => ("Open", Tone::Error),
160 Self::Acknowledged => ("Acknowledged", Tone::Pending),
161 Self::Resolved => ("Resolved", Tone::Success),
162 }
163 }
164}
165
166struct Risk {
168 button: &'static str,
170 ask: String,
172 then: &'static str,
174 done: &'static str,
176 blocked: Option<&'static str>,
178}
179
180#[derive(Clone, Debug)]
182struct Service {
183 id: &'static str,
185 region: &'static str,
187 version: &'static str,
189 replicas: u32,
191 health: Health,
193 rps: Vec<f64>,
195 p95: f64,
197 errors: f64,
199}
200
201impl Service {
202 const fn action(&self) -> &'static str {
204 match self.health {
205 Health::Down => "Start",
206 Health::Healthy | Health::Degraded => "Scale up",
207 }
208 }
209
210 const fn act(&mut self) -> &'static str {
212 if matches!(self.health, Health::Down) {
213 self.replicas = 2;
214 self.health = Health::Healthy;
215 return "Started";
216 }
217 self.replicas += 1;
218 self.health = Health::Healthy;
219 "Scaled up"
220 }
221
222 fn risk(&self) -> Risk {
224 Risk {
225 button: "Drain",
226 ask: format!("Drain {}?", self.id),
227 then: "It stops taking traffic until you start it again.",
228 done: "Drained",
229 blocked: matches!(self.health, Health::Down).then_some("Already drained"),
230 }
231 }
232}
233
234#[derive(Clone, Debug)]
236struct Deploy {
237 id: &'static str,
239 service: &'static str,
241 commit: &'static str,
243 region: &'static str,
245 state: State,
247 done: f64,
249 rps: Vec<f64>,
251}
252
253impl Deploy {
254 const fn action(&self) -> Option<&'static str> {
256 match self.state {
257 State::Waiting => Some("Approve"),
258 State::Failed => Some("Retry"),
259 State::Running | State::Done => None,
260 }
261 }
262
263 const fn act(&mut self) -> Option<&'static str> {
265 match self.state {
266 State::Waiting => {
267 self.state = State::Running;
268 self.done = 0.02;
269 Some("Approved")
270 },
271 State::Failed => {
272 self.state = State::Running;
273 self.done = 0.0;
274 Some("Retrying")
275 },
276 State::Running | State::Done => None,
277 }
278 }
279
280 fn risk(&self) -> Risk {
282 Risk {
283 button: "Cancel deploy",
284 ask: format!("Cancel the {} deploy?", self.id),
285 then: "Its rollout stops where it is.",
286 done: "Cancelled",
287 blocked: matches!(self.state, State::Done).then_some("Already live"),
288 }
289 }
290}
291
292#[derive(Clone, Debug)]
294struct Incident {
295 id: &'static str,
297 title: &'static str,
299 service: &'static str,
301 severity: Severity,
303 phase: Phase,
305 age: u64,
307}
308
309impl Incident {
310 const fn action(&self) -> Option<&'static str> {
312 match self.phase {
313 Phase::Open => Some("Acknowledge"),
314 Phase::Acknowledged => Some("Resolve"),
315 Phase::Resolved => None,
316 }
317 }
318
319 const fn act(&mut self) -> Option<&'static str> {
321 match self.phase {
322 Phase::Open => {
323 self.phase = Phase::Acknowledged;
324 Some("Acknowledged")
325 },
326 Phase::Acknowledged => {
327 self.phase = Phase::Resolved;
328 Some("Resolved")
329 },
330 Phase::Resolved => None,
331 }
332 }
333
334 fn risk(&self) -> Risk {
336 Risk {
337 button: "Dismiss",
338 ask: format!("Dismiss {}?", self.id),
339 then: "It leaves the list without a postmortem.",
340 done: "Dismissed",
341 blocked: None,
342 }
343 }
344}
345
346#[derive(Clone, Debug)]
348enum Msg {
349 Page(String),
351 Filter(String),
353 Refresh,
355 Select(Page, String),
357 Open(Page, String),
359 Act(Page, String),
361 Ask(Page, String),
363 Confirm,
365 Keep,
367}
368
369struct Dash {
371 services: Vec<Service>,
373 deploys: Vec<Deploy>,
375 incidents: Vec<Incident>,
377 page: Page,
379 filter: String,
381 selected: [Option<&'static str>; 3],
383 confirming: Option<(Page, &'static str)>,
385 note: String,
387 ticks: u32,
389}
390
391fn traffic(base: f64, seed: u32) -> Vec<f64> {
393 (0..12u32)
394 .map(|i| base + f64::from((i * 7 + seed) % 23))
395 .collect()
396}
397
398fn ago(secs: u64) -> String {
400 match secs {
401 0..60 => format!("{secs}s"),
402 60..3600 => format!("{}m {:02}s", secs / 60, secs % 60),
403 _ => format!("{}h {:02}m", secs / 3600, secs % 3600 / 60),
404 }
405}
406
407fn mono(v: impl Into<String>) -> Text {
409 ui::span(v).style("mono").into()
410}
411
412impl Dash {
413 fn new() -> Self {
415 let service = |id, region, version, replicas, health, p95, errors, seed| Service {
416 id,
417 region,
418 version,
419 replicas,
420 health,
421 rps: traffic(120.0, seed),
422 p95,
423 errors,
424 };
425 let deploy = |id: &'static str, service, commit, region, state, done, seed| Deploy {
426 id,
427 service,
428 commit,
429 region,
430 state,
431 done,
432 rps: traffic(40.0, seed),
433 };
434 let incident = |id, title, service, severity, phase, age| Incident {
435 id,
436 title,
437 service,
438 severity,
439 phase,
440 age,
441 };
442 Self {
443 services: vec![
444 service("api-gateway", "us-east-1", "2.14.0", 6, Health::Healthy, 84.0, 0.1, 3),
445 service("auth", "eu-west-1", "1.9.2", 3, Health::Healthy, 41.0, 0.0, 8),
446 service("search-indexer", "us-west-2", "0.31.4", 2, Health::Degraded, 412.0, 2.7, 13),
447 service("web", "us-east-1", "5.0.1", 8, Health::Healthy, 118.0, 0.2, 5),
448 service("billing", "ap-south-1", "3.3.0", 2, Health::Healthy, 67.0, 0.0, 11),
449 service("notifications", "eu-west-1", "1.2.7", 0, Health::Down, 0.0, 0.0, 2),
450 ],
451 deploys: vec![
452 deploy("api", "api-gateway", "4f2c9e1", "us-east-1", State::Running, 0.42, 3),
453 deploy("auth", "auth", "b71d03a", "eu-west-1", State::Waiting, 0.0, 8),
454 deploy("search", "search-indexer", "09ae5f7", "us-west-2", State::Failed, 0.63, 13),
455 deploy("web", "web", "e3c1a44", "us-east-1", State::Done, 1.0, 5),
456 deploy("billing", "billing", "5d8e2b0", "ap-south-1", State::Waiting, 0.0, 11),
457 ],
458 incidents: vec![
459 incident(
460 "INC-311",
461 "Search results lag behind writes",
462 "search-indexer",
463 Severity::Major,
464 Phase::Open,
465 14 * 60 + 12,
466 ),
467 incident(
468 "INC-310",
469 "Notification emails not sent",
470 "notifications",
471 Severity::Critical,
472 Phase::Acknowledged,
473 52 * 60 + 40,
474 ),
475 incident(
476 "INC-307",
477 "Slow logins from India",
478 "auth",
479 Severity::Minor,
480 Phase::Resolved,
481 5 * 3600 + 18 * 60,
482 ),
483 ],
484 page: Page::Services,
485 filter: "all".into(),
486 selected: [Some("api-gateway"), Some("api"), Some("INC-311")],
487 confirming: None,
488 note: String::new(),
489 ticks: 0,
490 }
491 }
492
493 fn shown_deploys(&self) -> Vec<&Deploy> {
495 self
496 .deploys
497 .iter()
498 .filter(|d| self.filter == "all" || matches!(d.state, State::Waiting | State::Failed))
499 .collect()
500 }
501
502 fn ids(&self, page: Page) -> Vec<&'static str> {
504 match page {
505 Page::Services => self.services.iter().map(|s| s.id).collect(),
506 Page::Deploys => self.shown_deploys().iter().map(|d| d.id).collect(),
507 Page::Incidents => self.incidents.iter().map(|i| i.id).collect(),
508 }
509 }
510
511 fn pick(&mut self, page: Page, by: isize) {
514 let ids = self.ids(page);
515 let slot = &mut self.selected[page.index()];
516 let Some(last) = ids.len().checked_sub(1) else {
517 *slot = None;
518 return;
519 };
520 let at = slot
521 .and_then(|s| ids.iter().position(|id| *id == s))
522 .map_or(0, |i| i.saturating_add_signed(by).min(last));
523 *slot = Some(ids[at]);
524 }
525
526 fn select(&mut self, page: Page, id: &str) {
528 self.selected[page.index()] = self.ids(page).into_iter().find(|s| *s == id);
529 }
530
531 fn risk(&self, page: Page, id: &str) -> Option<Risk> {
533 match page {
534 Page::Services => self.services.iter().find(|s| s.id == id).map(Service::risk),
535 Page::Deploys => self.deploys.iter().find(|d| d.id == id).map(Deploy::risk),
536 Page::Incidents => self
537 .incidents
538 .iter()
539 .find(|i| i.id == id)
540 .map(Incident::risk),
541 }
542 }
543
544 fn tick(&mut self) {
546 self.ticks += 1;
547 let ticks = self.ticks;
548 for (s, i) in self.services.iter_mut().zip(0u32..) {
549 let swing = f64::from((ticks * 5 + i * 11) % 17);
550 s.rps.remove(0);
551 if s.health == Health::Down {
552 s.rps.push(0.0);
553 s.p95 = 0.0;
554 } else {
555 s.rps.push(110.0 + swing * 3.0);
556 let base = if s.health == Health::Degraded {
557 380.0
558 } else {
559 60.0
560 };
561 s.p95 = base + swing * 4.0;
562 }
563 }
564 for (d, i) in self.deploys.iter_mut().zip(0u32..) {
565 let swing = f64::from((ticks * 5 + i * 11) % 17);
566 d.rps.remove(0);
567 d.rps.push(38.0 + swing);
568 if d.state == State::Running {
569 d.done = (d.done + 0.04).min(1.0);
570 if d.done >= 1.0 {
571 d.state = State::Done;
572 }
573 }
574 }
575 for inc in &mut self.incidents {
576 if inc.phase != Phase::Resolved {
577 inc.age += TICK.as_secs();
578 }
579 }
580 }
581
582 fn update(&mut self, msg: Msg) {
584 match msg {
585 Msg::Page(id) => {
586 if let Some(page) = Page::from_id(&id) {
587 self.page = page;
588 }
589 },
590 Msg::Filter(filter) => {
591 self.filter = filter;
592 self.pick(Page::Deploys, 0);
593 },
594 Msg::Refresh => {
595 self.tick();
596 self.note = "Refreshed".into();
597 },
598 Msg::Select(page, id) => self.select(page, &id),
599 Msg::Open(page, id) => {
600 self.select(page, &id);
601 self.note = format!("Opened {id}");
602 },
603 Msg::Act(page, id) => {
604 let done = match page {
605 Page::Services => self
606 .services
607 .iter_mut()
608 .find(|s| s.id == id)
609 .map(Service::act),
610 Page::Deploys => self
611 .deploys
612 .iter_mut()
613 .find(|d| d.id == id)
614 .and_then(Deploy::act),
615 Page::Incidents => self
616 .incidents
617 .iter_mut()
618 .find(|i| i.id == id)
619 .and_then(Incident::act),
620 };
621 if let Some(done) = done {
622 self.note = format!("{done} {id}");
623 }
624 self.pick(page, 0);
625 },
626 Msg::Ask(page, id) => {
627 self.confirming = self
628 .ids(page)
629 .into_iter()
630 .find(|s| *s == id)
631 .filter(|_| self.risk(page, &id).is_some_and(|r| r.blocked.is_none()))
632 .map(|s| (page, s));
633 },
634 Msg::Confirm => {
635 let Some((page, id)) = self.confirming.take() else {
636 return;
637 };
638 let Some(risk) = self.risk(page, id) else {
639 return;
640 };
641 match page {
642 Page::Services => {
643 if let Some(s) = self.services.iter_mut().find(|s| s.id == id) {
644 s.replicas = 0;
645 s.health = Health::Down;
646 }
647 },
648 Page::Deploys => self.deploys.retain(|d| d.id != id),
649 Page::Incidents => self.incidents.retain(|i| i.id != id),
650 }
651 self.note = format!("{} {id}", risk.done);
652 self.pick(page, 0);
653 },
654 Msg::Keep => self.confirming = None,
655 }
656 }
657
658 fn act_selected(&mut self) {
660 if let Some(id) = self.selected[self.page.index()] {
661 self.update(Msg::Act(self.page, id.into()));
662 }
663 }
664
665 fn view(&self, plain: bool) -> View<Msg> {
668 let main = nodes![self.head(), self.stats().key("stats"), self.nav(), self.body(plain)];
669 let mut dock: Vec<Node<Msg>> = Vec::new();
670 if let Some((page, id)) = self.confirming
671 && let Some(risk) = self.risk(page, id)
672 {
673 dock.push(confirm(&risk).into());
674 }
675 dock.push(self.legend().into());
676 View::new().main(main).dock(dock)
677 }
678
679 fn head(&self) -> ui::Row<Msg> {
681 let title = ui::text(spans![
682 ui::span("Fleet").style("title"),
683 " ",
684 ui::span(format!(
685 "production · {} services · up {}",
686 self.services.len(),
687 ago(UPTIME + u64::from(self.ticks))
688 ))
689 .style("muted"),
690 ])
691 .key("title");
692 let worst = self
693 .services
694 .iter()
695 .map(|s| s.health)
696 .max_by_key(|h| match h {
697 Health::Healthy => 0,
698 Health::Degraded => 1,
699 Health::Down => 2,
700 })
701 .unwrap_or(Health::Healthy);
702 let status = match worst {
703 Health::Healthy => ui::badge("Operational").tone(Tone::Success),
704 Health::Degraded => ui::badge("Degraded").tone(Tone::Warning),
705 Health::Down => ui::badge("Partial outage").tone(Tone::Error),
706 };
707 let refresh = ui::button("Refresh")
708 .key("refresh")
709 .icon("repeat")
710 .keys(["r"])
711 .on_click(Msg::Refresh);
712 ui::row()
713 .key("head")
714 .justify("between")
715 .align("center")
716 .child(title)
717 .child(
718 ui::row()
719 .key("end")
720 .gap("sm")
721 .align("center")
722 .children(nodes![status.key("status"), refresh]),
723 )
724 }
725
726 fn stats(&self) -> ui::Stats<Msg> {
728 let total = self.services.len();
729 let healthy = self
730 .services
731 .iter()
732 .filter(|s| s.health == Health::Healthy)
733 .count();
734 let deploys = |state| self.deploys.iter().filter(|d| d.state == state).count();
735 let open = self
736 .incidents
737 .iter()
738 .filter(|i| i.phase != Phase::Resolved)
739 .count();
740 let unacked = self
741 .incidents
742 .iter()
743 .filter(|i| i.phase == Phase::Open)
744 .count();
745 let rps: f64 = self.services.iter().filter_map(|s| s.rps.last()).sum();
746 let mut services = StatItem::new("Healthy", format!("{healthy}/{total}")).sub("services");
747 if healthy < total {
748 services = services.tone(Tone::Warning);
749 }
750 let mut incidents =
751 StatItem::new("Open incidents", open.to_string()).sub(format!("{unacked} unacknowledged"));
752 if unacked > 0 {
753 incidents = incidents.tone(Tone::Error);
754 }
755 ui::stats([
756 services,
757 StatItem::new("Rolling out", deploys(State::Running).to_string())
758 .sub(format!("{} need approval", deploys(State::Waiting))),
759 incidents,
760 StatItem::new("Traffic", format!("{rps:.0}")).sub("req/s"),
761 ])
762 }
763
764 fn nav(&self) -> ui::Tabs<Msg> {
766 let count = |n: usize| ui::span(n.to_string()).style("muted");
767 let open = self
768 .incidents
769 .iter()
770 .filter(|i| i.phase != Phase::Resolved)
771 .count();
772 ui::tabs()
773 .key("nav")
774 .active(self.page.id())
775 .tab("services", spans!["Services ", count(self.services.len())])
776 .tab("deploys", spans!["Deploys ", count(self.deploys.len())])
777 .tab("incidents", spans!["Incidents ", count(open)])
778 .on_select(|id| Msg::Page(id.to_owned()))
779 }
780
781 fn body(&self, plain: bool) -> Node<Msg> {
784 let page = self.page;
785 let selected = self.selected[page.index()];
786 let (list, detail) = match page {
787 Page::Services => (
788 nodes![self.services_table()],
789 selected
790 .and_then(|id| self.services.iter().find(|s| s.id == id))
791 .map(service_card),
792 ),
793 Page::Deploys => (
794 nodes![self.deploys_bar(), self.deploys_table()],
795 selected
796 .and_then(|id| self.deploys.iter().find(|d| d.id == id))
797 .map(deploy_card),
798 ),
799 Page::Incidents => (
800 nodes![self.incidents_table()],
801 selected
802 .and_then(|id| self.incidents.iter().find(|i| i.id == id))
803 .map(incident_card),
804 ),
805 };
806 let mut parts: Vec<Node<Msg>> = nodes![
807 ui::col()
808 .key("list")
809 .gap("sm")
810 .grow(1.0)
811 .basis(0.0)
812 .children(list)
813 ];
814 if let Some(card) = detail {
815 parts.push(
816 card
817 .key("detail")
818 .min(Bound::w(Extent::Ch(44.0)))
819 .max(Bound::w(Extent::Ch(56.0)))
820 .into(),
821 );
822 }
823 if plain {
824 ui::col().key(page.id()).gap("md").children(parts).into()
825 } else {
826 ui::row()
827 .key(page.id())
828 .align("stretch")
829 .gap("lg")
830 .children(parts)
831 .into()
832 }
833 }
834
835 fn table(&self, page: Page, empty: &str) -> ui::Table<Msg> {
837 let mut table = ui::table()
838 .key("table")
839 .selectable(true)
840 .empty(empty)
841 .on_select(move |id| Msg::Select(page, id.to_owned()))
842 .on_activate(move |id| Msg::Open(page, id.to_owned()));
843 if let Some(id) = self.selected[page.index()] {
844 table = table.selected(id);
845 }
846 table
847 }
848
849 fn services_table(&self) -> ui::Table<Msg> {
851 let mut table = self
852 .table(Page::Services, "No services yet")
853 .col(Column::new("name", "Service").grow(1.0).priority(6.0))
854 .col(Column::new("region", "Region").priority(3.0))
855 .col(Column::new("version", "Version").mono(true).priority(2.0))
856 .col(Column::new("traffic", "Traffic").priority(1.0))
857 .col(
858 Column::new("rps", "req/s")
859 .align(TextAlign::End)
860 .mono(true)
861 .priority(4.0),
862 )
863 .col(
864 Column::new("p95", "p95")
865 .align(TextAlign::End)
866 .mono(true)
867 .priority(4.0),
868 )
869 .col(
870 Column::new("errors", "Errors")
871 .align(TextAlign::End)
872 .mono(true)
873 .priority(4.0),
874 )
875 .col(Column::new("health", "Health").priority(5.0));
876 for s in &self.services {
877 let (label, tone) = s.health.look();
878 let mut errors = TextCell::new(format!("{:.1}%", s.errors));
879 if s.errors >= 1.0 {
880 errors = errors.tone(Tone::Error);
881 }
882 table = table.row(
883 TableRow::new(s.id)
884 .cell("name", TextCell::new(s.id).dot(tone.clone()))
885 .cell("region", s.region)
886 .cell("version", s.version)
887 .cell("traffic", SparkCell::new(s.rps.clone()))
888 .cell("rps", format!("{:.0}", s.rps.last().copied().unwrap_or_default()))
889 .cell("p95", format!("{:.0} ms", s.p95))
890 .cell("errors", errors)
891 .cell("health", BadgeSpec::new(label).tone(tone)),
892 );
893 }
894 table
895 }
896
897 fn deploys_bar(&self) -> ui::Row<Msg> {
899 let attention = self
900 .deploys
901 .iter()
902 .filter(|d| matches!(d.state, State::Waiting | State::Failed))
903 .count();
904 ui::row()
905 .key("bar")
906 .justify("between")
907 .align("center")
908 .child(
909 ui::text(ui::span("Rollouts to production, newest first").style("muted")).key("about"),
910 )
911 .child(
912 ui::tabs()
913 .key("filter")
914 .variant("segmented")
915 .active(self.filter.clone())
916 .tab("all", "All")
917 .tab("attention", spans![
918 "Needs you ",
919 ui::span(attention.to_string()).style("muted")
920 ])
921 .on_select(|id| Msg::Filter(id.to_owned())),
922 )
923 }
924
925 fn deploys_table(&self) -> ui::Table<Msg> {
927 let mut table = self
928 .table(Page::Deploys, "Nothing needs you right now")
929 .col(Column::new("service", "Service").grow(1.0).priority(6.0))
930 .col(Column::new("commit", "Commit").mono(true).priority(3.0))
931 .col(Column::new("region", "Region").priority(2.0))
932 .col(Column::new("traffic", "Traffic").priority(1.0))
933 .col(Column::new("rollout", "Rollout").priority(1.0))
934 .col(Column::new("state", "State").priority(5.0));
935 for d in self.shown_deploys() {
936 let (label, tone) = d.state.look();
937 table = table.row(
938 TableRow::new(d.id)
939 .cell("service", TextCell::new(d.service).dot(tone.clone()))
940 .cell("commit", d.commit)
941 .cell("region", d.region)
942 .cell("traffic", SparkCell::new(d.rps.clone()))
943 .cell("rollout", MeterCell {
944 value: Some(d.done),
945 title: Some(format!("{:.0}%", d.done * 100.0)),
946 ..MeterCell::default()
947 })
948 .cell("state", BadgeSpec::new(label).tone(tone)),
949 );
950 }
951 table
952 }
953
954 fn incidents_table(&self) -> ui::Table<Msg> {
956 let mut table = self
957 .table(Page::Incidents, "No incidents. All quiet.")
958 .col(Column::new("title", "Incident").grow(1.0).priority(6.0))
959 .col(Column::new("id", "Id").mono(true).priority(2.0))
960 .col(Column::new("service", "Service").priority(3.0))
961 .col(Column::new("severity", "Severity").priority(4.0))
962 .col(
963 Column::new("age", "Open for")
964 .align(TextAlign::End)
965 .mono(true)
966 .priority(4.0),
967 )
968 .col(Column::new("phase", "State").priority(5.0));
969 for i in &self.incidents {
970 let (sev, sev_tone) = i.severity.look();
971 let (phase, phase_tone) = i.phase.look();
972 table = table.row(
973 TableRow::new(i.id)
974 .cell("title", TextCell::new(i.title).dot(sev_tone.clone()))
975 .cell("id", i.id)
976 .cell("service", i.service)
977 .cell("severity", BadgeSpec::new(sev).tone(sev_tone))
978 .cell("age", ago(i.age))
979 .cell("phase", BadgeSpec::new(phase).tone(phase_tone)),
980 );
981 }
982 table
983 }
984
985 fn legend(&self) -> ui::Row<Msg> {
987 let key = |k: &str| ui::span(k).style("key");
988 let muted = |t: &str| ui::span(t).style("muted");
989 let keys = if self.confirming.is_some() {
990 spans![key("enter"), muted(" confirm "), key("esc"), muted(" back")]
991 } else {
992 spans![
993 key("tab"),
994 muted(" views "),
995 key("j"),
996 key("k"),
997 muted(" move "),
998 key("enter"),
999 muted(" open "),
1000 key("a"),
1001 muted(" action "),
1002 key("r"),
1003 muted(" refresh "),
1004 key("q"),
1005 muted(" quit"),
1006 ]
1007 };
1008 let note = if self.note.is_empty() {
1009 "Up to date"
1010 } else {
1011 self.note.as_str()
1012 };
1013 ui::row()
1014 .key("legend")
1015 .justify("between")
1016 .align("center")
1017 .child(ui::text(keys).key("keys"))
1018 .child(ui::text(muted(note)).key("note"))
1019 }
1020}
1021
1022fn detail(
1025 page: Page,
1026 id: &'static str,
1027 meta: impl Into<Text>,
1028 kv: ui::Kv<Msg>,
1029 action: Option<&'static str>,
1030 risk: &Risk,
1031) -> ui::Card<Msg> {
1032 let mut card = ui::card().head(id).meta(meta);
1033 if let Some(label) = action {
1034 card = card.end(
1035 ui::button(label)
1036 .key("act")
1037 .variant("primary")
1038 .keys(["a"])
1039 .on_click(Msg::Act(page, id.into())),
1040 );
1041 }
1042 let mut trigger = ui::button(risk.button)
1043 .key("risk")
1044 .variant("danger")
1045 .on_click(Msg::Ask(page, id.into()));
1046 if let Some(why) = risk.blocked {
1047 trigger = trigger.disabled(why);
1048 }
1049 card
1050 .child(kv.key("kv"))
1051 .child(ui::row().key("bar").child(trigger))
1052}
1053
1054fn service_card(s: &Service) -> ui::Card<Msg> {
1056 let (label, _) = s.health.look();
1057 let rps = s.rps.last().copied().unwrap_or_default();
1058 let kv = ui::kv()
1059 .item("Health", ui::span(label).style("strong"))
1060 .item("Version", mono(s.version))
1061 .item("Replicas", mono(s.replicas.to_string()))
1062 .item("Traffic", mono(format!("{rps:.0} req/s")))
1063 .item("p95", mono(format!("{:.0} ms", s.p95)))
1064 .item("Errors", mono(format!("{:.1}%", s.errors)));
1065 detail(Page::Services, s.id, s.region, kv, Some(s.action()), &s.risk())
1066}
1067
1068fn deploy_card(d: &Deploy) -> ui::Card<Msg> {
1070 let (label, _) = d.state.look();
1071 let rps = d.rps.last().copied().unwrap_or_default();
1072 let kv = ui::kv()
1073 .item("State", ui::span(label).style("strong"))
1074 .item("Service", d.service)
1075 .item("Commit", mono(d.commit))
1076 .item("Rollout", mono(format!("{:.0}%", d.done * 100.0)))
1077 .item("Traffic", mono(format!("{rps:.0} req/s")));
1078 detail(Page::Deploys, d.id, d.region, kv, d.action(), &d.risk())
1079}
1080
1081fn incident_card(i: &Incident) -> ui::Card<Msg> {
1083 let (phase, _) = i.phase.look();
1084 let (sev, _) = i.severity.look();
1085 let kv = ui::kv()
1086 .item("State", ui::span(phase).style("strong"))
1087 .item("What", i.title)
1088 .item("Service", i.service)
1089 .item("Severity", sev)
1090 .item("Open for", mono(ago(i.age)));
1091 detail(Page::Incidents, i.id, i.service, kv, i.action(), &i.risk())
1092}
1093
1094fn confirm(risk: &Risk) -> ui::Card<Msg> {
1097 let buttons = ui::row()
1098 .gap("sm")
1099 .child(
1100 ui::button("Keep")
1101 .key("keep")
1102 .variant("quiet")
1103 .keys(["esc"])
1104 .on_click(Msg::Keep),
1105 )
1106 .child(
1107 ui::button(risk.button)
1108 .key("go")
1109 .variant("danger")
1110 .keys(["enter"])
1111 .on_click(Msg::Confirm),
1112 );
1113 ui::card()
1114 .key("confirm")
1115 .tone(Tone::Error)
1116 .head(risk.ask.clone())
1117 .end(buttons)
1118 .child(ui::text(risk.then).key("t"))
1119}
1120
1121fn main() -> Result<(), tern_sdk::Error> {
1122 let mut dash = Dash::new();
1123 let Some(mut session) = Session::connect(Options::new().app("dashboard"))? else {
1124 print!("{}", dash.view(true).plain(100));
1125 return Ok(());
1126 };
1127 let sf = session.open(SurfaceOptions::screen().title("Fleet").font(Font::Sans))?;
1128 session.render(sf, dash.view(false))?;
1129 let mut next = Instant::now() + TICK;
1130 loop {
1131 let left = next.saturating_duration_since(Instant::now());
1132 match session.next(Some(left))? {
1133 None => {
1134 dash.tick();
1135 next = Instant::now() + TICK;
1136 },
1137 Some(Input::Msg(msg, _)) => dash.update(msg),
1138 Some(Input::Key(key)) => {
1139 if key.is("escape") && dash.confirming.is_some() {
1140 dash.update(Msg::Keep);
1141 } else if key.is("enter") && dash.confirming.is_some() {
1142 dash.update(Msg::Confirm);
1143 } else if key.is("escape") || key.is("q") || key.is("ctrl+c") {
1144 break;
1145 } else if key.is("tab") || key.is("]") {
1146 dash.page = dash.page.turn(false);
1147 } else if key.is("shift+tab") || key.is("[") {
1148 dash.page = dash.page.turn(true);
1149 } else if key.is("up") || key.is("k") {
1150 dash.pick(dash.page, -1);
1151 } else if key.is("down") || key.is("j") {
1152 dash.pick(dash.page, 1);
1153 } else if key.is("r") {
1154 dash.update(Msg::Refresh);
1155 } else if key.is("a") {
1156 dash.act_selected();
1157 } else if key.is("enter") {
1158 if let Some(id) = dash.selected[dash.page.index()] {
1159 dash.update(Msg::Open(dash.page, id.into()));
1160 }
1161 } else {
1162 continue;
1163 }
1164 },
1165 Some(Input::Event(_) | Input::Clipboard(_)) => continue,
1166 }
1167 session.render(sf, dash.view(false))?;
1168 }
1169 session.close()?;
1170 Ok(())
1171}