From 3b547e078fdeb357beb90fdbad845d0c0360a99c Mon Sep 17 00:00:00 2001 From: mkn Date: Mon, 5 Oct 2026 15:23:40 +0200 Subject: [PATCH] fix(jobs): name the hosting by its domain, not its id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-hosting forms post the hosting id as their selector and spawn_job stored it verbatim as the job's subject, so job pages read "target 01a0f15b-…" and "← Back to 01a0f15b-…". - spawn_job resolves an id-shaped target to the hosting's domain before storing it; after a delete the domain is the only name left. - /jobs, /jobs/ and the progress fragment resolve ids on older rows at render time (one cluster listing, skipped when no row needs it). - A finished hosting_delete no longer offers a back link to a 404. Co-Authored-By: Claude Opus 5.5 --- bin/hyperion-web/src/handlers/jobs.rs | 84 +++++++++++++++++++++++++-- crates/hyperion-types/src/jobs.rs | 25 ++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/bin/hyperion-web/src/handlers/jobs.rs b/bin/hyperion-web/src/handlers/jobs.rs index e86e2fce..711a7826 100644 --- a/bin/hyperion-web/src/handlers/jobs.rs +++ b/bin/hyperion-web/src/handlers/jobs.rs @@ -343,11 +343,12 @@ pub async fn get_jobs( }, ) .await?; - let jobs = match resp { + let mut jobs = match resp { RpcResponse::JobList(v) => v, RpcResponse::Error(e) => return Err(AppError::Rpc(e.to_string())), _ => return Err(AppError::Internal("unexpected response".into())), }; + humanize_targets(&state, &mut jobs).await; let tpl = JobsListTpl { username: &ctx.username, user_initial: super::user_initial(&ctx.username), @@ -371,7 +372,7 @@ pub async fn get_job_detail( axum::response::Redirect::to("/?flash_error=admin+role+required").into_response(), ); } - let job = match fetch_job(&state, &id).await? { + let mut job = match fetch_job(&state, &id).await? { Some(j) => j, None => { return Ok(axum::response::Redirect::to( @@ -380,6 +381,7 @@ pub async fn get_job_detail( .into_response()); } }; + humanize_targets(&state, std::slice::from_mut(&mut job)).await; let is_running = !job.is_terminal(); let elapsed = format_elapsed(&job); let csrf_token = super::session_csrf_token(&state, &ctx); @@ -407,7 +409,7 @@ pub async fn get_job_progress( Html("
admin role required
".to_string()).into_response(), ); } - let job = match fetch_job(&state, &id).await? { + let mut job = match fetch_job(&state, &id).await? { Some(j) => j, None => { return Ok( @@ -416,6 +418,7 @@ pub async fn get_job_progress( ); } }; + humanize_targets(&state, std::slice::from_mut(&mut job)).await; let is_running = !job.is_terminal(); let terminal = job.is_terminal(); let elapsed = format_elapsed(&job); @@ -452,6 +455,52 @@ async fn fetch_job( } } +/// True for a hosting id (a UUID: 36 chars, hyphens at 8/13/18/23, hex +/// elsewhere). Domains always contain a dot; job labels never look like this. +fn looks_like_hosting_id(s: &str) -> bool { + s.len() == 36 + && s.char_indices().all(|(i, c)| match i { + 8 | 13 | 18 | 23 => c == '-', + _ => c.is_ascii_hexdigit(), + }) +} + +/// [`humanize_targets`] for a single subject, before it is stored. +async fn humanize_target(state: &SharedState, target: &mut Option) { + let mut probe = [hyperion_types::JobView { + target: target.take(), + ..Default::default() + }]; + humanize_targets(state, &mut probe).await; + *target = probe[0].target.take(); +} + +/// Show the hosting's domain instead of its id for jobs whose subject is +/// an id — rows written before `spawn_job` stored the domain. One cluster +/// listing for the whole batch, and none at all when no row needs it, so +/// the 2-second progress poll stays a single RPC for new jobs. A hosting +/// that has since been deleted keeps its id: nothing else names it. +async fn humanize_targets(state: &SharedState, jobs: &mut [hyperion_types::JobView]) { + if !jobs + .iter() + .any(|j| j.target.as_deref().is_some_and(looks_like_hosting_id)) + { + return; + } + let Ok(rows) = crate::handlers::hostings::list_hostings(state).await else { + return; + }; + for j in jobs.iter_mut() { + if let Some(h) = j + .target + .as_deref() + .and_then(|t| rows.iter().find(|h| h.id.as_str() == t)) + { + j.target = Some(h.domain.clone()); + } + } +} + /// Render "1m 47s" or similar. Caps at hours since no current job /// is expected to take days; if it does, "h m s" is still readable. fn format_elapsed(j: &hyperion_types::JobView) -> String { @@ -559,11 +608,18 @@ where F: FnOnce(JobReporter) -> Fut + Send + 'static, Fut: std::future::Future + Send + 'static, { + // Most per-hosting forms post the hosting's id as their selector, and + // that id used to be stored as the job's subject — so the job pages + // read "target 01a0f15b-…" where the operator expects "example.cz". + // Store the domain instead. After a delete it is the only name left: + // the id no longer resolves to anything. + let mut target = target.map(String::from); + humanize_target(&state, &mut target).await; let resp = hyperion_rpc_client::call( &state.agent_socket, Request::JobStart { kind: kind.to_string(), - target: target.map(String::from), + target, payload_json: payload_json.to_string(), actor_label: actor_label.to_string(), actor_uid, @@ -582,3 +638,23 @@ where tokio::spawn(work(reporter)); Ok(id) } + +#[cfg(test)] +mod tests { + use super::looks_like_hosting_id; + + #[test] + fn hosting_id_shape() { + assert!(looks_like_hosting_id( + "01a0f15b-b22e-750c-b871-6b348ee5ad1c" + )); + assert!(!looks_like_hosting_id("example.cz")); + assert!(!looks_like_hosting_id("3 hostings")); + assert!(!looks_like_hosting_id( + "01a0f15b-b22e-750c-b871-6b348ee5ad1" + )); + assert!(!looks_like_hosting_id( + "01a0f15bxb22e-750c-b871-6b348ee5ad1c" + )); + } +} diff --git a/crates/hyperion-types/src/jobs.rs b/crates/hyperion-types/src/jobs.rs index 0eb2405a..71471a84 100644 --- a/crates/hyperion-types/src/jobs.rs +++ b/crates/hyperion-types/src/jobs.rs @@ -74,6 +74,11 @@ impl JobView { "profile_apply", "wp_install", ]; + // A finished delete has nothing left to go back to — the link + // would land on a 404. + if self.kind == "hosting_delete" && self.state == "done" { + return None; + } match &self.target { Some(t) if HOSTING_KINDS.contains(&self.kind.as_str()) => Some(t.as_str()), _ => None, @@ -319,3 +324,23 @@ pub struct QuotaEnableSummary { /// Human-readable result / next-step message. pub message: String, } + +#[cfg(test)] +mod tests { + use super::JobView; + + #[test] + fn finished_delete_has_no_back_link() { + let mut j = JobView { + kind: "hosting_delete".into(), + target: Some("example.cz".into()), + state: "running".into(), + ..Default::default() + }; + assert_eq!(j.hosting_target(), Some("example.cz")); + j.state = "failed".into(); + assert_eq!(j.hosting_target(), Some("example.cz")); + j.state = "done".into(); + assert_eq!(j.hosting_target(), None); + } +}