Complete Track C roadmap: dashboard, job follow, actions, activity, roles
CI / test (push) Successful in 9m55s

Ship the modern dashboard, job tray log follow, container overflow actions, stacks search, activity history, role-gated controls, invite form with copy token, first-connect checklist, and mark Track C done on the roadmap.
This commit is contained in:
2026-07-10 22:31:16 -04:00
parent 2d8b24ec0e
commit a42f5e4dcf
7 changed files with 1350 additions and 523 deletions
+113 -6
View File
@@ -202,13 +202,48 @@ export async function runJob(kind, steps, meta = {}) {
}
}
/**
* True if the log element is scrolled near the bottom (user is "following").
* @param {HTMLElement|null} el
* @param {number} [thresholdPx=48]
*/
function isLogFollowing(el, thresholdPx = 48) {
if (!el) return true
return el.scrollHeight - el.scrollTop - el.clientHeight <= thresholdPx
}
/**
* Scroll job log to latest line (follow output).
* @param {HTMLElement|null} logEl
*/
export function scrollJobLogToBottom(logEl) {
if (!logEl) return
// Double rAF so layout has applied after innerHTML swap
requestAnimationFrame(() => {
requestAnimationFrame(() => {
logEl.scrollTop = logEl.scrollHeight
})
})
}
/**
* Render job into a DOM host element.
* Live log stays open while running and auto-scrolls to the latest lines
* unless the user has scrolled up to read history.
* @param {HTMLElement} host
* @param {Job} job
*/
export function renderJobPanel(host, job) {
if (!host || !job) return
const prevLog = host.querySelector('.job-log')
const prevDetails = host.querySelector('.job-log-details')
const wasFollowing = isLogFollowing(prevLog)
const keepDetailsOpen =
job.status === 'running' ||
job.status === 'error' ||
Boolean(prevDetails?.open)
const stepsHtml = job.steps
.map((s) => {
const icon =
@@ -229,16 +264,29 @@ export function renderJobPanel(host, job) {
</div>`
})
.join('')
const logHtml = job.log
.slice(-80)
.map((l) => `<div class="job-log-line job-log--${l.level || 'info'}">${escapeHtml(l.line)}</div>`)
// Show a generous tail; full length still reflected in summary count
const logTail = job.log.slice(-200)
const logHtml = logTail
.map((l) => {
const ts = l.t ? new Date(l.t).toLocaleTimeString() : ''
return `<div class="job-log-line job-log--${escapeHtml(l.level || 'info')}" data-t="${l.t || ''}">${
ts ? `<span class="job-log-ts">${escapeHtml(ts)}</span> ` : ''
}${escapeHtml(l.line)}</div>`
})
.join('')
const statusClass =
job.status === 'success' ? 'success' : job.status === 'error' ? 'danger' : 'primary'
const autoHint =
job.status === 'success'
? '<span class="job-auto-dismiss-hint">Closing in 3s…</span>'
: ''
const followHint =
job.status === 'running'
? '<span class="job-log-follow-hint" title="Log auto-scrolls to new output"><i class="fas fa-angles-down"></i> Following</span>'
: ''
host.innerHTML = `
<div class="job-panel job-panel--${escapeHtml(job.status || 'running')}" data-job-id="${job.id}">
<div class="job-panel-header">
@@ -252,11 +300,69 @@ export function renderJobPanel(host, job) {
</div>
</div>
<div class="job-steps">${stepsHtml}</div>
<details class="job-log-details" ${job.status === 'error' || job.status === 'running' ? 'open' : ''}>
<summary>Live log (${job.log.length})</summary>
<div class="job-log">${logHtml || '<div class="text-muted">No output yet</div>'}</div>
<details class="job-log-details" ${keepDetailsOpen ? 'open' : ''}>
<summary>
<span>Live log (${job.log.length})</span>
${followHint}
</summary>
<div class="job-log" data-job-log="${escapeHtml(job.id)}" role="log" aria-live="polite" aria-relevant="additions">
${logHtml || '<div class="text-muted job-log-empty">No output yet</div>'}
</div>
</details>
</div>`
const logEl = host.querySelector('.job-log')
const details = host.querySelector('.job-log-details')
// While running, always keep details expanded so output is visible
if (details && (job.status === 'running' || keepDetailsOpen)) {
details.open = true
}
// Follow output when near bottom (or first paint). Respect scroll-up to read history.
const shouldFollow = !prevLog || wasFollowing
if (logEl && shouldFollow) {
scrollJobLogToBottom(logEl)
logEl.dataset.following = '1'
} else if (logEl) {
logEl.dataset.following = '0'
const hint = host.querySelector('.job-log-follow-hint')
if (hint) {
hint.classList.add('is-paused')
hint.innerHTML = '<i class="fas fa-pause"></i> Paused — scroll to bottom'
}
}
// Toggle follow when user scrolls; click paused hint to jump to end
if (logEl && !logEl.dataset.followBound) {
logEl.dataset.followBound = '1'
logEl.addEventListener('scroll', () => {
const following = isLogFollowing(logEl)
logEl.dataset.following = following ? '1' : '0'
const hint = host.querySelector('.job-log-follow-hint')
if (hint) {
hint.classList.toggle('is-paused', !following)
hint.innerHTML = following
? '<i class="fas fa-angles-down"></i> Following'
: '<i class="fas fa-pause"></i> Paused — scroll to bottom'
}
})
}
const followBtn = host.querySelector('.job-log-follow-hint')
if (followBtn && !followBtn.dataset.bound) {
followBtn.dataset.bound = '1'
followBtn.style.cursor = 'pointer'
followBtn.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
if (logEl) {
logEl.dataset.following = '1'
scrollJobLogToBottom(logEl)
followBtn.classList.remove('is-paused')
followBtn.innerHTML = '<i class="fas fa-angles-down"></i> Following'
}
})
}
}
function escapeHtml(s) {
@@ -274,6 +380,7 @@ export default {
appendJobLog,
completeJob,
renderJobPanel,
scrollJobLogToBottom,
subscribeJobs,
listJobs,
getJob,