job.tsx (4878B)
1 // web/src/screen/job.tsx - one job, and the stage graph of its tasks 2 3 import m from 'mithril'; 4 import { get, post, ApiError } from '../util/api'; 5 import { poll } from '../util/poll'; 6 import { settled, duration, shortSha, stamp } from '../util/format'; 7 import { Badge, Failed, Gone, Loading, When, session } from '../component/layout'; 8 9 type Task = { 10 id: string; name: string; base_name: string; state: string; arch: string | null; 11 attempt: number; exit_code: number | null; error: string | null; worker_name: string | null; 12 created_at: number; started_at: number | null; finished_at: number | null; 13 }; 14 15 type Dep = { task_id: string; depends_on_id: string; kind: string }; 16 17 // The longest path to each task, so tasks that can run together line up. 18 // Memoised per task rather than sharing one visited set, which would let a 19 // diamond settle at whichever depth it was reached by first. 20 function stages(tasks: Task[], deps: Dep[]): Task[][] { 21 const incoming = new Map<string, string[]>(); 22 for (const task of tasks) incoming.set(task.id, []); 23 for (const dep of deps) incoming.get(dep.task_id)?.push(dep.depends_on_id); 24 25 const depth = new Map<string, number>(); 26 const measure = (id: string, seen: Set<string>): number => { 27 if (depth.has(id)) return depth.get(id) as number; 28 if (seen.has(id)) return 0; 29 seen.add(id); 30 31 let best = 0; 32 for (const parent of incoming.get(id) ?? []) { 33 if (!incoming.has(parent)) continue; 34 best = Math.max(best, measure(parent, seen) + 1); 35 } 36 seen.delete(id); 37 depth.set(id, best); 38 return best; 39 }; 40 41 const out: Task[][] = []; 42 for (const task of tasks) { 43 const at = measure(task.id, new Set()); 44 (out[at] ??= []).push(task); 45 } 46 return out.filter(Boolean); 47 } 48 49 export const Job: m.Component = { 50 oninit(vnode: any) { 51 const id = m.route.param('job'); 52 vnode.state.job = null; 53 vnode.state.stages = []; 54 vnode.state.error = null; 55 vnode.state.gone = false; 56 vnode.state.busy = false; 57 58 vnode.state.load = async () => { 59 try { 60 vnode.state.job = await get<any>(`/jobs/${id}`); 61 const graph = await get<{ tasks: Task[]; deps: Dep[] }>(`/jobs/${id}/tasks`, { limit: 200 }); 62 vnode.state.stages = stages(graph.tasks, graph.deps); 63 vnode.state.error = null; 64 } catch (e) { 65 if ((e as ApiError).status === 404) vnode.state.gone = true; 66 else vnode.state.error = e; 67 return false; 68 } 69 return !settled(vnode.state.job.state); 70 }; 71 72 vnode.state.poller = poll(vnode.state.load, 4000); 73 vnode.state.poller.start(); 74 }, 75 76 onremove: (vnode: any) => vnode.state.poller.stop(), 77 78 view(vnode: any) { 79 const { job, error, gone } = vnode.state; 80 if (gone) return <Gone what="job" />; 81 if (error) return <Failed error={error} />; 82 if (!job) return <Loading />; 83 84 const act = async (what: string) => { 85 vnode.state.busy = true; 86 try { 87 const res = await post<any>(`/jobs/${job.id}/${what}`); 88 if (res?.job_id) m.route.set(`/jobs/${res.job_id}`); 89 else await vnode.state.load(); 90 } catch (e) { 91 vnode.state.error = e; 92 } finally { 93 vnode.state.busy = false; 94 } 95 }; 96 97 return [ 98 <h1> 99 <m.route.Link href={`/projects/${job.project_id}`}>{job.project_id}</m.route.Link> 100 {` #${job.number}`} 101 <Badge state={job.state} /> 102 </h1>, 103 104 <dl class="facts"> 105 <dt>ref</dt><dd class="ref">{job.ref?.replace(/^refs\/heads\//, '')}</dd> 106 <dt>commit</dt><dd class="mono">{shortSha(job.head_sha)}</dd> 107 <dt>trigger</dt><dd>{job.trigger_type}{job.actor ? ` by ${job.actor}` : ''}</dd> 108 <dt>started</dt><dd>{stamp(job.started_at)}</dd> 109 <dt>took</dt><dd>{duration(job.started_at, job.finished_at)}</dd> 110 <dt>created</dt><dd><When at={job.created_at} /></dd> 111 </dl>, 112 113 job.error ? <div class="error"><p>{job.error}</p></div> : null, 114 115 session.user ? ( 116 <div class="actions"> 117 {job.state === 'running' 118 ? <button disabled={vnode.state.busy} onclick={() => act('cancel')}>cancel</button> 119 : null} 120 <button disabled={vnode.state.busy} onclick={() => act('retry')}>retry</button> 121 </div> 122 ) : null, 123 124 vnode.state.stages.map((stage: Task[], at: number) => ( 125 <section class="stage"> 126 <h2>{`stage ${at + 1}`}</h2> 127 <div class="tasks"> 128 {stage.map((task) => ( 129 <m.route.Link href={`/tasks/${task.id}`} class={`task s-${task.state}`} key={task.id}> 130 <span class="name">{task.name}</span> 131 <Badge state={task.state} /> 132 <span class="muted">{duration(task.started_at, task.finished_at)}</span> 133 </m.route.Link> 134 ))} 135 </div> 136 </section> 137 )), 138 ]; 139 }, 140 };