Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 80 additions & 4 deletions bin/hyperion-web/src/handlers/jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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(
Expand All @@ -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);
Expand Down Expand Up @@ -407,7 +409,7 @@ pub async fn get_job_progress(
Html("<div class=\"text-soft\">admin role required</div>".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(
Expand All @@ -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);
Expand Down Expand Up @@ -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<String>) {
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 {
Expand Down Expand Up @@ -559,11 +608,18 @@ where
F: FnOnce(JobReporter) -> Fut + Send + 'static,
Fut: std::future::Future<Output = ()> + 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,
Expand All @@ -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"
));
}
}
25 changes: 25 additions & 0 deletions crates/hyperion-types/src/jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
}