697 lines
28 KiB
JavaScript
Executable File
697 lines
28 KiB
JavaScript
Executable File
/*
|
|
* SysDeck - Fester Panel (v0.2.0)
|
|
* Author: Jeremy Anderson (https://dcos.net)
|
|
*
|
|
* v0.2.0 REAL INTEGRATION — replaces the v0.1.3 build-jobs stub (which
|
|
* listed systemd units whose name contained "fester"/"build" and never
|
|
* talked to an orchestrator). This panel is now a live client of the
|
|
* vendored fester service — the distributed DAG build orchestrator
|
|
* from web/mini-services/fester, REST + WebSocket on 127.0.0.1:3010 —
|
|
* through bridge/fester.py:
|
|
*
|
|
* status / metrics / builds / nodes / targets / sessions read polls
|
|
* startBuild → POST /api/build (build_id)
|
|
* cancel → POST /api/builds/<id>/cancel
|
|
* replay → POST /api/sessions (session)
|
|
* timeline → GET /api/timeline/<id>
|
|
*
|
|
* Layout: service status card + stat grid, cluster nodes table,
|
|
* builds table (live first, then history) with per-row Cancel /
|
|
* Replay / Timeline actions, and a Start-a-Build form fed by the
|
|
* project/target catalog. Auto-refreshes every 5s; the refresh loop
|
|
* re-renders only the status/nodes/builds containers — the form is
|
|
* rendered once, so its state survives every tick. If the service is
|
|
* down the panel shows the bridge's error message verbatim (it carries
|
|
* the remediation hint) and keeps retrying, flipping back online on
|
|
* its own.
|
|
*/
|
|
|
|
export async function mount(panel, { bridge, EventBus }) {
|
|
panel.innerHTML = renderSkeleton();
|
|
|
|
const ctx = {
|
|
panel,
|
|
bridge,
|
|
EventBus,
|
|
laidOut: false, // full layout rendered (vs offline card)
|
|
expanded: new Set(), // build ids whose timeline row is open
|
|
firstRenderDone: false,
|
|
targetsResp: null, // project catalog for the start form
|
|
};
|
|
|
|
// The status probe decides online vs offline — its error message
|
|
// carries the remediation hint, shown verbatim when offline.
|
|
let status = null;
|
|
let statusErr = null;
|
|
try {
|
|
status = await bridge.fester.status();
|
|
} catch (err) {
|
|
statusErr = err;
|
|
}
|
|
|
|
if (statusErr || !status || status.ok === false) {
|
|
renderOffline(ctx, errMessage(statusErr, status));
|
|
EventBus.emit('fester.offline', {});
|
|
} else {
|
|
await mountLayout(ctx, status);
|
|
}
|
|
|
|
// ── auto-refresh (5s) ─────────────────────────────────────────
|
|
// Re-fetches status + metrics + builds + nodes and re-renders only
|
|
// the status/nodes/builds containers. Also watches for the service
|
|
// going away (→ offline card) or coming back (→ full layout).
|
|
if (panel._festerInterval) clearInterval(panel._festerInterval);
|
|
panel._festerInterval = setInterval(() => { poll(ctx); }, 5000);
|
|
|
|
// Clean up the interval when the panel leaves the DOM
|
|
// (house pattern from netsec.js).
|
|
// one observer per panel: a re-mount releases the previous one
|
|
// instead of stacking body-wide observers on every refresh
|
|
if (panel.festerObserver) panel.festerObserver.disconnect();
|
|
if (panel.festerObserver) panel.festerObserver.disconnect();
|
|
panel.festerObserver = new MutationObserver(() => {
|
|
if (!document.body.contains(panel)) {
|
|
clearInterval(panel._festerInterval);
|
|
panel.festerObserver.disconnect();
|
|
}
|
|
});
|
|
panel.festerObserver.observe(document.body, { childList: true, subtree: true });
|
|
}
|
|
|
|
// ── refresh loop ────────────────────────────────────────────────────
|
|
|
|
async function poll(ctx) {
|
|
let status = null;
|
|
let statusErr = null;
|
|
try {
|
|
status = await ctx.bridge.fester.status();
|
|
} catch (err) {
|
|
statusErr = err;
|
|
}
|
|
const online = !statusErr && status && status.ok !== false;
|
|
|
|
if (online && !ctx.laidOut) {
|
|
// Service came back after an offline render — build the layout.
|
|
await mountLayout(ctx, status);
|
|
return;
|
|
}
|
|
if (!online && ctx.laidOut) {
|
|
// Service dropped — swap to the offline card.
|
|
renderOffline(ctx, errMessage(statusErr, status));
|
|
ctx.EventBus.emit('fester.offline', {});
|
|
return;
|
|
}
|
|
if (!online) return; // still offline — the card is already shown
|
|
|
|
await refreshData(ctx, status);
|
|
}
|
|
|
|
async function refreshNow(ctx) {
|
|
// Immediate re-fetch after a user action (start/cancel/manual).
|
|
let status = null;
|
|
let statusErr = null;
|
|
try {
|
|
status = await ctx.bridge.fester.status();
|
|
} catch (err) {
|
|
statusErr = err;
|
|
}
|
|
if (!statusErr && status && status.ok !== false && ctx.laidOut) {
|
|
await refreshData(ctx, status);
|
|
}
|
|
}
|
|
|
|
async function refreshData(ctx, status) {
|
|
if (!ctx.laidOut) return;
|
|
const { bridge, panel } = ctx;
|
|
|
|
// Per-call try/catch: one failing endpoint must not sink the panel.
|
|
const metricsResp = await safe(bridge.fester.metrics());
|
|
const buildsResp = await safe(bridge.fester.builds());
|
|
const nodesResp = await safe(bridge.fester.nodes());
|
|
|
|
const statusEl = panel.querySelector('#fester-status');
|
|
const nodesEl = panel.querySelector('#fester-nodes');
|
|
const buildsEl = panel.querySelector('#fester-builds');
|
|
if (statusEl) statusEl.innerHTML = renderStatus(status, metricsResp, nodesResp);
|
|
if (nodesEl) nodesEl.innerHTML = renderNodes(nodesResp);
|
|
if (buildsEl) buildsEl.innerHTML = renderBuilds(buildsResp);
|
|
|
|
wireStatusAndRows(ctx);
|
|
|
|
// Re-open timeline rows that were expanded before the re-render
|
|
// (the events re-fetch keeps them live).
|
|
await reopenExpanded(ctx);
|
|
|
|
if (!ctx.firstRenderDone) {
|
|
ctx.firstRenderDone = true;
|
|
ctx.EventBus.emit('fester.loaded', { builds: countBuilds(buildsResp) });
|
|
}
|
|
}
|
|
|
|
// ── layout ──────────────────────────────────────────────────────────
|
|
|
|
async function mountLayout(ctx, status) {
|
|
const { panel, bridge } = ctx;
|
|
ctx.laidOut = true;
|
|
|
|
// Target catalog feeds the Start-a-Build form. Fetched once per
|
|
// layout — the form is static for the panel's lifetime, which is
|
|
// what preserves its state across refresh ticks.
|
|
let targetsResp = null;
|
|
let targetsErr = null;
|
|
try {
|
|
targetsResp = await bridge.fester.targets();
|
|
} catch (err) {
|
|
targetsErr = err;
|
|
}
|
|
ctx.targetsResp = targetsResp;
|
|
|
|
const baseUrl = (status && status.base_url) || 'http://127.0.0.1:3010';
|
|
panel.innerHTML = `
|
|
<header>
|
|
<h2 class="suite-panel-title">SysDeck Fester</h2>
|
|
<p class="suite-panel-subtitle">Distributed DAG build orchestration — vendored fester service (${escapeHtml(baseUrl)} · REST+WS)</p>
|
|
</header>
|
|
<div id="fester-status"></div>
|
|
<div id="fester-nodes"></div>
|
|
<div id="fester-builds"></div>
|
|
<div id="fester-form">
|
|
${renderStartForm(targetsResp, targetsErr)}
|
|
</div>
|
|
`;
|
|
|
|
wireStartForm(ctx);
|
|
await refreshData(ctx, status);
|
|
}
|
|
|
|
function renderOffline(ctx, message) {
|
|
ctx.laidOut = false;
|
|
ctx.panel.innerHTML = `
|
|
<header>
|
|
<h2 class="suite-panel-title">SysDeck Fester</h2>
|
|
<p class="suite-panel-subtitle">Distributed DAG build orchestration — vendored fester service (web/mini-services/fester · REST+WS)</p>
|
|
</header>
|
|
<div class="suite-card">
|
|
<div class="suite-card-header">
|
|
<h3 class="suite-card-title">Fester Service Offline</h3>
|
|
<span class="suite-badge danger">offline</span>
|
|
</div>
|
|
<p class="suite-card-body suite-mono">${escapeHtml(message)}</p>
|
|
<p class="suite-muted">Retrying every 5 seconds — the panel reconnects automatically when the service is back.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ── status card + stat grid ─────────────────────────────────────────
|
|
|
|
function renderStatus(status, metricsResp, nodesResp) {
|
|
const m = (metricsResp && metricsResp.ok !== false) ? metricsResp : null;
|
|
const metrics = (m && m.metrics) || {};
|
|
const builds = metrics.builds || {};
|
|
const actions = metrics.actions || {};
|
|
const nodesOk = nodesResp && nodesResp.ok !== false;
|
|
const nodeCount = nodesOk && Array.isArray(nodesResp.nodes)
|
|
? nodesResp.nodes.length
|
|
: (status && typeof status.nodes === 'number' ? status.nodes : 0);
|
|
const stat = (v) => (m ? n(v) : '—');
|
|
|
|
return `
|
|
<div class="suite-card">
|
|
<div class="suite-card-header">
|
|
<h3 class="suite-card-title">Service</h3>
|
|
<div class="suite-row" style="gap:0.5rem">
|
|
<span class="suite-badge success">online</span>
|
|
<button class="suite-btn suite-btn-ghost" id="btn-fester-refresh">↻ Refresh</button>
|
|
</div>
|
|
</div>
|
|
<div class="suite-card-body">
|
|
<div class="suite-row-between">
|
|
<span class="suite-muted">fester v${escapeHtml(status && status.version)}</span>
|
|
<span class="suite-muted suite-mono">${escapeHtml(status && status.base_url)} · ${escapeHtml(status && status.transport)} · clock ${escapeHtml(status && status.clock)}</span>
|
|
</div>
|
|
<div class="suite-row-between">
|
|
<span class="suite-muted">${nodeCount} cluster nodes</span>
|
|
<span class="suite-muted">up ${escapeHtml(fmtDuration(status && status.uptime_s))}</span>
|
|
</div>
|
|
${m ? '' : `<p class="suite-muted">metrics unavailable${metricsResp && metricsResp.error ? ': ' + escapeHtml(metricsResp.error) : ''}</p>`}
|
|
</div>
|
|
</div>
|
|
<div class="suite-grid cols-3">
|
|
<div class="suite-card">
|
|
<div class="suite-stat-value">${stat(builds.total)}</div>
|
|
<div class="suite-stat-label">builds total</div>
|
|
</div>
|
|
<div class="suite-card">
|
|
<div class="suite-stat-value ${n(builds.running) > 0 ? 'suite-warn' : ''}">${stat(builds.running)}</div>
|
|
<div class="suite-stat-label">running</div>
|
|
</div>
|
|
<div class="suite-card">
|
|
<div class="suite-stat-value">${stat(builds.succeeded)}</div>
|
|
<div class="suite-stat-label">succeeded</div>
|
|
</div>
|
|
<div class="suite-card">
|
|
<div class="suite-stat-value">${stat(builds.failed)}</div>
|
|
<div class="suite-stat-label">failed</div>
|
|
</div>
|
|
<div class="suite-card">
|
|
<div class="suite-stat-value">${m ? fmtNum(actions.cache_hit_rate, 1) + '%' : '—'}</div>
|
|
<div class="suite-stat-label">cache-hit rate (${stat(actions.cache_hits)}/${stat(actions.total)} actions)</div>
|
|
</div>
|
|
<div class="suite-card">
|
|
<div class="suite-stat-value">${nodesOk ? nodeCount : '—'}</div>
|
|
<div class="suite-stat-label">cluster nodes</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ── cluster nodes table ─────────────────────────────────────────────
|
|
|
|
function renderNodes(nodesResp) {
|
|
const nodes = (nodesResp && Array.isArray(nodesResp.nodes)) ? nodesResp.nodes : null;
|
|
if (!nodes) {
|
|
const msg = (nodesResp && nodesResp.error)
|
|
? nodesResp.error
|
|
: 'No cluster nodes registered.';
|
|
return `
|
|
<div class="suite-card">
|
|
<h3 class="suite-card-title">Cluster Nodes</h3>
|
|
<p class="suite-card-body suite-muted">${escapeHtml(msg)}</p>
|
|
</div>`;
|
|
}
|
|
const rows = nodes.map((nd) => `<tr>
|
|
<td class="suite-table-mono">${escapeHtml(nd.name)}</td>
|
|
<td>${nodeStateBadge(nd.state)}</td>
|
|
<td class="suite-mono">${fmtNum(nd.cpu_load, 1)}%</td>
|
|
<td class="suite-mono">${fmtNum(nd.temp, 1)}°C</td>
|
|
<td class="suite-mono">${n(nd.active_jobs)}/${n(nd.max_jobs)}</td>
|
|
</tr>`).join('');
|
|
return `
|
|
<div class="suite-card">
|
|
<h3 class="suite-card-title">Cluster Nodes</h3>
|
|
<table class="suite-table">
|
|
<thead><tr><th>Node</th><th>State</th><th>CPU load</th><th>Temp</th><th>Jobs (active/max)</th></tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
</div>`;
|
|
}
|
|
|
|
function nodeStateBadge(state) {
|
|
const s = String(state || '');
|
|
const cls = s === 'online' ? 'suite-badge success'
|
|
: s === 'degraded' ? 'suite-badge warn'
|
|
: 'suite-badge';
|
|
return `<span class="${cls}">${escapeHtml(s || '—')}</span>`;
|
|
}
|
|
|
|
// ── builds table ────────────────────────────────────────────────────
|
|
|
|
function renderBuilds(buildsResp) {
|
|
if (!buildsResp || buildsResp.ok === false) {
|
|
const msg = (buildsResp && buildsResp.error)
|
|
? buildsResp.error
|
|
: 'build list unavailable';
|
|
return `
|
|
<div class="suite-card">
|
|
<h3 class="suite-card-title">Builds</h3>
|
|
<p class="suite-card-body suite-muted">${escapeHtml(msg)}</p>
|
|
</div>`;
|
|
}
|
|
const live = Array.isArray(buildsResp.builds) ? buildsResp.builds : [];
|
|
const history = Array.isArray(buildsResp.history) ? buildsResp.history : [];
|
|
const seen = new Set(live.map((b) => String(b.build_id || '')));
|
|
|
|
const rows = [
|
|
...live.map((b) => buildRow(b, true)),
|
|
...history
|
|
.filter((b) => !seen.has(String(b.build_id || '')))
|
|
.map((b) => buildRow(b, false)),
|
|
];
|
|
|
|
return `
|
|
<div class="suite-card">
|
|
<h3 class="suite-card-title">Builds</h3>
|
|
<table class="suite-table">
|
|
<thead><tr>
|
|
<th>Build</th><th>Project</th><th>State</th><th>Actions</th>
|
|
<th>Cache hits</th><th>Critical path</th><th>Started</th><th></th>
|
|
</tr></thead>
|
|
<tbody>
|
|
${rows.join('') || '<tr><td colspan="8" class="suite-muted">No builds yet — start one below.</td></tr>'}
|
|
</tbody>
|
|
</table>
|
|
<p class="suite-muted">Live builds first, then history (${live.length} live · ${history.length} stored).</p>
|
|
</div>`;
|
|
}
|
|
|
|
function buildRow(b, live) {
|
|
const id = String(b.build_id || '');
|
|
const state = String(b.state || '');
|
|
const running = state === 'running' || state === 'queued';
|
|
const done = live ? null : b.actions_done;
|
|
const total = live ? b.actions : b.actions_total;
|
|
const cacheHits = live ? null : b.cache_hits;
|
|
const criticalMs = live ? null : b.critical_path_ms;
|
|
|
|
const buttons = [
|
|
running ? `<button class="suite-btn" data-fx="cancel" data-build="${escapeHtml(id)}">Cancel</button>` : '',
|
|
!running ? `<button class="suite-btn" data-fx="replay" data-build="${escapeHtml(id)}">Replay</button>` : '',
|
|
`<button class="suite-btn suite-btn-ghost" data-fx="timeline" data-build="${escapeHtml(id)}">Timeline</button>`,
|
|
].filter(Boolean).join(' ');
|
|
|
|
return `<tr data-build="${escapeHtml(id)}">
|
|
<td class="suite-table-mono">${escapeHtml(id)}</td>
|
|
<td>${escapeHtml(b.project || '')}</td>
|
|
<td>${stateBadge(state)}</td>
|
|
<td class="suite-mono">${done == null ? '—' : n(done)}/${n(total)}</td>
|
|
<td class="suite-mono">${cacheHits == null ? '—' : n(cacheHits)}</td>
|
|
<td class="suite-mono">${criticalMs == null ? '—' : n(criticalMs) + ' ms'}</td>
|
|
<td class="suite-muted">${escapeHtml(fmtTime(b.started_at))}</td>
|
|
<td>
|
|
<div class="suite-row" style="gap:0.35rem;flex-wrap:nowrap">${buttons}</div>
|
|
<div class="suite-muted suite-mono" data-note="${escapeHtml(id)}" style="font-size:0.75rem;margin-top:0.25rem"></div>
|
|
</td>
|
|
</tr>`;
|
|
}
|
|
|
|
function stateBadge(state) {
|
|
const s = String(state || '');
|
|
const cls = (s === 'running' || s === 'queued') ? 'suite-badge warn'
|
|
: s === 'succeeded' ? 'suite-badge success'
|
|
: s === 'failed' ? 'suite-badge danger'
|
|
: 'suite-badge'; // cancelled / unknown → neutral gray
|
|
return `<span class="${cls}">${escapeHtml(s || '—')}</span>`;
|
|
}
|
|
|
|
// ── timeline expansion ──────────────────────────────────────────────
|
|
|
|
async function toggleTimeline(ctx, buildId) {
|
|
if (ctx.expanded.has(buildId)) {
|
|
ctx.expanded.delete(buildId);
|
|
const row = findTimelineRow(ctx, buildId);
|
|
if (row) row.remove();
|
|
return;
|
|
}
|
|
ctx.expanded.add(buildId);
|
|
await insertTimelineRow(ctx, buildId);
|
|
}
|
|
|
|
async function insertTimelineRow(ctx, buildId) {
|
|
const buildRowEl = findBuildRow(ctx, buildId);
|
|
if (!buildRowEl) return;
|
|
const tr = document.createElement('tr');
|
|
tr.setAttribute('data-timeline', buildId);
|
|
tr.innerHTML = `<td colspan="8" class="suite-muted">loading timeline…</td>`;
|
|
buildRowEl.after(tr);
|
|
try {
|
|
const resp = await ctx.bridge.fester.timeline(buildId);
|
|
tr.innerHTML = timelineTd(resp);
|
|
} catch (err) {
|
|
tr.innerHTML = `<td colspan="8" class="suite-muted">${escapeHtml(errMessage(err, null))}</td>`;
|
|
}
|
|
}
|
|
|
|
async function reopenExpanded(ctx) {
|
|
for (const id of ctx.expanded) {
|
|
if (!findBuildRow(ctx, id)) continue;
|
|
await insertTimelineRow(ctx, id);
|
|
}
|
|
}
|
|
|
|
function timelineTd(resp) {
|
|
const events = (resp && Array.isArray(resp.events)) ? resp.events : [];
|
|
if (!events.length) {
|
|
return `<td colspan="8" class="suite-muted">No timeline events.</td>`;
|
|
}
|
|
const lines = events.slice(-15).map((ev) =>
|
|
`<div class="suite-mono" style="font-size:0.8rem">${escapeHtml(fmtEvent(ev))}</div>`
|
|
).join('');
|
|
return `<td colspan="8" style="padding:0.5rem 0.75rem;background:rgba(255,255,255,0.03)">${lines}</td>`;
|
|
}
|
|
|
|
function fmtEvent(ev) {
|
|
let line = `#${n(ev.id)} ${fmtTimeShort(ev.ts)} ${String(ev.type || '?')}`;
|
|
if (ev.state != null) line += `/${String(ev.state)}`;
|
|
if (ev.action) line += ` — ${ev.action}`;
|
|
return line;
|
|
}
|
|
|
|
// ── start-a-build form ──────────────────────────────────────────────
|
|
|
|
function renderStartForm(targetsResp, targetsErr) {
|
|
const projects = (targetsResp && Array.isArray(targetsResp.projects)) ? targetsResp.projects : null;
|
|
if (targetsErr || !projects) {
|
|
const msg = targetsErr ? errMessage(targetsErr, null)
|
|
: (targetsResp && targetsResp.error) || 'target catalog unavailable';
|
|
return `
|
|
<div class="suite-card">
|
|
<h3 class="suite-card-title">Start a Build</h3>
|
|
<p class="suite-card-body suite-muted">${escapeHtml(msg)}</p>
|
|
</div>`;
|
|
}
|
|
return `
|
|
<div class="suite-card">
|
|
<div class="suite-card-header">
|
|
<h3 class="suite-card-title">Start a Build</h3>
|
|
<span class="suite-muted">POST /api/build via bridge.fester.startBuild</span>
|
|
</div>
|
|
<div class="suite-card-body">
|
|
<div class="suite-row-between" style="margin-bottom:0.5rem">
|
|
<label class="suite-muted" for="fester-project">Project</label>
|
|
<select class="suite-input" id="fester-project">
|
|
${projects.map((p) =>
|
|
`<option value="${escapeHtml(p.name)}">${escapeHtml(p.name)} (${(p.targets || []).length} targets)</option>`
|
|
).join('')}
|
|
</select>
|
|
</div>
|
|
<div class="suite-muted" style="margin-bottom:0.25rem">Targets</div>
|
|
<div class="suite-row" id="fester-targets" style="margin-bottom:0.5rem"></div>
|
|
<div class="suite-row" style="align-items:center;margin-bottom:0.75rem">
|
|
<label class="suite-row" style="gap:0.35rem;align-items:center">
|
|
<input type="checkbox" id="fester-nocache" />
|
|
<span>no-cache</span>
|
|
</label>
|
|
<label class="suite-row" style="gap:0.35rem;align-items:center">
|
|
<span>retries</span>
|
|
<select class="suite-input" id="fester-retries">
|
|
<option value="0">0</option>
|
|
<option value="1">1</option>
|
|
<option value="2">2</option>
|
|
<option value="3">3</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<button class="suite-btn suite-btn-primary" id="btn-fester-start">▶ Start Build</button>
|
|
<div class="suite-muted suite-mono" id="fester-start-result" style="margin-top:0.5rem"></div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function wireStartForm(ctx) {
|
|
const { panel } = ctx;
|
|
const select = panel.querySelector('#fester-project');
|
|
const targetsBox = panel.querySelector('#fester-targets');
|
|
if (!select || !targetsBox) return;
|
|
|
|
const renderTargetOptions = () => {
|
|
const name = select.value;
|
|
const project = ((ctx.targetsResp && ctx.targetsResp.projects) || [])
|
|
.find((p) => p.name === name);
|
|
const list = (project && project.targets) || [];
|
|
targetsBox.innerHTML = list.map((t) => `
|
|
<label class="suite-row" style="gap:0.35rem;align-items:center;margin-right:0.75rem">
|
|
<input type="checkbox" class="fester-target-cb" value="${escapeHtml(t.name)}" />
|
|
<span>${escapeHtml(t.name)} <span class="suite-muted">${escapeHtml(t.system)}/${escapeHtml(t.arch)}</span></span>
|
|
</label>`).join('') || '<span class="suite-muted">No targets for this project.</span>';
|
|
};
|
|
renderTargetOptions();
|
|
select.addEventListener('change', renderTargetOptions);
|
|
|
|
panel.querySelector('#btn-fester-start')?.addEventListener('click', async () => {
|
|
const result = panel.querySelector('#fester-start-result');
|
|
const btn = panel.querySelector('#btn-fester-start');
|
|
const project = select.value;
|
|
const targets = Array.from(panel.querySelectorAll('.fester-target-cb:checked'))
|
|
.map((el) => el.value);
|
|
const noCache = !!(panel.querySelector('#fester-nocache')?.checked);
|
|
const retries = Number(panel.querySelector('#fester-retries')?.value || '0');
|
|
|
|
if (!project || targets.length === 0) {
|
|
if (result) result.textContent = 'Pick a project and at least one target.';
|
|
return;
|
|
}
|
|
if (btn) btn.disabled = true;
|
|
if (result) result.textContent = `starting ${project} (${targets.join(', ')})…`;
|
|
try {
|
|
const res = await ctx.bridge.fester.startBuild(project, targets, { noCache, retries });
|
|
if (result) {
|
|
result.textContent = (res && res.ok)
|
|
? `started build ${res.build_id} (retries ${res.retries != null ? res.retries : 0})`
|
|
: `start failed: ${(res && res.error) || 'unknown error'}`;
|
|
}
|
|
} catch (err) {
|
|
if (result) result.textContent = `start failed: ${errMessage(err, null)}`;
|
|
}
|
|
if (btn) btn.disabled = false;
|
|
// Immediate refresh so the new build shows up right away.
|
|
refreshNow(ctx);
|
|
});
|
|
}
|
|
|
|
// ── row actions (cancel / replay / timeline) ────────────────────────
|
|
|
|
function wireStatusAndRows(ctx) {
|
|
const { panel } = ctx;
|
|
|
|
panel.querySelector('#btn-fester-refresh')?.addEventListener('click', () => {
|
|
refreshNow(ctx);
|
|
});
|
|
|
|
panel.querySelectorAll('#fester-builds button[data-fx]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const fx = btn.getAttribute('data-fx');
|
|
const id = btn.getAttribute('data-build');
|
|
if (!id) return;
|
|
if (fx === 'cancel') doCancel(ctx, id, btn);
|
|
else if (fx === 'replay') doReplay(ctx, id, btn);
|
|
else if (fx === 'timeline') toggleTimeline(ctx, id);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function doCancel(ctx, buildId, btn) {
|
|
btn.disabled = true;
|
|
let res = null;
|
|
let err = null;
|
|
try {
|
|
res = await ctx.bridge.fester.cancel(buildId);
|
|
} catch (e) {
|
|
err = e;
|
|
}
|
|
// A 409-style {ok:false, error:"build not running"} is surfaced
|
|
// as-is — the bridge prints the service's JSON verbatim.
|
|
const msg = err ? errMessage(err, null)
|
|
: (res && res.ok) ? 'cancel requested'
|
|
: `cancel failed: ${(res && res.error) || 'unknown error'}`;
|
|
setRowNote(ctx, buildId, msg);
|
|
refreshNow(ctx);
|
|
}
|
|
|
|
async function doReplay(ctx, buildId, btn) {
|
|
btn.disabled = true;
|
|
let res = null;
|
|
let err = null;
|
|
try {
|
|
res = await ctx.bridge.fester.replay(buildId);
|
|
} catch (e) {
|
|
err = e;
|
|
}
|
|
let msg;
|
|
if (err) {
|
|
msg = `replay failed: ${errMessage(err, null)}`;
|
|
} else if (res && res.ok) {
|
|
const sid = res.session && res.session.session_id;
|
|
msg = sid ? `session ${sid}` : 'session created';
|
|
} else {
|
|
msg = `replay failed: ${(res && res.error) || 'unknown error'}`;
|
|
}
|
|
setRowNote(ctx, buildId, msg);
|
|
btn.disabled = false;
|
|
}
|
|
|
|
function setRowNote(ctx, buildId, msg) {
|
|
ctx.panel.querySelectorAll('[data-note]').forEach((el) => {
|
|
if (el.getAttribute('data-note') === buildId) el.textContent = msg;
|
|
});
|
|
}
|
|
|
|
function findBuildRow(ctx, buildId) {
|
|
let found = null;
|
|
ctx.panel.querySelectorAll('#fester-builds tr[data-build]').forEach((el) => {
|
|
if (el.getAttribute('data-build') === buildId) found = el;
|
|
});
|
|
return found;
|
|
}
|
|
|
|
function findTimelineRow(ctx, buildId) {
|
|
let found = null;
|
|
ctx.panel.querySelectorAll('#fester-builds tr[data-timeline]').forEach((el) => {
|
|
if (el.getAttribute('data-timeline') === buildId) found = el;
|
|
});
|
|
return found;
|
|
}
|
|
|
|
// ── utilities ───────────────────────────────────────────────────────
|
|
|
|
async function safe(p) {
|
|
try {
|
|
const v = await p;
|
|
return v ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function errMessage(err, resp) {
|
|
if (resp && resp.error) return String(resp.error);
|
|
if (err && err.message) return String(err.message);
|
|
if (err) return String(err);
|
|
return 'fester service unreachable';
|
|
}
|
|
|
|
function countBuilds(buildsResp) {
|
|
if (!buildsResp || buildsResp.ok === false) return 0;
|
|
const live = Array.isArray(buildsResp.builds) ? buildsResp.builds.length : 0;
|
|
const hist = Array.isArray(buildsResp.history) ? buildsResp.history.length : 0;
|
|
return live + hist;
|
|
}
|
|
|
|
function n(v) {
|
|
const num = Number(v);
|
|
return Number.isFinite(num) ? num : 0;
|
|
}
|
|
|
|
function fmtNum(v, digits) {
|
|
const num = Number(v);
|
|
return Number.isFinite(num) ? num.toFixed(digits) : '—';
|
|
}
|
|
|
|
function fmtTime(ts) {
|
|
const num = Number(ts);
|
|
if (!Number.isFinite(num) || num <= 0) return '—';
|
|
return new Date(num * 1000).toLocaleString();
|
|
}
|
|
|
|
function fmtTimeShort(ts) {
|
|
const num = Number(ts);
|
|
if (!Number.isFinite(num) || num <= 0) return '--:--:--';
|
|
return new Date(num * 1000).toLocaleTimeString();
|
|
}
|
|
|
|
function fmtDuration(s) {
|
|
const num = Number(s);
|
|
if (!Number.isFinite(num) || num < 0) return '—';
|
|
const h = Math.floor(num / 3600);
|
|
const m = Math.floor((num % 3600) / 60);
|
|
if (h > 0) return `${h}h ${m}m`;
|
|
if (m > 0) return `${m}m ${Math.floor(num % 60)}s`;
|
|
return `${Math.floor(num)}s`;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s == null ? '' : s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function renderSkeleton() {
|
|
return `<div class="suite-skeleton">
|
|
<div class="suite-skeleton-line w-1/3"></div>
|
|
<div class="suite-skeleton-line w-2/3"></div>
|
|
<div class="suite-skeleton-line w-1/2"></div>
|
|
</div>`;
|
|
}
|