Skip to main content

greentic_setup/ui/
mod.rs

1//! Web-based setup UI server.
2//!
3//! Launches an Axum HTTP server on a random port, opens the browser, and serves
4//! a single-page app that drives the setup wizard through the same FormSpec
5//! infrastructure as the terminal wizard.
6
7mod 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
29// ── Types ──
30
31struct 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
89/// Extra fields from setup.yaml not in FormSpec.
90struct 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
109// ── Public API ──
110
111/// Launch the setup UI server and open in browser.
112pub 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
164// ── Static assets ──
165
166async 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
190// ── API handlers ──
191
192/// Well-known locales with display labels.
193const 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    // Use query locale override, fall back to CLI locale
243    let locale = query.locale.as_deref().or(state.locale.as_deref());
244
245    // Load i18n strings for the UI
246    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    // Detect shared questions
278    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    // Build lookup maps for extra fields (placeholder, group, docs_url) from setup.yaml
303    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
389// ── Execution ──
390
391fn 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    // Collect manual steps before moving answers into request
419    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    // Capture plan summary
455    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
491// ── Helpers ──
492
493fn 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    // Resolve title and help from i18n if available
516    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}