use crate::orchestrate::cards2pack;
use crate::ui::state::{AppState, LogKind, PackJob, PackJobStatus, PackLogLine};
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::http::header;
use axum::response::IntoResponse;
use serde::Deserialize;
use serde_json::{Value, json};
use std::sync::Arc;
#[derive(Deserialize)]
pub struct CardPair {
pub id: String,
#[serde(default)]
pub card: Option<Value>,
#[serde(default, rename = "type")]
pub entry_type: Option<String>,
#[serde(default)]
pub config: Option<Value>,
}
#[derive(Deserialize)]
pub struct PackBody {
pub name: String,
pub cards: Vec<CardPair>,
#[serde(default)]
pub i18n: bool,
#[serde(default)]
pub langs: Option<Vec<String>>,
#[serde(default)]
pub strict: bool,
#[serde(default)]
pub verbose: bool,
#[serde(default)]
pub bundle: bool,
#[serde(default)]
pub providers: Option<Vec<String>>,
}
pub async fn post_pack(
State(state): State<Arc<AppState>>,
Json(body): Json<PackBody>,
) -> impl IntoResponse {
if body.cards.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "no cards provided"})),
)
.into_response();
}
let job_id = format!("pack-{}", uuid_short());
let card_count = body.cards.len();
let card_pairs: Vec<(String, Value)> = body
.cards
.into_iter()
.map(|cp| {
let value = if let Some(card) = cp.card {
card
} else if matches!(cp.entry_type.as_deref(), Some("http")) {
json!({
"type": "http",
"config": cp.config.unwrap_or_else(|| json!({})),
})
} else {
json!({})
};
(cp.id, value)
})
.collect();
let prep = match cards2pack::prepare_cards(&body.name, &card_pairs) {
Ok(p) => p,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)
.into_response();
}
};
{
let mut jobs = state.pack_jobs.lock().await;
jobs.insert(
job_id.clone(),
PackJob {
lines: vec![PackLogLine {
text: format!("Starting pack: {} ({} cards)", body.name, card_count),
kind: LogKind::Info,
}],
progress: 5,
step: "Preparing workspace...".to_string(),
status: PackJobStatus::Running,
pack_path: None,
workspace_path: None,
download_url: None,
filename: None,
error: None,
},
);
}
let mut args = vec![
"generate".to_string(),
"--cards".to_string(),
prep.cards_dir.display().to_string(),
"--out".to_string(),
prep.out_dir.display().to_string(),
"--name".to_string(),
body.name.clone(),
"--default-flow".to_string(),
prep.flow_name.clone(),
];
if body.i18n {
if let Some(langs) = body.langs.as_ref().filter(|l| !l.is_empty()) {
args.extend([
"--auto-translate".to_string(),
"--langs".to_string(),
langs.join(","),
]);
}
} else {
args.push("--no-auto-i18n".to_string());
}
if body.strict {
args.push("--strict".to_string());
}
if body.verbose {
args.push("--verbose".to_string());
}
let state_clone = state.clone();
let jid = job_id.clone();
let pack_name = body.name.clone();
let out_dir = prep.out_dir.clone();
let post_opts = PostProcessOpts {
build_bundle: body.bundle,
providers: body.providers.clone(),
http_entries: prep.http_entries.clone(),
};
tokio::spawn(async move {
run_pack_subprocess(&state_clone, &jid, &args, &out_dir, &pack_name, &post_opts).await;
prep.persist();
});
(StatusCode::OK, Json(json!({ "job_id": job_id }))).into_response()
}
pub async fn get_job(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
let jobs = state.pack_jobs.lock().await;
match jobs.get(&id) {
Some(job) => {
let lines: Vec<Value> = job
.lines
.iter()
.map(|l| json!({"text": l.text, "kind": l.kind.as_str()}))
.collect();
(
StatusCode::OK,
Json(json!({
"status": job.status.as_str(),
"progress": job.progress,
"step": job.step,
"lines": lines,
"pack_path": job.pack_path,
"workspace_path": job.workspace_path,
"download_url": job.download_url,
"filename": job.filename,
"error": job.error,
})),
)
.into_response()
}
None => (
StatusCode::NOT_FOUND,
Json(json!({"error": "job not found"})),
)
.into_response(),
}
}
#[derive(Deserialize)]
pub struct DownloadQuery {
pub path: String,
}
pub async fn get_download(Query(q): Query<DownloadQuery>) -> impl IntoResponse {
let path = std::path::Path::new(&q.path);
let valid_ext = path
.extension()
.is_some_and(|e| e == "gtpack" || e == "gtbundle");
if !q.path.starts_with("/tmp/") || !valid_ext {
return (StatusCode::FORBIDDEN, "Access denied").into_response();
}
match tokio::fs::read(path).await {
Ok(bytes) => {
let filename = path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| "flow.gtpack".to_string());
(
StatusCode::OK,
[
(header::CONTENT_TYPE, "application/octet-stream".to_string()),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
),
],
bytes,
)
.into_response()
}
Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(),
}
}
struct PostProcessOpts {
build_bundle: bool,
providers: Option<Vec<String>>,
http_entries: Vec<crate::orchestrate::http_inject::HttpNodeEntry>,
}
async fn run_pack_subprocess(
state: &Arc<AppState>,
job_id: &str,
args: &[String],
out_dir: &std::path::Path,
pack_name: &str,
opts: &PostProcessOpts,
) {
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
let mut cmd = Command::new("greentic-cards2pack");
cmd.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
let mut jobs = state.pack_jobs.lock().await;
if let Some(job) = jobs.get_mut(job_id) {
job.status = PackJobStatus::Failed;
job.error = Some(format!("Failed to spawn greentic-cards2pack: {e}"));
job.progress = 0;
}
return;
}
};
let stderr = child.stderr.take();
if let Some(stderr) = stderr {
let mut reader = BufReader::new(stderr).lines();
while let Ok(Some(raw_line)) = reader.next_line().await {
let sub_lines: Vec<&str> = raw_line
.split('\r')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
let mut jobs = state.pack_jobs.lock().await;
if let Some(job) = jobs.get_mut(job_id) {
for sub in &sub_lines {
let line = sub.to_string();
let is_progress = line.contains("Progress:");
if is_progress {
if let Some(pos) = job.lines.iter().rposition(|l| {
l.text.contains("Progress:")
&& l.text.split("Progress:").next()
== line.split("Progress:").next()
}) {
job.lines.remove(pos);
}
}
let (kind, progress) = classify_line(&line);
job.lines.push(PackLogLine {
text: line.clone(),
kind,
});
if let Some(p) = progress {
job.progress = p;
}
job.step = summarize_line(&line);
}
}
}
}
let exit = child.wait().await;
let success = exit.is_ok_and(|s| s.success());
let mut jobs = state.pack_jobs.lock().await;
if let Some(job) = jobs.get_mut(job_id) {
if success {
let dist = out_dir.join("dist");
let pack_path = std::fs::read_dir(&dist).ok().and_then(|mut rd| {
rd.find_map(|e| {
let p = e.ok()?.path();
if p.extension()? == "gtpack" {
Some(p)
} else {
None
}
})
});
if let Some(pp) = pack_path {
let pack_filename = pp
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| format!("{pack_name}.gtpack"));
if !opts.http_entries.is_empty() {
let flow_path = out_dir.join("flows").join("main.ygtc");
if let Ok(ygtc_content) = std::fs::read_to_string(&flow_path) {
let injected = crate::orchestrate::http_inject::inject_http_nodes(
&ygtc_content,
&opts.http_entries,
);
let _ = std::fs::write(&flow_path, injected);
let pack_yaml = out_dir.join("pack.yaml");
let _ = crate::orchestrate::http_inject::ensure_component_http_source(
&pack_yaml,
);
}
job.lines.push(PackLogLine {
text: format!(
"Injected {} HTTP API node(s) into flow",
opts.http_entries.len()
),
kind: LogKind::Done,
});
}
if opts.build_bundle {
job.lines.push(PackLogLine {
text: "Building .gtbundle...".to_string(),
kind: LogKind::Progress,
});
job.step = "Building .gtbundle...".to_string();
job.progress = 90;
drop(jobs);
match crate::orchestrate::deployer::build_bundle(
&pp,
pack_name,
opts.providers.as_deref(),
) {
Ok(br) => {
let fname = br
.bundle_path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| format!("{pack_name}.gtbundle"));
let dl = download_url(&br.bundle_path);
let mut jobs = state.pack_jobs.lock().await;
if let Some(job) = jobs.get_mut(job_id) {
job.pack_path = Some(br.bundle_path.display().to_string());
job.workspace_path = Some(br.workspace_path.display().to_string());
job.download_url = Some(dl);
job.filename = Some(fname);
job.status = PackJobStatus::Done;
job.progress = 100;
job.step = "Complete!".to_string();
job.lines.push(PackLogLine {
text: format!("Bundle ready: {}", br.bundle_path.display()),
kind: LogKind::Done,
});
}
}
Err(e) => {
let mut jobs = state.pack_jobs.lock().await;
if let Some(job) = jobs.get_mut(job_id) {
job.pack_path = Some(pp.display().to_string());
job.workspace_path = Some(out_dir.display().to_string());
job.download_url = Some(download_url(&pp));
job.filename = Some(pack_filename.clone());
job.status = PackJobStatus::Done;
job.progress = 100;
job.step = "Pack done (bundle failed)".to_string();
job.lines.push(PackLogLine {
text: format!("Bundle build failed: {e}"),
kind: LogKind::Warning,
});
}
}
}
return;
}
job.pack_path = Some(pp.display().to_string());
job.workspace_path = Some(out_dir.display().to_string());
job.download_url = Some(download_url(&pp));
job.filename = Some(pack_filename);
job.status = PackJobStatus::Done;
job.progress = 100;
job.step = "Complete!".to_string();
} else {
job.status = PackJobStatus::Failed;
job.error = Some("No .gtpack file found in dist/".to_string());
}
} else {
job.status = PackJobStatus::Failed;
job.error = Some("greentic-cards2pack failed".to_string());
}
}
}
fn classify_line(line: &str) -> (LogKind, Option<u8>) {
if line.contains("Progress:") {
let pct = line.split("Progress:").nth(1).and_then(|s| {
let parts: Vec<&str> = s.trim().split('/').collect();
if parts.len() == 2 {
let cur: f32 = parts[0].trim().parse().ok()?;
let total: f32 = parts[1].trim().parse().ok()?;
Some((20.0 + (cur / total) * 50.0) as u8) } else {
None
}
});
(LogKind::Progress, pct)
} else if line.contains("error") || line.contains("Error") || line.starts_with("ERR") {
(LogKind::Error, None)
} else if line.contains("warning") || line.contains("Warning") {
(LogKind::Warning, None)
} else if line.contains("wrote") || line.contains("Pack:") || line.contains("OK ") {
(LogKind::Done, Some(85))
} else if line.contains("[flow]") {
(LogKind::Progress, Some(30))
} else if line.contains("pack.yaml") {
(LogKind::Progress, Some(50))
} else if line.contains("Running:") {
(LogKind::Progress, Some(75))
} else {
(LogKind::Info, None)
}
}
fn summarize_line(line: &str) -> String {
if line.contains("created pack at") {
"Creating workspace...".to_string()
} else if line.contains("[flow]") {
line.trim().to_string()
} else if line.contains("pack.yaml updated") {
"Updating pack manifest...".to_string()
} else if line.starts_with("OK ") || line.contains("valid") {
"Validating flows...".to_string()
} else if line.contains("wrote") && line.contains(".gtpack") {
"Writing .gtpack archive...".to_string()
} else if line.contains("Running:") {
"Running greentic-pack build...".to_string()
} else if line.contains("Pack:") && !line.contains("pack.yaml") {
"Pack summary...".to_string()
} else {
line.chars().take(60).collect()
}
}
fn download_url(path: &std::path::Path) -> String {
format!(
"/api/pack/download?path={}",
urlencoding::encode(&path.display().to_string())
)
}
fn uuid_short() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let t = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
format!("{t:x}")
}