Skip to content

Commit c7f0b43

Browse files
authored
console: add TUI app, simple top-style view (#2)
This branch adds an initial `tui` app skeleton for the `console` CLI, including a fairly basic `top(1)`-inspired task list view. There's a lot more work to do here, but it works pretty nicely for a quick first pass. Signed-off-by: Eliza Weisman <[email protected]> Co-authored-by: David Barsky <[email protected]> ![Screenshot_20210504_110732](https://user-images.githubusercontent.com/2796466/117050047-c3e68300-acc9-11eb-85ee-4aba2827d002.png)
1 parent b21654f commit c7f0b43

File tree

4 files changed

+243
-3
lines changed

4 files changed

+243
-3
lines changed

console-api/console

Whitespace-only changes.

console/Cargo.toml

+9-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
[package]
22
name = "console"
33
version = "0.1.0"
4-
authors = ["Eliza Weisman <[email protected]>"]
4+
authors = ["Eliza Weisman <[email protected]>", "David Barsky <[email protected]>"]
55
edition = "2018"
66

77
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
88

99
[dependencies]
10+
tonic = { version = "0.4", features = ["transport"] }
11+
console-api = { path = "../console-api", features = ["transport"]}
12+
tokio = { version = "1", features = ["full", "rt-multi-thread"]}
13+
futures = "0.3"
14+
tui = { version = "0.15.0", features = ["crossterm"] }
15+
chrono = "0.4"
16+
tracing = "0.1"
17+
prost-types = "0.7"

console/src/main.rs

+81-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,82 @@
1-
fn main() {
2-
println!("Hello, world!");
1+
use chrono::Local;
2+
use console_api::tasks::{tasks_client::TasksClient, TasksRequest};
3+
use futures::stream::StreamExt;
4+
use std::io;
5+
use tokio::select;
6+
7+
use tui::{
8+
backend::TermionBackend,
9+
layout::{Constraint, Direction, Layout},
10+
style::{Color, Modifier, Style},
11+
text::{Span, Spans},
12+
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
13+
Terminal,
14+
};
15+
16+
use tui::backend::CrosstermBackend;
17+
use tui::style::*;
18+
use tui::text::*;
19+
use tui::widgets::*;
20+
21+
mod tasks;
22+
23+
#[tokio::main]
24+
async fn main() -> Result<(), Box<dyn std::error::Error>> {
25+
let mut args = std::env::args();
26+
args.next(); // drop the first arg (the name of the binary)
27+
let target = args.next().unwrap_or_else(|| {
28+
eprintln!("using default address (http://127.0.0.1:6669)");
29+
String::from("http://127.0.0.1:6669")
30+
});
31+
32+
let backend = CrosstermBackend::new(io::stdout());
33+
let mut terminal = Terminal::new(backend)?;
34+
terminal.clear()?;
35+
36+
let mut client = TasksClient::connect(target.clone()).await?;
37+
let request = tonic::Request::new(TasksRequest {});
38+
let mut stream = client.watch_tasks(request).await?.into_inner();
39+
let mut tasks = tasks::State::default();
40+
while let Some(update) = stream.next().await {
41+
match update {
42+
Ok(update) => {
43+
tasks.update(update);
44+
}
45+
Err(e) => {
46+
eprintln!("update stream error: {}", e);
47+
return Err(e.into());
48+
}
49+
}
50+
51+
terminal.draw(|f| {
52+
let chunks = Layout::default()
53+
.direction(Direction::Vertical)
54+
.margin(0)
55+
.constraints([Constraint::Length(2), Constraint::Percentage(95)].as_ref())
56+
.split(f.size());
57+
58+
let header_block = Block::default().title(vec![
59+
Span::raw("connected to: "),
60+
Span::styled(
61+
target.as_str(),
62+
Style::default().add_modifier(Modifier::BOLD),
63+
),
64+
]);
65+
66+
let text = vec![Spans::from(vec![
67+
Span::styled(
68+
format!("{}", tasks.len()),
69+
Style::default().add_modifier(Modifier::BOLD),
70+
),
71+
Span::raw(" tasks"),
72+
])];
73+
let header = Paragraph::new(text)
74+
.block(header_block)
75+
.wrap(Wrap { trim: true });
76+
f.render_widget(header, chunks[0]);
77+
tasks.render(f, chunks[1]);
78+
})?;
79+
}
80+
81+
Ok(())
382
}

console/src/tasks.rs

+153
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
use console_api as proto;
2+
use std::collections::HashMap;
3+
use std::time::Duration;
4+
use tui::{
5+
layout,
6+
style::{self, Style},
7+
widgets::{Block, Cell, Row, Table, TableState},
8+
};
9+
10+
#[derive(Default, Debug)]
11+
pub(crate) struct State {
12+
tasks: HashMap<u64, Task>,
13+
table_state: TableState,
14+
}
15+
16+
#[derive(Default, Debug)]
17+
struct Task {
18+
id_hex: String,
19+
fields: String,
20+
kind: &'static str,
21+
stats: Stats,
22+
}
23+
24+
#[derive(Default, Debug)]
25+
struct Stats {
26+
polls: u64,
27+
busy: Duration,
28+
idle: Duration,
29+
total: Duration,
30+
}
31+
impl State {
32+
pub(crate) fn len(&self) -> usize {
33+
self.tasks.len()
34+
}
35+
pub(crate) fn update(&mut self, update: proto::tasks::TaskUpdate) {
36+
let new_tasks = update.new_tasks.into_iter().filter_map(|task| {
37+
if task.id.is_none() {
38+
tracing::warn!(?task, "skipping task with no id");
39+
}
40+
let kind = match task.kind() {
41+
proto::tasks::task::Kind::Spawn => "T",
42+
proto::tasks::task::Kind::Blocking => "B",
43+
};
44+
45+
let id = task.id?.id;
46+
let task = Task {
47+
id_hex: format!("{:x}", id),
48+
fields: task.string_fields,
49+
kind,
50+
stats: Default::default(),
51+
};
52+
Some((id, task))
53+
});
54+
self.tasks.extend(new_tasks);
55+
56+
for (id, stats) in update.stats_update {
57+
if let Some(task) = self.tasks.get_mut(&id) {
58+
task.stats = stats.into();
59+
}
60+
}
61+
62+
for proto::SpanId { id } in update.completed {
63+
if self.tasks.remove(&id).is_none() {
64+
tracing::warn!(?id, "tried to complete a task that didn't exist");
65+
}
66+
}
67+
}
68+
69+
pub(crate) fn render<B: tui::backend::Backend>(
70+
&mut self,
71+
frame: &mut tui::terminal::Frame<B>,
72+
area: layout::Rect,
73+
) {
74+
const HEADER: &[&str] = &["TID", "KIND", "TOTAL", "BUSY", "IDLE", "POLLS", "FIELDS"];
75+
const DUR_LEN: usize = 10;
76+
// This data is only updated every second, so it doesn't make a ton of
77+
// sense to have a lot of precision in timestamps (and this makes sure
78+
// there's room for the unit!)
79+
const DUR_PRECISION: usize = 4;
80+
const POLLS_LEN: usize = 5;
81+
let rows = self.tasks.values().map(|task| {
82+
let row = Row::new(vec![
83+
Cell::from(task.id_hex.as_str()),
84+
// TODO(eliza): is there a way to write a `fmt::Debug` impl
85+
// directly to tui without doing an allocation?
86+
Cell::from(task.kind),
87+
Cell::from(format!(
88+
"{:>width$.prec$?}",
89+
task.stats.total,
90+
width = DUR_LEN,
91+
prec = DUR_PRECISION,
92+
)),
93+
Cell::from(format!(
94+
"{:>width$.prec$?}",
95+
task.stats.busy,
96+
width = DUR_LEN,
97+
prec = DUR_PRECISION,
98+
)),
99+
Cell::from(format!(
100+
"{:>width$.prec$?}",
101+
task.stats.idle,
102+
width = DUR_LEN,
103+
prec = DUR_PRECISION,
104+
)),
105+
Cell::from(format!("{:>width$}", task.stats.polls, width = POLLS_LEN)),
106+
Cell::from(task.fields.as_str()),
107+
]);
108+
row
109+
});
110+
let t = Table::new(rows)
111+
.header(
112+
Row::new(HEADER.iter().map(|&v| Cell::from(v)))
113+
.height(1)
114+
.style(Style::default().add_modifier(style::Modifier::REVERSED)),
115+
)
116+
.block(Block::default())
117+
.widths(&[
118+
layout::Constraint::Min(20),
119+
layout::Constraint::Length(4),
120+
layout::Constraint::Min(DUR_LEN as u16),
121+
layout::Constraint::Min(DUR_LEN as u16),
122+
layout::Constraint::Min(DUR_LEN as u16),
123+
layout::Constraint::Min(POLLS_LEN as u16),
124+
layout::Constraint::Min(10),
125+
]);
126+
127+
frame.render_widget(t, area)
128+
}
129+
}
130+
131+
impl From<proto::tasks::Stats> for Stats {
132+
fn from(pb: proto::tasks::Stats) -> Self {
133+
fn pb_duration(dur: prost_types::Duration) -> Duration {
134+
use std::convert::TryFrom;
135+
136+
let secs =
137+
u64::try_from(dur.seconds).expect("a task should not have a negative duration!");
138+
let nanos =
139+
u64::try_from(dur.nanos).expect("a task should not have a negative duration!");
140+
Duration::from_secs(secs) + Duration::from_nanos(nanos)
141+
}
142+
143+
let total = pb.total_time.map(pb_duration).unwrap_or_default();
144+
let busy = pb.busy_time.map(pb_duration).unwrap_or_default();
145+
let idle = total - busy;
146+
Self {
147+
total,
148+
idle,
149+
busy,
150+
polls: pb.polls,
151+
}
152+
}
153+
}

0 commit comments

Comments
 (0)