pub struct Row<M = ()>(/* private fields */);Expand description
row: a horizontal flex row.
Implementations§
Source§impl<M> Row<M>
impl<M> Row<M>
Sourcepub fn key(self, key: impl Into<NodeKey>) -> Self
pub fn key(self, key: impl Into<NodeKey>) -> Self
Identity among siblings: keeps the node’s id (and Tern’s view state) across reorders. A string, or an integer.
Examples found in repository?
28fn view() -> View<()> {
29 let sizes = SIZES.iter().map(|(value, label)| choice(value, label));
30 View::new().main([html::form().key("ask").child(
31 ui::card()
32 .key("card")
33 .head("Which size?")
34 .max(Bound::w(Extent::Ch(48.0)))
35 .child(ui::row().key("sizes").gap("lg").children(sizes))
36 .child(
37 ui::button("Create")
38 .key("go")
39 .variant("primary")
40 .actions(Actions::new().click("submit")),
41 ),
42 )])
43}More examples
40fn view(build: &Build) -> View {
41 let fraction = build.done as f64 / STEPS.len() as f64;
42 let running = build.took.is_none();
43 let activity: ui::Node = if running {
44 ui::spinner()
45 .style("dots")
46 .label(STEPS[build.done.min(STEPS.len() - 1)])
47 .into()
48 } else {
49 ui::icon(if build.status == Status::Done {
50 "check"
51 } else {
52 "x"
53 })
54 .tone(if build.status == Status::Done {
55 "success"
56 } else {
57 "error"
58 })
59 .into()
60 };
61 let mut timer = ui::elapsed().age(0);
62 if let Some(took) = build.took {
63 timer = timer.stopped(took);
64 }
65 View::new().main([ui::card()
66 .key("build")
67 .head("Building demo")
68 .status(build.status.clone())
69 .child(
70 ui::row()
71 .key("head")
72 .gap("sm")
73 .children(nodes![activity.key("a"), timer.key("t")]),
74 )
75 .child(
76 ui::ansi(&build.log)
77 .key("log")
78 .follow(true)
79 .max(Bound::h(Extent::Lines(8.0))),
80 )
81 .child(ui::progress().key("bar").value(fraction).label(format!(
82 "{}/{}",
83 build.done,
84 STEPS.len()
85 )))])
86}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}Sourcepub fn role(self, role: impl Into<String>) -> Self
pub fn role(self, role: impl Into<String>) -> Self
A name of yours, exposed to CSS as data-role.
Keeps the node mounted but not laid out.
Sourcepub fn mark(self, mark: impl Into<Mark>) -> Self
pub fn mark(self, mark: impl Into<Mark>) -> Self
A transient selection mark drawn over the node.
Sourcepub fn aria(self, aria: impl Into<String>) -> Self
pub fn aria(self, aria: impl Into<String>) -> Self
The accessible name of a node that shows no text.
Sourcepub fn hovercard<N>(self, card: impl Into<Node<N>>) -> Self
pub fn hovercard<N>(self, card: impl Into<Node<N>>) -> Self
A floating card of card’s tree shown while the pointer rests on
the node, in place of title. Sent id-less (ids, keys and handlers
inside are ignored); an older Tern ignores it.
Sourcepub fn href(self, href: impl Into<String>) -> Self
pub fn href(self, href: impl Into<String>) -> Self
A link: ⌘-click opens it; copy and open use it.
Sourcepub fn basis(self, basis: impl Into<Basis>) -> Self
pub fn basis(self, basis: impl Into<Basis>) -> Self
Flex basis: a fraction of the parent, or the content’s size.
Sourcepub fn prop(self, name: &str, value: impl Serialize) -> Self
pub fn prop(self, name: &str, value: impl Serialize) -> Self
Sets any prop untyped; null removes it.
Sourcepub fn on_click(self, msg: M) -> Self
pub fn on_click(self, msg: M) -> Self
Sends msg on a click (sets actions.click to "click" unless
the node names its click action itself).
Sourcepub fn on_dblclick(self, msg: M) -> Self
pub fn on_dblclick(self, msg: M) -> Self
Sends msg on a double click (sets actions.dblclick to
"dblclick" unless the node names it).
Adds act to the context menu and sends msg when it is picked.
Sourcepub fn on_action(self, act: impl Into<String>, msg: M) -> Self
pub fn on_action(self, act: impl Into<String>, msg: M) -> Self
Sends msg on action events whose action is act (name or
name=value).
Sourcepub fn on_action_with(
self,
act: impl Into<String>,
f: impl Fn(&Action) -> M + Send + Sync + 'static,
) -> Self
pub fn on_action_with( self, act: impl Into<String>, f: impl Fn(&Action) -> M + Send + Sync + 'static, ) -> Self
Maps action events whose action is act to a message.
Sourcepub fn on_toggle(self, f: impl Fn(bool) -> M + Send + Sync + 'static) -> Self
pub fn on_toggle(self, f: impl Fn(bool) -> M + Send + Sync + 'static) -> Self
Maps toggle events (the new collapsed; a menu, select or trace
line that opened is not collapsed) to a message.
Sourcepub fn on_select(self, f: impl Fn(&str) -> M + Send + Sync + 'static) -> Self
pub fn on_select(self, f: impl Fn(&str) -> M + Send + Sync + 'static) -> Self
Maps select events (the item) to a message. On a list’s item the
item’s own handler runs first.
Sourcepub fn on_activate(self, f: impl Fn(&str) -> M + Send + Sync + 'static) -> Self
pub fn on_activate(self, f: impl Fn(&str) -> M + Send + Sync + 'static) -> Self
Maps activate events (the item) to a message.
Sourcepub fn on_change(self, f: impl Fn(&Change) -> M + Send + Sync + 'static) -> Self
pub fn on_change(self, f: impl Fn(&Change) -> M + Send + Sync + 'static) -> Self
Maps change events to a message.
Sourcepub fn on_edit(self, f: impl Fn(&Edit) -> M + Send + Sync + 'static) -> Self
pub fn on_edit(self, f: impl Fn(&Edit) -> M + Send + Sync + 'static) -> Self
Maps edit events (native editing) to a message.
Source§impl<M> Row<M>
impl<M> Row<M>
Sourcepub fn child(self, child: impl Into<Node<M>>) -> Self
pub fn child(self, child: impl Into<Node<M>>) -> Self
Adds a child.
Examples found in repository?
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}
1093
1094/// The confirm strip before a destructive action: `error` with a `danger`
1095/// confirm; its buttons sit in the head's end.
1096fn 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}Sourcepub fn children<C: Into<Node<M>>>(
self,
children: impl IntoIterator<Item = C>,
) -> Self
pub fn children<C: Into<Node<M>>>( self, children: impl IntoIterator<Item = C>, ) -> Self
Adds children.
Examples found in repository?
28fn view() -> View<()> {
29 let sizes = SIZES.iter().map(|(value, label)| choice(value, label));
30 View::new().main([html::form().key("ask").child(
31 ui::card()
32 .key("card")
33 .head("Which size?")
34 .max(Bound::w(Extent::Ch(48.0)))
35 .child(ui::row().key("sizes").gap("lg").children(sizes))
36 .child(
37 ui::button("Create")
38 .key("go")
39 .variant("primary")
40 .actions(Actions::new().click("submit")),
41 ),
42 )])
43}More examples
40fn view(build: &Build) -> View {
41 let fraction = build.done as f64 / STEPS.len() as f64;
42 let running = build.took.is_none();
43 let activity: ui::Node = if running {
44 ui::spinner()
45 .style("dots")
46 .label(STEPS[build.done.min(STEPS.len() - 1)])
47 .into()
48 } else {
49 ui::icon(if build.status == Status::Done {
50 "check"
51 } else {
52 "x"
53 })
54 .tone(if build.status == Status::Done {
55 "success"
56 } else {
57 "error"
58 })
59 .into()
60 };
61 let mut timer = ui::elapsed().age(0);
62 if let Some(took) = build.took {
63 timer = timer.stopped(took);
64 }
65 View::new().main([ui::card()
66 .key("build")
67 .head("Building demo")
68 .status(build.status.clone())
69 .child(
70 ui::row()
71 .key("head")
72 .gap("sm")
73 .children(nodes![activity.key("a"), timer.key("t")]),
74 )
75 .child(
76 ui::ansi(&build.log)
77 .key("log")
78 .follow(true)
79 .max(Bound::h(Extent::Lines(8.0))),
80 )
81 .child(ui::progress().key("bar").value(fraction).label(format!(
82 "{}/{}",
83 build.done,
84 STEPS.len()
85 )))])
86}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 }Source§impl<M> Row<M>
impl<M> Row<M>
Sourcepub fn gap(self, value: impl Into<Gap>) -> Self
pub fn gap(self, value: impl Into<Gap>) -> Self
Space between children.
Examples found in repository?
28fn view() -> View<()> {
29 let sizes = SIZES.iter().map(|(value, label)| choice(value, label));
30 View::new().main([html::form().key("ask").child(
31 ui::card()
32 .key("card")
33 .head("Which size?")
34 .max(Bound::w(Extent::Ch(48.0)))
35 .child(ui::row().key("sizes").gap("lg").children(sizes))
36 .child(
37 ui::button("Create")
38 .key("go")
39 .variant("primary")
40 .actions(Actions::new().click("submit")),
41 ),
42 )])
43}More examples
40fn view(build: &Build) -> View {
41 let fraction = build.done as f64 / STEPS.len() as f64;
42 let running = build.took.is_none();
43 let activity: ui::Node = if running {
44 ui::spinner()
45 .style("dots")
46 .label(STEPS[build.done.min(STEPS.len() - 1)])
47 .into()
48 } else {
49 ui::icon(if build.status == Status::Done {
50 "check"
51 } else {
52 "x"
53 })
54 .tone(if build.status == Status::Done {
55 "success"
56 } else {
57 "error"
58 })
59 .into()
60 };
61 let mut timer = ui::elapsed().age(0);
62 if let Some(took) = build.took {
63 timer = timer.stopped(took);
64 }
65 View::new().main([ui::card()
66 .key("build")
67 .head("Building demo")
68 .status(build.status.clone())
69 .child(
70 ui::row()
71 .key("head")
72 .gap("sm")
73 .children(nodes![activity.key("a"), timer.key("t")]),
74 )
75 .child(
76 ui::ansi(&build.log)
77 .key("log")
78 .follow(true)
79 .max(Bound::h(Extent::Lines(8.0))),
80 )
81 .child(ui::progress().key("bar").value(fraction).label(format!(
82 "{}/{}",
83 build.done,
84 STEPS.len()
85 )))])
86}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}
1093
1094/// The confirm strip before a destructive action: `error` with a `danger`
1095/// confirm; its buttons sit in the head's end.
1096fn 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}Sourcepub fn align(self, value: impl Into<Align>) -> Self
pub fn align(self, value: impl Into<Align>) -> Self
Cross-axis alignment.
Examples found in repository?
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 }Sourcepub fn justify(self, value: impl Into<Justify>) -> Self
pub fn justify(self, value: impl Into<Justify>) -> Self
Main-axis distribution.
Examples found in repository?
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 }