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 }