conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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 };