Skip to content

Commit 854f3a3

Browse files
committed
Improve dfctl TUI activity and docs
1 parent 2f026f9 commit 854f3a3

8 files changed

Lines changed: 277 additions & 35 deletions

File tree

rust/otap-dataflow/crates/enginectl/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,27 @@ It is built on top of the public Rust SDK `otap-df-admin-api`, but end users
66
should think in terms of the installed `dfctl` command. The Rust package name
77
for this crate remains `otap-df-enginectl`.
88

9+
## Design Goals
10+
11+
`dfctl` is intended to be the practical control surface for local and remote
12+
OTAP Dataflow Engine instances:
13+
14+
- expose the public admin SDK without inventing a parallel protocol
15+
- keep local and remote engine workflows consistent
16+
- provide stable machine-readable output for scripts, CI, and agents
17+
- provide readable tables, color, diagnostics, and a TUI for humans
18+
- make long-running operations observable with watch streams and progress
19+
feedback
20+
- keep mutation flows safe through confirmation, dry-run or preflight checks,
21+
and clear failure reporting where possible
22+
- keep commands, renderers, TUI panes, and tests modular enough to evolve with
23+
the admin API
24+
25+
For the full design principles, see
26+
[docs/admin/enginectl.md](../../docs/admin/enginectl.md#design-principles).
27+
For security and privacy behavior, see
28+
[docs/admin/enginectl.md](../../docs/admin/enginectl.md#security-and-privacy).
29+
930
## Before You Start
1031

1132
- The default admin target is `http://127.0.0.1:8085`.

rust/otap-dataflow/crates/enginectl/src/ui/app/model.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,12 @@ pub(crate) struct EnginePipelineItem {
759759
pub(crate) rollout: String,
760760
}
761761

762+
#[derive(Clone, Debug, Default, Eq, PartialEq)]
763+
pub(crate) struct ActivityIndicator {
764+
pub(crate) active: bool,
765+
pub(crate) frame: u16,
766+
}
767+
762768
#[derive(Clone, Debug)]
763769
pub(crate) struct AppState {
764770
pub(crate) view: View,
@@ -783,6 +789,7 @@ pub(crate) struct AppState {
783789
pub(crate) engine_readyz: Option<engine::ProbeResponse>,
784790
pub(crate) engine_vitals: EngineVitals,
785791
pub(crate) command_context: UiCommandContext,
792+
pub(crate) activity_indicator: ActivityIndicator,
786793
pub(crate) pipelines: PipelinePaneState,
787794
pub(crate) groups: GroupPaneState,
788795
pub(crate) engine: EnginePaneState,

rust/otap-dataflow/crates/enginectl/src/ui/app/state.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ impl AppState {
3030
engine_readyz: None,
3131
engine_vitals: EngineVitals::default(),
3232
command_context: UiCommandContext::local_default(logs_tail),
33+
activity_indicator: ActivityIndicator::default(),
3334
pipelines: PipelinePaneState::default(),
3435
groups: GroupPaneState::default(),
3536
engine: EnginePaneState::new(),
@@ -418,6 +419,29 @@ impl AppState {
418419
&self.command_context.target_url
419420
}
420421

422+
pub(crate) fn begin_activity(&mut self) {
423+
self.activity_indicator.active = true;
424+
}
425+
426+
pub(crate) fn end_activity(&mut self) {
427+
self.activity_indicator.active = false;
428+
self.activity_indicator.frame = 0;
429+
}
430+
431+
pub(crate) fn is_activity_active(&self) -> bool {
432+
self.activity_indicator.active
433+
}
434+
435+
pub(crate) fn activity_frame(&self) -> u16 {
436+
self.activity_indicator.frame
437+
}
438+
439+
pub(crate) fn advance_activity_frame(&mut self) {
440+
if self.activity_indicator.active {
441+
self.activity_indicator.frame = self.activity_indicator.frame.wrapping_add(1);
442+
}
443+
}
444+
421445
pub(crate) fn active_header(&self) -> Option<&DetailHeader> {
422446
match self.view {
423447
View::Pipelines => match self.pipeline_tab {

rust/otap-dataflow/crates/enginectl/src/ui/mod.rs

Lines changed: 36 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ use std::sync::{
7474
use std::thread;
7575
use std::time::Duration;
7676
use tempfile::Builder;
77-
use tokio::sync::mpsc;
77+
use tokio::{sync::mpsc, time::MissedTickBehavior};
7878

7979
pub(crate) use self::refresh::build_command_context;
8080

@@ -107,38 +107,34 @@ pub(crate) async fn run_ui(
107107
let refresh_in_flight = Arc::new(AtomicBool::new(false));
108108

109109
let mut refresh = tokio::time::interval(args.refresh_interval);
110+
refresh.set_missed_tick_behavior(MissedTickBehavior::Skip);
110111
let _ = refresh.tick().await;
112+
let mut activity_tick = tokio::time::interval(Duration::from_millis(120));
113+
activity_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
114+
let _ = activity_tick.tick().await;
111115

112116
let result = loop {
113117
session.draw(&mut app)?;
114118

115119
tokio::select! {
116120
_ = tokio::signal::ctrl_c() => break Ok(()),
121+
_ = activity_tick.tick(), if app.is_activity_active() => {
122+
app.advance_activity_frame();
123+
}
117124
_ = refresh.tick() => {
118-
spawn_refresh_task(
119-
client.clone(),
120-
app.clone(),
121-
args.clone(),
122-
tx.clone(),
123-
refresh_in_flight.clone(),
124-
);
125+
request_refresh(client, &mut app, &args, &tx, &refresh_in_flight);
125126
}
126127
Some(event) = rx.recv() => {
127128
match event {
128129
UiEvent::RefreshComplete(refreshed) => {
129130
apply_refreshed_app(&mut app, *refreshed);
131+
app.end_activity();
130132
}
131133
UiEvent::Terminal(event) => {
132134
match handle_event(event, &mut app) {
133135
EventOutcome::Quit => break Ok(()),
134136
EventOutcome::Refresh => {
135-
spawn_refresh_task(
136-
client.clone(),
137-
app.clone(),
138-
args.clone(),
139-
tx.clone(),
140-
refresh_in_flight.clone(),
141-
);
137+
request_refresh(client, &mut app, &args, &tx, &refresh_in_flight);
142138
}
143139
EventOutcome::OpenPipelineEditor { group_id, pipeline_id } => {
144140
if let Err(err) = stage_pipeline_editor_draft(
@@ -155,27 +151,15 @@ pub(crate) async fn run_ui(
155151
{
156152
app.last_error = Some(err.to_string());
157153
}
158-
spawn_refresh_task(
159-
client.clone(),
160-
app.clone(),
161-
args.clone(),
162-
tx.clone(),
163-
refresh_in_flight.clone(),
164-
);
154+
request_refresh(client, &mut app, &args, &tx, &refresh_in_flight);
165155
}
166156
EventOutcome::Execute(action) => {
167157
if let Err(err) =
168158
execute_ui_action(client, &mut app, &args, action).await
169159
{
170160
app.last_error = Some(err.to_string());
171161
}
172-
spawn_refresh_task(
173-
client.clone(),
174-
app.clone(),
175-
args.clone(),
176-
tx.clone(),
177-
refresh_in_flight.clone(),
178-
);
162+
request_refresh(client, &mut app, &args, &tx, &refresh_in_flight);
179163
}
180164
EventOutcome::Continue => {}
181165
}
@@ -192,25 +176,44 @@ pub(crate) async fn run_ui(
192176
result
193177
}
194178

179+
fn request_refresh(
180+
client: &AdminClient,
181+
app: &mut AppState,
182+
args: &UiArgs,
183+
tx: &mpsc::Sender<UiEvent>,
184+
refresh_in_flight: &Arc<AtomicBool>,
185+
) {
186+
if spawn_refresh_task(
187+
client.clone(),
188+
app.clone(),
189+
args.clone(),
190+
tx.clone(),
191+
refresh_in_flight.clone(),
192+
) {
193+
app.begin_activity();
194+
}
195+
}
196+
195197
fn spawn_refresh_task(
196198
client: AdminClient,
197199
mut app: AppState,
198200
args: UiArgs,
199201
tx: mpsc::Sender<UiEvent>,
200202
refresh_in_flight: Arc<AtomicBool>,
201-
) {
203+
) -> bool {
202204
if refresh_in_flight
203205
.compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
204206
.is_err()
205207
{
206-
return;
208+
return false;
207209
}
208210

209211
let _handle = tokio::spawn(async move {
210212
refresh_view(&client, &mut app, &args).await;
211213
refresh_in_flight.store(false, Ordering::Relaxed);
212214
let _ = tx.send(UiEvent::RefreshComplete(Box::new(app))).await;
213215
});
216+
true
214217
}
215218

216219
fn apply_refreshed_app(app: &mut AppState, refreshed: AppState) {
@@ -234,6 +237,7 @@ fn apply_refreshed_app(app: &mut AppState, refreshed: AppState) {
234237
let detail_scroll = app.detail_scroll;
235238
let terminal_size = app.terminal_size;
236239
let command_context = app.command_context.clone();
240+
let activity_indicator = app.activity_indicator.clone();
237241

238242
*app = refreshed;
239243
app.view = view;
@@ -248,6 +252,7 @@ fn apply_refreshed_app(app: &mut AppState, refreshed: AppState) {
248252
app.detail_scroll = detail_scroll;
249253
app.terminal_size = terminal_size;
250254
app.command_context = command_context;
255+
app.activity_indicator = activity_indicator;
251256
}
252257

253258
#[cfg(test)]

rust/otap-dataflow/crates/enginectl/src/ui/view/chrome.rs

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,20 +83,64 @@ pub(super) fn draw_top_tabs(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
8383
}
8484

8585
pub(super) fn draw_title_bar(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
86-
let line = Line::from(vec![
87-
Span::styled("Open", open_brand_style(app.color_enabled)),
88-
Span::styled("Telemetry", telemetry_brand_style(app.color_enabled)),
86+
let mut spans = brand_spans(app);
87+
spans.extend([
8988
Span::styled(" - Rust Dataflow Engine", title_style(app.color_enabled)),
9089
Span::styled(" | ", separator_style(app.color_enabled)),
9190
Span::styled("target ", muted_style(app.color_enabled)),
9291
Span::styled(app.target_url(), target_style(app.color_enabled)),
9392
]);
93+
let line = Line::from(spans);
9494
let title = Paragraph::new(line)
9595
.alignment(Alignment::Left)
9696
.style(page_style(app.color_enabled));
9797
frame.render_widget(title, area);
9898
}
9999

100+
pub(super) fn brand_spans(app: &AppState) -> Vec<Span<'static>> {
101+
if !app.color_enabled || !app.is_activity_active() {
102+
return vec![
103+
Span::styled("Open", open_brand_style(app.color_enabled)),
104+
Span::styled("Telemetry", telemetry_brand_style(app.color_enabled)),
105+
];
106+
}
107+
108+
"OpenTelemetry"
109+
.chars()
110+
.enumerate()
111+
.map(|(index, character)| {
112+
Span::styled(
113+
character.to_string(),
114+
shimmering_brand_style(index, app.activity_frame()),
115+
)
116+
})
117+
.collect()
118+
}
119+
120+
fn shimmering_brand_style(index: usize, frame: u16) -> Style {
121+
let base_style = if index < "Open".len() {
122+
open_brand_style(true)
123+
} else {
124+
telemetry_brand_style(true)
125+
};
126+
let brand_len = "OpenTelemetry".len();
127+
let cycle_len = brand_len + 4;
128+
let center = (usize::from(frame) % cycle_len) as isize - 2;
129+
let distance = (index as isize - center).unsigned_abs();
130+
131+
match distance {
132+
0 => base_style
133+
.fg(Color::Rgb(238, 250, 255))
134+
.bg(Color::Rgb(35, 72, 86))
135+
.add_modifier(Modifier::BOLD),
136+
1 => base_style
137+
.fg(Color::Rgb(170, 231, 232))
138+
.add_modifier(Modifier::BOLD),
139+
2 => base_style.fg(Color::Rgb(113, 177, 211)),
140+
_ => base_style,
141+
}
142+
}
143+
100144
pub(super) fn draw_engine_vitals_panel(
101145
frame: &mut Frame<'_>,
102146
area: Rect,

rust/otap-dataflow/crates/enginectl/src/ui/view/tests.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,36 @@ fn title_bar_and_command_overlay_render() {
117117
assert!(rendered.contains("https://admin.example.com:8443/engine-a"));
118118
}
119119

120+
/// Scenario: activity is in progress while the title bar is rendered.
121+
/// Guarantees: the shimmer splits the brand into fixed-position character
122+
/// spans without changing the visible OpenTelemetry label text.
123+
#[test]
124+
fn active_brand_shimmer_preserves_label_text() {
125+
let mut app = AppState::new(UiStartView::Pipelines, true, 200);
126+
assert_eq!(chrome::brand_spans(&app).len(), 2);
127+
128+
app.begin_activity();
129+
app.advance_activity_frame();
130+
app.advance_activity_frame();
131+
132+
let active_spans = chrome::brand_spans(&app);
133+
let label = active_spans
134+
.iter()
135+
.map(|span| span.content.as_ref())
136+
.collect::<String>();
137+
assert_eq!(label, "OpenTelemetry");
138+
assert_eq!(active_spans.len(), "OpenTelemetry".len());
139+
assert!(
140+
active_spans
141+
.iter()
142+
.any(|span| span.style != open_brand_style(true)
143+
&& span.style != telemetry_brand_style(true))
144+
);
145+
146+
app.end_activity();
147+
assert_eq!(chrome::brand_spans(&app).len(), 2);
148+
}
149+
120150
/// Scenario: the header renders a fresh engine-vitals snapshot.
121151
/// Guarantees: the condensed vitals rail shows CPU, RSS, and pressure
122152
/// values without reintroducing the removed dedicated panel title.

rust/otap-dataflow/docs/admin/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ This section documents the admin surface of the OTAP Dataflow Engine:
1010
## Document map
1111

1212
- [Admin UI Architecture](architecture.md)
13-
- [dfctl CLI](enginectl.md)
13+
- [dfctl CLI](enginectl.md) including command reference, output modes, and
14+
design principles
1415
- [Live Pipeline Reconfiguration](live-reconfiguration.md)
1516
- [Crate README (admin endpoints and crate layout)](../../crates/admin/README.md)
1617
- [Public Rust SDK README](../../crates/admin-api/README.md)

0 commit comments

Comments
 (0)