pub fn span(t: impl Into<String>) -> SpanExpand description
A styled span of text.
Examples found in repository?
examples/dashboard.rs (line 409)
408fn mono(v: impl Into<String>) -> Text {
409 ui::span(v).style("mono").into()
410}
411
412impl Dash {
413 /// Sample data.
414 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 /// The deploys the filter shows.
494 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 /// The ids of the rows `page` shows, in order.
503 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 /// Moves `page`'s selection by `by` rows; `0` keeps a selection still
512 /// shown and otherwise picks the first row.
513 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 /// Selects row `id` of `page` if it is there.
527 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 /// The destructive action on row `id` of `page`.
532 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 /// Advances the rollouts, the traffic and the clocks one tick.
545 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 /// Applies a message from the view.
583 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 /// Runs the selected row's primary action (key `a`).
659 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 /// The whole screen. `plain` stacks the detail under the table instead
666 /// of beside it, for the plain-text fallback.
667 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 /// The head row: the title and muted meta, a status badge and Refresh.
680 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 /// The stats strip: at most four tiles, directly on the page.
727 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 /// The tabs: one per view, with muted counts.
765 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 /// The active view: its table beside the selected row's detail card
782 /// (under it when `plain`).
783 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 /// A selectable table of `page` with its handlers and selection.
836 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 /// The services table.
850 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 /// The deploys toolbar: a muted description and the filter.
898 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 /// The deploys table.
926 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 /// The incidents table.
955 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 /// The key legend.
986 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
1022/// A detail card: the head with the primary action at its end, the `kv` and
1023/// the `danger` trigger.
1024fn 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
1054/// The selected service's detail card.
1055fn 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
1068/// The selected deploy's detail card.
1069fn 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
1081/// The selected incident's detail card.
1082fn 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}