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 }