Complete Track C roadmap: dashboard, job follow, actions, activity, roles
CI / test (push) Successful in 9m55s
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:
+113
-6
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user