conductor

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

pages.js (26102B)


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