ui: GitHub-Actions-style detail page, sub-steps, mini-tile run-view
Reshapes the detail page into a run-view: hybrid horizontal pipeline
+ expanded active-step pane with sub-steps, a per-step log pane with
line-numbered permalinks and client-side search, and a runs-history
sidebar that navigates via ?run=N. Default step is server-picked
(running → failed → Reporting) so the operator lands on the thing
that's moving.
Adds a sub_steps table + SSE topic (substep-{run}-{stage}-{ordinal})
so per-disk and per-pass work (SMART, CPUStress CPU/RAM, Storage,
GPU) is visible in the UI instead of buried in stage summary JSON.
Agent emits sub-step reports from existing per-iteration loops.
Dashboard tiles become a mini run-view with a 9-dot step strip so
the operator reads run health across the whole grid at a glance.
Register page gets the same card shell + button styling.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+408
-15
@@ -202,8 +202,14 @@ button.danger:hover { background: rgba(229,100,102,.1); }
|
||||
.tile-pass { border-color: rgba(53,194,123,.5); }
|
||||
.tile-active { border-color: var(--accent); }
|
||||
|
||||
.form-wrap { max-width: 640px; }
|
||||
.form-wrap h1 { font-size: 20px; }
|
||||
.form-wrap { max-width: 640px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.form-wrap h1 { font-size: 20px; margin: 0; }
|
||||
.form-wrap-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.host-form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.host-form label { display: flex; flex-direction: column; gap: 4px; color: var(--text-dim); font-size: 13px; }
|
||||
@@ -245,14 +251,10 @@ button.danger:hover { background: rgba(229,100,102,.1); }
|
||||
|
||||
body.bare main { max-width: none; }
|
||||
|
||||
.quick-register {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.quick-register h2 { margin: 0 0 8px; font-size: 16px; }
|
||||
/* .quick-register now inherits card shell from .detail-section; these
|
||||
rules only cover its own content. */
|
||||
.quick-register h2 { margin: 0 0 8px; font-size: 15px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-dim); font-weight: 600; }
|
||||
.quick-register h2 .muted { text-transform: none; letter-spacing: 0; }
|
||||
.quick-register p { margin: 6px 0; font-size: 13px; color: var(--text-dim); }
|
||||
.quick-register p b { color: var(--text); }
|
||||
.quick-register .muted { color: var(--text-dim); font-weight: 400; }
|
||||
@@ -270,14 +272,31 @@ body.bare main { max-width: none; }
|
||||
}
|
||||
.quick-register .one-liner code { white-space: pre; }
|
||||
|
||||
.manual-register { margin-top: 16px; }
|
||||
.manual-register-card { padding-top: 10px; padding-bottom: 14px; }
|
||||
.manual-register summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
padding: 6px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.manual-register summary:hover { color: var(--text); }
|
||||
.manual-register summary::before {
|
||||
content: "▸";
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
transition: transform .1s ease;
|
||||
}
|
||||
.manual-register[open] > summary::before { transform: rotate(90deg); }
|
||||
.manual-register summary h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.manual-register summary:hover h2 { color: var(--text); }
|
||||
.manual-register[open] summary { margin-bottom: 12px; }
|
||||
|
||||
/* ===== Host detail page ===== */
|
||||
@@ -549,3 +568,377 @@ body.bare main { max-width: none; }
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(60,130,246,.55); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(60,130,246,0); }
|
||||
}
|
||||
|
||||
/* ===== Host detail v2 — GitHub-Actions-style layout ===== */
|
||||
|
||||
.detail-v2 { gap: 12px; }
|
||||
|
||||
.host-meta-drawer {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.host-meta-drawer > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
padding: 4px 0;
|
||||
}
|
||||
.host-meta-drawer > summary::before {
|
||||
content: "▸";
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
transition: transform .1s ease;
|
||||
}
|
||||
.host-meta-drawer[open] > summary::before { transform: rotate(90deg); }
|
||||
.host-meta-drawer .meta-summary-label { color: var(--text); font-weight: 600; }
|
||||
.host-meta-drawer .meta-summary-mac { font-family: var(--mono); margin-left: auto; }
|
||||
.host-meta-drawer[open] > summary { margin-bottom: 12px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
|
||||
|
||||
.run-header {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.run-header.tile-fail { border-color: rgba(229,100,102,.6); }
|
||||
.run-header.tile-pass { border-color: rgba(53,194,123,.5); }
|
||||
.run-header.tile-active { border-color: var(--accent); }
|
||||
.run-header-left { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; }
|
||||
.run-header-right { display: flex; align-items: center; gap: 14px; font-size: 13px; }
|
||||
.run-header .detail-name { margin: 0; font-size: 22px; }
|
||||
.run-number { font-family: var(--mono); font-size: 15px; color: var(--text-dim); }
|
||||
.run-status-badge {
|
||||
padding: 3px 10px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elev-2);
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.run-status-pass { background: rgba(53,194,123,.15); border-color: rgba(53,194,123,.5); color: var(--success); }
|
||||
.run-status-fail { background: rgba(229,100,102,.12); border-color: rgba(229,100,102,.5); color: var(--danger); }
|
||||
.run-status-active { background: rgba(60,130,246,.15); border-color: rgba(60,130,246,.5); color: var(--accent); }
|
||||
.run-duration { font-family: var(--mono); font-size: 13px; color: var(--text-dim); }
|
||||
.run-failed-stage { color: var(--danger); }
|
||||
.run-failed-stage strong { font-family: var(--mono); }
|
||||
.run-diffs { color: var(--danger); }
|
||||
|
||||
.hold-banner {
|
||||
background: rgba(229,100,102,.1);
|
||||
border: 1px solid rgba(229,100,102,.5);
|
||||
border-left: 4px solid var(--danger);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hold-banner-label { color: var(--danger); font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.hold-banner .hold-ssh {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
user-select: all;
|
||||
padding: 6px 10px;
|
||||
background: rgba(0,0,0,.3);
|
||||
border: 1px solid rgba(229,100,102,.3);
|
||||
border-radius: 4px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
.detail-hold-placeholder { display: none; }
|
||||
|
||||
.detail-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 260px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.detail-body { grid-template-columns: 1fr; }
|
||||
}
|
||||
.active-step-pane { display: flex; flex-direction: column; gap: 8px; }
|
||||
.detail-empty {
|
||||
padding: 24px;
|
||||
background: var(--bg-elev);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.step {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.step[open] { border-color: var(--accent); }
|
||||
.step-passed { border-left: 3px solid var(--success); }
|
||||
.step-running { border-left: 3px solid var(--accent); }
|
||||
.step-failed { border-left: 3px solid var(--danger); }
|
||||
.step-skipped { opacity: .55; }
|
||||
.step-pending { opacity: .7; }
|
||||
.step > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.step > summary::-webkit-details-marker { display: none; }
|
||||
.step-summary .stage-dot { width: 20px; height: 20px; font-size: 12px; flex-shrink: 0; }
|
||||
.step-name { font-weight: 600; color: var(--text); flex: 1; }
|
||||
.step-duration { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
|
||||
.step-body {
|
||||
padding: 8px 14px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.substep-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.substep {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-elev);
|
||||
font-size: 13px;
|
||||
}
|
||||
.substep-badge {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.substep-badge-passed { background: var(--success); border-color: var(--success); color: #0b0d12; }
|
||||
.substep-badge-running { background: var(--accent-strong); border-color: var(--accent); color: #fff; animation: pulse 1.2s ease-in-out infinite; }
|
||||
.substep-badge-failed { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||
.substep-badge-skipped { opacity: .5; }
|
||||
.substep-name { flex: 1; }
|
||||
.substep-duration { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
|
||||
.substep-failed { border-color: rgba(229,100,102,.5); }
|
||||
.substep-running { border-color: rgba(60,130,246,.5); }
|
||||
|
||||
.log-search-wrap { display: flex; }
|
||||
.log-search {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-family: var(--mono);
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
.log-search::placeholder { color: var(--text-dim); }
|
||||
.log-search:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
/* Log pane: now a standalone block (no tabs). Each step owns its pane. */
|
||||
.step .log-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 6px 0;
|
||||
background: #0b0d12;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
order: unset;
|
||||
}
|
||||
.step .log-pane:empty::before {
|
||||
content: "(no log output for this step yet)";
|
||||
padding: 10px 12px;
|
||||
color: var(--text-dim);
|
||||
opacity: .5;
|
||||
}
|
||||
.log-line {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 48px 56px 72px auto;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 2px 10px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.log-line:target { background: rgba(60,130,246,.12); border-left-color: var(--accent); }
|
||||
.log-line .log-anchor {
|
||||
color: var(--text-dim);
|
||||
opacity: 0;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
}
|
||||
.log-line:hover .log-anchor { opacity: 1; }
|
||||
.log-line .ln { color: var(--text-dim); opacity: .6; text-align: right; user-select: none; }
|
||||
.log-line .lvl { color: var(--text-dim); text-transform: uppercase; font-size: 10px; font-weight: 700; letter-spacing: .5px; }
|
||||
.log-line .log-ts { color: var(--text-dim); opacity: .75; }
|
||||
.log-line .log-stage { color: var(--text-dim); opacity: .75; margin-right: 4px; }
|
||||
.log-line .log-text { color: var(--text); white-space: pre-wrap; word-break: break-word; }
|
||||
.log-line.log-warn .log-text { color: var(--warn); }
|
||||
.log-line.log-warn .lvl { color: var(--warn); }
|
||||
.log-line.log-error .log-text { color: var(--danger); }
|
||||
.log-line.log-error .lvl { color: var(--danger); }
|
||||
.log-line.log-debug { opacity: .6; }
|
||||
.log-line.log-hit { background: rgba(228,169,75,.08); }
|
||||
|
||||
.runs-sidebar {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.runs-sidebar-heading {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.runs-sidebar-empty { color: var(--text-dim); font-size: 13px; margin: 0; }
|
||||
.runs-sidebar-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.runs-sidebar-item a {
|
||||
display: grid;
|
||||
grid-template-columns: 16px auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
.runs-sidebar-item a:hover { background: var(--bg-elev-2); text-decoration: none; }
|
||||
.runs-sidebar-active a { background: rgba(60,130,246,.12); border: 1px solid rgba(60,130,246,.5); }
|
||||
.runs-sidebar-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.runs-sidebar-dot-pass { background: var(--success); border-color: var(--success); color: #0b0d12; }
|
||||
.runs-sidebar-dot-fail { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||
.runs-sidebar-dot-active { background: var(--accent-strong); border-color: var(--accent); color: #fff; }
|
||||
.runs-sidebar-id { font-family: var(--mono); font-weight: 600; }
|
||||
.runs-sidebar-started { color: var(--text-dim); }
|
||||
.runs-sidebar-duration { font-family: var(--mono); color: var(--text-dim); font-size: 11px; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-strong);
|
||||
border-color: var(--accent-strong);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent); border-color: var(--accent); }
|
||||
.btn-danger {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
background: transparent;
|
||||
}
|
||||
.btn-danger:hover { background: rgba(229,100,102,.1); }
|
||||
|
||||
/* ---------- Dashboard tile mini run-view (Phase 3) ---------------- */
|
||||
|
||||
/* Small variant of stage-dot for the compact step list. Same colour
|
||||
rules as the full-size pipeline dot so operators read one language
|
||||
everywhere; only the geometry shrinks. */
|
||||
.stage-dot-sm {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 9px;
|
||||
border-width: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tile-meta-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
padding: 4px 0 6px;
|
||||
}
|
||||
.tile-run-id { font-variant-numeric: tabular-nums; }
|
||||
.tile-run-duration { margin-left: auto; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.tile-steplist {
|
||||
list-style: none;
|
||||
margin: 0 0 8px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2px 10px;
|
||||
}
|
||||
.tile-steplist .tile-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-dim);
|
||||
min-width: 0;
|
||||
}
|
||||
.tile-steplist .tile-step-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Passed/failed/running steps keep full-strength text so the eye jumps
|
||||
to active work; pending/skipped fade back into the background. */
|
||||
.tile-step-passed .tile-step-name,
|
||||
.tile-step-failed .tile-step-name,
|
||||
.tile-step-running .tile-step-name { color: var(--text); }
|
||||
.tile-step-skipped { opacity: .5; }
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Detail-page client behaviors. Loaded in layout.templ with `defer` so the
|
||||
// DOM is parsed before any listeners fire. Three jobs:
|
||||
//
|
||||
// 1. Auto-advance: when a substep-* SSE event lands with state=running,
|
||||
// open the parent step panel and collapse any previously-running step
|
||||
// that's now completed. Keeps the operator's attention on the thing
|
||||
// that's currently moving without manual clicks.
|
||||
// 2. In-step search: filter `.log-line` rows inside the current step by
|
||||
// substring match. Client-side only — the log pane's `<details>` ancestor
|
||||
// scopes the filter naturally.
|
||||
// 3. Permalink scroll + highlight: when the URL carries `#L{run}-{stage}-{ord}`
|
||||
// on load, scroll that log line into view; anchor clicks update
|
||||
// `location.hash` without a reload.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// --- 1. auto-advance on substep SSE ---------------------------------
|
||||
|
||||
document.body.addEventListener('htmx:sseMessage', function (ev) {
|
||||
var name = ev.detail && ev.detail.type;
|
||||
if (!name || name.indexOf('substep-') !== 0) {
|
||||
return;
|
||||
}
|
||||
// After htmx has applied the swap, check which step the just-updated
|
||||
// substep belongs to. We scan *after* the swap so we see the new
|
||||
// class ("substep-running" / "substep-passed") rather than the old.
|
||||
setTimeout(function () {
|
||||
autoAdvance();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
function autoAdvance() {
|
||||
var steps = document.querySelectorAll('.step[data-stage]');
|
||||
var runningStep = null;
|
||||
steps.forEach(function (step) {
|
||||
if (step.querySelector('.substep-running')) {
|
||||
runningStep = step;
|
||||
}
|
||||
});
|
||||
if (!runningStep) {
|
||||
return;
|
||||
}
|
||||
// Open the running step; collapse any other open step that no longer
|
||||
// has a running substep. The default-open step picked server-side
|
||||
// stays open if nothing is running yet.
|
||||
steps.forEach(function (step) {
|
||||
if (step === runningStep) {
|
||||
if (!step.open) { step.open = true; }
|
||||
return;
|
||||
}
|
||||
if (step.open && !step.querySelector('.substep-running')) {
|
||||
// Leave the "currently-failed" step open even when we
|
||||
// auto-advance — operator still wants to see what broke.
|
||||
if (step.classList.contains('step-failed')) { return; }
|
||||
step.open = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- 2. in-step search ----------------------------------------------
|
||||
|
||||
document.body.addEventListener('input', function (ev) {
|
||||
var el = ev.target;
|
||||
if (!el.classList || !el.classList.contains('log-search')) {
|
||||
return;
|
||||
}
|
||||
var step = el.closest('.step');
|
||||
if (!step) { return; }
|
||||
var query = el.value.trim().toLowerCase();
|
||||
step.querySelectorAll('.log-line').forEach(function (line) {
|
||||
if (!query) {
|
||||
line.style.display = '';
|
||||
line.classList.remove('log-hit');
|
||||
return;
|
||||
}
|
||||
var text = (line.textContent || '').toLowerCase();
|
||||
if (text.indexOf(query) === -1) {
|
||||
line.style.display = 'none';
|
||||
line.classList.remove('log-hit');
|
||||
} else {
|
||||
line.style.display = '';
|
||||
line.classList.add('log-hit');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- 3. permalink scroll + highlight on load ------------------------
|
||||
|
||||
function scrollToHash() {
|
||||
var hash = (location.hash || '').replace(/^#/, '');
|
||||
if (!hash) { return; }
|
||||
var target = document.getElementById(hash);
|
||||
if (!target) { return; }
|
||||
// Open the enclosing step so the target is actually visible.
|
||||
var step = target.closest('.step');
|
||||
if (step && !step.open) { step.open = true; }
|
||||
target.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
|
||||
window.addEventListener('load', scrollToHash);
|
||||
window.addEventListener('hashchange', scrollToHash);
|
||||
|
||||
// Anchor clicks update location.hash without triggering navigation;
|
||||
// the hashchange listener above handles the scroll + highlight.
|
||||
document.body.addEventListener('click', function (ev) {
|
||||
var a = ev.target.closest && ev.target.closest('.log-anchor');
|
||||
if (!a) { return; }
|
||||
ev.preventDefault();
|
||||
var href = a.getAttribute('href') || '';
|
||||
if (href.indexOf('#') === 0) {
|
||||
history.replaceState(null, '', href);
|
||||
scrollToHash();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,96 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
)
|
||||
|
||||
// ActiveStepData is the per-stage payload for the expanded step panel.
|
||||
// The handler builds one per stage in DefaultStageOrder and hands it to
|
||||
// ActiveStep so the template stays free of any slicing logic.
|
||||
type ActiveStepData struct {
|
||||
RunID int64
|
||||
Stage model.Stage
|
||||
SubSteps []model.SubStep
|
||||
LogReplay string
|
||||
Open bool
|
||||
}
|
||||
|
||||
// ActiveStep renders one stage's expanded panel: the header summary
|
||||
// (state badge, stage name, duration), any sub-step rows, a per-step
|
||||
// search box, and a live log pane scoped to that stage's SSE topic.
|
||||
// Uses <details open?={ d.Open }> so the server-picked default stage
|
||||
// opens automatically on page load; app.js takes over after that for
|
||||
// SSE-driven auto-advance.
|
||||
templ ActiveStep(d ActiveStepData) {
|
||||
<details class={ "step", "step-" + string(d.Stage.State) } open?={ d.Open } data-stage={ d.Stage.Name }>
|
||||
<summary class="step-summary">
|
||||
<span class={ "stage-dot", "stage-dot-" + string(d.Stage.State) }>{ stageMarker(string(d.Stage.State)) }</span>
|
||||
<span class="step-name">{ d.Stage.Name }</span>
|
||||
<span class="step-duration">{ stageDurationFromStage(d.Stage) }</span>
|
||||
</summary>
|
||||
<div class="step-body">
|
||||
if len(d.SubSteps) > 0 {
|
||||
<ol class="substep-list">
|
||||
for _, ss := range d.SubSteps {
|
||||
@SubStepRow(ss)
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
<div class="log-search-wrap">
|
||||
<input class="log-search" type="search" placeholder="Search this step" data-step={ d.Stage.Name }/>
|
||||
</div>
|
||||
<div
|
||||
class="log-pane"
|
||||
id={ fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name) }
|
||||
sse-swap={ fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name) }
|
||||
hx-swap="beforeend show:bottom"
|
||||
>
|
||||
@templ.Raw(d.LogReplay)
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
|
||||
// SubStepsForStage filters a flat []SubStep to just the entries for one
|
||||
// stage. Used by host_detail when wiring ActiveStepData — keeps the
|
||||
// filtering logic testable and off the template surface.
|
||||
func SubStepsForStage(all []model.SubStep, stageName string) []model.SubStep {
|
||||
out := make([]model.SubStep, 0, len(all))
|
||||
for _, ss := range all {
|
||||
if ss.StageName == stageName {
|
||||
out = append(out, ss)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stageDurationFromStage is stageDuration adapted to a model.Stage — same
|
||||
// formatting rules, different input shape.
|
||||
func stageDurationFromStage(s model.Stage) string {
|
||||
if s.StartedAt == nil {
|
||||
return ""
|
||||
}
|
||||
end := time.Now()
|
||||
if s.CompletedAt != nil {
|
||||
end = *s.CompletedAt
|
||||
}
|
||||
d := end.Sub(*s.StartedAt)
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
switch {
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||
case d < 10*time.Second:
|
||||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm", int(d/time.Minute))
|
||||
default:
|
||||
return fmt.Sprintf("%dh", int(d/time.Hour))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
)
|
||||
|
||||
// ActiveStepData is the per-stage payload for the expanded step panel.
|
||||
// The handler builds one per stage in DefaultStageOrder and hands it to
|
||||
// ActiveStep so the template stays free of any slicing logic.
|
||||
type ActiveStepData struct {
|
||||
RunID int64
|
||||
Stage model.Stage
|
||||
SubSteps []model.SubStep
|
||||
LogReplay string
|
||||
Open bool
|
||||
}
|
||||
|
||||
// ActiveStep renders one stage's expanded panel: the header summary
|
||||
// (state badge, stage name, duration), any sub-step rows, a per-step
|
||||
// search box, and a live log pane scoped to that stage's SSE topic.
|
||||
// Uses <details open?={ d.Open }> so the server-picked default stage
|
||||
// opens automatically on page load; app.js takes over after that for
|
||||
// SSE-driven auto-advance.
|
||||
func ActiveStep(d ActiveStepData) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var2 = []any{"step", "step-" + string(d.Stage.State)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<details class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.Open {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " open")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " data-stage=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 28, Col: 102}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"><summary class=\"step-summary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 = []any{"stage-dot", "stage-dot-" + string(d.Stage.State)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<span class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var5).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(string(d.Stage.State)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 30, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span> <span class=\"step-name\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 31, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span> <span class=\"step-duration\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(stageDurationFromStage(d.Stage))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 32, Col: 64}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span></summary><div class=\"step-body\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(d.SubSteps) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<ol class=\"substep-list\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, ss := range d.SubSteps {
|
||||
templ_7745c5c3_Err = SubStepRow(ss).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</ol>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"log-search-wrap\"><input class=\"log-search\" type=\"search\" placeholder=\"Search this step\" data-step=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 43, Col: 99}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"></div><div class=\"log-pane\" id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 47, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" sse-swap=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 48, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" hx-swap=\"beforeend show:bottom\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templ.Raw(d.LogReplay).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div></div></details>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SubStepsForStage filters a flat []SubStep to just the entries for one
|
||||
// stage. Used by host_detail when wiring ActiveStepData — keeps the
|
||||
// filtering logic testable and off the template surface.
|
||||
func SubStepsForStage(all []model.SubStep, stageName string) []model.SubStep {
|
||||
out := make([]model.SubStep, 0, len(all))
|
||||
for _, ss := range all {
|
||||
if ss.StageName == stageName {
|
||||
out = append(out, ss)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stageDurationFromStage is stageDuration adapted to a model.Stage — same
|
||||
// formatting rules, different input shape.
|
||||
func stageDurationFromStage(s model.Stage) string {
|
||||
if s.StartedAt == nil {
|
||||
return ""
|
||||
}
|
||||
end := time.Now()
|
||||
if s.CompletedAt != nil {
|
||||
end = *s.CompletedAt
|
||||
}
|
||||
d := end.Sub(*s.StartedAt)
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
switch {
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||
case d < 10*time.Second:
|
||||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm", int(d/time.Minute))
|
||||
default:
|
||||
return fmt.Sprintf("%dh", int(d/time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -9,10 +9,14 @@ import (
|
||||
// TileData pairs a host with its latest run and the derived fields the
|
||||
// tile needs to render: spec-diff count (server-side diff result) and
|
||||
// the on-disk path to the hold-key artifact when the run is holding.
|
||||
// LastSeenAt is the host-mode agent's most recent heartbeat.
|
||||
// LastSeenAt is the host-mode agent's most recent heartbeat. Stages is
|
||||
// the list of canonical-order stage rows for Latest, used by HostTile
|
||||
// to render the mini run-view; nil/empty for never-run hosts (a ghost
|
||||
// dot strip is rendered from DefaultStageOrder).
|
||||
type TileData struct {
|
||||
Host model.Host
|
||||
Latest *model.Run
|
||||
Stages []model.Stage
|
||||
SpecDiffCritical int
|
||||
HoldKeyPath string
|
||||
LastSeenAt *time.Time
|
||||
|
||||
@@ -17,10 +17,14 @@ import (
|
||||
// TileData pairs a host with its latest run and the derived fields the
|
||||
// tile needs to render: spec-diff count (server-side diff result) and
|
||||
// the on-disk path to the hold-key artifact when the run is holding.
|
||||
// LastSeenAt is the host-mode agent's most recent heartbeat.
|
||||
// LastSeenAt is the host-mode agent's most recent heartbeat. Stages is
|
||||
// the list of canonical-order stage rows for Latest, used by HostTile
|
||||
// to render the mini run-view; nil/empty for never-run hosts (a ghost
|
||||
// dot strip is rendered from DefaultStageOrder).
|
||||
type TileData struct {
|
||||
Host model.Host
|
||||
Latest *model.Run
|
||||
Stages []model.Stage
|
||||
SpecDiffCritical int
|
||||
HoldKeyPath string
|
||||
LastSeenAt *time.Time
|
||||
|
||||
@@ -4,34 +4,56 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
"vetting/internal/store"
|
||||
)
|
||||
|
||||
// HostDetailData is the full payload the detail handler hands to the
|
||||
// HostDetail template. Tile carries host + latest-run enrichment (same
|
||||
// shape the dashboard tile uses), Stages/SpecDiffs drive the pipeline
|
||||
// and diff list. LogReplay is the pre-rendered history fragment
|
||||
// produced by logs.Hub.Replay on the initial page render so the operator
|
||||
// sees prior output without waiting for a fresh SSE event.
|
||||
// HostDetail template. Tile carries host + viewed-run enrichment (same
|
||||
// shape the dashboard tile uses), Stages/SpecDiffs/SubSteps drive the
|
||||
// pipeline, diff list, and expanded step panel. History backs the runs
|
||||
// sidebar (last 20, newest first). DefaultStepStage is the stage name
|
||||
// whose <details> opens by default on page load — running → failed →
|
||||
// Reporting. LogReplay is the pre-rendered history fragment produced
|
||||
// by logs.Hub.Replay on the initial page render so the operator sees
|
||||
// prior output without waiting for a fresh SSE event.
|
||||
type HostDetailData struct {
|
||||
Tile TileData
|
||||
Stages []model.Stage
|
||||
SpecDiffs []model.SpecDiff
|
||||
LogReplay string
|
||||
Tile TileData
|
||||
Stages []model.Stage
|
||||
SpecDiffs []model.SpecDiff
|
||||
SubSteps []model.SubStep
|
||||
History []model.Run
|
||||
DefaultStepStage string
|
||||
LogReplay string
|
||||
// LogReplayByStage is the pre-rendered log HTML grouped by stage
|
||||
// name. Each ActiveStep panel picks its own bucket so the detail
|
||||
// page doesn't fire nine disk scans per reload. The "" key holds
|
||||
// orphan/framing lines (no stage set), surfaced under the "Run"
|
||||
// pseudo-step at the top of the page.
|
||||
LogReplayByStage map[string]string
|
||||
}
|
||||
|
||||
// HostDetail is the GitHub-Actions-style run view. Layout is: meta
|
||||
// drawer (collapsed) → run header + actions → hold banner → horizontal
|
||||
// pipeline → two-column body (active-step pane + runs sidebar) → spec
|
||||
// diffs at the bottom. Each section keeps its own sse-swap target so
|
||||
// live updates don't trigger whole-page reflows.
|
||||
templ HostDetail(d HostDetailData) {
|
||||
@Layout(d.Tile.Host.Name) {
|
||||
<section class="detail" hx-ext="sse" sse-connect="/events">
|
||||
<section class="detail detail-v2" hx-ext="sse" sse-connect="/events">
|
||||
<nav class="breadcrumb">
|
||||
<a href="/">Dashboard</a>
|
||||
<span class="breadcrumb-sep">/</span>
|
||||
<span>{ d.Tile.Host.Name }</span>
|
||||
</nav>
|
||||
|
||||
@HostMetaDrawer(d)
|
||||
|
||||
@DetailSummary(d)
|
||||
@DetailActions(d)
|
||||
@DetailHold(d)
|
||||
|
||||
if d.Tile.Latest != nil {
|
||||
@PipelineSection(d.Tile.Latest, BuildPipeline(d.Tile.Latest, d.Stages))
|
||||
@@ -42,51 +64,41 @@ templ HostDetail(d HostDetailData) {
|
||||
</section>
|
||||
}
|
||||
|
||||
@DetailHold(d)
|
||||
@DetailActions(d)
|
||||
@DetailSpecDiffs(d)
|
||||
|
||||
if d.Tile.Latest != nil {
|
||||
@LogTabs(d.Tile.Latest.ID, d.LogReplay)
|
||||
}
|
||||
|
||||
<section class="detail-section detail-host-meta">
|
||||
<details>
|
||||
<summary><h2>Host details</h2></summary>
|
||||
if d.Tile.Host.Notes != "" {
|
||||
<div class="detail-notes">
|
||||
<h3>Notes</h3>
|
||||
<p>{ d.Tile.Host.Notes }</p>
|
||||
</div>
|
||||
<div class="detail-body">
|
||||
<div class="active-step-pane">
|
||||
if d.Tile.Latest != nil {
|
||||
for _, stageName := range store.DefaultStageOrder {
|
||||
@ActiveStep(ActiveStepData{
|
||||
RunID: d.Tile.Latest.ID,
|
||||
Stage: stageForName(d.Stages, stageName),
|
||||
SubSteps: SubStepsForStage(d.SubSteps, stageName),
|
||||
LogReplay: d.LogReplayByStage[stageName],
|
||||
Open: stageName == d.DefaultStepStage,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
<p class="detail-empty">No run yet. Click <strong>Start vetting</strong> to begin.</p>
|
||||
}
|
||||
<div class="detail-spec">
|
||||
<h3>Expected spec</h3>
|
||||
<pre class="detail-spec-yaml">{ d.Tile.Host.ExpectedSpecYAML }</pre>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
@RunsSidebar(d)
|
||||
</div>
|
||||
|
||||
@DetailSpecDiffs(d)
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
// DetailSummary is the status header at the top of the detail page:
|
||||
// name, last-seen badge, run status, MAC/WoL/failed-stage/spec-diffs
|
||||
// meta grid. Keyed on host ID so the SSE event name is stable across
|
||||
// run turnover.
|
||||
templ DetailSummary(d HostDetailData) {
|
||||
<header
|
||||
id={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
|
||||
class={ "detail-summary", "tile-" + tileMood(d.Tile.Latest) }
|
||||
sse-swap={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<div class="detail-summary-head">
|
||||
<h1 class="detail-name">{ d.Tile.Host.Name }</h1>
|
||||
<div class="detail-status-row">
|
||||
<span class={ "tile-last-seen", lastSeenClass(d.Tile.LastSeenAt) }>{ lastSeenLabel(d.Tile.LastSeenAt) }</span>
|
||||
<span class="tile-status">{ tileStatus(d.Tile.Latest) }</span>
|
||||
</div>
|
||||
</div>
|
||||
// HostMetaDrawer is the collapsed "host details" block at the top of the
|
||||
// page: MAC, WoL, last-seen, expected spec, and notes. <details> defaults
|
||||
// to closed so the run itself stays above the fold; operators open it
|
||||
// when they need the provisioning info.
|
||||
templ HostMetaDrawer(d HostDetailData) {
|
||||
<details class="host-meta-drawer">
|
||||
<summary>
|
||||
<span class="meta-summary-label">Host details</span>
|
||||
<span class={ "tile-last-seen", lastSeenClass(d.Tile.LastSeenAt) }>{ lastSeenLabel(d.Tile.LastSeenAt) }</span>
|
||||
<span class="meta-summary-mac">{ d.Tile.Host.MAC }</span>
|
||||
</summary>
|
||||
<dl class="detail-meta">
|
||||
<div>
|
||||
<dt>MAC</dt>
|
||||
@@ -96,19 +108,48 @@ templ DetailSummary(d HostDetailData) {
|
||||
<dt>WoL</dt>
|
||||
<dd>{ fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort) }</dd>
|
||||
</div>
|
||||
</dl>
|
||||
if d.Tile.Host.Notes != "" {
|
||||
<div class="detail-notes">
|
||||
<h3>Notes</h3>
|
||||
<p>{ d.Tile.Host.Notes }</p>
|
||||
</div>
|
||||
}
|
||||
<div class="detail-spec">
|
||||
<h3>Expected spec</h3>
|
||||
<pre class="detail-spec-yaml">{ d.Tile.Host.ExpectedSpecYAML }</pre>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
|
||||
// DetailSummary is the run header: host name on the left, run number,
|
||||
// status icon, and elapsed/total duration. Keyed on host ID so the SSE
|
||||
// event name is stable across run turnover.
|
||||
templ DetailSummary(d HostDetailData) {
|
||||
<header
|
||||
id={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
|
||||
class={ "run-header", "tile-" + tileMood(d.Tile.Latest) }
|
||||
sse-swap={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<div class="run-header-left">
|
||||
<h1 class="detail-name">{ d.Tile.Host.Name }</h1>
|
||||
if d.Tile.Latest != nil {
|
||||
<span class="run-number">{ fmt.Sprintf("run #%d", d.Tile.Latest.ID) }</span>
|
||||
}
|
||||
<span class={ "run-status-badge", "run-status-" + tileMood(d.Tile.Latest) }>{ tileStatus(d.Tile.Latest) }</span>
|
||||
if d.Tile.Latest != nil {
|
||||
<span class="run-duration">{ runDuration(d.Tile.Latest) }</span>
|
||||
}
|
||||
</div>
|
||||
<div class="run-header-right">
|
||||
if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" {
|
||||
<div>
|
||||
<dt>Failed at</dt>
|
||||
<dd class="bad">{ d.Tile.Latest.FailedStage }</dd>
|
||||
</div>
|
||||
<span class="run-failed-stage">failed at <strong>{ d.Tile.Latest.FailedStage }</strong></span>
|
||||
}
|
||||
if d.Tile.SpecDiffCritical > 0 {
|
||||
<div>
|
||||
<dt>Spec diffs</dt>
|
||||
<dd class="bad">{ fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical) }</dd>
|
||||
</div>
|
||||
<span class="run-diffs bad">{ fmt.Sprintf("%d critical diff", d.Tile.SpecDiffCritical) }</span>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
|
||||
@@ -124,7 +165,6 @@ templ DetailActions(d HostDetailData) {
|
||||
sse-swap={ fmt.Sprintf("detail-actions-%d", d.Tile.Host.ID) }
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<h2>Actions</h2>
|
||||
<div class="detail-actions-row">
|
||||
if canStart(d.Tile) {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Tile.Host.ID)) } class="inline detail-start-form">
|
||||
@@ -132,7 +172,7 @@ templ DetailActions(d HostDetailData) {
|
||||
<input type="checkbox" name="non_destructive" value="1"/>
|
||||
Non-destructive (skip wipe-probe + disk writes)
|
||||
</label>
|
||||
<button type="submit">Start vetting</button>
|
||||
<button type="submit" class="btn-primary">Start vetting</button>
|
||||
</form>
|
||||
} else if canStartIfOnline(d.Tile.Latest) {
|
||||
<button type="button" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button>
|
||||
@@ -141,19 +181,19 @@ templ DetailActions(d HostDetailData) {
|
||||
}
|
||||
if canCancel(d.Tile.Latest) {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Tile.Host.ID)) } class="inline" onsubmit="return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');">
|
||||
<button type="submit" class="danger">Cancel run</button>
|
||||
<button type="submit" class="btn-danger">Cancel run</button>
|
||||
</form>
|
||||
}
|
||||
if canOverrideWipe(d.Tile.Latest) {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Tile.Host.ID)) } class="inline">
|
||||
<button type="submit" class="danger">Override wipe-probe</button>
|
||||
<button type="submit" class="btn-danger">Override wipe-probe</button>
|
||||
</form>
|
||||
}
|
||||
if hasReport(d.Tile.Latest) {
|
||||
<a class="button-like" href={ templ.SafeURL(fmt.Sprintf("/reports/%d", d.Tile.Latest.ID)) } target="_blank" rel="noopener">View report</a>
|
||||
}
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Tile.Host.ID)) } class="inline">
|
||||
<button type="submit" class="danger">Delete host</button>
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Tile.Host.ID)) } class="inline" onsubmit="return confirm('Delete host and all its runs?');">
|
||||
<button type="submit" class="btn-danger">Delete host</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
@@ -192,20 +232,21 @@ templ DetailSpecDiffs(d HostDetailData) {
|
||||
}
|
||||
}
|
||||
|
||||
// DetailHold renders the "Host is holding — SSH available" block while
|
||||
// a run is in FailedHolding with an IP recorded. Otherwise it emits an
|
||||
// empty wrapper so the first push when the hold actually fires has a
|
||||
// target. Keyed on run ID for the same reason as DetailSpecDiffs.
|
||||
// DetailHold renders the "Host is holding — SSH available" strip across
|
||||
// the top when a run is in FailedHolding with an IP recorded. Otherwise
|
||||
// it emits an empty wrapper so the first SSE push when the hold actually
|
||||
// fires has a target. Keyed on run ID for the same reason as
|
||||
// DetailSpecDiffs.
|
||||
templ DetailHold(d HostDetailData) {
|
||||
if d.Tile.Latest != nil {
|
||||
if d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" {
|
||||
<section
|
||||
id={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
|
||||
class="detail-section detail-hold"
|
||||
class="hold-banner"
|
||||
sse-swap={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<h2>Host is holding — SSH available</h2>
|
||||
<span class="hold-banner-label">Host is holding — SSH available:</span>
|
||||
<code class="hold-ssh">{ sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP) }</code>
|
||||
</section>
|
||||
} else {
|
||||
@@ -219,6 +260,32 @@ templ DetailHold(d HostDetailData) {
|
||||
}
|
||||
}
|
||||
|
||||
// RunsSidebar is the right-rail history list: last 20 runs for this
|
||||
// host, newest first. Each entry links back to /hosts/{id}?run=N for
|
||||
// navigation into a past run. The row for the currently-viewed run is
|
||||
// flagged so CSS can highlight it.
|
||||
templ RunsSidebar(d HostDetailData) {
|
||||
<aside class="runs-sidebar">
|
||||
<h2 class="runs-sidebar-heading">History</h2>
|
||||
if len(d.History) == 0 {
|
||||
<p class="runs-sidebar-empty">No runs yet.</p>
|
||||
} else {
|
||||
<ul class="runs-sidebar-list">
|
||||
for _, r := range d.History {
|
||||
<li class={ "runs-sidebar-item", "runs-sidebar-" + tileMood(&r), runSidebarActiveClass(d.Tile.Latest, r.ID) }>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/hosts/%d?run=%d", d.Tile.Host.ID, r.ID)) }>
|
||||
<span class={ "runs-sidebar-dot", "runs-sidebar-dot-" + tileMood(&r) }>{ runSidebarGlyph(&r) }</span>
|
||||
<span class="runs-sidebar-id">{ fmt.Sprintf("#%d", r.ID) }</span>
|
||||
<span class="runs-sidebar-started">{ relativeTime(r.StartedAt) }</span>
|
||||
<span class="runs-sidebar-duration">{ runDuration(&r) }</span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</aside>
|
||||
}
|
||||
|
||||
// RenderDetailSummaryString, RenderDetailActionsString,
|
||||
// RenderDetailSpecDiffsString, RenderDetailHoldString each render one
|
||||
// component to a string so the orchestrator can publish SSE fragments
|
||||
@@ -259,37 +326,98 @@ func hasCriticalDiff(diffs []model.SpecDiff) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// LogTabs renders an "All" tab plus one tab per stage in DefaultStageOrder.
|
||||
// Switching is pure CSS: hidden radio inputs drive sibling-selector
|
||||
// visibility on the panes. Each pane carries its own sse-swap target so
|
||||
// live events append only to the relevant pane. The All pane is seeded
|
||||
// with replay HTML so reload on an in-flight run still shows history.
|
||||
templ LogTabs(runID int64, replay string) {
|
||||
<section class="detail-section log-section">
|
||||
<h2>Log</h2>
|
||||
<div class="log-tabs">
|
||||
<input type="radio" name={ fmt.Sprintf("log-tab-%d", runID) } id={ fmt.Sprintf("log-tab-%d-all", runID) } class="log-tab-input log-tab-all" checked/>
|
||||
<label for={ fmt.Sprintf("log-tab-%d-all", runID) } class="log-tab-label">All</label>
|
||||
for _, s := range store.DefaultStageOrder {
|
||||
<input type="radio" name={ fmt.Sprintf("log-tab-%d", runID) } id={ fmt.Sprintf("log-tab-%d-%s", runID, s) } class={ "log-tab-input", "log-tab-" + s }/>
|
||||
<label for={ fmt.Sprintf("log-tab-%d-%s", runID, s) } class="log-tab-label">{ s }</label>
|
||||
}
|
||||
<div
|
||||
class="log-pane log-pane-all"
|
||||
id={ fmt.Sprintf("log-%d", runID) }
|
||||
sse-swap={ fmt.Sprintf("log-%d", runID) }
|
||||
hx-swap="beforeend show:bottom"
|
||||
>
|
||||
@templ.Raw(replay)
|
||||
</div>
|
||||
for _, s := range store.DefaultStageOrder {
|
||||
<div
|
||||
class={ "log-pane", "log-pane-" + s }
|
||||
id={ fmt.Sprintf("log-%d-%s", runID, s) }
|
||||
sse-swap={ fmt.Sprintf("log-%d-%s", runID, s) }
|
||||
hx-swap="beforeend show:bottom"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
// stageForName returns the persisted Stage row for a given name, or a
|
||||
// synthetic pending-state stub when no row has been seeded yet (e.g.
|
||||
// the run is still in a pre-stage). Keeps the template free of nil
|
||||
// checks and ghost logic — ActiveStep always gets a concrete Stage.
|
||||
func stageForName(stages []model.Stage, name string) model.Stage {
|
||||
for _, s := range stages {
|
||||
if s.Name == name {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return model.Stage{Name: name, State: model.StagePending}
|
||||
}
|
||||
|
||||
// runSidebarActiveClass marks the row for the currently-viewed run so
|
||||
// CSS can highlight it. Empty string (no class added) when the row isn't
|
||||
// the active one.
|
||||
func runSidebarActiveClass(viewed *model.Run, rowID int64) string {
|
||||
if viewed != nil && viewed.ID == rowID {
|
||||
return "runs-sidebar-active"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runDuration formats the elapsed time for a run using the same buckets
|
||||
// as stageDuration. In-flight runs clock from StartedAt to now so the
|
||||
// header duration keeps updating on each SSE tick.
|
||||
func runDuration(r *model.Run) string {
|
||||
if r == nil || r.StartedAt.IsZero() {
|
||||
return ""
|
||||
}
|
||||
end := time.Now()
|
||||
if r.CompletedAt != nil {
|
||||
end = *r.CompletedAt
|
||||
}
|
||||
d := end.Sub(r.StartedAt)
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
switch {
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||
case d < 10*time.Second:
|
||||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm %ds", int(d/time.Minute), int((d%time.Minute)/time.Second))
|
||||
default:
|
||||
return fmt.Sprintf("%dh %dm", int(d/time.Hour), int((d%time.Hour)/time.Minute))
|
||||
}
|
||||
}
|
||||
|
||||
// relativeTime renders a past time as "2m ago" / "1h ago" / "3d ago"
|
||||
// for the runs-sidebar. Future times (clock skew on the host) render as
|
||||
// "now" so the sidebar never shows nonsense.
|
||||
func relativeTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
d := time.Since(t)
|
||||
if d < 0 {
|
||||
return "now"
|
||||
}
|
||||
if d < time.Minute {
|
||||
return "just now"
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%dm ago", int(d/time.Minute))
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh ago", int(d/time.Hour))
|
||||
}
|
||||
return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
|
||||
}
|
||||
|
||||
// runSidebarGlyph mirrors stageMarker for run-state: ✓ / ! / ● / –.
|
||||
// Used inside the sidebar dot so the color + glyph carry redundant
|
||||
// meaning.
|
||||
func runSidebarGlyph(r *model.Run) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
switch r.State {
|
||||
case model.StateCompleted:
|
||||
return "✓"
|
||||
case model.StateFailed, model.StateFailedHolding:
|
||||
return "!"
|
||||
case model.StateReleased, model.StateCancelled:
|
||||
return "–"
|
||||
}
|
||||
if r.State.IsTerminal() {
|
||||
return ""
|
||||
}
|
||||
return "●"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,13 +5,18 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
"vetting/internal/store"
|
||||
)
|
||||
|
||||
// HostTile renders a single dashboard card. The whole tile is a link
|
||||
// to /hosts/{id} (via a CSS-overlay <a>) — every control beyond the one
|
||||
// primary action lives on the detail page. It's the SSE-swap target
|
||||
// for per-host tile refreshes (`tile-N`).
|
||||
// HostTile renders a single dashboard card as a mini run-view. The whole
|
||||
// tile is a link to /hosts/{id} (via a CSS-overlay <a>) — every control
|
||||
// beyond the one primary action lives on the detail page. It's the SSE-
|
||||
// swap target for per-host tile refreshes (`tile-N`). The step list is
|
||||
// a compact vertical strip of the 9 canonical stages with just a
|
||||
// coloured dot per stage; operators can read run health at a glance
|
||||
// across the whole dashboard without drilling in.
|
||||
templ HostTile(t TileData) {
|
||||
<article
|
||||
id={ fmt.Sprintf("host-%d", t.Host.ID) }
|
||||
@@ -27,6 +32,17 @@ templ HostTile(t TileData) {
|
||||
<div class="tile-status">{ tileStatus(t.Latest) }</div>
|
||||
</div>
|
||||
</header>
|
||||
if t.Latest != nil {
|
||||
<div class="tile-meta-row">
|
||||
<span class="tile-run-id">{ fmt.Sprintf("#%d", t.Latest.ID) }</span>
|
||||
<span class="tile-run-duration">{ runDuration(t.Latest) }</span>
|
||||
</div>
|
||||
}
|
||||
<ol class="tile-steplist">
|
||||
for _, name := range store.DefaultStageOrder {
|
||||
@tileStep(stageForName(t.Stages, name))
|
||||
}
|
||||
</ol>
|
||||
<div class="tile-primary-action">
|
||||
if canStart(t) {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)) } class="inline tile-start-form">
|
||||
@@ -49,6 +65,17 @@ templ HostTile(t TileData) {
|
||||
</article>
|
||||
}
|
||||
|
||||
// tileStep renders one entry of the tile's mini step-list: a small
|
||||
// coloured dot plus the short stage name. Kept as its own templ so the
|
||||
// markup stays consistent with the detail page's larger stage-dot
|
||||
// elements (same class prefix, different size via the `-sm` modifier).
|
||||
templ tileStep(s model.Stage) {
|
||||
<li class={ "tile-step", "tile-step-" + string(s.State) }>
|
||||
<span class={ "stage-dot", "stage-dot-sm", "stage-dot-" + string(s.State) }>{ stageMarker(string(s.State)) }</span>
|
||||
<span class="tile-step-name">{ s.Name }</span>
|
||||
</li>
|
||||
}
|
||||
|
||||
func canOverrideWipe(r *model.Run) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
|
||||
@@ -13,13 +13,18 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
"vetting/internal/store"
|
||||
)
|
||||
|
||||
// HostTile renders a single dashboard card. The whole tile is a link
|
||||
// to /hosts/{id} (via a CSS-overlay <a>) — every control beyond the one
|
||||
// primary action lives on the detail page. It's the SSE-swap target
|
||||
// for per-host tile refreshes (`tile-N`).
|
||||
// HostTile renders a single dashboard card as a mini run-view. The whole
|
||||
// tile is a link to /hosts/{id} (via a CSS-overlay <a>) — every control
|
||||
// beyond the one primary action lives on the detail page. It's the SSE-
|
||||
// swap target for per-host tile refreshes (`tile-N`). The step list is
|
||||
// a compact vertical strip of the 9 canonical stages with just a
|
||||
// coloured dot per stage; operators can read run health at a glance
|
||||
// across the whole dashboard without drilling in.
|
||||
func HostTile(t TileData) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
@@ -53,7 +58,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("host-%d", t.Host.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 17, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 22, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -79,7 +84,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("tile-%d", t.Host.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 19, Col: 46}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -92,7 +97,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var6 templ.SafeURL
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d", t.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 22, Col: 80}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 27, Col: 80}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -105,7 +110,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs("Open " + t.Host.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 22, Col: 117}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 27, Col: 117}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -118,7 +123,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(t.Host.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 39}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 29, Col: 39}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -153,7 +158,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(t.LastSeenAt))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 26, Col: 95}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 31, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -166,77 +171,222 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(t.Latest))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 27, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 32, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div></div></header><div class=\"tile-primary-action\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div></div></header>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if canStart(t) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<form method=\"post\" action=\"")
|
||||
if t.Latest != nil {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"tile-meta-row\"><span class=\"tile-run-id\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 templ.SafeURL
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)))
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("#%d", t.Latest.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 32, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 37, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"inline tile-start-form\"><label class=\"tile-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> Non-destructive</label> <button type=\"submit\">Start vetting</button></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span> <span class=\"tile-run-duration\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if canStartIfOnline(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<button type=\"button\" disabled title=\"host is not heartbeating — install the reporter via /register/quick.sh on the target host\">Start vetting</button>")
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(runDuration(t.Latest))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if canCancel(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<form method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 templ.SafeURL
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", t.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 42, Col: 90}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 38, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" class=\"inline tile-cancel-form\" onsubmit=\"return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');\"><button type=\"submit\" class=\"danger\">Cancel run</button></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if hasReport(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<a class=\"button-like\" href=\"")
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<ol class=\"tile-steplist\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, name := range store.DefaultStageOrder {
|
||||
templ_7745c5c3_Err = tileStep(stageForName(t.Stages, name)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</ol><div class=\"tile-primary-action\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if canStart(t) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<form method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 templ.SafeURL
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)))
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 46, Col: 88}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 48, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" target=\"_blank\" rel=\"noopener\">View report</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"inline tile-start-form\"><label class=\"tile-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> Non-destructive</label> <button type=\"submit\">Start vetting</button></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if canStartIfOnline(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<button type=\"button\" disabled title=\"host is not heartbeating — install the reporter via /register/quick.sh on the target host\">Start vetting</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if canCancel(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<form method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 templ.SafeURL
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", t.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 58, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"inline tile-cancel-form\" onsubmit=\"return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');\"><button type=\"submit\" class=\"danger\">Cancel run</button></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if hasReport(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<a class=\"button-like\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 templ.SafeURL
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 62, Col: 88}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" target=\"_blank\" rel=\"noopener\">View report</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></article>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div></article>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// tileStep renders one entry of the tile's mini step-list: a small
|
||||
// coloured dot plus the short stage name. Kept as its own templ so the
|
||||
// markup stays consistent with the detail page's larger stage-dot
|
||||
// elements (same class prefix, different size via the `-sm` modifier).
|
||||
func tileStep(s model.Stage) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var18 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var18 == nil {
|
||||
templ_7745c5c3_Var18 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var19 = []any{"tile-step", "tile-step-" + string(s.State)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var19...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<li class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var19).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 = []any{"stage-dot", "stage-dot-sm", "stage-dot-" + string(s.State)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var21...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<span class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var21).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(string(s.State)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 74, Col: 108}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span> <span class=\"tile-step-name\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 75, Col: 39}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</span></li>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ package templates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
"vetting/internal/store"
|
||||
)
|
||||
|
||||
func TestHumanAgoFrom(t *testing.T) {
|
||||
@@ -96,6 +98,99 @@ func TestHostTile_DisabledStartWhenOffline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostTile_MiniRunView asserts the tile renders a step-list entry
|
||||
// for every canonical stage, colours the dots according to the mixed
|
||||
// stage states in the fixture, and surfaces the run id + duration in
|
||||
// the meta row. This is the contract the dashboard leans on: the
|
||||
// operator should be able to read run health across all tiles without
|
||||
// drilling into any of them.
|
||||
func TestHostTile_MiniRunView(t *testing.T) {
|
||||
now := time.Now()
|
||||
started := now.Add(-3 * time.Minute)
|
||||
latest := &model.Run{
|
||||
ID: 17,
|
||||
State: model.StateSMART,
|
||||
StartedAt: started,
|
||||
}
|
||||
// Mixed states: first two stages passed, SMART running, rest pending.
|
||||
stages := []model.Stage{
|
||||
{Name: "Inventory", State: model.StagePassed},
|
||||
{Name: "SpecValidate", State: model.StagePassed},
|
||||
{Name: "SMART", State: model.StageRunning},
|
||||
}
|
||||
data := TileData{
|
||||
Host: model.Host{ID: 42, Name: "tile-test", MAC: "aa:bb:cc:dd:ee:ff"},
|
||||
Latest: latest,
|
||||
Stages: stages,
|
||||
LastSeenAt: &now,
|
||||
}
|
||||
var buf strings.Builder
|
||||
if err := HostTile(data).Render(context.Background(), &buf); err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
html := buf.String()
|
||||
|
||||
// Step list exists and contains every canonical stage name so the
|
||||
// operator reads a full 9-dot strip regardless of how far the run got.
|
||||
if !strings.Contains(html, `<ol class="tile-steplist">`) {
|
||||
t.Fatalf("tile missing step list: %s", html)
|
||||
}
|
||||
for _, s := range store.DefaultStageOrder {
|
||||
want := fmt.Sprintf(`<span class="tile-step-name">%s</span>`, s)
|
||||
if !strings.Contains(html, want) {
|
||||
t.Fatalf("tile missing step name %q: %s", s, html)
|
||||
}
|
||||
}
|
||||
// Colours: the two passed stages got passed dots; SMART got a running
|
||||
// dot; CPUStress (no fixture row) falls back to pending.
|
||||
mustContain := []string{
|
||||
`stage-dot stage-dot-sm stage-dot-passed`,
|
||||
`stage-dot stage-dot-sm stage-dot-running`,
|
||||
`stage-dot stage-dot-sm stage-dot-pending`,
|
||||
}
|
||||
for _, c := range mustContain {
|
||||
if !strings.Contains(html, c) {
|
||||
t.Fatalf("tile missing expected dot classes %q: %s", c, html)
|
||||
}
|
||||
}
|
||||
// Meta row: run id + a duration string (minutes for a 3m-old run).
|
||||
if !strings.Contains(html, `#17`) {
|
||||
t.Fatalf("tile missing run id #17: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, `class="tile-run-duration"`) {
|
||||
t.Fatalf("tile missing duration element: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostTile_GhostSteplist: a never-run host still gets a 9-dot
|
||||
// ghost strip (all pending). Keeps the tile height stable so the
|
||||
// dashboard grid doesn't reflow as hosts gain their first run.
|
||||
func TestHostTile_GhostSteplist(t *testing.T) {
|
||||
now := time.Now()
|
||||
data := TileData{
|
||||
Host: model.Host{ID: 1, Name: "fresh", MAC: "aa:bb:cc:dd:ee:01"},
|
||||
LastSeenAt: &now,
|
||||
}
|
||||
var buf strings.Builder
|
||||
if err := HostTile(data).Render(context.Background(), &buf); err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
html := buf.String()
|
||||
for _, s := range store.DefaultStageOrder {
|
||||
want := fmt.Sprintf(`<span class="tile-step-name">%s</span>`, s)
|
||||
if !strings.Contains(html, want) {
|
||||
t.Fatalf("ghost tile missing stage %q: %s", s, html)
|
||||
}
|
||||
}
|
||||
if strings.Contains(html, `stage-dot-passed`) || strings.Contains(html, `stage-dot-running`) || strings.Contains(html, `stage-dot-failed`) {
|
||||
t.Fatalf("ghost tile should have only pending dots: %s", html)
|
||||
}
|
||||
// No run → no meta row (suppresses "#0 · 0s" when no run exists).
|
||||
if strings.Contains(html, `class="tile-run-id"`) {
|
||||
t.Fatalf("ghost tile should omit run id: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastSeenLabelAndClass(t *testing.T) {
|
||||
if got := lastSeenLabel(nil); got != "never" {
|
||||
t.Fatalf("label nil = %q, want never", got)
|
||||
|
||||
@@ -10,6 +10,7 @@ templ Layout(title string) {
|
||||
<link rel="stylesheet" href="/static/app.css"/>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.2" integrity="sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/htmx-ext-sse@2.2.2" integrity="sha384-fw+eTlCc7suMV/1w/7fr2/PmwElUIt5i82bi+qTiLXvjRXZ2/FkiTNA/w0MhXnGI" crossorigin="anonymous"></script>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</head>
|
||||
<body hx-boost="true">
|
||||
<header class="topbar">
|
||||
|
||||
@@ -42,7 +42,7 @@ func Layout(title string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " — Vetting</title><link rel=\"stylesheet\" href=\"/static/app.css\"><script src=\"https://unpkg.com/htmx.org@2.0.2\" integrity=\"sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ\" crossorigin=\"anonymous\"></script><script src=\"https://unpkg.com/htmx-ext-sse@2.2.2\" integrity=\"sha384-fw+eTlCc7suMV/1w/7fr2/PmwElUIt5i82bi+qTiLXvjRXZ2/FkiTNA/w0MhXnGI\" crossorigin=\"anonymous\"></script></head><body hx-boost=\"true\"><header class=\"topbar\"><div class=\"brand\">Vetting</div><nav><a href=\"/\">Dashboard</a> <a href=\"/hosts/new\">Register host</a></nav><div class=\"session\"><span class=\"heartbeat\" hx-ext=\"sse\" sse-connect=\"/events\" sse-swap=\"heartbeat\">·</span></div></header><main>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " — Vetting</title><link rel=\"stylesheet\" href=\"/static/app.css\"><script src=\"https://unpkg.com/htmx.org@2.0.2\" integrity=\"sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ\" crossorigin=\"anonymous\"></script><script src=\"https://unpkg.com/htmx-ext-sse@2.2.2\" integrity=\"sha384-fw+eTlCc7suMV/1w/7fr2/PmwElUIt5i82bi+qTiLXvjRXZ2/FkiTNA/w0MhXnGI\" crossorigin=\"anonymous\"></script><script src=\"/static/app.js\" defer></script></head><body hx-boost=\"true\"><header class=\"topbar\"><div class=\"brand\">Vetting</div><nav><a href=\"/\">Dashboard</a> <a href=\"/hosts/new\">Register host</a></nav><div class=\"session\"><span class=\"heartbeat\" hx-ext=\"sse\" sse-connect=\"/events\" sse-swap=\"heartbeat\">·</span></div></header><main>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func BareLayout(title string) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 38, Col: 17}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 39, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -14,53 +14,58 @@ type RegistrationForm struct {
|
||||
templ Registration(form RegistrationForm) {
|
||||
@Layout("Register host") {
|
||||
<section class="form-wrap">
|
||||
<h1>Register host</h1>
|
||||
<header class="form-wrap-head">
|
||||
<h1>Register host</h1>
|
||||
<a class="button-secondary" href="/">Back to dashboard</a>
|
||||
</header>
|
||||
if form.Error != "" {
|
||||
<div class="error">{ form.Error }</div>
|
||||
}
|
||||
if form.QuickRegisterURL != "" {
|
||||
<div class="quick-register">
|
||||
<section class="detail-section quick-register">
|
||||
<h2>Quick register <span class="muted">(recommended)</span></h2>
|
||||
<p>Run this on the target host as root before wiping. It auto-detects MAC and hardware, then registers with this orchestrator:</p>
|
||||
<pre class="one-liner"><code>{ "curl -fsSL " + form.QuickRegisterURL + "/register/quick.sh | sudo bash" }</code></pre>
|
||||
<p class="muted">After the script prints <code>OK</code>, refresh the dashboard and click <b>Start vetting</b> on the new host.</p>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
<details class="manual-register">
|
||||
<summary>Register manually</summary>
|
||||
<form method="post" action="/hosts" class="host-form">
|
||||
<label>
|
||||
Name
|
||||
<input type="text" name="name" value={ form.Name } required pattern="[A-Za-z0-9_\-\.]+" placeholder="pve-node-03"/>
|
||||
</label>
|
||||
<label>
|
||||
MAC address
|
||||
<input type="text" name="mac" value={ form.MAC } required placeholder="aa:bb:cc:dd:ee:ff"/>
|
||||
</label>
|
||||
<div class="grid-2">
|
||||
<section class="detail-section manual-register-card">
|
||||
<details class="manual-register">
|
||||
<summary><h2>Register manually</h2></summary>
|
||||
<form method="post" action="/hosts" class="host-form">
|
||||
<label>
|
||||
WoL broadcast IP
|
||||
<input type="text" name="wol_broadcast_ip" value={ form.WoLBroadcastIP } required placeholder="10.0.0.255"/>
|
||||
Name
|
||||
<input type="text" name="name" value={ form.Name } required pattern="[A-Za-z0-9_\-\.]+" placeholder="pve-node-03"/>
|
||||
</label>
|
||||
<label>
|
||||
WoL port
|
||||
<input type="number" name="wol_port" value={ defaultPort(form.WoLPort) } min="1" max="65535"/>
|
||||
MAC address
|
||||
<input type="text" name="mac" value={ form.MAC } required placeholder="aa:bb:cc:dd:ee:ff"/>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Expected hardware spec (YAML)
|
||||
<textarea name="expected_spec_yaml" rows="12" required placeholder="cpu: model_match: ...">{ form.ExpectedSpecYAML }</textarea>
|
||||
</label>
|
||||
<label>
|
||||
Notes
|
||||
<textarea name="notes" rows="3">{ form.Notes }</textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit">Register</button>
|
||||
<a class="button-secondary" href="/">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
<div class="grid-2">
|
||||
<label>
|
||||
WoL broadcast IP
|
||||
<input type="text" name="wol_broadcast_ip" value={ form.WoLBroadcastIP } required placeholder="10.0.0.255"/>
|
||||
</label>
|
||||
<label>
|
||||
WoL port
|
||||
<input type="number" name="wol_port" value={ defaultPort(form.WoLPort) } min="1" max="65535"/>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Expected hardware spec (YAML)
|
||||
<textarea name="expected_spec_yaml" rows="12" required placeholder="cpu: model_match: ...">{ form.ExpectedSpecYAML }</textarea>
|
||||
</label>
|
||||
<label>
|
||||
Notes
|
||||
<textarea name="notes" rows="3">{ form.Notes }</textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-primary">Register</button>
|
||||
<a class="button-secondary" href="/">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section class=\"form-wrap\"><h1>Register host</h1>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section class=\"form-wrap\"><header class=\"form-wrap-head\"><h1>Register host</h1><a class=\"button-secondary\" href=\"/\">Back to dashboard</a></header>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(form.Error)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 19, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 22, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -76,32 +76,32 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
}
|
||||
}
|
||||
if form.QuickRegisterURL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"quick-register\"><h2>Quick register <span class=\"muted\">(recommended)</span></h2><p>Run this on the target host as root before wiping. It auto-detects MAC and hardware, then registers with this orchestrator:</p><pre class=\"one-liner\"><code>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<section class=\"detail-section quick-register\"><h2>Quick register <span class=\"muted\">(recommended)</span></h2><p>Run this on the target host as root before wiping. It auto-detects MAC and hardware, then registers with this orchestrator:</p><pre class=\"one-liner\"><code>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("curl -fsSL " + form.QuickRegisterURL + "/register/quick.sh | sudo bash")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 25, Col: 108}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 28, Col: 108}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</code></pre><p class=\"muted\">After the script prints <code>OK</code>, refresh the dashboard and click <b>Start vetting</b> on the new host.</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</code></pre><p class=\"muted\">After the script prints <code>OK</code>, refresh the dashboard and click <b>Start vetting</b> on the new host.</p></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<details class=\"manual-register\"><summary>Register manually</summary><form method=\"post\" action=\"/hosts\" class=\"host-form\"><label>Name <input type=\"text\" name=\"name\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<section class=\"detail-section manual-register-card\"><details class=\"manual-register\"><summary><h2>Register manually</h2></summary><form method=\"post\" action=\"/hosts\" class=\"host-form\"><label>Name <input type=\"text\" name=\"name\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(form.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 34, Col: 54}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 38, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -114,7 +114,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(form.MAC)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 38, Col: 52}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 42, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -127,7 +127,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(form.WoLBroadcastIP)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 43, Col: 77}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 47, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -140,7 +140,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(defaultPort(form.WoLPort))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 47, Col: 77}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 51, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -153,7 +153,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(form.ExpectedSpecYAML)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 52, Col: 126}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 56, Col: 127}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -166,13 +166,13 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(form.Notes)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 56, Col: 50}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 60, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</textarea></label><div class=\"actions\"><button type=\"submit\">Register</button> <a class=\"button-secondary\" href=\"/\">Cancel</a></div></form></details></section>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</textarea></label><div class=\"actions\"><button type=\"submit\" class=\"btn-primary\">Register</button> <a class=\"button-secondary\" href=\"/\">Cancel</a></div></form></details></section></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
)
|
||||
|
||||
// subStepDuration formats a sub-step's elapsed time the same way
|
||||
// stageDuration does for pipeline nodes. Empty string when not started.
|
||||
func subStepDuration(ss model.SubStep) string {
|
||||
if ss.StartedAt == nil {
|
||||
return ""
|
||||
}
|
||||
end := time.Now()
|
||||
if ss.CompletedAt != nil {
|
||||
end = *ss.CompletedAt
|
||||
}
|
||||
d := end.Sub(*ss.StartedAt)
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
switch {
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||
case d < 10*time.Second:
|
||||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm", int(d/time.Minute))
|
||||
default:
|
||||
return fmt.Sprintf("%dh", int(d/time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
// subStepMarker mirrors stageMarker — a single-char glyph used inside the
|
||||
// state badge. StageState values reused verbatim for sub-steps.
|
||||
func subStepMarker(s model.StageState) string {
|
||||
switch s {
|
||||
case model.StagePassed:
|
||||
return "✓"
|
||||
case model.StageFailed:
|
||||
return "!"
|
||||
case model.StageRunning:
|
||||
return "●"
|
||||
case model.StageSkipped:
|
||||
return "–"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SubStepRow renders one sub-step entry for the expanded-step pane. The
|
||||
// outer <div> carries the sse-swap target keyed by (runID, stage,
|
||||
// ordinal) so the orchestrator's PublishSubStepUpdate can swap just this
|
||||
// row without touching the rest of the stage panel. hx-swap="outerHTML"
|
||||
// keeps the attributes intact across repeat swaps.
|
||||
templ SubStepRow(ss model.SubStep) {
|
||||
<div
|
||||
id={ fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal) }
|
||||
class={ "substep", "substep-" + string(ss.State) }
|
||||
sse-swap={ fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal) }
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<span class={ "substep-badge", "substep-badge-" + string(ss.State) }>{ subStepMarker(ss.State) }</span>
|
||||
<span class="substep-name">{ ss.Name }</span>
|
||||
<span class="substep-duration">{ subStepDuration(ss) }</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
// RenderSubStepRowString is the one-shot renderer the orchestrator
|
||||
// registers as SubStepRenderer so it can emit substep-* SSE payloads
|
||||
// without importing the templates package directly.
|
||||
func RenderSubStepRowString(ss model.SubStep) string {
|
||||
var buf bytes.Buffer
|
||||
_ = SubStepRow(ss).Render(context.Background(), &buf)
|
||||
return buf.String()
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
)
|
||||
|
||||
// subStepDuration formats a sub-step's elapsed time the same way
|
||||
// stageDuration does for pipeline nodes. Empty string when not started.
|
||||
func subStepDuration(ss model.SubStep) string {
|
||||
if ss.StartedAt == nil {
|
||||
return ""
|
||||
}
|
||||
end := time.Now()
|
||||
if ss.CompletedAt != nil {
|
||||
end = *ss.CompletedAt
|
||||
}
|
||||
d := end.Sub(*ss.StartedAt)
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
switch {
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||
case d < 10*time.Second:
|
||||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm", int(d/time.Minute))
|
||||
default:
|
||||
return fmt.Sprintf("%dh", int(d/time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
// subStepMarker mirrors stageMarker — a single-char glyph used inside the
|
||||
// state badge. StageState values reused verbatim for sub-steps.
|
||||
func subStepMarker(s model.StageState) string {
|
||||
switch s {
|
||||
case model.StagePassed:
|
||||
return "✓"
|
||||
case model.StageFailed:
|
||||
return "!"
|
||||
case model.StageRunning:
|
||||
return "●"
|
||||
case model.StageSkipped:
|
||||
return "–"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SubStepRow renders one sub-step entry for the expanded-step pane. The
|
||||
// outer <div> carries the sse-swap target keyed by (runID, stage,
|
||||
// ordinal) so the orchestrator's PublishSubStepUpdate can swap just this
|
||||
// row without touching the rest of the stage panel. hx-swap="outerHTML"
|
||||
// keeps the attributes intact across repeat swaps.
|
||||
func SubStepRow(ss model.SubStep) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var2 = []any{"substep", "substep-" + string(ss.State)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 63, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" sse-swap=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 65, Col: 80}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" hx-swap=\"outerHTML\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 = []any{"substep-badge", "substep-badge-" + string(ss.State)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<span class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var6).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(subStepMarker(ss.State))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 68, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span> <span class=\"substep-name\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(ss.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 69, Col: 38}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span> <span class=\"substep-duration\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(subStepDuration(ss))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 70, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RenderSubStepRowString is the one-shot renderer the orchestrator
|
||||
// registers as SubStepRenderer so it can emit substep-* SSE payloads
|
||||
// without importing the templates package directly.
|
||||
func RenderSubStepRowString(ss model.SubStep) string {
|
||||
var buf bytes.Buffer
|
||||
_ = SubStepRow(ss).Render(context.Background(), &buf)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
Reference in New Issue
Block a user