1use std::borrow::Cow;
7use std::fmt;
8
9use super::{Job, JobState, JobSummary, Sample, SampleSeries, Step, Timeline};
10use crate::api::{QueuedJob, Record};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum OutcomeKind {
15 Succeeded,
17 Failed,
19 Running,
21 Waiting,
23 Ended,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Outcome {
30 pub label: Cow<'static, str>,
32 pub kind: OutcomeKind,
34}
35
36impl Outcome {
37 fn new(label: &'static str, kind: OutcomeKind) -> Outcome {
38 Outcome {
39 label: Cow::Borrowed(label),
40 kind,
41 }
42 }
43
44 fn concluded(conclusion: &str) -> Outcome {
46 let kind = match conclusion {
47 "success" => OutcomeKind::Succeeded,
48 failed if failed_conclusion(failed) => OutcomeKind::Failed,
49 _ => OutcomeKind::Ended,
50 };
51 Outcome {
52 label: Cow::Owned(conclusion.replace('_', " ")),
53 kind,
54 }
55 }
56}
57
58fn failed_conclusion(conclusion: &str) -> bool {
60 matches!(conclusion, "failure" | "timed_out")
61}
62
63impl JobSummary {
64 pub fn is_failure(&self) -> bool {
67 match self.state {
68 JobState::Lost => true,
69 JobState::Completed => self.conclusion.as_deref().is_some_and(failed_conclusion),
70 JobState::Queued | JobState::Running | JobState::Elsewhere => false,
71 }
72 }
73
74 pub fn outcome(&self) -> Outcome {
78 let started = self.timeline.started_at.is_some();
79 match (self.state, self.conclusion.as_deref()) {
80 (JobState::Queued, _) => Outcome::new("queued", OutcomeKind::Waiting),
81 (JobState::Running, _) => Outcome::new("running", OutcomeKind::Running),
82 (JobState::Lost, _) => Outcome::new("lost", OutcomeKind::Failed),
83 (JobState::Elsewhere, Some("cancelled")) if !started => {
84 Outcome::new("cancelled in queue", OutcomeKind::Ended)
85 }
86 (JobState::Elsewhere, Some(conclusion)) if !started => Outcome {
87 kind: OutcomeKind::Ended,
88 ..Outcome::concluded(conclusion)
89 },
90 (JobState::Elsewhere, _) => Outcome::new("elsewhere", OutcomeKind::Ended),
91 (JobState::Completed, None) => Outcome::new("completed", OutcomeKind::Ended),
92 (JobState::Completed, Some(conclusion)) => Outcome::concluded(conclusion),
93 }
94 }
95
96 pub fn waited(&self, now: u64) -> Option<u64> {
99 let end = match self.timeline.started_at {
100 Some(started) => started,
101 None if !self.state.is_final() => now,
102 None => return None,
103 };
104 Some(end.saturating_sub(self.timeline.queued_at))
105 }
106
107 pub fn ran(&self, now: u64) -> Option<u64> {
110 let started = self.timeline.started_at?;
111 let end = match self.timeline.finished_at {
112 Some(finished) => finished,
113 None if !self.state.is_final() => now,
114 None => return None,
115 };
116 Some(end.saturating_sub(started))
117 }
118
119 pub fn phases(&self) -> Vec<Phase> {
123 let timeline = &self.timeline;
124 let open = !self.state.is_final();
125 let until = |end: Option<u64>| match end {
127 Some(end) => Some(Some(end)),
128 None if open => Some(None),
129 None => timeline.finished_at.map(Some),
130 };
131 let runner = timeline.runner_from();
132 let mut phases = Vec::new();
133 if let Some(to) = until(runner.or(timeline.started_at)) {
134 phases.push(Phase {
135 kind: PhaseKind::Waiting,
136 from: timeline.queued_at,
137 to,
138 });
139 }
140 if let Some(from) = runner
141 && let Some(to) = until(timeline.started_at)
142 {
143 phases.push(Phase {
144 kind: PhaseKind::Starting,
145 from,
146 to,
147 });
148 }
149 if let Some(from) = timeline.started_at
150 && let Some(to) = timeline.finished_at.map(Some).or(open.then_some(None))
151 {
152 phases.push(Phase {
153 kind: PhaseKind::Running,
154 from,
155 to,
156 });
157 }
158 if let Some(from) = timeline.finished_at
159 && runner.is_some()
160 {
161 phases.push(Phase {
162 kind: PhaseKind::CleaningUp,
163 from,
164 to: timeline.cleaned_at,
165 });
166 }
167 phases
168 }
169
170 pub fn title(&self) -> JobTitle<'_> {
172 job_title(self.workflow.as_deref(), &self.name, Some(self.id))
173 }
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub struct Phase {
179 pub kind: PhaseKind,
181 pub from: u64,
183 pub to: Option<u64>,
185}
186
187impl Phase {
188 pub fn seconds(&self, now: u64) -> u64 {
190 self.to.unwrap_or(now).saturating_sub(self.from)
191 }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum PhaseKind {
197 Waiting,
199 Starting,
201 Running,
203 CleaningUp,
205}
206
207impl PhaseKind {
208 pub fn label(self) -> &'static str {
210 match self {
211 PhaseKind::Waiting => "Waiting for a runner",
212 PhaseKind::Starting => "Runner starting",
213 PhaseKind::Running => "Running",
214 PhaseKind::CleaningUp => "Cleaning up",
215 }
216 }
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub struct Milestone {
222 pub label: &'static str,
224 pub at: u64,
226}
227
228impl Timeline {
229 pub fn milestones(&self) -> Vec<Milestone> {
231 let mut milestones: Vec<Milestone> = [
232 ("Queued", Some(self.queued_at)),
233 ("Container started", self.container_started_at),
234 ("Job started", self.started_at),
235 ("Finished", self.finished_at),
236 ("Cleaned up", self.cleaned_at),
237 ]
238 .into_iter()
239 .filter_map(|(label, at)| Some(Milestone { label, at: at? }))
240 .collect();
241 milestones.sort_by_key(|milestone| milestone.at);
242 milestones
243 }
244}
245
246impl Step {
247 pub fn outcome(&self) -> Outcome {
250 match (self.conclusion.as_deref(), self.status.as_str()) {
251 (Some(conclusion), _) => Outcome::concluded(conclusion),
252 (None, "in_progress") => Outcome::new("running", OutcomeKind::Running),
253 (None, "completed") => Outcome::new("completed", OutcomeKind::Ended),
254 (None, _) => Outcome::new("pending", OutcomeKind::Waiting),
255 }
256 }
257
258 pub fn is_running(&self) -> bool {
260 self.status == "in_progress" && self.conclusion.is_none()
261 }
262
263 pub fn took(&self, now: u64) -> Option<u64> {
266 let started = self.started_at?;
267 let end = match self.completed_at {
268 Some(completed) => completed,
269 None if self.is_running() => now,
270 None => return None,
271 };
272 Some(end.saturating_sub(started))
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Eq)]
278pub struct JobTitle<'job> {
279 pub workflow: Option<&'job str>,
281 pub name: Cow<'job, str>,
283}
284
285pub fn job_title<'job>(
288 workflow: Option<&'job str>,
289 name: &'job str,
290 id: Option<u64>,
291) -> JobTitle<'job> {
292 let name = match (name, id) {
293 ("", Some(id)) => Cow::Owned(format!("job {id}")),
294 ("", None) => Cow::Borrowed("a job"),
295 (name, _) => Cow::Borrowed(name),
296 };
297 JobTitle { workflow, name }
298}
299
300impl fmt::Display for JobTitle<'_> {
301 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
302 match self.workflow {
303 Some(workflow) => write!(f, "{workflow} / {}", self.name),
304 None => f.write_str(&self.name),
305 }
306 }
307}
308
309impl QueuedJob {
310 pub fn title(&self) -> JobTitle<'_> {
312 job_title(self.workflow.as_deref(), &self.name, Some(self.id))
313 }
314}
315
316impl Record {
317 pub fn title(&self) -> JobTitle<'_> {
319 job_title(
320 self.workflow.as_deref(),
321 self.name.as_deref().unwrap_or_default(),
322 Some(self.job),
323 )
324 }
325}
326
327impl Job {
328 pub fn observe(&mut self, sample: &Sample) {
331 let metrics = &mut self.summary.metrics;
332 if !self.summary.state.is_final() {
333 metrics.memory_peak = metrics.memory_peak.max(Some(sample.memory));
334 metrics.cpu_peak = metrics.cpu_peak.max(Some(sample.cpu));
335 }
336 }
337
338 pub fn reread(&mut self, read: Job) {
341 let seen = self.summary.metrics.clone();
342 *self = read;
343 if !self.summary.state.is_final() {
344 let metrics = &mut self.summary.metrics;
345 metrics.memory_peak = metrics.memory_peak.max(seen.memory_peak);
346 metrics.cpu_peak = metrics.cpu_peak.max(seen.cpu_peak);
347 }
348 }
349
350 pub fn compare(&self, runs: &[JobSummary], now: u64) -> RunComparison {
353 let runs: Vec<JobSummary> = runs
354 .iter()
355 .map(|run| {
356 if run.id == self.summary.id {
357 self.summary.clone()
358 } else {
359 run.clone()
360 }
361 })
362 .collect();
363 let finished: Vec<&JobSummary> = runs
365 .iter()
366 .filter(|run| {
367 matches!(run.state, JobState::Completed | JobState::Lost)
368 && run.timeline.started_at.is_some()
369 })
370 .collect();
371 let median = |mut values: Vec<u64>| {
373 values.sort_unstable();
374 values.get(values.len().saturating_sub(1) / 2).copied()
375 };
376 RunComparison {
377 median_ran: median(finished.iter().filter_map(|run| run.ran(now)).collect()),
378 median_waited: median(finished.iter().filter_map(|run| run.waited(now)).collect()),
379 finished: finished.len(),
380 failed: finished.iter().filter(|run| run.is_failure()).count(),
381 largest_peak: runs.iter().filter_map(|run| run.metrics.memory_peak).max(),
382 runs,
383 }
384 }
385}
386
387#[derive(Debug, Clone, PartialEq, Eq)]
389pub struct RunComparison {
390 pub runs: Vec<JobSummary>,
392 pub median_ran: Option<u64>,
394 pub median_waited: Option<u64>,
396 pub finished: usize,
398 pub failed: usize,
400 pub largest_peak: Option<u64>,
402}
403
404impl SampleSeries {
405 pub fn usage_between(&self, from: u64, to: u64) -> Option<SampleUsage> {
408 let inside: Vec<&Sample> = self
409 .samples
410 .iter()
411 .filter(|sample| from < sample.at && sample.at <= to)
412 .collect();
413 let count = u64::try_from(inside.len())
414 .ok()
415 .filter(|count| *count > 0)?;
416 Some(SampleUsage {
417 memory_peak: inside.iter().map(|sample| sample.memory).max()?,
418 cpu_average: inside.iter().map(|sample| sample.cpu).sum::<u64>() / count,
419 })
420 }
421}
422
423#[derive(Debug, Clone, Copy, PartialEq, Eq)]
425pub struct SampleUsage {
426 pub memory_peak: u64,
428 pub cpu_average: u64,
430}
431
432impl SampleUsage {
433 pub fn memory(&self) -> String {
435 format!("peak {}", crate::config::ByteSize(self.memory_peak))
436 }
437
438 pub fn cpu(&self) -> String {
440 format!("average {}", super::super::in_cores(self.cpu_average))
441 }
442
443 pub fn description(&self) -> String {
445 format!("{}, {}", self.memory(), self.cpu())
446 }
447}
448
449#[cfg(test)]
450mod tests;