Skip to main content

dashboard/
dashboard.rs

1//! A `screen` dashboard in the UI face (`font: sans`) with no CSS of its own,
2//! laid out as a page: a head row (the title, muted meta, a status badge and
3//! Refresh at its end), a flat stats strip, underline tabs with a view per tab
4//! (Services, Deploys, Incidents). A view is a full-width table with one
5//! growing text column beside a detail card for the selected row: its `kv`,
6//! the one `primary` action with its key in the card head's end and a
7//! `danger` trigger that opens a confirm strip in the dock, above the key
8//! legend. It updates on a timer. Keys: Tab and ⇧Tab switch views, ↑/↓ or
9//! j/k move, Enter opens, a runs the selected row's action, r refreshes; in
10//! the confirm strip Enter confirms and Esc backs out; otherwise Esc or q
11//! quits.
12//! Outside Tern it prints the dashboard as plain text.
13
14use 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
24/// How often the numbers move.
25const TICK: Duration = Duration::from_secs(1);
26
27/// How long the fleet had been up when the dashboard started, in seconds.
28const UPTIME: u64 = 39 * 60;
29
30/// A view of the page, one per tab.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32enum Page {
33	/// The services and their health.
34	Services,
35	/// The deploys rolling out.
36	Deploys,
37	/// The incidents.
38	Incidents,
39}
40
41impl Page {
42	/// The tabs in order.
43	const ALL: [Self; 3] = [Self::Services, Self::Deploys, Self::Incidents];
44
45	/// The tab id.
46	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	/// The page with tab id `id`.
55	fn from_id(id: &str) -> Option<Self> {
56		Self::ALL.into_iter().find(|p| p.id() == id)
57	}
58
59	/// Its place among the tabs.
60	const fn index(self) -> usize {
61		match self {
62			Self::Services => 0,
63			Self::Deploys => 1,
64			Self::Incidents => 2,
65		}
66	}
67
68	/// The next tab, or the previous one with `back`, wrapping around.
69	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/// A service's health.
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77enum Health {
78	/// Serving normally.
79	Healthy,
80	/// Slow or erroring.
81	Degraded,
82	/// Drained: no replicas.
83	Down,
84}
85
86impl Health {
87	/// The label and the tone of its dot and badge.
88	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/// A deploy's state.
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99enum State {
100	/// Rolling out.
101	Running,
102	/// Built, waiting for someone to approve it.
103	Waiting,
104	/// Failed its checks.
105	Failed,
106	/// Live everywhere.
107	Done,
108}
109
110impl State {
111	/// The label and the tone of its dot and badge.
112	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/// How bad an incident is.
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124enum Severity {
125	/// Customers can't work.
126	Critical,
127	/// Something is broken for some.
128	Major,
129	/// A nuisance.
130	Minor,
131}
132
133impl Severity {
134	/// The label and the tone of its dot and badge.
135	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/// Where an incident is.
145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146enum Phase {
147	/// Nobody has picked it up.
148	Open,
149	/// Someone is on it.
150	Acknowledged,
151	/// Fixed.
152	Resolved,
153}
154
155impl Phase {
156	/// The label and the tone of its badge.
157	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
166/// A destructive action behind the confirm strip.
167struct Risk {
168	/// The trigger's and the confirm button's label.
169	button:  &'static str,
170	/// The confirm strip's question.
171	ask:     String,
172	/// What happens, under the question.
173	then:    &'static str,
174	/// The note once done.
175	done:    &'static str,
176	/// Why it can't be done now, if it can't.
177	blocked: Option<&'static str>,
178}
179
180/// One service.
181#[derive(Clone, Debug)]
182struct Service {
183	/// Row id and name.
184	id:       &'static str,
185	/// The region.
186	region:   &'static str,
187	/// The running version.
188	version:  &'static str,
189	/// Running replicas.
190	replicas: u32,
191	/// How it's doing.
192	health:   Health,
193	/// Requests per second, oldest first.
194	rps:      Vec<f64>,
195	/// 95th percentile latency, in milliseconds.
196	p95:      f64,
197	/// Share of requests failing, in percent.
198	errors:   f64,
199}
200
201impl Service {
202	/// The label of its primary action.
203	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	/// Runs its primary action; returns the note.
211	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	/// Draining it.
223	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/// One deploy.
235#[derive(Clone, Debug)]
236struct Deploy {
237	/// Row id.
238	id:      &'static str,
239	/// The service.
240	service: &'static str,
241	/// The commit.
242	commit:  &'static str,
243	/// The region.
244	region:  &'static str,
245	/// Where it is.
246	state:   State,
247	/// Rollout progress, 0–1.
248	done:    f64,
249	/// Requests per second, oldest first.
250	rps:     Vec<f64>,
251}
252
253impl Deploy {
254	/// The label of its primary action.
255	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	/// Runs its primary action; returns the note.
264	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	/// Cancelling it.
281	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/// One incident.
293#[derive(Clone, Debug)]
294struct Incident {
295	/// Row id.
296	id:       &'static str,
297	/// What's wrong.
298	title:    &'static str,
299	/// The service it hits.
300	service:  &'static str,
301	/// How bad it is.
302	severity: Severity,
303	/// Where it is.
304	phase:    Phase,
305	/// Seconds since it opened.
306	age:      u64,
307}
308
309impl Incident {
310	/// The label of its primary action.
311	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	/// Runs its primary action; returns the note.
320	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	/// Dismissing it.
335	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/// What the view's handlers report.
347#[derive(Clone, Debug)]
348enum Msg {
349	/// A tab of the page was picked.
350	Page(String),
351	/// The deploys filter was picked.
352	Filter(String),
353	/// Refresh was clicked.
354	Refresh,
355	/// A row was selected.
356	Select(Page, String),
357	/// A row was opened (double click).
358	Open(Page, String),
359	/// The detail card's primary action.
360	Act(Page, String),
361	/// The detail card's `danger` trigger (shows the confirm strip).
362	Ask(Page, String),
363	/// The confirm strip's destructive button.
364	Confirm,
365	/// The confirm strip's Keep button.
366	Keep,
367}
368
369/// The dashboard's state.
370struct Dash {
371	/// All services.
372	services:   Vec<Service>,
373	/// All deploys.
374	deploys:    Vec<Deploy>,
375	/// All incidents.
376	incidents:  Vec<Incident>,
377	/// The tab shown.
378	page:       Page,
379	/// The deploys filter: `all` or `attention`.
380	filter:     String,
381	/// The selected row of each page.
382	selected:   [Option<&'static str>; 3],
383	/// The row the confirm strip asks about.
384	confirming: Option<(Page, &'static str)>,
385	/// A note about the last thing that happened.
386	note:       String,
387	/// Ticks so far (drives the fake traffic).
388	ticks:      u32,
389}
390
391/// Sample traffic around `base`, twelve points.
392fn traffic(base: f64, seed: u32) -> Vec<f64> {
393	(0..12u32)
394		.map(|i| base + f64::from((i * 7 + seed) % 23))
395		.collect()
396}
397
398/// `secs` as a short duration: `42s`, `4m 05s`, `1h 03m`.
399fn 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
407/// `v` in the mono face.
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}
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}
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}