Skip to content

Commit b7a00d3

Browse files
feat(turborepo): web ui (#8895)
### Description Implements a very very alpha version of a web UI. Uses a GraphQL query to get the current run and tasks. ### Testing Instructions Would love some ideas here on tests. To try out manually, go to #35066 in the other repo and run `turbo-studio`, then execute a turbo command with the `ui` flag set to `web`
1 parent bd2bffa commit b7a00d3

File tree

25 files changed

+889
-188
lines changed

25 files changed

+889
-188
lines changed

Cargo.lock

+25
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+2
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ async-compression = { version = "0.3.13", default-features = false, features = [
7676
"gzip",
7777
"tokio",
7878
] }
79+
async-graphql = "7.0.7"
80+
async-graphql-axum = "7.0.7"
7981
async-trait = "0.1.64"
8082
atty = "0.2.14"
8183
axum = "0.7.5"

crates/turborepo-lib/Cargo.toml

+2-2
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ turborepo-vercel-api-mock = { workspace = true }
3232
workspace = true
3333

3434
[dependencies]
35-
async-graphql = "7.0.7"
36-
async-graphql-axum = "7.0.7"
35+
async-graphql = { workspace = true }
36+
async-graphql-axum = { workspace = true }
3737
atty = { workspace = true }
3838
axum = { workspace = true }
3939
biome_deserialize = { workspace = true }

crates/turborepo-lib/src/commands/run.rs

+8-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use std::future::Future;
22

33
use tracing::error;
44
use turborepo_telemetry::events::command::CommandEventBuilder;
5+
use turborepo_ui::sender::UISender;
56

67
use crate::{commands::CommandBase, run, run::builder::RunBuilder, signal::SignalHandler};
78

@@ -44,15 +45,20 @@ pub async fn run(base: CommandBase, telemetry: CommandEventBuilder) -> Result<i3
4445
.build(&handler, telemetry)
4546
.await?;
4647

47-
let (sender, handle) = run.start_experimental_ui()?.unzip();
48+
let (sender, handle) = run.start_ui()?.unzip();
49+
4850
let result = run.run(sender.clone(), false).await;
4951

5052
if let Some(analytics_handle) = analytics_handle {
5153
analytics_handle.close_with_timeout().await;
5254
}
5355

54-
if let (Some(handle), Some(sender)) = (handle, sender) {
56+
// We only stop if it's the TUI, for the web UI we don't need to stop
57+
if let Some(UISender::Tui(sender)) = sender {
5558
sender.stop();
59+
}
60+
61+
if let Some(handle) = handle {
5662
if let Err(e) = handle.await.expect("render thread panicked") {
5763
error!("error encountered rendering tui: {e}");
5864
}

crates/turborepo-lib/src/opts.rs

+5
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use crate::{
1313
commands::CommandBase,
1414
config::ConfigurationOptions,
1515
run::task_id::TaskId,
16+
turbo_json::UIMode,
1617
};
1718

1819
#[derive(Debug, Error)]
@@ -166,6 +167,7 @@ pub struct RunOpts {
166167
pub summarize: Option<Option<bool>>,
167168
pub(crate) experimental_space_id: Option<String>,
168169
pub is_github_actions: bool,
170+
pub ui_mode: UIMode,
169171
}
170172

171173
impl RunOpts {
@@ -267,6 +269,7 @@ impl<'a> TryFrom<OptsInputs<'a>> for RunOpts {
267269
env_mode: inputs.config.env_mode(),
268270
cache_dir: inputs.config.cache_dir().into(),
269271
is_github_actions,
272+
ui_mode: inputs.config.ui(),
270273
})
271274
}
272275
}
@@ -394,6 +397,7 @@ mod test {
394397
use crate::{
395398
cli::DryRunMode,
396399
opts::{Opts, RunCacheOpts, ScopeOpts},
400+
turbo_json::UIMode,
397401
};
398402

399403
#[derive(Default)]
@@ -499,6 +503,7 @@ mod test {
499503
only: opts_input.only,
500504
dry_run: opts_input.dry_run,
501505
graph: None,
506+
ui_mode: UIMode::Stream,
502507
single_package: false,
503508
log_prefix: crate::opts::ResolvedLogPrefix::Task,
504509
log_order: crate::opts::ResolvedLogOrder::Stream,

crates/turborepo-lib/src/run/builder.rs

+6-6
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ pub struct RunBuilder {
5757
root_turbo_json_path: AbsoluteSystemPathBuf,
5858
color_config: ColorConfig,
5959
version: &'static str,
60-
ui_mode: UIMode,
6160
api_client: APIClient,
6261
analytics_sender: Option<AnalyticsSender>,
6362
// In watch mode, we can have a changed package that we want to serve as an entrypoint.
@@ -78,13 +77,12 @@ impl RunBuilder {
7877
let allow_missing_package_manager = config.allow_no_package_manager();
7978

8079
let version = base.version();
81-
let ui_mode = config.ui();
8280
let processes = ProcessManager::new(
8381
// We currently only use a pty if the following are met:
8482
// - we're attached to a tty
8583
atty::is(atty::Stream::Stdout) &&
8684
// - if we're on windows, we're using the UI
87-
(!cfg!(windows) || matches!(ui_mode, UIMode::Tui)),
85+
(!cfg!(windows) || matches!(opts.run_opts.ui_mode, UIMode::Tui)),
8886
);
8987
let root_turbo_json_path = config.root_turbo_json_path(&base.repo_root);
9088

@@ -101,7 +99,6 @@ impl RunBuilder {
10199
repo_root,
102100
color_config: ui,
103101
version,
104-
ui_mode,
105102
api_auth,
106103
analytics_sender: None,
107104
entrypoint_packages: None,
@@ -413,7 +410,6 @@ impl RunBuilder {
413410
Ok(Run {
414411
version: self.version,
415412
color_config: self.color_config,
416-
ui_mode: self.ui_mode,
417413
start_at,
418414
processes: self.processes,
419415
run_telemetry,
@@ -468,7 +464,11 @@ impl RunBuilder {
468464

469465
if !self.opts.run_opts.parallel {
470466
engine
471-
.validate(pkg_dep_graph, self.opts.run_opts.concurrency, self.ui_mode)
467+
.validate(
468+
pkg_dep_graph,
469+
self.opts.run_opts.concurrency,
470+
self.opts.run_opts.ui_mode,
471+
)
472472
.map_err(Error::EngineValidation)?;
473473
}
474474

crates/turborepo-lib/src/run/error.rs

+2
Original file line numberDiff line numberDiff line change
@@ -57,5 +57,7 @@ pub enum Error {
5757
#[error(transparent)]
5858
Daemon(#[from] daemon::DaemonError),
5959
#[error(transparent)]
60+
UI(#[from] turborepo_ui::Error),
61+
#[error(transparent)]
6062
Tui(#[from] tui::Error),
6163
}

crates/turborepo-lib/src/run/mod.rs

+36-16
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ use turborepo_env::EnvironmentVariableMap;
3131
use turborepo_repository::package_graph::{PackageGraph, PackageName, PackageNode};
3232
use turborepo_scm::SCM;
3333
use turborepo_telemetry::events::generic::GenericEventBuilder;
34-
use turborepo_ui::{cprint, cprintln, tui, tui::AppSender, ColorConfig, BOLD_GREY, GREY};
34+
use turborepo_ui::{
35+
cprint, cprintln, sender::UISender, tui, tui::TuiSender, wui::sender::WebUISender, ColorConfig,
36+
BOLD_GREY, GREY,
37+
};
3538

3639
pub use crate::run::error::Error;
3740
use crate::{
@@ -69,9 +72,13 @@ pub struct Run {
6972
task_access: TaskAccess,
7073
daemon: Option<DaemonClient<DaemonConnector>>,
7174
should_print_prelude: bool,
72-
ui_mode: UIMode,
7375
}
7476

77+
type UIResult<T> = Result<Option<(T, JoinHandle<Result<(), turborepo_ui::Error>>)>, Error>;
78+
79+
type WuiResult = UIResult<WebUISender>;
80+
type TuiResult = UIResult<TuiSender>;
81+
7582
impl Run {
7683
fn has_persistent_tasks(&self) -> bool {
7784
self.engine.has_persistent_tasks
@@ -195,24 +202,41 @@ impl Run {
195202
}
196203

197204
pub fn has_tui(&self) -> bool {
198-
self.ui_mode.use_tui()
205+
self.opts.run_opts.ui_mode.use_tui()
199206
}
200207

201208
pub fn should_start_ui(&self) -> Result<bool, Error> {
202-
Ok(self.ui_mode.use_tui()
209+
Ok(self.opts.run_opts.ui_mode.use_tui()
203210
&& self.opts.run_opts.dry_run.is_none()
204211
&& tui::terminal_big_enough()?)
205212
}
206213

207-
#[allow(clippy::type_complexity)]
208-
pub fn start_experimental_ui(
209-
&self,
210-
) -> Result<Option<(AppSender, JoinHandle<Result<(), tui::Error>>)>, Error> {
214+
pub fn start_ui(&self) -> UIResult<UISender> {
211215
// Print prelude here as this needs to happen before the UI is started
212216
if self.should_print_prelude {
213217
self.print_run_prelude();
214218
}
215219

220+
match self.opts.run_opts.ui_mode {
221+
UIMode::Tui => self
222+
.start_terminal_ui()
223+
.map(|res| res.map(|(sender, handle)| (UISender::Tui(sender), handle))),
224+
UIMode::Stream => Ok(None),
225+
UIMode::Web => self
226+
.start_web_ui()
227+
.map(|res| res.map(|(sender, handle)| (UISender::Wui(sender), handle))),
228+
}
229+
}
230+
fn start_web_ui(&self) -> WuiResult {
231+
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
232+
233+
let handle = tokio::spawn(turborepo_ui::wui::server::start_server(rx));
234+
235+
Ok(Some((WebUISender { tx }, handle)))
236+
}
237+
238+
#[allow(clippy::type_complexity)]
239+
fn start_terminal_ui(&self) -> TuiResult {
216240
if !self.should_start_ui()? {
217241
return Ok(None);
218242
}
@@ -223,8 +247,8 @@ impl Run {
223247
return Ok(None);
224248
}
225249

226-
let (sender, receiver) = AppSender::new();
227-
let handle = tokio::task::spawn_blocking(move || tui::run_app(task_names, receiver));
250+
let (sender, receiver) = TuiSender::new();
251+
let handle = tokio::task::spawn_blocking(move || Ok(tui::run_app(task_names, receiver)?));
228252

229253
Ok(Some((sender, handle)))
230254
}
@@ -236,11 +260,7 @@ impl Run {
236260
}
237261
}
238262

239-
pub async fn run(
240-
&mut self,
241-
experimental_ui_sender: Option<AppSender>,
242-
is_watch: bool,
243-
) -> Result<i32, Error> {
263+
pub async fn run(&mut self, ui_sender: Option<UISender>, is_watch: bool) -> Result<i32, Error> {
244264
let skip_cache_writes = self.opts.runcache_opts.skip_writes;
245265
if let Some(subscriber) = self.signal_handler.subscribe() {
246266
let run_cache = self.run_cache.clone();
@@ -427,7 +447,7 @@ impl Run {
427447
self.processes.clone(),
428448
&self.repo_root,
429449
global_env,
430-
experimental_ui_sender,
450+
ui_sender,
431451
is_watch,
432452
);
433453

crates/turborepo-lib/src/run/watch.rs

+8-6
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use tokio::{
1111
use tracing::{instrument, trace};
1212
use turborepo_repository::package_graph::PackageName;
1313
use turborepo_telemetry::events::command::CommandEventBuilder;
14-
use turborepo_ui::{tui, tui::AppSender};
14+
use turborepo_ui::sender::UISender;
1515

1616
use crate::{
1717
cli::{Command, RunArgs},
@@ -53,8 +53,8 @@ pub struct WatchClient {
5353
base: CommandBase,
5454
telemetry: CommandEventBuilder,
5555
handler: SignalHandler,
56-
ui_sender: Option<AppSender>,
57-
ui_handle: Option<JoinHandle<Result<(), tui::Error>>>,
56+
ui_sender: Option<UISender>,
57+
ui_handle: Option<JoinHandle<Result<(), turborepo_ui::Error>>>,
5858
}
5959

6060
struct PersistentRunHandle {
@@ -99,6 +99,8 @@ pub enum Error {
9999
SignalInterrupt,
100100
#[error("package change error")]
101101
PackageChange(#[from] tonic::Status),
102+
#[error(transparent)]
103+
UI(#[from] turborepo_ui::Error),
102104
#[error("could not connect to UI thread")]
103105
UISend(String),
104106
#[error("cannot use root turbo.json at {0} with watch mode")]
@@ -134,7 +136,7 @@ impl WatchClient {
134136

135137
let watched_packages = run.get_relevant_packages();
136138

137-
let (sender, handle) = run.start_experimental_ui()?.unzip();
139+
let (ui_sender, ui_handle) = run.start_ui()?.unzip();
138140

139141
let connector = DaemonConnector {
140142
can_start_server: true,
@@ -150,8 +152,8 @@ impl WatchClient {
150152
handler,
151153
telemetry,
152154
persistent_tasks_handle: None,
153-
ui_sender: sender,
154-
ui_handle: handle,
155+
ui_sender,
156+
ui_handle,
155157
})
156158
}
157159

0 commit comments

Comments
 (0)