conductor

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

pages.js (21646B)


      1 // src/conductor/ui/pages.js - the pages and the fragments they poll
      2 //
      3 // Each live region follows the same htmx pattern: the fragment carries its
      4 // own polling attribute only while there is something left to happen. When
      5 // the job or task finishes, the replacement fragment has no trigger, so
      6 // polling stops by itself rather than needing to be cancelled.
      7 
      8 import { html, raw } from './html.js';
      9 import { badge, notice, oneTimeSecret, shortSha, ago, duration, ACTIVE_JOB_STATES, ACTIVE_TASK_STATES } from './layout.js';
     10 
     11 const poll = (url, seconds = 3) => raw(`hx-get="${url}" hx-trigger="every ${seconds}s" hx-swap="outerHTML"`);
     12 
     13 // --- jobs ---
     14 
     15 export function jobsPage({ jobs, user, anonymous }) {
     16   return html`
     17     <h2>Jobs</h2>
     18     ${anonymous ? html`<p class="muted">Showing public jobs. <a href="/login">Sign in</a> to see your own.</p>` : ''}
     19     ${jobsTable(jobs)}
     20   `;
     21 }
     22 
     23 export function jobsTable(jobs) {
     24   const live = jobs.some((r) => ACTIVE_JOB_STATES.includes(r.state));
     25   return html`<div id="jobs" ${live ? poll('/partials/jobs', 4) : ''}>
     26     <table>
     27       <thead><tr>
     28         <th>job</th><th>project</th><th>ref</th><th>commit</th><th>state</th><th>started</th>
     29       </tr></thead>
     30       <tbody>
     31         ${jobs.length === 0
     32     ? html`<tr><td colspan="6" class="muted">No jobs to show.</td></tr>`
     33     : jobs.map((job) => html`<tr>
     34             <td><a href="/jobs/${job.id}">#${job.number}</a></td>
     35             <td>${job.project_id}</td>
     36             <td class="muted">${(job.ref ?? '').replace('refs/heads/', '')}</td>
     37             <td class="mono muted">${shortSha(job.head_sha)}</td>
     38             <td>${badge(job.state)}</td>
     39             <td class="muted">${ago(job.created_at)}</td>
     40           </tr>`)}
     41       </tbody>
     42     </table>
     43   </div>`;
     44 }
     45 
     46 export function jobPage({ job, tasks, canManage }) {
     47   return html`
     48     <div class="panel">
     49       <h2>${job.title || `Job #${job.number}`}</h2>
     50       <div class="row">
     51         ${field('project', html`<a href="/jobs?project=${job.project_id}">${job.project_id}</a>`)}
     52         ${field('state', badge(job.state))}
     53         ${field('ref', (job.ref ?? '').replace('refs/heads/', '') || '-')}
     54         ${field('commit', html`<span class="mono">${shortSha(job.head_sha)}</span>`)}
     55         ${field('trigger', `${job.trigger_type}${job.actor ? ` by ${job.actor}` : ''}`)}
     56         ${field('visibility', job.visibility)}
     57         ${field('duration', duration(job.started_at, job.finished_at))}
     58       </div>
     59       ${canManage ? html`<div class="actions">
     60         ${job.state === 'running'
     61     ? html`<button hx-post="/jobs/${job.id}/cancel" hx-target="#tasks" hx-swap="outerHTML">cancel</button>`
     62     : ''}
     63         <button hx-post="/jobs/${job.id}/retry" hx-swap="none">run again</button>
     64       </div>` : ''}
     65     </div>
     66     ${tasksTable(job, tasks)}
     67   `;
     68 }
     69 
     70 export function tasksTable(job, tasks) {
     71   const live = ACTIVE_JOB_STATES.includes(job.state);
     72 
     73   // Grouped by graph depth, which is how the pipeline reads.
     74   const byId = new Map(tasks.map((j) => [j.id, j]));
     75   const depthOf = (task, seen = new Set()) => {
     76     if (seen.has(task.id)) return 0;
     77     seen.add(task.id);
     78     const deps = (task.needs ?? []).map((id) => byId.get(id)).filter(Boolean);
     79     return deps.length === 0 ? 0 : Math.max(...deps.map((d) => depthOf(d, seen) + 1));
     80   };
     81 
     82   const stages = new Map();
     83   for (const task of tasks) {
     84     const depth = depthOf(task);
     85     if (!stages.has(depth)) stages.set(depth, []);
     86     stages.get(depth).push(task);
     87   }
     88 
     89   return html`<div id="tasks" ${live ? poll(`/partials/jobs/${job.id}/tasks`, 3) : ''}>
     90     ${[...stages.keys()].sort((a, b) => a - b).map((depth) => html`
     91       <div class="stage">
     92         <h3>stage ${depth + 1}</h3>
     93         <table><tbody>
     94           ${stages.get(depth).sort((a, b) => a.name.localeCompare(b.name)).map((task) => html`<tr>
     95             <td><a href="/tasks/${task.id}">${task.name}</a></td>
     96             <td>${badge(task.state)}</td>
     97             <td class="muted">${task.arch ?? ''}</td>
     98             <td class="muted">${task.worker_name ?? ''}</td>
     99             <td class="muted">${duration(task.started_at, task.finished_at)}</td>
    100             <td class="muted">${task.exit_code === null ? '' : `exit ${task.exit_code}`}</td>
    101           </tr>`)}
    102         </tbody></table>
    103       </div>`)}
    104   </div>`;
    105 }
    106 
    107 // --- tasks ---
    108 
    109 export function taskPage({ task, artifacts, log }) {
    110   return html`
    111     <div class="panel">
    112       <h2>${task.name}</h2>
    113       <div class="row">
    114         ${field('state', badge(task.state))}
    115         ${field('job', html`<a href="/jobs/${task.job_id}">back to job</a>`)}
    116         ${field('image', html`<span class="mono">${task.image}</span>`)}
    117         ${field('arch', task.arch ?? '-')}
    118         ${field('attempt', `${task.attempt} of ${task.max_attempts}`)}
    119         ${field('worker', task.worker_name ?? '-')}
    120         ${field('duration', duration(task.started_at, task.finished_at))}
    121       </div>
    122       ${task.error ? notice(task.error) : ''}
    123     </div>
    124 
    125     <h3>log</h3>
    126     <div class="logpane">${taskLog(task, log)}</div>
    127 
    128     ${artifacts.length === 0 ? '' : html`
    129       <h3>artifacts</h3>
    130       <table><tbody>
    131         ${artifacts.map((a) => html`<tr>
    132           <td><a href="/api/v1/projects/${task.project_id}/jobs/${task.job_id}/tasks/${task.id}/artifacts/${a.id}">${a.path}</a></td>
    133           <td class="muted">${a.size} bytes</td>
    134           <td class="mono muted">${a.sha256.slice(0, 12)}</td>
    135         </tr>`)}
    136       </tbody></table>`}
    137   `;
    138 }
    139 
    140 export function taskLog(task, log) {
    141   const live = ACTIVE_TASK_STATES.includes(task.state);
    142   return html`<pre id="log" class="log" ${live ? poll(`/partials/tasks/${task.id}/log`, 2) : ''}>${
    143     log || (live ? 'Waiting for output.' : 'No output.')
    144   }</pre>`;
    145 }
    146 
    147 // --- login ---
    148 
    149 export function loginPage({ error }) {
    150   return html`<div class="center">
    151     <div class="panel narrow">
    152       <h2>Sign in</h2>
    153       ${notice(error)}
    154       <form hx-post="/login" hx-target="body" hx-swap="outerHTML">
    155         <label>username<input name="username" autocomplete="username" required autofocus></label>
    156         <label>password<input name="password" type="password" autocomplete="current-password" required></label>
    157         <div class="actions"><button type="submit">sign in</button></div>
    158       </form>
    159     </div>
    160   </div>`;
    161 }
    162 
    163 // --- projects ---
    164 
    165 export function projectsPage({ projects, user }) {
    166   return html`
    167     <h2>Projects</h2>
    168 
    169     <div class="panel">
    170       <h3>Add a project</h3>
    171       <form hx-post="/projects" hx-target="body" hx-swap="none">
    172         <div class="grid">
    173           <label>id<input name="id" placeholder="my-project" required></label>
    174           <label>repository<input name="repo_url" placeholder="https://git.example.com/repo.git" required></label>
    175           <label>pipeline file<input name="config_path" value=".conductor.yml"></label>
    176           <label>visibility
    177             <select name="visibility">
    178               <option value="private">private</option>
    179               <option value="public">public</option>
    180             </select>
    181           </label>
    182         </div>
    183         <div class="actions"><button type="submit">create</button></div>
    184       </form>
    185     </div>
    186 
    187     ${projectsTable(projects, user)}
    188   `;
    189 }
    190 
    191 export function projectsTable(projects, user) {
    192   return html`<table id="projects">
    193     <thead><tr><th>project</th><th>repository</th><th>visibility</th><th>owner</th><th>jobs</th><th></th></tr></thead>
    194     <tbody>
    195       ${projects.length === 0
    196     ? html`<tr><td colspan="6" class="muted">No projects yet.</td></tr>`
    197     : projects.map((p) => html`<tr>
    198           <td><a href="/projects/${p.id}">${p.id}</a></td>
    199           <td class="muted mono">${p.repo_url}</td>
    200           <td>${badge(p.visibility === 'public' ? 'success' : 'skipped')}<span class="muted"> ${p.visibility}</span></td>
    201           <td class="muted">${p.owner_id === null ? 'shared' : (p.owner_id === user.id ? 'you' : p.owner_id)}</td>
    202           <td class="muted">${p.run_counter}</td>
    203           <td><a href="/projects/${p.id}">manage</a></td>
    204         </tr>`)}
    205     </tbody>
    206   </table>`;
    207 }
    208 
    209 export function projectPage({ project, variables, triggerUrl, secret, user, retention }) {
    210   return html`
    211     <h2>${project.id}</h2>
    212     ${secret ? oneTimeSecret(
    213     'Trigger secret',
    214     secret,
    215     'Set this as CONDUCTOR_SECRET in the post-receive hook. It is shown here once.'
    216   ) : ''}
    217 
    218     <div class="panel">
    219       <div class="row">
    220         ${field('repository', html`<span class="mono">${project.repo_url}</span>`)}
    221         ${field('pipeline', html`<span class="mono">${project.config_path}</span>`)}
    222 
    223         ${field('owner', project.owner_id === null ? 'shared' : (project.owner_id === user.id ? 'you' : project.owner_id))}
    224         ${field('jobs', project.run_counter)}
    225       </div>
    226       <p class="muted">Trigger endpoint</p>
    227       <input class="wide mono" type="text" readonly value="${triggerUrl}" onclick="this.select()">
    228     </div>
    229 
    230     <div class="panel">
    231       <h3>Settings</h3>
    232       <form hx-patch="/projects/${project.id}" hx-target="body" hx-swap="none">
    233         <div class="grid">
    234           <label>visibility
    235             <select name="visibility">
    236               <option value="private" ${project.visibility === 'private' ? 'selected' : ''}>private</option>
    237               <option value="public" ${project.visibility === 'public' ? 'selected' : ''}>public</option>
    238             </select>
    239           </label>
    240           <label>enabled
    241             <select name="enabled">
    242               <option value="true" ${project.enabled === 1 ? 'selected' : ''}>yes</option>
    243               <option value="false" ${project.enabled === 1 ? '' : 'selected'}>no</option>
    244             </select>
    245           </label>
    246           <label>working directory
    247             <input name="workdir" class="mono" placeholder="${retention.workdir}"
    248                    value="${project.workdir ?? ''}">
    249           </label>
    250         </div>
    251         <p class="muted">
    252           A pipeline may override visibility per commit with a top level
    253           visibility key, and the working directory with a workdir key.
    254           Left empty, tasks run in ${retention.workdir}.
    255         </p>
    256         <div class="actions"><button type="submit">save</button></div>
    257       </form>
    258     </div>
    259 
    260     <div class="panel">
    261       <h3>Retention</h3>
    262       <p class="muted">
    263         Leave a field empty to follow the server default, shown as the
    264         placeholder. Zero keeps things forever.
    265       </p>
    266       <form hx-patch="/projects/${project.id}/retention" hx-target="body" hx-swap="none">
    267         <div class="grid">
    268           <label>keep artifacts for jobs
    269             <input name="artifact_keep_jobs" type="number" min="0" inputmode="numeric"
    270                    placeholder="${retention.artifact_keep_jobs}"
    271                    value="${project.artifact_keep_jobs ?? ''}">
    272           </label>
    273           <label>keep artifacts for days
    274             <input name="artifact_keep_days" type="number" min="0" inputmode="numeric"
    275                    placeholder="${retention.artifact_keep_days}"
    276                    value="${project.artifact_keep_days ?? ''}">
    277           </label>
    278           <label>keep logs for days
    279             <input name="log_keep_days" type="number" min="0" inputmode="numeric"
    280                    placeholder="${retention.log_keep_days}"
    281                    value="${project.log_keep_days ?? ''}">
    282           </label>
    283         </div>
    284         <p class="muted">
    285           An artifact is kept if either rule wants it, so the last
    286           ${project.artifact_keep_jobs ?? retention.artifact_keep_jobs} jobs survive
    287           whatever their age. The most recent successful job is always kept. A task
    288           that sets artifacts.expire overrides all of this.
    289         </p>
    290         <div class="actions"><button type="submit">save</button></div>
    291       </form>
    292     </div>
    293 
    294     <div class="panel">
    295       <h3>Variables</h3>
    296       <p class="muted">Injected into every task. Masked values are redacted from logs.</p>
    297       ${variablesTable(project, variables)}
    298       <form hx-put="/projects/${project.id}/variables" hx-target="#variables" hx-swap="outerHTML">
    299         <div class="grid">
    300           <label>name<input name="name" placeholder="DEPLOY_TOKEN" required></label>
    301           <label>value<input name="value" type="password" required></label>
    302           <label>masked
    303             <select name="masked"><option value="true">yes</option><option value="false">no</option></select>
    304           </label>
    305         </div>
    306         <div class="actions"><button type="submit">set</button></div>
    307       </form>
    308     </div>
    309 
    310     <div class="panel">
    311       <h3>Danger</h3>
    312       <div class="actions">
    313         <button hx-post="/projects/${project.id}/trigger-secret" hx-target="body" hx-swap="none">
    314           rotate trigger secret
    315         </button>
    316         <button class="danger" onclick="setupDeleteProjectDialog('${project.id}')">delete project</button>
    317       </div>
    318     </div>
    319 
    320     <dialog id="deleteProjectDialog">
    321       <p>Delete "<span id="deleteProjectId"></span>" and all of its jobs?</p>
    322       <div class="actions">
    323         <button type="button" id="deleteProjectConfirm" class="danger">delete project</button>
    324         <button type="button" onclick="this.closest('dialog').close()">cancel</button>
    325       </div>
    326     </dialog>
    327 
    328     <script>
    329     function setupDeleteProjectDialog(id) {
    330       document.getElementById('deleteProjectId').textContent = id;
    331       document.getElementById('deleteProjectConfirm').onclick = function() {
    332         htmx.ajax('DELETE', '/projects/' + id, { target: 'body', swap: 'none' });
    333         document.getElementById('deleteProjectDialog').close();
    334       };
    335       document.getElementById('deleteProjectDialog').showModal();
    336     }
    337     </script>
    338   `;
    339 }
    340 
    341 export function variablesTable(project, variables) {
    342   return html`<table id="variables"><tbody>
    343     ${variables.length === 0
    344     ? html`<tr><td class="muted">None.</td></tr>`
    345     : variables.map((v) => html`<tr>
    346         <td class="mono">${v.name}</td>
    347         <td class="muted">${v.masked ? 'masked' : 'visible'}</td>
    348         <td class="muted">${v.plaintext_at_rest ? 'stored in the clear' : 'encrypted'}</td>
    349         <td><button class="link"
    350           hx-delete="/projects/${project.id}/variables/${encodeURIComponent(v.name)}"
    351           hx-target="#variables" hx-swap="outerHTML">remove</button></td>
    352       </tr>`)}
    353   </tbody></table>`;
    354 }
    355 
    356 // --- workers ---
    357 
    358 export function workersPage({ tokens, user, created }) {
    359   return html`
    360     <h2>Workers</h2>
    361     <p class="muted">
    362       A worker you register only ever receives tasks from your own projects.
    363       ${user.role === 'admin' ? 'A shared worker receives tasks from any project.' : ''}
    364     </p>
    365     ${created ? oneTimeSecret(
    366     `Worker token for ${created.name}`,
    367     created.token,
    368     'Put this in the worker configuration. It is shown once and stored only as a hash.'
    369   ) : ''}
    370 
    371     <div class="panel">
    372       <h3>Register a worker</h3>
    373       <form hx-post="/workers" hx-target="body" hx-swap="outerHTML">
    374         <div class="grid">
    375           <label>name<input name="name" placeholder="my-laptop" required></label>
    376           ${user.role === 'admin' ? html`<label>shared
    377             <select name="shared"><option value="false">no, mine</option><option value="true">yes, any project</option></select>
    378           </label>` : ''}
    379         </div>
    380         <div class="actions"><button type="submit">create token</button></div>
    381       </form>
    382     </div>
    383 
    384     ${workersTable(tokens, user)}
    385 
    386     <dialog id="disableDialog">
    387       <p>Disable worker "<span id="disableWorkerName"></span>"?</p>
    388       <div class="actions">
    389         <button type="button" id="disableConfirm" class="danger">disable</button>
    390         <button type="button" onclick="this.closest('dialog').close()">cancel</button>
    391       </div>
    392     </dialog>
    393 
    394     <dialog id="removeDialog">
    395       <p>Remove worker "<span id="removeWorkerName"></span>"?</p>
    396       <div class="actions">
    397         <button type="button" id="removeConfirm" class="danger">remove</button>
    398         <button type="button" onclick="this.closest('dialog').close()">cancel</button>
    399       </div>
    400     </dialog>
    401 
    402     <script>
    403     function setupDisableDialog(id, name) {
    404       document.getElementById('disableWorkerName').textContent = name;
    405       document.getElementById('disableConfirm').onclick = function() {
    406         htmx.ajax('PATCH', '/workers/' + id, { target: '#workers', swap: 'outerHTML', values: { enabled: false } });
    407         document.getElementById('disableDialog').close();
    408       };
    409       document.getElementById('disableDialog').showModal();
    410     }
    411     function setupRemoveDialog(id, name) {
    412       document.getElementById('removeWorkerName').textContent = name;
    413       document.getElementById('removeConfirm').onclick = function() {
    414         htmx.ajax('DELETE', '/workers/' + id, { target: '#workers', swap: 'outerHTML' });
    415         document.getElementById('removeDialog').close();
    416       };
    417       document.getElementById('removeDialog').showModal();
    418     }
    419     </script>
    420   `;
    421 }
    422 
    423 export function workersTable(tokens, user) {
    424   return html`<table id="workers">
    425     <thead><tr><th>name</th><th>scope</th><th>state</th><th>last seen</th><th>address</th><th></th></tr></thead>
    426     <tbody>
    427       ${tokens.length === 0
    428     ? html`<tr><td colspan="6" class="muted">No workers registered.</td></tr>`
    429     : tokens.map((t) => html`<tr>
    430           <td>${t.name}</td>
    431           <td class="muted">${t.owner_id === null ? 'shared' : (t.owner_id === user.id ? 'yours' : t.owner_id)}</td>
    432           <td>${t.enabled === 1 ? badge('success') : badge('skipped')}</td>
    433           <td class="muted">${t.last_seen_at ? ago(t.last_seen_at) : 'never'}</td>
    434           <td class="muted mono">${t.last_ip ?? ''}</td>
    435           <td class="row-actions">
    436             ${t.enabled === 1
    437       ? html`<button class="link" data-worker-id="${t.id}" data-worker-name="${t.name}" onclick="setupDisableDialog(this.dataset.workerId, this.dataset.workerName)">disable</button>`
    438       : html`<button class="link" hx-patch="/workers/${t.id}" hx-vals='{"enabled": true}' hx-target="#workers" hx-swap="outerHTML">enable</button>`}
    439             <button class="link danger" data-worker-id="${t.id}" data-worker-name="${t.name}" onclick="setupRemoveDialog(this.dataset.workerId, this.dataset.workerName)">remove</button>
    440           </td>
    441         </tr>`)}
    442     </tbody>
    443   </table>`;
    444 }
    445 
    446 // --- users ---
    447 
    448 export function usersPage({ users, localLogin, error }) {
    449   return html`
    450     <h2>Users</h2>
    451     ${localLogin ? '' : html`<p class="muted">
    452       Authentication goes through the identity provider, so these accounts are inactive.
    453     </p>`}
    454     ${error ? notice(error) : ''}
    455 
    456     <div class="panel">
    457       <h3>Add a user</h3>
    458       <form hx-post="/users" hx-target="body" hx-swap="outerHTML">
    459         <div class="grid">
    460           <label>username<input name="username" required></label>
    461           <label>password<input name="password" type="password" required></label>
    462           <label>role
    463             <select name="role"><option value="user">user</option><option value="admin">admin</option></select>
    464           </label>
    465         </div>
    466         <div class="actions"><button type="submit">create</button></div>
    467       </form>
    468     </div>
    469 
    470     ${usersTable(users)}
    471 
    472     <dialog id="removeUserDialog">
    473       <p id="removeUserMessage"></p>
    474       <div class="actions">
    475         <button type="button" id="removeUserConfirm" class="danger">remove</button>
    476         <button type="button" onclick="this.closest('dialog').close()">cancel</button>
    477       </div>
    478     </dialog>
    479 
    480     <script>
    481     function setupRemoveUserDialog(id, message) {
    482       document.getElementById('removeUserMessage').textContent = message;
    483       document.getElementById('removeUserConfirm').onclick = function() {
    484         htmx.ajax('DELETE', '/users/' + id, { target: '#users', swap: 'outerHTML' });
    485         document.getElementById('removeUserDialog').close();
    486       };
    487       document.getElementById('removeUserDialog').showModal();
    488     }
    489     </script>
    490   `;
    491 }
    492 
    493 export function usersTable(users) {
    494   return html`<table id="users">
    495     <thead><tr>
    496       <th>username</th><th>role</th><th>state</th><th>owns</th><th>last login</th><th></th>
    497     </tr></thead>
    498     <tbody>
    499       ${users.map((u) => html`<tr>
    500         <td>${u.username}</td>
    501         <td>${u.role}</td>
    502         <td>${u.disabled === 1 ? badge('skipped') : badge('success')}</td>
    503         <td class="muted">${u.project_count} project(s), ${u.worker_count} worker(s)</td>
    504         <td class="muted">${u.last_login_at ? ago(u.last_login_at) : 'never'}</td>
    505         <td class="row-actions">
    506           <button class="link" hx-patch="/users/${u.id}" hx-vals='{"disabled": ${u.disabled === 1 ? 'false' : 'true'}}'
    507             hx-target="#users" hx-swap="outerHTML">${u.disabled === 1 ? 'enable' : 'disable'}</button>
    508           <button class="link danger" onclick="setupRemoveUserDialog('${u.id}', ${JSON.stringify(deleteWarning(u))})">remove</button>
    509         </td>
    510       </tr>`)}
    511     </tbody>
    512   </table>`;
    513 }
    514 
    515 // Removing an account takes everything it owns with it, so the
    516 // confirmation says so plainly rather than asking a vague question.
    517 function deleteWarning(user) {
    518   if (user.project_count === 0 && user.worker_count === 0) {
    519     return `Remove ${user.username}? They own nothing.`;
    520   }
    521   const parts = [];
    522   if (user.project_count > 0) {
    523     parts.push(`${user.project_count} project(s) and all of their job history`);
    524   }
    525   if (user.worker_count > 0) parts.push(`${user.worker_count} worker token(s)`);
    526   return `Remove ${user.username}? This also deletes ${parts.join(' and ')}. This cannot be undone.`;
    527 }
    528 
    529 function field(label, value) {
    530   return html`<div><span class="label">${label}</span><span>${value}</span></div>`;
    531 }