From 02e2bf895e2b6bb696821b4ad254ea80a64aaa6c Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 23 Jul 2026 15:20:29 +0200 Subject: [PATCH] refactor(#2591): DagView carries host-computed timestamps + NodeView.has_log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconcile with mara + argus's review on the frontend PR (#2660): - DagView regains started_at/finished_at (DateTime), computed host-side as min/max over ALL subtree nodes (including the Done ones filtered off the wire). The client can't derive these — the earliest/only-started node is often Done and absent — so the backend sets them, per mara's call. - NodeView gains has_log: bool = build_log_id.is_some(), the precise old 'node has a captured build log' guard so the dashboard only shows a log link for nodes that actually produce one. --- hive-c0re/src/job_queue/mod.rs | 17 +++++++++++++++++ hive-sh4re/src/jobs.rs | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/hive-c0re/src/job_queue/mod.rs b/hive-c0re/src/job_queue/mod.rs index 4e4f0021..304243c9 100644 --- a/hive-c0re/src/job_queue/mod.rs +++ b/hive-c0re/src/job_queue/mod.rs @@ -38,6 +38,7 @@ mod tests; use std::collections::HashMap; use std::sync::Mutex; +use chrono::{DateTime, Utc}; use hive_jobq::resources::ResourceTable; use hive_jobq::scheduler::{Outcome, Scheduler}; use hive_jobq::{Dep, DepWhen as JobDepWhen, Graph, NodeId, State as JobState}; @@ -758,10 +759,21 @@ impl QueueInner { fn dag_view(&self, container: NodeId) -> Option { let meta = self.dag_meta(container)?; let mut nodes = Vec::new(); + // DAG-level timestamps are taken over *all* subtree nodes (including the + // `Done` ones excluded from the wire) — the client can't derive them + // from a `Done`-filtered node set, so the host computes them here. + let mut started: Vec> = Vec::new(); + let mut finished: Vec> = Vec::new(); for id in self.subtree(container) { let Some(node) = self.sched.graph().node(id) else { continue; }; + if let Some(s) = node.started_at { + started.push(s); + } + if let Some(f) = node.finished_at { + finished.push(f); + } if node.state == JobState::Done { continue; } @@ -782,6 +794,7 @@ impl QueueInner { } else { Vec::new() }; + let has_log = self.node_rt.get(&id).and_then(|r| r.build_log_id).is_some(); nodes.push(NodeView { id: id.get(), agent: node.payload.agent().to_owned(), @@ -793,16 +806,20 @@ impl QueueInner { error: node.error.clone(), approval_id, inputs, + has_log, }); } if nodes.is_empty() { return None; } + let is_terminal = self.dag_is_terminal(container); Some(DagView { id: container.get(), source: meta.source, reason: meta.reason.clone(), created_at: hive_sh4re::wire_time::from_secs(meta.created_at), + started_at: started.into_iter().min(), + finished_at: is_terminal.then(|| finished.into_iter().max()).flatten(), nodes, }) } diff --git a/hive-sh4re/src/jobs.rs b/hive-sh4re/src/jobs.rs index caf2fc04..a3075977 100644 --- a/hive-sh4re/src/jobs.rs +++ b/hive-sh4re/src/jobs.rs @@ -121,6 +121,12 @@ pub struct NodeView { /// node. Display-only payload, not derivable from the graph. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub inputs: Vec, + /// Whether this node has a captured build log fetchable at + /// `GET /api/build-log/`. Only the nix-heavy nodes that stream build + /// output set one; the client gates its log link on this so lock / noop / + /// store-only nodes don't render a link that 404s. + #[serde(default)] + pub has_log: bool, } /// A queued / running / failed DAG — a thin projection of one container @@ -137,6 +143,17 @@ pub struct DagView { pub reason: String, /// When the DAG was enqueued. pub created_at: DateTime, + /// When the DAG's first node started (min over *all* its nodes) — computed + /// host-side, **not** derived on the client: `Done` nodes are excluded from + /// `nodes` below, so the earliest-started node is usually absent from the + /// wire and the client can't take the min itself. `None` until a node runs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at: Option>, + /// When the DAG finished (max `finished_at` over all its nodes), set only + /// once the DAG has settled terminal. Host-computed for the same reason as + /// `started_at`. `None` while the DAG is still live. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub finished_at: Option>, /// Nodes of this DAG with `Done` ones excluded. A DAG whose nodes are /// all `Done` is omitted from the snapshot entirely; a `Failed` DAG /// lingers until aged out by the history cap.