1mod assets;
8
9use std::path::{Path, PathBuf};
10use std::sync::Mutex;
11
12use anyhow::Result;
13use axum::extract::State;
14use axum::http::header;
15use axum::response::IntoResponse;
16use axum::routing::{get, post};
17use axum::{Json, Router};
18use serde::{Deserialize, Serialize};
19use serde_json::{Map as JsonMap, Value};
20use tokio::sync::broadcast;
21
22use crate::cli_i18n::CliI18n;
23use crate::engine::{SetupConfig, SetupRequest};
24use crate::plan::TenantSelection;
25use crate::platform_setup::StaticRoutesPolicy;
26use crate::qa::wizard;
27use crate::{SetupEngine, SetupMode, discovery, setup_to_formspec};
28
29struct UiState {
32 bundle_path: PathBuf,
33 tenant: String,
34 team: Option<String>,
35 env: String,
36 #[allow(dead_code)]
37 advanced: bool,
38 locale: Option<String>,
39 shutdown_tx: broadcast::Sender<()>,
40 #[allow(dead_code)]
41 result: Mutex<Option<ExecutionResult>>,
42}
43
44#[derive(Serialize)]
45#[allow(dead_code)]
46struct ProvidersResponse {
47 bundle_path: String,
48 providers: Vec<ProviderInfo>,
49 provider_forms: Vec<ProviderForm>,
50 shared_questions: Vec<QuestionInfo>,
51}
52
53#[derive(Serialize)]
54struct ProviderInfo {
55 provider_id: String,
56 domain: String,
57 question_count: usize,
58}
59
60#[derive(Serialize)]
61struct ProviderForm {
62 provider_id: String,
63 title: String,
64 questions: Vec<QuestionInfo>,
65}
66
67#[derive(Serialize, Clone)]
68struct QuestionInfo {
69 id: String,
70 title: String,
71 kind: String,
72 required: bool,
73 secret: bool,
74 default_value: Option<String>,
75 help: Option<String>,
76 choices: Option<Vec<String>>,
77 visible_if: Option<VisibleIfInfo>,
78 placeholder: Option<String>,
79 group: Option<String>,
80 docs_url: Option<String>,
81}
82
83#[derive(Serialize, Clone)]
84struct VisibleIfInfo {
85 field: String,
86 eq: Option<String>,
87}
88
89struct SetupQuestionExtras {
91 placeholder: Option<String>,
92 group: Option<String>,
93 docs_url: Option<String>,
94}
95
96#[derive(Deserialize)]
97struct ExecuteRequest {
98 answers: JsonMap<String, Value>,
99}
100
101#[derive(Serialize, Clone)]
102struct ExecutionResult {
103 success: bool,
104 stdout: String,
105 stderr: String,
106 manual_steps: Vec<crate::webhook::ProviderInstruction>,
107}
108
109pub async fn launch(
113 bundle_path: &Path,
114 tenant: &str,
115 team: Option<&str>,
116 env: &str,
117 advanced: bool,
118 locale: Option<&str>,
119) -> Result<()> {
120 let (shutdown_tx, _) = broadcast::channel::<()>(1);
121
122 let state = std::sync::Arc::new(UiState {
123 bundle_path: bundle_path.to_path_buf(),
124 tenant: tenant.to_string(),
125 team: team.map(String::from),
126 env: env.to_string(),
127 advanced,
128 locale: locale.map(String::from),
129 shutdown_tx: shutdown_tx.clone(),
130 result: Mutex::new(None),
131 });
132
133 let router = build_router(state.clone());
134
135 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
136 let port = listener.local_addr()?.port();
137 let url = format!("http://127.0.0.1:{port}");
138
139 eprintln!("Setup UI started at: {url}");
140 let _ = open::that(&url);
141
142 let mut shutdown_rx = shutdown_tx.subscribe();
143 axum::serve(listener, router)
144 .with_graceful_shutdown(async move {
145 let _ = shutdown_rx.recv().await;
146 })
147 .await?;
148
149 Ok(())
150}
151
152fn build_router(state: std::sync::Arc<UiState>) -> Router {
153 Router::new()
154 .route("/", get(serve_index))
155 .route("/app.js", get(serve_js))
156 .route("/style.css", get(serve_css))
157 .route("/api/locales", get(get_locales))
158 .route("/api/providers", get(get_providers))
159 .route("/api/execute", post(post_execute))
160 .route("/api/shutdown", post(post_shutdown))
161 .with_state(state)
162}
163
164async fn serve_index() -> impl IntoResponse {
167 (
168 [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
169 assets::INDEX_HTML,
170 )
171}
172
173async fn serve_js() -> impl IntoResponse {
174 (
175 [(
176 header::CONTENT_TYPE,
177 "application/javascript; charset=utf-8",
178 )],
179 assets::APP_JS,
180 )
181}
182
183async fn serve_css() -> impl IntoResponse {
184 (
185 [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
186 assets::STYLE_CSS,
187 )
188}
189
190const LOCALE_OPTIONS: &[(&str, &str)] = &[
194 ("en", "English"),
195 ("id", "Bahasa Indonesia"),
196 ("ja", "日本語"),
197 ("zh", "中文"),
198 ("ko", "한국어"),
199 ("es", "Español"),
200 ("fr", "Français"),
201 ("de", "Deutsch"),
202 ("pt", "Português"),
203 ("ru", "Русский"),
204 ("ar", "العربية"),
205 ("th", "ไทย"),
206 ("vi", "Tiếng Việt"),
207 ("tr", "Türkçe"),
208 ("it", "Italiano"),
209 ("nl", "Nederlands"),
210 ("pl", "Polski"),
211 ("sv", "Svenska"),
212 ("hi", "हिन्दी"),
213 ("ms", "Bahasa Melayu"),
214];
215
216async fn get_locales(State(state): State<std::sync::Arc<UiState>>) -> Json<Value> {
217 let current = state.locale.as_deref().unwrap_or("en");
218 let locales: Vec<Value> = LOCALE_OPTIONS
219 .iter()
220 .map(|(code, label)| {
221 serde_json::json!({
222 "code": code,
223 "label": label,
224 "selected": *code == current,
225 })
226 })
227 .collect();
228 Json(serde_json::json!({ "locales": locales, "current": current }))
229}
230
231#[derive(Deserialize)]
232struct ProviderQuery {
233 locale: Option<String>,
234}
235
236async fn get_providers(
237 State(state): State<std::sync::Arc<UiState>>,
238 axum::extract::Query(query): axum::extract::Query<ProviderQuery>,
239) -> Json<Value> {
240 let bundle_path = &state.bundle_path;
241
242 let locale = query.locale.as_deref().or(state.locale.as_deref());
244
245 let i18n = CliI18n::from_request(locale)
247 .unwrap_or_else(|_| CliI18n::from_request(Some("en")).expect("en locale must exist"));
248 let ui_strings = i18n.keys_with_prefix("ui.");
249
250 let discovered = match discovery::discover(bundle_path) {
251 Ok(d) => d,
252 Err(e) => {
253 return Json(serde_json::json!({
254 "bundle_path": bundle_path.display().to_string(),
255 "providers": [],
256 "provider_forms": [],
257 "shared_questions": [],
258 "i18n": ui_strings,
259 "error": e.to_string(),
260 }));
261 }
262 };
263
264 let provider_form_specs: Vec<wizard::ProviderFormSpec> = discovered
265 .providers
266 .iter()
267 .filter_map(|provider| {
268 setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
269 |form_spec| wizard::ProviderFormSpec {
270 provider_id: provider.provider_id.clone(),
271 form_spec,
272 },
273 )
274 })
275 .collect();
276
277 let shared_questions = if provider_form_specs.len() > 1 {
279 let shared = wizard::collect_shared_questions(&provider_form_specs);
280 shared
281 .shared_questions
282 .iter()
283 .map(|q| form_question_to_info(q, Some(&i18n)))
284 .collect::<Vec<_>>()
285 } else {
286 vec![]
287 };
288
289 let providers: Vec<ProviderInfo> = discovered
290 .providers
291 .iter()
292 .map(|p| {
293 let form = setup_to_formspec::pack_to_form_spec(&p.pack_path, &p.provider_id);
294 ProviderInfo {
295 provider_id: p.provider_id.clone(),
296 domain: p.domain.clone(),
297 question_count: form.as_ref().map(|f| f.questions.len()).unwrap_or(0),
298 }
299 })
300 .collect();
301
302 let mut extras_by_provider: std::collections::HashMap<
304 String,
305 std::collections::HashMap<String, SetupQuestionExtras>,
306 > = std::collections::HashMap::new();
307 for provider in &discovered.providers {
308 if let Ok(Some(spec)) = crate::setup_input::load_setup_spec(&provider.pack_path) {
309 let mut map = std::collections::HashMap::new();
310 for q in &spec.questions {
311 map.insert(
312 q.name.clone(),
313 SetupQuestionExtras {
314 placeholder: q.placeholder.clone(),
315 group: q.group.clone(),
316 docs_url: q.docs_url.clone(),
317 },
318 );
319 }
320 extras_by_provider.insert(provider.provider_id.clone(), map);
321 }
322 }
323
324 let provider_forms: Vec<ProviderForm> = provider_form_specs
325 .iter()
326 .map(|pfs| {
327 let extras = extras_by_provider.get(&pfs.provider_id);
328 ProviderForm {
329 provider_id: pfs.provider_id.clone(),
330 title: pfs.form_spec.title.clone(),
331 questions: pfs
332 .form_spec
333 .questions
334 .iter()
335 .map(|q| {
336 let mut info = form_question_to_info(q, Some(&i18n));
337 if let Some(ext) = extras.and_then(|m| m.get(&q.id)) {
338 if info.placeholder.is_none() {
339 info.placeholder = ext.placeholder.clone();
340 }
341 info.group = ext.group.clone();
342 info.docs_url = ext.docs_url.clone();
343 }
344 info
345 })
346 .collect(),
347 }
348 })
349 .collect();
350
351 Json(serde_json::json!({
352 "bundle_path": bundle_path.display().to_string(),
353 "providers": providers,
354 "provider_forms": provider_forms,
355 "shared_questions": shared_questions,
356 "i18n": ui_strings,
357 }))
358}
359
360async fn post_execute(
361 State(state): State<std::sync::Arc<UiState>>,
362 Json(req): Json<ExecuteRequest>,
363) -> Json<ExecutionResult> {
364 let bundle_path = state.bundle_path.clone();
365 let tenant = state.tenant.clone();
366 let team = state.team.clone();
367 let env = state.env.clone();
368 let answers = req.answers;
369
370 let result = tokio::task::spawn_blocking(move || {
371 execute_setup(&bundle_path, &tenant, team.as_deref(), &env, answers)
372 })
373 .await
374 .unwrap_or_else(|e| ExecutionResult {
375 success: false,
376 stdout: String::new(),
377 stderr: format!("Task panicked: {e}"),
378 manual_steps: vec![],
379 });
380
381 *state.result.lock().unwrap() = Some(result.clone());
382 Json(result)
383}
384
385async fn post_shutdown(State(state): State<std::sync::Arc<UiState>>) {
386 let _ = state.shutdown_tx.send(());
387}
388
389fn execute_setup(
392 bundle_path: &Path,
393 tenant: &str,
394 team: Option<&str>,
395 env: &str,
396 answers: JsonMap<String, Value>,
397) -> ExecutionResult {
398 let config = SetupConfig {
399 tenant: tenant.to_string(),
400 team: team.map(String::from),
401 env: env.to_string(),
402 offline: false,
403 verbose: true,
404 };
405
406 let static_routes = match StaticRoutesPolicy::normalize(None, env) {
407 Ok(sr) => sr,
408 Err(e) => {
409 return ExecutionResult {
410 success: false,
411 stdout: String::new(),
412 stderr: format!("Failed to normalize static routes: {e}"),
413 manual_steps: vec![],
414 };
415 }
416 };
417
418 let provider_configs: Vec<(String, serde_json::Value)> = answers
420 .iter()
421 .map(|(id, val)| (id.clone(), val.clone()))
422 .collect();
423 let team_str = team.unwrap_or("default");
424 let manual_steps =
425 crate::webhook::collect_post_setup_instructions(&provider_configs, tenant, team_str);
426
427 let request = SetupRequest {
428 bundle: bundle_path.to_path_buf(),
429 tenants: vec![TenantSelection {
430 tenant: tenant.to_string(),
431 team: team.map(String::from),
432 allow_paths: Vec::new(),
433 }],
434 static_routes,
435 deployment_targets: Vec::new(),
436 setup_answers: answers,
437 ..Default::default()
438 };
439
440 let engine = SetupEngine::new(config);
441
442 let plan = match engine.plan(SetupMode::Create, &request, false) {
443 Ok(p) => p,
444 Err(e) => {
445 return ExecutionResult {
446 success: false,
447 stdout: String::new(),
448 stderr: format!("Failed to build plan: {e}"),
449 manual_steps: vec![],
450 };
451 }
452 };
453
454 let mut stdout = String::new();
456 for step in &plan.steps {
457 stdout.push_str(&format!(" {:?}: {}\n", step.kind, step.description));
458 }
459
460 match engine.execute(&plan) {
461 Ok(report) => {
462 stdout.push_str(&format!(
463 "\n{} provider(s) updated, {} pack(s) resolved.\n",
464 report.provider_updates,
465 report.resolved_packs.len()
466 ));
467 if !report.warnings.is_empty() {
468 for w in &report.warnings {
469 stdout.push_str(&format!(" warning: {w}\n"));
470 }
471 }
472 ExecutionResult {
473 success: true,
474 stdout: format!(
475 "Plan ({} steps):\n{stdout}Setup completed successfully.",
476 plan.steps.len()
477 ),
478 stderr: String::new(),
479 manual_steps,
480 }
481 }
482 Err(e) => ExecutionResult {
483 success: false,
484 stdout,
485 stderr: format!("Execution failed: {e}"),
486 manual_steps: vec![],
487 },
488 }
489}
490
491fn form_question_to_info(q: &qa_spec::QuestionSpec, i18n: Option<&CliI18n>) -> QuestionInfo {
494 let visible_if = q.visible_if.as_ref().and_then(|v| match v {
495 qa_spec::Expr::Eq { left, right } => {
496 let field = match left.as_ref() {
497 qa_spec::Expr::Answer { path } => path.clone(),
498 _ => return None,
499 };
500 let eq = match right.as_ref() {
501 qa_spec::Expr::Literal { value } => {
502 Some(value.as_str().unwrap_or("true").to_string())
503 }
504 _ => None,
505 };
506 Some(VisibleIfInfo { field, eq })
507 }
508 qa_spec::Expr::Answer { path } => Some(VisibleIfInfo {
509 field: path.clone(),
510 eq: None,
511 }),
512 _ => None,
513 });
514
515 let title_key = format!("ui.q.{}", q.id);
517 let help_key = format!("ui.q.{}.help", q.id);
518
519 let title = i18n
520 .and_then(|i| {
521 let t = i.t(&title_key);
522 if t != title_key { Some(t) } else { None }
523 })
524 .unwrap_or_else(|| q.title.clone());
525
526 let help = i18n
527 .and_then(|i| {
528 let t = i.t(&help_key);
529 if t != help_key { Some(t) } else { None }
530 })
531 .or_else(|| q.description.clone());
532
533 QuestionInfo {
534 id: q.id.clone(),
535 title,
536 kind: format!("{:?}", q.kind),
537 required: q.required,
538 secret: q.secret,
539 default_value: q.default_value.clone(),
540 help,
541 choices: q.choices.clone(),
542 visible_if,
543 placeholder: None,
544 group: None,
545 docs_url: None,
546 }
547}