Compare commits

...

7 Commits

Author SHA1 Message Date
josh 017c3c38fe feat(ui): 15-point UX overhaul — affordances, feedback, and navigation
CI / Lint + build + test (push) Successful in 1m43s
Release / detect (push) Successful in 6s
Release / build-live-image (push) Has been skipped
Release / bundle (push) Successful in 52s
Address friction points identified in a full interface audit:
- Re-add status badge to dashboard tiles so run state is visible at a glance
- Add active nav indicator and SSE connection health monitor (live/stale)
- Show manual registration form by default instead of hiding behind <details>
- Add copy-to-clipboard buttons on SSH hold command and quick-register one-liner
- Replace tooltip-only profile descriptions with inline visible text
- Clarify non-destructive toggle with explicit stage impact description
- Replace disabled "Start vetting" button with actionable offline guidance
- Swap browser confirm() dialogs for styled inline confirmations
- Add colored badge to spec diffs summary visible when collapsed
- Add distinct "cancelled" mood for cancelled runs (vs idle)
- Add match count to log search and aria-label for accessibility
- Add styled 404 page rendered inside the app shell

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 20:08:07 -04:00
josh 8367ec2a9f docs: comprehensive documentation expansion
CI / Lint + build + test (push) Successful in 1m36s
Release / detect (push) Successful in 5s
Release / build-live-image (push) Has been skipped
Release / bundle (push) Successful in 49s
Add 4 new doc files (configuration reference, development guide, API
reference with full request/response schemas, database schema), expand
the README with a feature list and how-it-works walkthrough, fix
missing Firmware and Burn stages in architecture.md and test-suite.md,
add threshold engine and host-mode agent sections, and add godoc
comments to 11 packages and 6 model types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 18:37:26 -04:00
josh 17ec55cb85 chore: cleanup sprint — dead CSS, dedup helpers, handler refactor
CI / Lint + build + test (push) Successful in 1m34s
Release / detect (push) Successful in 4s
Release / build-live-image (push) Has been skipped
Release / bundle (push) Successful in 1m5s
Remove ~126 lines of orphaned CSS from tile slim-down and old detail
layout. Consolidate 4 duplicate duration formatters into shared
elapsed()/fmtElapsed() helpers. Break 160-line Result handler into
focused sub-functions. Implement real Hub.Shutdown() (was a no-op).
Standardize agent error responses to JSON. Replace panic() in router
init with error return. Extract magic numbers as named constants.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 20:39:38 -04:00
josh c11573eeeb feat(ui): slim dashboard tile to hostname + online/offline only
CI / Lint + build + test (push) Successful in 1m33s
Release / detect (push) Successful in 5s
Release / build-live-image (push) Has been skipped
Release / bundle (push) Successful in 53s
Run status, Start/Cancel/View controls, and non-destructive toggle all
live on /hosts/{id} — duplicating them on the dashboard tile clogged
the grid and wouldn't scale past a handful of hosts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 22:56:05 -04:00
josh 6d50f3a804 feat(install): polish install UX with banner, spinner, progress bar, summary
CI / Lint + build + test (push) Successful in 1m38s
Release / detect (push) Successful in 7s
Release / build-live-image (push) Has been skipped
Release / bundle (push) Successful in 55s
Wrap the three install scripts in a shared inline style block (TTY/UTF-8/
NO_COLOR-aware) so the one-liner install looks and feels intentional:
banner on start, timed step lines, braille spinner over silent apt/
systemctl calls with failure log dumps, single-line curl progress bars
with size-prefixed headers, and a summary box at the end with live-image
version + service state + next steps. install.sh defers banner/summary
to proxmox-install.sh when VETTING_INSTALL_WRAPPED is set so the two
scripts compose without duplication.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 22:29:44 -04:00
josh 48f992a451 bump live-image
CI / Lint + build + test (push) Successful in 1m35s
Release / detect (push) Successful in 6s
Release / build-live-image (push) Successful in 7m40s
Release / bundle (push) Successful in 50s
2026-04-20 21:31:09 -04:00
josh 98cdd95b50 chore(release): add registry auth diagnostic to build-live-image
CI / Lint + build + test (push) Successful in 1m38s
Release / detect (push) Successful in 5s
Release / build-live-image (push) Has been skipped
Release / bundle (push) Failing after 53s
Echoes OWNER, token length, and whoami before the upload so a 401
disambiguates: missing/empty token, bad OWNER resolution, or token
authenticating as a different user.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 21:27:23 -04:00
50 changed files with 3080 additions and 962 deletions
+18
View File
@@ -110,6 +110,24 @@ jobs:
- name: Build live image
run: make live-image
- name: Debug registry auth context
run: |
set -euo pipefail
echo "OWNER='${OWNER}'"
echo "LI_VERSION='${LI_VERSION}'"
echo "REGISTRY_URL='${REGISTRY_URL}'"
echo "TOKEN_LEN=${#REGISTRY_TOKEN}"
# Probe whoami via the token to confirm it authenticates and
# resolves to the expected user. A 401 here narrows the
# failure to token/secret injection; a 200 with a different
# username narrows it to OWNER mismatch.
curl -sS -o /tmp/whoami.json -w 'whoami_status=%{http_code}\n' \
-H "Authorization: token ${REGISTRY_TOKEN}" \
"${REGISTRY_URL}/api/v1/user"
echo "whoami body:"
cat /tmp/whoami.json
echo
- name: Publish live-image/${{ env.LI_VERSION }}/
run: |
set -euo pipefail
+59 -2
View File
@@ -11,13 +11,70 @@ Built for solo-operator home labs: one Go binary, SQLite + flat files,
HTMX + SSE UI, bundled dnsmasq, optional ntfy / Discord / SMTP
notifications.
## Features
- **Automated PXE boot** — dnsmasq proxy-DHCP serves a disposable
Debian live image to registered MACs. No VLAN, no dedicated bridge.
- **11-stage validation pipeline** — Inventory, Firmware, SpecValidate,
SMART, CPUStress, Storage, Network, Burn, GPU, PSU, Reporting.
- **Three vetting profiles** — quick (~10 min), deep (~8-12 h),
soak (~36-40 h). Same probes and gates; only durations scale.
- **Server-side threshold engine** — per-run rules evaluate every
sensor batch in real time. Critical breaches (thermal runaway,
EDAC UE, voltage sag) fail the run immediately.
- **FailedHolding with SSH** — when a stage fails the pipeline parks
the host and issues a one-time SSH key so you can triage in the
live image.
- **Real-time dashboard** — HTMX + SSE push tile updates, stage
progress, sub-step detail, and live log tailing to the browser.
- **Pluggable notifications** — ntfy, Discord webhooks, and SMTP with
severity-routed delivery.
- **Non-destructive mode** — skip badblocks + wipe for hosts with
data you want to keep.
- **Host-mode agent** — a persistent reporter that heartbeats from
installed hosts and reboots into the live image on command.
- **Self-contained HTML reports** — offline-viewable summaries with
inlined CSS; machine-readable JSON alongside.
- **Four-layer safety gates** — MAC allowlist, signed run token,
wipe probe, device allowlist protect against accidental disk wipes.
- **Janitor** — automatic retention-based cleanup of artifact files
and log files.
## How it works
1. Install the host-mode agent on each node (one-liner from the
dashboard's quick-register script).
2. Register the host in the web UI — name, MAC, expected hardware
spec (YAML).
3. Click **Start Vetting** and choose a profile (quick / deep / soak).
4. The host-mode agent receives a `reboot_for_vetting` heartbeat
command and reboots into PXE.
5. dnsmasq serves the iPXE script; the host boots a disposable Linux
live image containing the vetting agent.
6. The agent claims the run (token auth), then walks through each
stage — posting logs, sensor readings, and results back to the
orchestrator.
7. Thresholds are evaluated server-side on every sensor batch.
8. **Pass** — auto-reboot to local disk, HTML report generated,
notification fires.
9. **Fail** — pipeline parks in FailedHolding, SSH key issued,
notification fires. Operator triages and retries or releases.
## Documentation
- [docs/operations.md](docs/operations.md) — install + first run +
- [docs/operations.md](docs/operations.md) — install, first run,
troubleshooting
- [docs/architecture.md](docs/architecture.md) — packages, state
machine, protocol
machine, protocol, safety model
- [docs/test-suite.md](docs/test-suite.md) — what each stage measures
- [docs/configuration.md](docs/configuration.md) — every YAML config
knob, profiles, thresholds
- [docs/api-reference.md](docs/api-reference.md) — HTTP API with
request/response schemas, SSE events
- [docs/database.md](docs/database.md) — SQLite schema, tables,
entity relationships
- [docs/development.md](docs/development.md) — dev setup, building,
testing, adding stages
## Quick start (local, against QEMU)
+4
View File
@@ -1,3 +1,7 @@
// Agent binary. Runs in two modes: live-image (default, no args)
// parses /proc/cmdline and enters the claim loop; host-mode
// ("vetting-agent host") reads /etc/vetting/host-agent.yaml and
// becomes a persistent heartbeat reporter.
package main
import (
+7 -1
View File
@@ -1,3 +1,6 @@
// Orchestrator binary. Wires config, stores, runner, dispatcher,
// PXE supervisor, iperf supervisor, janitor, notifiers, and HTTP
// router, then serves until SIGTERM/SIGINT.
package main
import (
@@ -208,12 +211,15 @@ func main() {
ui.PXE = supervisor
}
router := httpserver.NewRouter(httpserver.Deps{
router, err := httpserver.NewRouter(httpserver.Deps{
UI: ui,
Agent: agentAPI,
LiveDir: cfg.PXE.LiveDir,
AgentAssetDir: cfg.Agent.AssetDir,
})
if err != nil {
log.Fatalf("router: %v", err)
}
srv := &http.Server{
Addr: cfg.Server.Bind,
+256 -70
View File
@@ -33,6 +33,172 @@
#
set -euo pipefail
# ---- style helpers -----------------------------------------------------
# Identical block across proxmox-install.sh, install.sh, pxe-setup.sh.
# Inlined (not sourced) so the curl|bash entrypoint renders without a
# prior fetch. Respects NO_COLOR, non-TTY, and non-UTF-8 locales.
_color=0; _unicode=0
if [[ -t 2 && -z "${NO_COLOR:-}" && "${TERM:-dumb}" != "dumb" ]]; then
_color=1
fi
case "${LC_ALL:-}${LANG:-}" in
*UTF-8*|*utf8*|*UTF8*) _unicode=1 ;;
esac
if (( _color )); then
_B=$'\033[1m'; _D=$'\033[2m'; _R=$'\033[0m'
_C=$'\033[1;36m'; _G=$'\033[32m'; _Y=$'\033[33m'; _E=$'\033[31m'
else
_B=""; _D=""; _R=""; _C=""; _G=""; _Y=""; _E=""
fi
if (( _unicode )); then
_S_STEP="▸"; _S_OK="✓"; _S_WARN="⚠"; _S_FAIL="✗"; _S_INFO="·"
_B_TL="╭"; _B_TR="╮"; _B_BL="╰"; _B_BR="╯"; _B_H="─"; _B_V="│"
else
_S_STEP="-->"; _S_OK="[ok]"; _S_WARN="[!]"; _S_FAIL="[x]"; _S_INFO="*"
_B_TL="+"; _B_TR="+"; _B_BL="+"; _B_BR="+"; _B_H="-"; _B_V="|"
fi
_start_epoch="$(date +%s)"; _step_epoch=""; _quiet_log=""; _spin_pid=""
_fmt_dur() {
local s=$1
if (( s < 60 )); then printf '%ds' "$s"
elif (( s < 3600 )); then printf '%dm %ds' "$((s/60))" "$((s%60))"
else printf '%dh %dm' "$((s/3600))" "$(((s%3600)/60))"
fi
}
banner() {
local inner=" $1 " w line="" i=0
w=${#inner}
while (( i < w )); do line+="${_B_H}"; i=$((i+1)); done
printf '\n %s%s%s%s%s\n' "${_C}" "${_B_TL}" "${line}" "${_B_TR}" "${_R}" >&2
printf ' %s%s%s%s%s%s%s%s%s\n' "${_C}" "${_B_V}" "${_R}" "${_B}" "${inner}" "${_R}" "${_C}" "${_B_V}" "${_R}" >&2
printf ' %s%s%s%s%s\n\n' "${_C}" "${_B_BL}" "${line}" "${_B_BR}" "${_R}" >&2
}
step() {
_step_epoch="$(date +%s)"
printf '%s%s%s %s%s%s\n' "${_C}" "${_S_STEP}" "${_R}" "${_B}" "$1" "${_R}" >&2
}
ok() {
local tail=""
if [[ -n "${_step_epoch}" ]]; then
tail=" ${_D}($(_fmt_dur $(( $(date +%s) - _step_epoch ))))${_R}"
fi
printf ' %s%s%s %s%s\n' "${_G}" "${_S_OK}" "${_R}" "$1" "${tail}" >&2
_step_epoch=""
}
info() { printf ' %s%s %s%s\n' "${_D}" "${_S_INFO}" "$1" "${_R}" >&2; }
warn() { printf ' %s%s %s%s\n' "${_Y}" "${_S_WARN}" "$1" "${_R}" >&2; }
die() {
printf '\n%s%s %s%s\n' "${_E}" "${_S_FAIL}" "$1" "${_R}" >&2
if [[ -n "${_quiet_log:-}" && -s "${_quiet_log}" ]]; then
printf ' %s── last 40 lines of output ──%s\n' "${_D}" "${_R}" >&2
tail -n 40 "${_quiet_log}" | sed 's/^/ /' >&2
printf ' %sfull log: %s%s\n' "${_D}" "${_quiet_log}" "${_R}" >&2
fi
exit 1
}
_SPIN=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
_start_spin() {
(( _color && _unicode )) || return 0
local label="$1"
(
local i=0
while :; do
printf '\r %s%s%s %s ' "${_C}" "${_SPIN[i]}" "${_R}" "${label}" >&2
i=$(( (i+1) % ${#_SPIN[@]} ))
sleep 0.1
done
) &
_spin_pid=$!
}
_stop_spin() {
[[ -n "${_spin_pid}" ]] || return 0
kill "${_spin_pid}" 2>/dev/null || true
wait "${_spin_pid}" 2>/dev/null || true
_spin_pid=""
printf '\r\033[2K' >&2
}
# run_quiet "<label>" -- <cmd ...>
run_quiet() {
local label="$1"; shift
[[ "${1:-}" == "--" ]] && shift
local log start rc=0
log="$(mktemp -t vetting-install.XXXXXX)"
_quiet_log="${log}"
start="$(date +%s)"
_start_spin "${label}"
"$@" >"${log}" 2>&1 || rc=$?
_stop_spin
local dt=$(( $(date +%s) - start ))
if (( rc == 0 )); then
printf ' %s%s%s %s %s(%s)%s\n' "${_G}" "${_S_OK}" "${_R}" "${label}" "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
rm -f "${log}"; _quiet_log=""
return 0
fi
printf ' %s%s %s failed (exit %d)%s\n' "${_E}" "${_S_FAIL}" "${label}" "${rc}" "${_R}" >&2
printf ' %s── last 40 lines of output ──%s\n' "${_D}" "${_R}" >&2
tail -n 40 "${log}" | sed 's/^/ /' >&2
printf ' %sfull log: %s%s\n' "${_D}" "${log}" "${_R}" >&2
exit "${rc}"
}
# curl_pretty --label LABEL --url URL -- [extra curl args, including -o PATH]
curl_pretty() {
local label="download" url=""
local args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--label) label="$2"; shift 2 ;;
--url) url="$2"; args+=("$2"); shift 2 ;;
--) shift; args+=("$@"); break ;;
*) args+=("$1"); shift ;;
esac
done
local size_str=""
if [[ -n "${url}" ]]; then
local size_bytes
size_bytes="$(curl -fsSLI --max-time 5 "${url}" 2>/dev/null \
| awk 'tolower($1) ~ /^content-length:/ {print $2}' \
| tr -d '\r' | tail -n1)"
if [[ "${size_bytes}" =~ ^[0-9]+$ ]]; then
local human
human="$(numfmt --to=iec --suffix=B --format='%.1f' "${size_bytes}" 2>/dev/null || echo "${size_bytes}B")"
size_str=" ${_D}(${human})${_R}"
fi
fi
printf '%s%s%s %sdownloading %s%s%s\n' "${_C}" "${_S_STEP}" "${_R}" "${_B}" "${label}" "${_R}" "${size_str}" >&2
local start rc=0
start="$(date +%s)"
if [[ -t 2 ]]; then
curl -fL --progress-bar "${args[@]}" || rc=$?
else
curl -fsSL "${args[@]}" || rc=$?
fi
local dt=$(( $(date +%s) - start ))
if (( rc == 0 )); then
printf ' %s%s%s %s ready %s(%s)%s\n' "${_G}" "${_S_OK}" "${_R}" "${label}" "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
return 0
fi
printf ' %s%s %s download failed (exit %d)%s\n' "${_E}" "${_S_FAIL}" "${label}" "${rc}" "${_R}" >&2
exit "${rc}"
}
rule_open() {
local title="$1"
local dashes=$(( 46 - ${#title} - 4 ))
local tail="" i=0
(( dashes < 2 )) && dashes=2
while (( i < dashes )); do tail+="${_B_H}"; i=$((i+1)); done
printf '\n%s%s%s %s %s%s\n' "${_C}" "${_B_TL}" "${_B_H}" "${title}" "${tail}" "${_R}" >&2
}
rule_close() {
local line="" i=0
while (( i < 46 )); do line+="${_B_H}"; i=$((i+1)); done
printf '%s%s%s%s\n' "${_C}" "${_B_BL}" "${line:1}" "${_R}" >&2
}
total_elapsed() {
local dt=$(( $(date +%s) - _start_epoch ))
printf '%sinstalled in %s%s\n\n' "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
}
# ---- end style helpers -------------------------------------------------
BINARY=""
AGENT_BINARY=""
CONFIG_DIR="/etc/vetting"
@@ -78,8 +244,21 @@ while [[ $# -gt 0 ]]; do
done
if [[ $EUID -ne 0 ]]; then
echo "install.sh must be run as root (try: sudo $0)" >&2
exit 1
die "install.sh must be run as root (try: sudo $0)"
fi
# Standalone invocation gets its own banner; wrapped runs share the one
# from proxmox-install.sh. Tracked in VETTING_INSTALL_WRAPPED so we don't
# render the banner or summary twice.
if [[ -z "${VETTING_INSTALL_WRAPPED:-}" ]]; then
banner "TheWrightServer · Vetting"
fi
# Snapshot service state so the summary can pick between fresh-install
# and upgrade-path wording even after we restart the service below.
_prev_svc_enabled=0
if systemctl is-enabled --quiet vetting.service 2>/dev/null; then
_prev_svc_enabled=1
fi
# heal_pxe_config: make sure /etc/vetting/vetting.yaml's pxe.interface
@@ -140,18 +319,17 @@ heal_pxe_config() {
fi
if [[ -z "${detected_iface}" || -z "${detected_subnet}" ]]; then
echo "WARN: pxe is enabled in ${config} but pxe.interface=${cur_iface:-<empty>} / pxe.subnet=${cur_subnet:-<empty>} is stale," >&2
echo " and no default-route NIC was found to auto-detect from. Edit the file manually before starting." >&2
warn "pxe enabled but pxe.interface=${cur_iface:-<empty>} / pxe.subnet=${cur_subnet:-<empty>} is stale, and no default-route NIC was found to auto-detect from. Edit ${config} manually before starting."
return 0
fi
local iface_to_write="${cur_iface}" subnet_to_write="${cur_subnet}"
if (( iface_ok == 0 )); then
echo "==> pxe.interface \"${cur_iface}\" is not present on this host; auto-patching to \"${detected_iface}\""
info "pxe.interface \"${cur_iface}\" is not present on this host; auto-patching to \"${detected_iface}\""
iface_to_write="${detected_iface}"
fi
if (( subnet_ok == 0 )); then
echo "==> pxe.subnet \"${cur_subnet:-<empty>}\" is missing/invalid; auto-patching to \"${detected_subnet}\""
info "pxe.subnet \"${cur_subnet:-<empty>}\" is missing/invalid; auto-patching to \"${detected_subnet}\""
subnet_to_write="${detected_subnet}"
fi
@@ -181,7 +359,7 @@ refresh_live_image() {
local bundle_ver
bundle_ver="$(tr -d '[:space:]' < "${pointer}" 2>/dev/null || true)"
if [[ -z "${bundle_ver}" ]]; then
echo "WARN: bundle's ${pointer} is empty; skipping live-image fetch" >&2
warn "bundle's ${pointer} is empty; skipping live-image fetch"
return 0
fi
@@ -191,27 +369,25 @@ refresh_live_image() {
fi
if [[ "${bundle_ver}" == "${installed_ver}" && "${FORCE_LIVE_IMAGE:-0}" != "1" ]]; then
echo "==> live-image already at ${bundle_ver}; skipping fetch (FORCE_LIVE_IMAGE=1 to redownload)"
info "live-image already at ${bundle_ver}; skipping fetch (FORCE_LIVE_IMAGE=1 to redownload)"
return 0
fi
if [[ -z "${REGISTRY_URL:-}" ]]; then
echo "WARN: REGISTRY_URL is not set; cannot fetch live-image ${bundle_ver}. Re-run via proxmox-install.sh or export REGISTRY_URL." >&2
warn "REGISTRY_URL is not set; cannot fetch live-image ${bundle_ver}. Re-run via proxmox-install.sh or export REGISTRY_URL."
return 0
fi
local owner="${PACKAGE_OWNER:-josh}"
local base="${REGISTRY_URL%/}/api/packages/${owner}/generic/live-image/${bundle_ver}"
echo "==> fetching live-image ${bundle_ver} (was '${installed_ver:-none}') from ${base}"
step "fetching live-image ${bundle_ver} (was '${installed_ver:-none}')"
local tmp
tmp="$(mktemp -d)"
# shellcheck disable=SC2064
trap "rm -rf '${tmp}'" RETURN
# Default curl meter shows rate + ETA, which matters for the ~300 MB
# initrd on slow links.
curl -fL -o "${tmp}/vmlinuz" "${base}/vmlinuz"
curl -fL -o "${tmp}/initrd.img" "${base}/initrd.img"
curl_pretty --label "kernel (vmlinuz)" --url "${base}/vmlinuz" -- -o "${tmp}/vmlinuz"
curl_pretty --label "initrd (initrd.img)" --url "${base}/initrd.img" -- -o "${tmp}/initrd.img"
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${LIVE_DIR}"
install -m 0644 -o "${SERVICE_USER}" -g "${SERVICE_USER}" \
@@ -235,8 +411,7 @@ if [[ -z "${BINARY}" ]]; then
done
fi
if [[ -z "${BINARY}" || ! -x "${BINARY}" ]]; then
echo "could not find a vetting binary to install; pass --binary PATH or run 'make orchestrator-linux' first" >&2
exit 1
die "could not find a vetting binary to install; pass --binary PATH or run 'make orchestrator-linux' first"
fi
if [[ -z "${AGENT_BINARY}" ]]; then
@@ -248,35 +423,41 @@ if [[ -z "${AGENT_BINARY}" ]]; then
done
fi
if [[ -z "${AGENT_BINARY}" || ! -x "${AGENT_BINARY}" ]]; then
echo "could not find a vetting-agent binary; pass --agent-binary PATH or run 'make agent-linux' first" >&2
exit 1
die "could not find a vetting-agent binary; pass --agent-binary PATH or run 'make agent-linux' first"
fi
echo "==> installing runtime dependencies"
step "installing runtime dependencies"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y --no-install-recommends \
ca-certificates dnsmasq iperf3
run_quiet "apt: dnsmasq, iperf3, ca-certificates" -- bash -c '
apt-get update -qq
apt-get install -qq -y --no-install-recommends ca-certificates dnsmasq iperf3
'
echo "==> creating ${SERVICE_USER} user"
step "creating ${SERVICE_USER} user"
if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then
useradd --system \
--home-dir "${STATE_DIR}" \
--shell /usr/sbin/nologin \
"${SERVICE_USER}"
ok "${SERVICE_USER} created"
else
info "${SERVICE_USER} user already exists"
fi
echo "==> preparing directories"
step "preparing directories"
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${STATE_DIR}"
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${LOG_DIR}"
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${ASSET_DIR}"
install -d -m 0755 "${CONFIG_DIR}"
info "state: ${STATE_DIR} · logs: ${LOG_DIR} · config: ${CONFIG_DIR}"
echo "==> installing binary"
step "installing binary"
install -m 0755 "${BINARY}" /usr/local/bin/vetting
install -m 0755 "${AGENT_BINARY}" "${ASSET_DIR}/vetting-agent-linux-amd64"
info "orchestrator: /usr/local/bin/vetting"
info "agent asset: ${ASSET_DIR}/vetting-agent-linux-amd64"
echo "==> installing config and systemd unit"
step "installing config and systemd unit"
# vetting.production.yaml uses absolute /var/lib/vetting + /var/log/vetting
# paths that match the systemd unit's ReadWritePaths. vetting.example.yaml
# uses ./var/... relatives and is only correct for `make run` in a dev tree.
@@ -284,22 +465,21 @@ if [[ ! -f "${CONFIG_DIR}/vetting.yaml" ]]; then
install -m 0640 -o root -g "${SERVICE_USER}" \
"${SCRIPT_DIR}/vetting.production.yaml" \
"${CONFIG_DIR}/vetting.yaml"
echo " -> installed default config at ${CONFIG_DIR}/vetting.yaml"
info "installed default config at ${CONFIG_DIR}/vetting.yaml"
else
echo " -> preserving existing ${CONFIG_DIR}/vetting.yaml"
info "preserving existing ${CONFIG_DIR}/vetting.yaml"
fi
install -m 0644 "${SCRIPT_DIR}/vetting.service" /etc/systemd/system/vetting.service
# Install pxe-setup.sh + its pinned iPXE SHAs into a stable path so the
# operator can run `vetting-pxe-setup ...` after the one-liner install.
# The bundle's tempdir gets wiped by proxmox-install.sh on exit, so
# without this the script would be inaccessible.
# operator can run `vetting-pxe-setup` after the one-liner install.
if [[ -f "${SCRIPT_DIR}/pxe-setup.sh" && -f "${SCRIPT_DIR}/ipxe-shas.txt" ]]; then
echo "==> installing pxe-setup.sh and ipxe-shas.txt"
step "installing pxe-setup.sh and ipxe-shas.txt"
install -d -m 0755 /usr/local/share/vetting
install -m 0755 "${SCRIPT_DIR}/pxe-setup.sh" /usr/local/share/vetting/pxe-setup.sh
install -m 0644 "${SCRIPT_DIR}/ipxe-shas.txt" /usr/local/share/vetting/ipxe-shas.txt
ln -sfn /usr/local/share/vetting/pxe-setup.sh /usr/local/sbin/vetting-pxe-setup
info "vetting-pxe-setup -> /usr/local/share/vetting/pxe-setup.sh"
fi
# Stage the live image into LIVE_DIR. Preference order:
@@ -322,7 +502,7 @@ if [[ -z "${LIVE_IMAGE_SRC}" ]]; then
fi
if [[ -n "${LIVE_IMAGE_SRC}" ]]; then
echo "==> staging live image from ${LIVE_IMAGE_SRC} into ${LIVE_DIR}"
step "staging live image from ${LIVE_IMAGE_SRC}"
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${LIVE_DIR}"
install -m 0644 -o "${SERVICE_USER}" -g "${SERVICE_USER}" \
"${LIVE_IMAGE_SRC}/vmlinuz" "${LIVE_DIR}/vmlinuz"
@@ -342,10 +522,11 @@ if [[ -n "${LIVE_IMAGE_SRC}" ]]; then
break
fi
done
ok "live image staged into ${LIVE_DIR}"
elif [[ -f "${SCRIPT_DIR}/live-image/VERSION" ]]; then
refresh_live_image
else
echo "==> no live image found (bundle/live-image or ../live-image/build); skipping live-dir staging"
info "no live image found (bundle/live-image or ../live-image/build); skipping live-dir staging"
fi
# Disable the distro's dnsmasq so only the orchestrator-supervised
@@ -353,50 +534,55 @@ fi
# something else can re-enable it after configuring a disjoint listen
# address.
if systemctl is-enabled --quiet dnsmasq 2>/dev/null; then
echo "==> disabling distro dnsmasq (orchestrator supervises its own)"
systemctl disable --now dnsmasq
step "disabling distro dnsmasq (orchestrator supervises its own)"
run_quiet "systemctl disable --now dnsmasq" -- systemctl disable --now dnsmasq
fi
echo "==> validating pxe config against this host's interfaces"
step "validating pxe config against this host's interfaces"
heal_pxe_config "${CONFIG_DIR}/vetting.yaml"
systemctl daemon-reload
run_quiet "systemctl daemon-reload" -- systemctl daemon-reload
# Upgrade path: if vetting.service is already enabled, restart it so the
# new binary + live image take effect without an explicit second
# command. First-install path (service not enabled yet) leaves the
# service alone so the operator can edit the config before starting.
if systemctl is-enabled --quiet vetting.service 2>/dev/null; then
echo "==> restarting vetting.service (upgrade path)"
if (( _prev_svc_enabled )); then
step "restarting vetting.service (upgrade path)"
systemctl reset-failed vetting.service 2>/dev/null || true
systemctl restart vetting.service
cat <<EOF
vetting upgraded and restarted. Tail logs with:
journalctl -fu vetting
EOF
else
cat <<EOF
vetting is installed but not yet enabled.
Next steps:
1. Edit ${CONFIG_DIR}/vetting.yaml and set:
- server.bind (127.0.0.1:8080 by default; switch to
0.0.0.0:8080 once you're ready to expose
it on the LAN)
- server.public_url (the URL you'll browse to)
- pxe.* if you want PXE boot support
- notifiers + routes (optional)
2. Start the service:
systemctl enable --now vetting
3. Watch the logs:
journalctl -fu vetting
The UI has no built-in auth — it trusts the LAN. If you need a
password, front the service with a reverse proxy (Caddy/nginx
basic-auth) instead.
EOF
run_quiet "systemctl restart vetting.service" -- systemctl restart vetting.service
fi
# Standalone summary (wrapped runs get the summary from proxmox-install.sh).
if [[ -z "${VETTING_INSTALL_WRAPPED:-}" ]]; then
li_ver="unknown"
[[ -f "${LIVE_DIR}/VERSION" ]] && li_ver="$(tr -d '[:space:]' < "${LIVE_DIR}/VERSION")"
svc_state="not yet enabled"
if systemctl is-enabled --quiet vetting.service 2>/dev/null; then
if systemctl is-active --quiet vetting.service 2>/dev/null; then
svc_state="enabled · running"
else
svc_state="enabled · stopped"
fi
fi
rule_open "installed"
info "live-image ${li_ver}"
info "service ${svc_state}"
info "config ${CONFIG_DIR}/vetting.yaml"
printf '\n' >&2
if (( _prev_svc_enabled )); then
info "upgraded · tail logs with:"
info " journalctl -fu vetting"
else
info "next:"
info " edit ${CONFIG_DIR}/vetting.yaml (server.bind, public_url, pxe.*)"
info " systemctl enable --now vetting"
info " journalctl -fu vetting"
printf '\n' >&2
info "pxe support:"
info " sudo vetting-pxe-setup --interface <NIC> --subnet <CIDR> \\"
info " --orchestrator-url http://<host>:8080"
fi
rule_close
total_elapsed
fi
+218 -36
View File
@@ -22,6 +22,172 @@
# (useful when the local files got corrupted).
set -euo pipefail
# ---- style helpers -----------------------------------------------------
# Identical block across proxmox-install.sh, install.sh, pxe-setup.sh.
# Inlined (not sourced) so the curl|bash entrypoint renders without a
# prior fetch. Respects NO_COLOR, non-TTY, and non-UTF-8 locales.
_color=0; _unicode=0
if [[ -t 2 && -z "${NO_COLOR:-}" && "${TERM:-dumb}" != "dumb" ]]; then
_color=1
fi
case "${LC_ALL:-}${LANG:-}" in
*UTF-8*|*utf8*|*UTF8*) _unicode=1 ;;
esac
if (( _color )); then
_B=$'\033[1m'; _D=$'\033[2m'; _R=$'\033[0m'
_C=$'\033[1;36m'; _G=$'\033[32m'; _Y=$'\033[33m'; _E=$'\033[31m'
else
_B=""; _D=""; _R=""; _C=""; _G=""; _Y=""; _E=""
fi
if (( _unicode )); then
_S_STEP="▸"; _S_OK="✓"; _S_WARN="⚠"; _S_FAIL="✗"; _S_INFO="·"
_B_TL="╭"; _B_TR="╮"; _B_BL="╰"; _B_BR="╯"; _B_H="─"; _B_V="│"
else
_S_STEP="-->"; _S_OK="[ok]"; _S_WARN="[!]"; _S_FAIL="[x]"; _S_INFO="*"
_B_TL="+"; _B_TR="+"; _B_BL="+"; _B_BR="+"; _B_H="-"; _B_V="|"
fi
_start_epoch="$(date +%s)"; _step_epoch=""; _quiet_log=""; _spin_pid=""
_fmt_dur() {
local s=$1
if (( s < 60 )); then printf '%ds' "$s"
elif (( s < 3600 )); then printf '%dm %ds' "$((s/60))" "$((s%60))"
else printf '%dh %dm' "$((s/3600))" "$(((s%3600)/60))"
fi
}
banner() {
local inner=" $1 " w line="" i=0
w=${#inner}
while (( i < w )); do line+="${_B_H}"; i=$((i+1)); done
printf '\n %s%s%s%s%s\n' "${_C}" "${_B_TL}" "${line}" "${_B_TR}" "${_R}" >&2
printf ' %s%s%s%s%s%s%s%s%s\n' "${_C}" "${_B_V}" "${_R}" "${_B}" "${inner}" "${_R}" "${_C}" "${_B_V}" "${_R}" >&2
printf ' %s%s%s%s%s\n\n' "${_C}" "${_B_BL}" "${line}" "${_B_BR}" "${_R}" >&2
}
step() {
_step_epoch="$(date +%s)"
printf '%s%s%s %s%s%s\n' "${_C}" "${_S_STEP}" "${_R}" "${_B}" "$1" "${_R}" >&2
}
ok() {
local tail=""
if [[ -n "${_step_epoch}" ]]; then
tail=" ${_D}($(_fmt_dur $(( $(date +%s) - _step_epoch ))))${_R}"
fi
printf ' %s%s%s %s%s\n' "${_G}" "${_S_OK}" "${_R}" "$1" "${tail}" >&2
_step_epoch=""
}
info() { printf ' %s%s %s%s\n' "${_D}" "${_S_INFO}" "$1" "${_R}" >&2; }
warn() { printf ' %s%s %s%s\n' "${_Y}" "${_S_WARN}" "$1" "${_R}" >&2; }
die() {
printf '\n%s%s %s%s\n' "${_E}" "${_S_FAIL}" "$1" "${_R}" >&2
if [[ -n "${_quiet_log:-}" && -s "${_quiet_log}" ]]; then
printf ' %s── last 40 lines of output ──%s\n' "${_D}" "${_R}" >&2
tail -n 40 "${_quiet_log}" | sed 's/^/ /' >&2
printf ' %sfull log: %s%s\n' "${_D}" "${_quiet_log}" "${_R}" >&2
fi
exit 1
}
_SPIN=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
_start_spin() {
(( _color && _unicode )) || return 0
local label="$1"
(
local i=0
while :; do
printf '\r %s%s%s %s ' "${_C}" "${_SPIN[i]}" "${_R}" "${label}" >&2
i=$(( (i+1) % ${#_SPIN[@]} ))
sleep 0.1
done
) &
_spin_pid=$!
}
_stop_spin() {
[[ -n "${_spin_pid}" ]] || return 0
kill "${_spin_pid}" 2>/dev/null || true
wait "${_spin_pid}" 2>/dev/null || true
_spin_pid=""
printf '\r\033[2K' >&2
}
# run_quiet "<label>" -- <cmd ...>
run_quiet() {
local label="$1"; shift
[[ "${1:-}" == "--" ]] && shift
local log start rc=0
log="$(mktemp -t vetting-install.XXXXXX)"
_quiet_log="${log}"
start="$(date +%s)"
_start_spin "${label}"
"$@" >"${log}" 2>&1 || rc=$?
_stop_spin
local dt=$(( $(date +%s) - start ))
if (( rc == 0 )); then
printf ' %s%s%s %s %s(%s)%s\n' "${_G}" "${_S_OK}" "${_R}" "${label}" "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
rm -f "${log}"; _quiet_log=""
return 0
fi
printf ' %s%s %s failed (exit %d)%s\n' "${_E}" "${_S_FAIL}" "${label}" "${rc}" "${_R}" >&2
printf ' %s── last 40 lines of output ──%s\n' "${_D}" "${_R}" >&2
tail -n 40 "${log}" | sed 's/^/ /' >&2
printf ' %sfull log: %s%s\n' "${_D}" "${log}" "${_R}" >&2
exit "${rc}"
}
# curl_pretty --label LABEL --url URL -- [extra curl args, including -o PATH]
curl_pretty() {
local label="download" url=""
local args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--label) label="$2"; shift 2 ;;
--url) url="$2"; args+=("$2"); shift 2 ;;
--) shift; args+=("$@"); break ;;
*) args+=("$1"); shift ;;
esac
done
local size_str=""
if [[ -n "${url}" ]]; then
local size_bytes
size_bytes="$(curl -fsSLI --max-time 5 "${url}" 2>/dev/null \
| awk 'tolower($1) ~ /^content-length:/ {print $2}' \
| tr -d '\r' | tail -n1)"
if [[ "${size_bytes}" =~ ^[0-9]+$ ]]; then
local human
human="$(numfmt --to=iec --suffix=B --format='%.1f' "${size_bytes}" 2>/dev/null || echo "${size_bytes}B")"
size_str=" ${_D}(${human})${_R}"
fi
fi
printf '%s%s%s %sdownloading %s%s%s\n' "${_C}" "${_S_STEP}" "${_R}" "${_B}" "${label}" "${_R}" "${size_str}" >&2
local start rc=0
start="$(date +%s)"
if [[ -t 2 ]]; then
curl -fL --progress-bar "${args[@]}" || rc=$?
else
curl -fsSL "${args[@]}" || rc=$?
fi
local dt=$(( $(date +%s) - start ))
if (( rc == 0 )); then
printf ' %s%s%s %s ready %s(%s)%s\n' "${_G}" "${_S_OK}" "${_R}" "${label}" "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
return 0
fi
printf ' %s%s %s download failed (exit %d)%s\n' "${_E}" "${_S_FAIL}" "${label}" "${rc}" "${_R}" >&2
exit "${rc}"
}
rule_open() {
local title="$1"
local dashes=$(( 46 - ${#title} - 4 ))
local tail="" i=0
(( dashes < 2 )) && dashes=2
while (( i < dashes )); do tail+="${_B_H}"; i=$((i+1)); done
printf '\n%s%s%s %s %s%s\n' "${_C}" "${_B_TL}" "${_B_H}" "${title}" "${tail}" "${_R}" >&2
}
rule_close() {
local line="" i=0
while (( i < 46 )); do line+="${_B_H}"; i=$((i+1)); done
printf '%s%s%s%s\n' "${_C}" "${_B_BL}" "${line:1}" "${_R}" >&2
}
total_elapsed() {
local dt=$(( $(date +%s) - _start_epoch ))
printf '%sinstalled in %s%s\n\n' "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
}
# ---- end style helpers -------------------------------------------------
REGISTRY_URL="${REGISTRY_URL:-https://gitea.thewrightserver.net}"
PACKAGE_OWNER="${PACKAGE_OWNER:-josh}"
FORCE_LIVE_IMAGE="${FORCE_LIVE_IMAGE:-0}"
@@ -29,7 +195,7 @@ FORCE_LIVE_IMAGE="${FORCE_LIVE_IMAGE:-0}"
for arg in "$@"; do
case "${arg}" in
--force-live-image) FORCE_LIVE_IMAGE=1 ;;
*) echo "unknown arg: ${arg}" >&2; exit 2 ;;
*) die "unknown arg: ${arg}" ;;
esac
done
@@ -41,27 +207,25 @@ export REGISTRY_URL PACKAGE_OWNER FORCE_LIVE_IMAGE
BUNDLE_URL="${REGISTRY_URL}/api/packages/${PACKAGE_OWNER}/generic/vetting/latest/vetting-bundle.tar.gz"
if [[ $EUID -ne 0 ]]; then
echo "proxmox-install.sh must be run as root (try: sudo bash)" >&2
exit 1
die "proxmox-install.sh must be run as root (try: sudo bash)"
fi
echo "==> installing prerequisites"
banner "TheWrightServer · Vetting"
step "installing prerequisites"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y --no-install-recommends \
curl ca-certificates
run_quiet "apt: curl, ca-certificates" -- bash -c '
apt-get update -qq
apt-get install -qq -y --no-install-recommends curl ca-certificates
'
tmp="$(mktemp -d)"
trap 'rm -rf "${tmp}"' EXIT
echo "==> fetching bundle from ${BUNDLE_URL}"
# -f fails on HTTP errors; -L follows redirects. Default meter (rate +
# ETA) is fine now that the bundle is ~30 MB.
curl -fL "${BUNDLE_URL}" -o "${tmp}/vetting-bundle.tar.gz"
curl_pretty --label "vetting bundle" --url "${BUNDLE_URL}" -- -o "${tmp}/vetting-bundle.tar.gz"
bundle_size="$(du -h "${tmp}/vetting-bundle.tar.gz" | cut -f1)"
echo "==> extracting (${bundle_size})"
tar -C "${tmp}" -xzf "${tmp}/vetting-bundle.tar.gz"
step "extracting bundle"
run_quiet "tar -xzf vetting-bundle.tar.gz" -- tar -C "${tmp}" -xzf "${tmp}/vetting-bundle.tar.gz"
# New bundle extracts to vetting-bundle/; legacy bundles used
# vetting-bundle-<sha>/. Match both so a downgrade-pin still works.
@@ -69,34 +233,52 @@ shopt -s nullglob
candidates=( "${tmp}"/vetting-bundle "${tmp}"/vetting-bundle-* )
shopt -u nullglob
if [[ ${#candidates[@]} -ne 1 || ! -d "${candidates[0]}" ]]; then
echo "unexpected bundle layout: expected exactly one vetting-bundle* dir" >&2
exit 1
die "unexpected bundle layout: expected exactly one vetting-bundle* dir"
fi
bundle_dir="${candidates[0]}"
info "bundle: ${bundle_dir##*/}"
echo "==> handing off to install.sh (bundle ${bundle_dir##*/})"
cd "${bundle_dir}"
# install.sh prints its own step/info/ok/warn lines via the same style
# helpers, so output flows seamlessly under this banner. VETTING_INSTALL_WRAPPED
# tells install.sh to suppress its own banner + summary, since we print
# them here.
export VETTING_INSTALL_WRAPPED=1
bash install.sh \
--binary "${bundle_dir}/bin/vetting-linux-amd64" \
--agent-binary "${bundle_dir}/bin/vetting-agent.linux-amd64"
orch_ver="$(cat "${bundle_dir}/VERSION" 2>/dev/null || echo unknown)"
li_ver="$(cat "${bundle_dir}/live-image/VERSION" 2>/dev/null || echo unknown)"
# ---- final summary -----------------------------------------------------
li_ver="$(tr -d '[:space:]' < "${bundle_dir}/live-image/VERSION" 2>/dev/null)"
[[ -z "${li_ver}" ]] && li_ver="unknown"
svc_state="not yet enabled"
if systemctl is-enabled --quiet vetting.service 2>/dev/null; then
if systemctl is-active --quiet vetting.service 2>/dev/null; then
svc_state="enabled · running"
else
svc_state="enabled · stopped"
fi
fi
cat <<EOF
vetting installed: orchestrator ${orch_ver}, live-image ${li_ver}.
To upgrade later, rerun this one-liner. It always pulls the current
latest bundle; the live image is re-downloaded only when its VERSION
has bumped (override with --force-live-image).
For PXE support, run:
sudo vetting-pxe-setup \\
--interface eth0 \\
--subnet 192.168.1.0/24 \\
--orchestrator-url http://<lxc-lan-ip>:8080
See docs/operations.md for the full flow.
EOF
rule_open "installed"
info "live-image ${li_ver}"
info "service ${svc_state}"
info "config /etc/vetting/vetting.yaml"
printf '\n' >&2
if [[ "${svc_state}" == "not yet enabled" ]]; then
info "next:"
info " edit /etc/vetting/vetting.yaml (bind addr, public_url, pxe.*)"
info " systemctl enable --now vetting"
info " journalctl -fu vetting"
else
info "next:"
info " journalctl -fu vetting"
fi
printf '\n' >&2
info "pxe support:"
info " sudo vetting-pxe-setup --interface <NIC> --subnet <CIDR> \\"
info " --orchestrator-url http://<host>:8080"
info "upgrade later:"
info " rerun the one-liner (--force-live-image to force refetch)"
rule_close
total_elapsed
+209 -51
View File
@@ -28,6 +28,172 @@
# --force overwrite a customised pxe: block
set -euo pipefail
# ---- style helpers -----------------------------------------------------
# Identical block across proxmox-install.sh, install.sh, pxe-setup.sh.
# Inlined (not sourced) so the curl|bash entrypoint renders without a
# prior fetch. Respects NO_COLOR, non-TTY, and non-UTF-8 locales.
_color=0; _unicode=0
if [[ -t 2 && -z "${NO_COLOR:-}" && "${TERM:-dumb}" != "dumb" ]]; then
_color=1
fi
case "${LC_ALL:-}${LANG:-}" in
*UTF-8*|*utf8*|*UTF8*) _unicode=1 ;;
esac
if (( _color )); then
_B=$'\033[1m'; _D=$'\033[2m'; _R=$'\033[0m'
_C=$'\033[1;36m'; _G=$'\033[32m'; _Y=$'\033[33m'; _E=$'\033[31m'
else
_B=""; _D=""; _R=""; _C=""; _G=""; _Y=""; _E=""
fi
if (( _unicode )); then
_S_STEP="▸"; _S_OK="✓"; _S_WARN="⚠"; _S_FAIL="✗"; _S_INFO="·"
_B_TL="╭"; _B_TR="╮"; _B_BL="╰"; _B_BR="╯"; _B_H="─"; _B_V="│"
else
_S_STEP="-->"; _S_OK="[ok]"; _S_WARN="[!]"; _S_FAIL="[x]"; _S_INFO="*"
_B_TL="+"; _B_TR="+"; _B_BL="+"; _B_BR="+"; _B_H="-"; _B_V="|"
fi
_start_epoch="$(date +%s)"; _step_epoch=""; _quiet_log=""; _spin_pid=""
_fmt_dur() {
local s=$1
if (( s < 60 )); then printf '%ds' "$s"
elif (( s < 3600 )); then printf '%dm %ds' "$((s/60))" "$((s%60))"
else printf '%dh %dm' "$((s/3600))" "$(((s%3600)/60))"
fi
}
banner() {
local inner=" $1 " w line="" i=0
w=${#inner}
while (( i < w )); do line+="${_B_H}"; i=$((i+1)); done
printf '\n %s%s%s%s%s\n' "${_C}" "${_B_TL}" "${line}" "${_B_TR}" "${_R}" >&2
printf ' %s%s%s%s%s%s%s%s%s\n' "${_C}" "${_B_V}" "${_R}" "${_B}" "${inner}" "${_R}" "${_C}" "${_B_V}" "${_R}" >&2
printf ' %s%s%s%s%s\n\n' "${_C}" "${_B_BL}" "${line}" "${_B_BR}" "${_R}" >&2
}
step() {
_step_epoch="$(date +%s)"
printf '%s%s%s %s%s%s\n' "${_C}" "${_S_STEP}" "${_R}" "${_B}" "$1" "${_R}" >&2
}
ok() {
local tail=""
if [[ -n "${_step_epoch}" ]]; then
tail=" ${_D}($(_fmt_dur $(( $(date +%s) - _step_epoch ))))${_R}"
fi
printf ' %s%s%s %s%s\n' "${_G}" "${_S_OK}" "${_R}" "$1" "${tail}" >&2
_step_epoch=""
}
info() { printf ' %s%s %s%s\n' "${_D}" "${_S_INFO}" "$1" "${_R}" >&2; }
warn() { printf ' %s%s %s%s\n' "${_Y}" "${_S_WARN}" "$1" "${_R}" >&2; }
die() {
printf '\n%s%s %s%s\n' "${_E}" "${_S_FAIL}" "$1" "${_R}" >&2
if [[ -n "${_quiet_log:-}" && -s "${_quiet_log}" ]]; then
printf ' %s── last 40 lines of output ──%s\n' "${_D}" "${_R}" >&2
tail -n 40 "${_quiet_log}" | sed 's/^/ /' >&2
printf ' %sfull log: %s%s\n' "${_D}" "${_quiet_log}" "${_R}" >&2
fi
exit 1
}
_SPIN=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
_start_spin() {
(( _color && _unicode )) || return 0
local label="$1"
(
local i=0
while :; do
printf '\r %s%s%s %s ' "${_C}" "${_SPIN[i]}" "${_R}" "${label}" >&2
i=$(( (i+1) % ${#_SPIN[@]} ))
sleep 0.1
done
) &
_spin_pid=$!
}
_stop_spin() {
[[ -n "${_spin_pid}" ]] || return 0
kill "${_spin_pid}" 2>/dev/null || true
wait "${_spin_pid}" 2>/dev/null || true
_spin_pid=""
printf '\r\033[2K' >&2
}
# run_quiet "<label>" -- <cmd ...>
run_quiet() {
local label="$1"; shift
[[ "${1:-}" == "--" ]] && shift
local log start rc=0
log="$(mktemp -t vetting-install.XXXXXX)"
_quiet_log="${log}"
start="$(date +%s)"
_start_spin "${label}"
"$@" >"${log}" 2>&1 || rc=$?
_stop_spin
local dt=$(( $(date +%s) - start ))
if (( rc == 0 )); then
printf ' %s%s%s %s %s(%s)%s\n' "${_G}" "${_S_OK}" "${_R}" "${label}" "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
rm -f "${log}"; _quiet_log=""
return 0
fi
printf ' %s%s %s failed (exit %d)%s\n' "${_E}" "${_S_FAIL}" "${label}" "${rc}" "${_R}" >&2
printf ' %s── last 40 lines of output ──%s\n' "${_D}" "${_R}" >&2
tail -n 40 "${log}" | sed 's/^/ /' >&2
printf ' %sfull log: %s%s\n' "${_D}" "${log}" "${_R}" >&2
exit "${rc}"
}
# curl_pretty --label LABEL --url URL -- [extra curl args, including -o PATH]
curl_pretty() {
local label="download" url=""
local args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--label) label="$2"; shift 2 ;;
--url) url="$2"; args+=("$2"); shift 2 ;;
--) shift; args+=("$@"); break ;;
*) args+=("$1"); shift ;;
esac
done
local size_str=""
if [[ -n "${url}" ]]; then
local size_bytes
size_bytes="$(curl -fsSLI --max-time 5 "${url}" 2>/dev/null \
| awk 'tolower($1) ~ /^content-length:/ {print $2}' \
| tr -d '\r' | tail -n1)"
if [[ "${size_bytes}" =~ ^[0-9]+$ ]]; then
local human
human="$(numfmt --to=iec --suffix=B --format='%.1f' "${size_bytes}" 2>/dev/null || echo "${size_bytes}B")"
size_str=" ${_D}(${human})${_R}"
fi
fi
printf '%s%s%s %sdownloading %s%s%s\n' "${_C}" "${_S_STEP}" "${_R}" "${_B}" "${label}" "${_R}" "${size_str}" >&2
local start rc=0
start="$(date +%s)"
if [[ -t 2 ]]; then
curl -fL --progress-bar "${args[@]}" || rc=$?
else
curl -fsSL "${args[@]}" || rc=$?
fi
local dt=$(( $(date +%s) - start ))
if (( rc == 0 )); then
printf ' %s%s%s %s ready %s(%s)%s\n' "${_G}" "${_S_OK}" "${_R}" "${label}" "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
return 0
fi
printf ' %s%s %s download failed (exit %d)%s\n' "${_E}" "${_S_FAIL}" "${label}" "${rc}" "${_R}" >&2
exit "${rc}"
}
rule_open() {
local title="$1"
local dashes=$(( 46 - ${#title} - 4 ))
local tail="" i=0
(( dashes < 2 )) && dashes=2
while (( i < dashes )); do tail+="${_B_H}"; i=$((i+1)); done
printf '\n%s%s%s %s %s%s\n' "${_C}" "${_B_TL}" "${_B_H}" "${title}" "${tail}" "${_R}" >&2
}
rule_close() {
local line="" i=0
while (( i < 46 )); do line+="${_B_H}"; i=$((i+1)); done
printf '%s%s%s%s\n' "${_C}" "${_B_BL}" "${line:1}" "${_R}" >&2
}
total_elapsed() {
local dt=$(( $(date +%s) - _start_epoch ))
printf '%sinstalled in %s%s\n\n' "${_D}" "$(_fmt_dur "$dt")" "${_R}" >&2
}
# ---- end style helpers -------------------------------------------------
INTERFACE=""
SUBNET=""
ORCH_URL=""
@@ -63,37 +229,33 @@ while [[ $# -gt 0 ]]; do
done
if [[ $EUID -ne 0 ]]; then
echo "pxe-setup.sh must be run as root (try: sudo $0 ...)" >&2
exit 1
die "pxe-setup.sh must be run as root (try: sudo $0 ...)"
fi
[[ -z "${INTERFACE}" ]] && { echo "ERROR: --interface is required" >&2; exit 2; }
[[ -z "${SUBNET}" ]] && { echo "ERROR: --subnet is required (e.g. 192.168.1.0/24)" >&2; exit 2; }
[[ -z "${ORCH_URL}" ]] && { echo "ERROR: --orchestrator-url is required" >&2; exit 2; }
[[ -z "${INTERFACE}" ]] && die "--interface is required"
[[ -z "${SUBNET}" ]] && die "--subnet is required (e.g. 192.168.1.0/24)"
[[ -z "${ORCH_URL}" ]] && die "--orchestrator-url is required"
banner "TheWrightServer · Vetting · PXE"
# --- sanity checks -----------------------------------------------------
if ! ip link show "${INTERFACE}" >/dev/null 2>&1; then
echo "ERROR: interface ${INTERFACE} not found on host. Check \`ip link\` — the" >&2
echo " interface must exist *before* the orchestrator starts dnsmasq." >&2
exit 1
die "interface ${INTERFACE} not found on host. Check \`ip link\` — the interface must exist *before* the orchestrator starts dnsmasq."
fi
# CIDR shape check — dnsmasq will re-validate, but catch the obvious
# errors before we write anything to disk.
if [[ ! "${SUBNET}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]{1,2}$ ]]; then
echo "ERROR: --subnet must be CIDR form (e.g. 192.168.1.0/24), got '${SUBNET}'" >&2
exit 2
die "--subnet must be CIDR form (e.g. 192.168.1.0/24), got '${SUBNET}'"
fi
if [[ ! -f "${CONFIG}" ]]; then
echo "ERROR: ${CONFIG} not found — run deploy/install.sh first." >&2
exit 1
die "${CONFIG} not found — run deploy/install.sh first."
fi
if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then
echo "ERROR: ${SERVICE_USER} user not found — run deploy/install.sh first." >&2
exit 1
die "${SERVICE_USER} user not found — run deploy/install.sh first."
fi
# Resolve the bundle dir. When pxe-setup.sh is run from a release
@@ -101,16 +263,11 @@ fi
# run from the repo tree it's deploy/pxe-setup.sh and the live image is
# under live-image/build/. Detect both.
if [[ -z "${BUNDLE_DIR}" ]]; then
if [[ -f "${SCRIPT_DIR}/ipxe-shas.txt" ]]; then
BUNDLE_DIR="${SCRIPT_DIR}"
else
BUNDLE_DIR="${SCRIPT_DIR}"
fi
fi
SHAS_FILE="${BUNDLE_DIR}/ipxe-shas.txt"
if [[ ! -f "${SHAS_FILE}" ]]; then
echo "ERROR: ${SHAS_FILE} not found — bundle is incomplete." >&2
exit 1
die "${SHAS_FILE} not found — bundle is incomplete."
fi
# --- iPXE binaries: stage, verify, install ----------------------------
@@ -120,8 +277,9 @@ fi
# install(1) unlink-replaces, which avoids ETXTBSY and makes the whole
# operation atomic per file.
echo "==> ensuring ${TFTP_ROOT} exists"
step "ensuring ${TFTP_ROOT} exists"
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${TFTP_ROOT}"
info "tftp root: ${TFTP_ROOT}"
STAGE="$(mktemp -d)"
trap 'rm -rf "${STAGE}"' EXIT
@@ -138,30 +296,28 @@ done
# we skip the fetch entirely; if not, re-download.
if (( ! need_fetch )); then
if ! ( cd "${TFTP_ROOT}" && sha256sum -c --status "${SHAS_FILE}" ); then
echo "==> ${TFTP_ROOT} iPXE binaries don't match pinned SHAs — re-fetching"
info "iPXE binaries in ${TFTP_ROOT} don't match pinned SHAs — re-fetching"
need_fetch=1
else
echo "==> iPXE binaries already match pins — skipping fetch"
info "iPXE binaries already match pins — skipping fetch"
fi
fi
if (( need_fetch )); then
echo "==> fetching iPXE binaries from boot.ipxe.org"
curl -fsSLo "${STAGE}/ipxe.efi" "https://boot.ipxe.org/x86_64-efi/ipxe.efi"
curl -fsSLo "${STAGE}/undionly.kpxe" "https://boot.ipxe.org/undionly.kpxe"
step "fetching iPXE binaries from boot.ipxe.org"
curl_pretty --label "ipxe.efi" --url "https://boot.ipxe.org/x86_64-efi/ipxe.efi" -- -o "${STAGE}/ipxe.efi"
curl_pretty --label "undionly.kpxe" --url "https://boot.ipxe.org/undionly.kpxe" -- -o "${STAGE}/undionly.kpxe"
echo "==> verifying SHA256 against ${SHAS_FILE}"
if ! ( cd "${STAGE}" && sha256sum -c "${SHAS_FILE}" ); then
echo "ERROR: iPXE SHA256 mismatch. Upstream binaries changed, or a MITM." >&2
echo " To accept the new binaries, regenerate ${SHAS_FILE} after" >&2
echo " independently verifying the new checksums, then re-run." >&2
exit 1
step "verifying SHA256 against ${SHAS_FILE}"
if ! run_quiet "sha256sum -c" -- bash -c "cd '${STAGE}' && sha256sum -c '${SHAS_FILE}'"; then
die "iPXE SHA256 mismatch. Upstream binaries changed, or a MITM. To accept the new binaries, regenerate ${SHAS_FILE} after independently verifying the new checksums, then re-run."
fi
install -m 0644 -o "${SERVICE_USER}" -g "${SERVICE_USER}" \
"${STAGE}/ipxe.efi" "${TFTP_ROOT}/ipxe.efi"
install -m 0644 -o "${SERVICE_USER}" -g "${SERVICE_USER}" \
"${STAGE}/undionly.kpxe" "${TFTP_ROOT}/undionly.kpxe"
ok "iPXE binaries staged into ${TFTP_ROOT}"
fi
# --- live image: copy from bundle into live_dir -----------------------
@@ -183,20 +339,19 @@ if [[ -z "${LIVE_SRC}" ]]; then
# the one-liner install, so a missing bundle/live-image/ is expected
# when pxe-setup.sh is run from /usr/local/sbin.
if [[ -f "${LIVE_DIR}/vmlinuz" && -f "${LIVE_DIR}/initrd.img" ]]; then
echo "==> live image already staged in ${LIVE_DIR} (from install.sh)"
info "live image already staged in ${LIVE_DIR} (from install.sh)"
else
echo "WARN: no live image found under ${BUNDLE_DIR}/live-image," >&2
echo " ${BUNDLE_DIR}/../live-image/build, or ${LIVE_DIR}." >&2
echo " The orchestrator will fail PXE startup validation until" >&2
echo " vmlinuz + initrd.img land in ${LIVE_DIR}." >&2
warn "no live image found under ${BUNDLE_DIR}/live-image, ${BUNDLE_DIR}/../live-image/build, or ${LIVE_DIR}."
warn "the orchestrator will fail PXE startup validation until vmlinuz + initrd.img land in ${LIVE_DIR}."
fi
else
echo "==> staging live image from ${LIVE_SRC} into ${LIVE_DIR}"
step "staging live image from ${LIVE_SRC}"
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${LIVE_DIR}"
install -m 0644 -o "${SERVICE_USER}" -g "${SERVICE_USER}" \
"${LIVE_SRC}/vmlinuz" "${LIVE_DIR}/vmlinuz"
install -m 0644 -o "${SERVICE_USER}" -g "${SERVICE_USER}" \
"${LIVE_SRC}/initrd.img" "${LIVE_DIR}/initrd.img"
ok "live image staged into ${LIVE_DIR}"
fi
# --- patch the pxe: block in vetting.yaml -----------------------------
@@ -243,16 +398,13 @@ existing_iface="$(extract_yaml_value interface "${CONFIG}")"
existing_subnet="$(extract_yaml_value subnet "${CONFIG}")"
if [[ -n "${existing_iface}" && "${existing_iface}" != "${INTERFACE}" && ${FORCE} -eq 0 ]]; then
echo "ERROR: pxe.interface in ${CONFIG} is already set to ${existing_iface}, which" >&2
echo " differs from --interface ${INTERFACE}. Pass --force to overwrite." >&2
exit 1
die "pxe.interface in ${CONFIG} is already set to ${existing_iface}, which differs from --interface ${INTERFACE}. Pass --force to overwrite."
fi
if [[ -n "${existing_subnet}" && "${existing_subnet}" != "${SUBNET}" && ${FORCE} -eq 0 ]]; then
echo "ERROR: pxe.subnet in ${CONFIG} is already ${existing_subnet}, which" >&2
echo " differs from --subnet ${SUBNET}. Pass --force to overwrite." >&2
exit 1
die "pxe.subnet in ${CONFIG} is already ${existing_subnet}, which differs from --subnet ${SUBNET}. Pass --force to overwrite."
fi
step "patching pxe: block in ${CONFIG}"
new_block=$(cat <<EOF
pxe:
enabled: true
@@ -286,11 +438,17 @@ orig_owner="$(stat -c '%U:%G' "${CONFIG}")"
install -m "${orig_mode}" -o "${orig_owner%:*}" -g "${orig_owner#*:}" \
"${tmp_yaml}" "${CONFIG}"
rm -f "${tmp_yaml}"
ok "pxe: block written"
echo
echo "==> rendered pxe: block in ${CONFIG}:"
echo "${new_block}" | sed 's/^/ /'
echo
echo "Next: systemctl restart vetting && journalctl -fu vetting"
echo "The orchestrator will refuse to start with clear errors if anything"
echo "is still missing; you should see dnsmasq come up cleanly."
rule_open "pxe configured"
while IFS= read -r ln; do
info "${ln}"
done <<<"${new_block}"
printf '\n' >&2
info "next:"
info " systemctl restart vetting"
info " journalctl -fu vetting"
info "the orchestrator refuses to start with clear errors if anything is still missing;"
info "you should see dnsmasq come up cleanly."
rule_close
total_elapsed
+490
View File
@@ -0,0 +1,490 @@
# API reference
Complete HTTP API for the vetting orchestrator. Routes are assembled
in `internal/httpserver/router.go`; handler logic lives in
`internal/api/agent_handlers.go` (agent-facing) and
`internal/api/ui_handlers.go` (browser + host-mode).
---
## Agent API
These endpoints are called by the in-image vetting agent during a
run. Every request must carry a `Authorization: Bearer <token>`
header. The token is issued per-run in the iPXE kernel cmdline and
verified against a bcrypt hash stored in `runs.agent_token_hash`.
### `GET /ipxe/{mac}`
iPXE chainload script. Called by iPXE itself after dnsmasq hands it
the chainload URL. No auth required — the MAC path parameter is the
key.
**Responses:**
| Scenario | Script |
|----------|--------|
| Known MAC with an active run | Boot script: kernel + initrd + cmdline (run_id, mac, token, orchestrator_url, tls_fpr). Triggers `PXEObserved` transition. |
| Known MAC, no active run | Poweroff script. |
| Unknown MAC | Halt/error script. |
---
### `POST /api/v1/runs/{id}/hello`
First call the agent makes once userspace is up. Idempotent. Writes a
log line; the authoritative transition comes from `/claim`.
**Request body:**
```json
{}
```
**Response (200):**
```json
{ "ok": true, "run_id": 42 }
```
---
### `POST /api/v1/runs/{id}/claim`
Binding call: the agent proves it holds the plaintext token for this
run. In return the orchestrator seeds stage rows, transitions to
`InventoryCheck`, and returns the stage list + per-profile config.
Subsequent claims are idempotent (safe after transient network
failures).
**Request body:**
```json
{
"agent_ip": "192.168.1.42" // optional; falls back to RemoteAddr
}
```
**Response (200):**
```json
{
"ok": true,
"run_id": 42,
"stages": ["Inventory", "Firmware", "SpecValidate", "SMART", "CPUStress",
"Storage", "Network", "Burn", "GPU", "PSU", "Reporting"],
"expected_disks": [
{ "serial": "WD-ABC123", "size_gb": 500 }
],
"iperf_port": 5201,
"non_destructive": false,
"current_state": "InventoryCheck",
"stage_config": {
"profile": "quick",
"stage_timeouts": { "CPUStress": "5m0s", "Storage": "5m0s" },
"cpustress": { "cpu_pass": "2m", "mem_pass": "2m", "edac_poll": "10s" },
"storage": { "mode": "fio_sample", "fio_size": "1GiB", "fio_time": "3m",
"fio_bs": "4k", "fio_rw": "randrw", "verify": "md5" },
"network": { "duration": "60s" },
"burn": { "duration": "2m", "cpu_workers": "all", "mem_pct": 50,
"fio_on_spare": true, "iperf_parallel": 2 }
}
}
```
**`stage_config` shape:**
| Field | Type | Description |
|-------|------|-------------|
| `profile` | string | `quick`, `deep`, or `soak`. |
| `stage_timeouts` | map[string]string | Per-stage timeout durations (Go duration strings). |
| `cpustress.cpu_pass` | string | stress-ng CPU pass duration. |
| `cpustress.mem_pass` | string | stress-ng memory pass duration. |
| `cpustress.edac_poll` | string | EDAC error counter polling interval. |
| `storage.mode` | string | `fio_sample` (skip badblocks) or `full_disk`. |
| `storage.fio_size` | string | fio test file size (fio_sample mode only). |
| `storage.fio_time` | string | fio runtime. |
| `storage.fio_bs` | string | fio block size. |
| `storage.fio_rw` | string | fio I/O pattern. |
| `storage.verify` | string | fio integrity mode (`md5` or empty). |
| `network.duration` | string | iperf3 test duration. |
| `burn.duration` | string | Total burn-in window. |
| `burn.cpu_workers` | string | `all` or a numeric string. |
| `burn.mem_pct` | int | Percentage of MemAvailable to stress. |
| `burn.fio_on_spare` | bool | Run fio inside Burn. |
| `burn.iperf_parallel` | int | iperf3 parallel stream count. |
---
### `POST /api/v1/runs/{id}/heartbeat`
Periodic liveness ping. The response body acts as a control channel.
**Request body:**
```json
{}
```
**Response (200):**
```json
{
"state": "CPUStress",
"cmd": "continue"
}
```
**`cmd` values:**
| cmd | When | Agent action |
|-----|------|--------------|
| `continue` | Normal case (including FailedHolding) | No-op; keep running current stage or wait for override. |
| `reboot` | Run reached `Completed` | `systemctl reboot` (falls through iPXE to local disk). |
| `abort` | Run in `Released` | Stop heartbeat loop. |
| `retry_stage` | Operator pressed "Override wipe & retry" | Re-enter the named stage with override flags. Response includes `stage` and `override_flags`. |
| `cancel_stage` | Operator clicked Cancel mid-stage | Kill running stage subprocess, then power off. |
---
### `POST /api/v1/runs/{id}/log`
Batch of log lines from the agent. Written to per-run flat file and
fanned out to SSE subscribers.
**Request body:**
```json
{
"lines": [
{
"ts": "2026-04-21T15:32:18.123Z",
"level": "info",
"stage": "SMART",
"text": "smartctl -a /dev/sda: PASSED"
}
]
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `ts` | string | no | RFC 3339 timestamp. Server clock used if empty. |
| `level` | string | no | `info`, `warn`, `error`, `debug`. |
| `stage` | string | no | Stage tag for per-stage SSE fan-out. |
| `text` | string | yes | Log message. |
**Response (200):**
```json
{ "ok": true, "written": 1 }
```
---
### `POST /api/v1/runs/{id}/sensor`
Batch of numeric samples (thermals, fan RPM, PSU rails, iperf
throughput, fio IOPS). Each sample is evaluated against the run's
seeded thresholds — critical breaches fail the run immediately.
**Request body:**
```json
{
"samples": [
{
"ts": "2026-04-21T15:32:18Z",
"kind": "temp",
"key": "cpu/0",
"value": 72.5,
"unit": "C"
}
]
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `ts` | string | no | RFC 3339 timestamp. Defaults to server-now. |
| `kind` | string | yes | `temp`, `fan`, `psu_volt`, `iperf`, `fio`, `fio_p99_us`, `smart_attr`, `nic_retrans`, `edac_ue`, `edac_ce`, `mce`. |
| `key` | string | yes | Identifies the source (e.g. `cpu/0`, `+12V`, `throughput_mbps`). |
| `value` | float | yes | Numeric sample value. |
| `unit` | string | no | Display unit (e.g. `C`, `V`, `Mbps`). |
**Response (200):**
```json
{
"ok": true,
"written": 1,
"breach": false,
"breach_kind": ""
}
```
When a critical breach is detected, `breach` is `true` and
`breach_kind` contains a human-readable label like
`"temp cpu/0=92.5 breached lt 92"`. The run transitions to
`FailedHolding`.
---
### `POST /api/v1/runs/{id}/result`
Stage outcome. Drives the state machine forward (pass) or into
`FailedHolding` (fail).
**Request body:**
```json
{
"stage": "SMART",
"passed": true,
"summary": { "disks_checked": 2, "reallocated": 0 },
"message": "",
"inventory": null,
"firmware": [],
"sub_steps": []
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `stage` | string | yes | Stage name (must match `DefaultStageOrder`). |
| `passed` | bool | yes | `true` = advance; `false` = fail. |
| `summary` | object | no | Arbitrary JSON persisted in `stages.summary_json`. |
| `message` | string | no | Human-readable detail (shown in notifications on failure). |
| `inventory` | object | no | Only set for `stage=Inventory`. Full `spec.Inventory` JSON. |
| `firmware` | array | no | Only set for `stage=Firmware`. Array of firmware snapshots. |
| `sub_steps` | array | no | Per-disk/per-NIC/per-GPU granular results. |
**`firmware[]` shape:**
| Field | Type | Description |
|-------|------|-------------|
| `component` | string | `bios`, `bmc`, `nic`, `hba`, `microcode`, `nvme_fw`. |
| `identifier` | string | Slot, serial, or device path that distinguishes this component. |
| `version` | string | Firmware version string. |
| `vendor` | string | Vendor name (optional). |
| `raw` | map | Additional key-value metadata (optional). |
**`sub_steps[]` shape:**
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Human-readable label (e.g. `sda SMART`, `eth0 iperf`). |
| `passed` | bool | Sub-step result. |
| `skipped` | bool | `true` if the sub-step was skipped (e.g. no GPU). |
| `started_at` | string | RFC 3339 timestamp. |
| `completed_at` | string | RFC 3339 timestamp. |
| `summary` | object | Arbitrary JSON persisted in `sub_steps.summary_json`. |
**Response (200, pass):**
```json
{ "ok": true, "next_state": "CPUStress" }
```
**Response (200, fail):**
```json
{ "ok": true, "next_state": "FailedHolding" }
```
**Response (409, stage mismatch):**
Returned when the agent reports a stage that doesn't match the
orchestrator's expected state. The run is parked in `FailedHolding`.
```json
{ "ok": false, "error": "stage mismatch: got SMART, expected CPUStress" }
```
---
### `POST /api/v1/runs/{id}/hold`
Request the per-run SSH key so the operator can SSH into a held host.
**Request body:**
```json
{
"agent_ip": "192.168.1.42"
}
```
**Response (200):**
```json
{
"authorized_key": "ssh-ed25519 AAAAC3... vetting-run-42",
"run_id": 42
}
```
The private key is written to
`artifacts/run-<N>/hold.key` on the orchestrator. The agent installs
the `authorized_key` into `/root/.ssh/authorized_keys` in the live
image.
---
## Host API
LAN-trusted endpoints called by the host-mode agent. No bearer token.
Same threat model as the browser UI.
### `POST /api/v1/hosts`
JSON host registration. Called by the quick-register one-liner.
**Request body:**
```json
{
"name": "node-01",
"mac": "aa:bb:cc:dd:ee:ff",
"wol_broadcast_ip": "192.168.1.255",
"wol_port": 9,
"expected_spec_yaml": "memory:\n total_gib: 64\ncpu:\n logical_cores: 16\n",
"notes": ""
}
```
**Response (201):**
```json
{ "ok": true, "id": 5 }
```
### `POST /api/v1/hosts/{mac}/heartbeat`
Host-mode agent liveness ping. Stamps `hosts.last_seen_at` and
triggers a dashboard tile refresh via SSE.
**Request body:** empty.
**Response (200):**
```json
{ "ok": true }
```
When a run is queued for this host:
```json
{ "ok": true, "cmd": "reboot_for_vetting", "run_id": 42 }
```
The agent reboots the host on receiving `cmd=reboot_for_vetting`.
The `run_id` is informational (for agent logging).
---
## Browser UI routes
No auth. Bind to loopback or LAN only, or front with a reverse proxy.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/` | Dashboard — host tile grid. |
| GET | `/hosts/new` | New host registration form. |
| POST | `/hosts` | Create host (form submission). |
| GET | `/hosts/{id}` | Host detail page (summary, actions, run history). |
| POST | `/hosts/{id}/delete` | Delete host. |
| POST | `/hosts/{id}/start` | Start a vetting run (queue it). |
| POST | `/hosts/{id}/cancel` | Cancel the active run. |
| POST | `/hosts/{id}/override-wipe` | Override the wipe-probe guard and retry Storage. |
| GET | `/runs/{runID}` | Run detail page (stages, spec diffs, pipeline). |
| GET | `/reports/{runID}` | HTML report artifact. |
| GET | `/register/quick.sh` | Quick-register bash one-liner script. |
| GET | `/events` | SSE event stream (browser subscriptions). |
**Static assets:**
| Path | Description |
|------|-------------|
| `/static/*` | Embedded CSS + JS (`internal/web/static/`). |
| `/live/*` | Live image files (vmlinuz + initrd.img) served from `pxe.live_dir`. |
| `/assets/*` | Agent binary served from `agent.asset_dir`. |
---
## SSE events
The browser connects to `GET /events` and receives server-sent events.
Each event has a `name` (the SSE `event:` field) and a `data` payload
containing a pre-rendered HTML fragment with `hx-swap-oob` attributes
that HTMX uses to swap the target DOM element.
### Connection events
| Event name | Payload | Description |
|------------|---------|-------------|
| `hello` | `ok` | Sent immediately on connection. |
| `heartbeat` | `<span data-heartbeat="<unix-ts>"></span>` | 15-second keep-alive. |
### Dashboard events
| Event name | Payload | Description |
|------------|---------|-------------|
| `tile-{hostID}` | Host tile HTML fragment | Refreshed on state transitions, heartbeats, holds. |
### Host detail page events
| Event name | Payload | Description |
|------------|---------|-------------|
| `detail-summary-{hostID}` | Summary section HTML | Host metadata + latest run status. |
| `detail-actions-{hostID}` | Actions row HTML | Start/Cancel/Override buttons. |
| `detail-inflight-{hostID}` | In-flight banner HTML | Active run progress indicator. |
| `runrow-{runID}` | Run history row HTML | Updated when a run completes or fails. |
### Run detail page events
| Event name | Payload | Description |
|------------|---------|-------------|
| `run-header-{runID}` | Run metadata HTML | State, profile, timing. |
| `detail-hold-{runID}` | Hold banner HTML | SSH command + hold IP. |
| `detail-specdiffs-{runID}` | Spec diffs list HTML | Expected-vs-actual divergences. |
| `pipeline-{runID}` | Pipeline dot visualization HTML | Stage progress dots. |
| `substep-{runID}-{stage}-{ordinal}` | Sub-step row HTML | Per-disk, per-NIC, per-GPU detail. |
### Log events
| Event name | Payload | Description |
|------------|---------|-------------|
| `log-{runID}` | Log line HTML | All log lines for a run. |
| `log-{runID}-{stage}` | Log line HTML | Stage-filtered log lines. |
---
## Authentication
### Agent bearer token lifecycle
1. **Issuance** — when a registered host's iPXE script is fetched
(`GET /ipxe/{mac}`), the orchestrator generates a random token,
hashes it with SHA-256, and stores the hash in
`runs.agent_token_hash`. The plaintext token is embedded in the
iPXE kernel cmdline as `token=<plaintext>`.
2. **Rotation** — each iPXE fetch rotates the token. Only the most
recent PXE boot can claim the run.
3. **Verification** — every `/api/v1/runs/{id}/*` endpoint extracts
the `Bearer` header, SHA-256 hashes it, and compares against the
stored hash using `crypto/subtle.ConstantTimeCompare`.
4. **Scope** — the token authenticates a single run. It cannot be
used to access other runs or host-level endpoints.
### LAN-trust model
Host-mode endpoints (`POST /api/v1/hosts`, `POST /api/v1/hosts/{mac}/heartbeat`)
and the browser UI have no authentication. They share a LAN-trust
assumption: anything that can reach the orchestrator's bind address is
trusted. To add a password, front the orchestrator with a reverse
proxy (Caddy, nginx, Traefik) that adds basic-auth or OIDC. See
[operations.md § Exposing outside the LAN](operations.md#exposing-outside-the-lan).
+61 -6
View File
@@ -37,10 +37,10 @@ Operator browser (HTMX + SSE, admin login)
|---|---|
| `cmd/vetting` | Orchestrator entrypoint. Wires config, stores, runner, dispatcher, iperf supervisor, PXE supervisor, janitor, HTTP router. |
| `cmd/vetting-agent` | In-image agent entrypoint. Reads kernel cmdline params, starts the agent loop. |
| `internal/config` | YAML loader + types. |
| `internal/config` | YAML loader + types. `ProfileRegistry` holds the quick/deep/soak profile definitions, threshold defaults, and per-stage probe knobs. |
| `internal/db` | SQLite open + embedded migrations. Pure Go via modernc.org/sqlite. |
| `internal/model` | Plain structs: `Host`, `Run`, `Stage`, `Measurement`, `SpecDiff`, `Artifact`. |
| `internal/store` | Repository layer; SQL is hand-written. |
| `internal/store` | Repository layer; SQL is hand-written (no ORM). Stores for hosts, runs, stages, sub-steps, artifacts, spec diffs, measurements, thresholds, firmware. |
| `internal/orchestrator` | State machine, dispatcher, per-run runner, WoL sender, HMAC run tokens, iperf supervisor. |
| `internal/api` | HTTP handlers: `agent_handlers.go` (the agent-facing API) and `ui_handlers.go` (HTMX fragments + SSE). |
| `internal/httpserver` | chi router assembly — lives here to avoid `api ↔ orchestrator` cyclic imports. |
@@ -66,11 +66,13 @@ Per-run state is the single source of truth; the UI is a pure
projection of DB + event stream.
```
Registered → Queued → WaitingWoL → Booting → InventoryCheck
SpecValidate → SMART → CPUStress → Storage → Network
GPU → PSU → Reporting → Completed
Registered → Queued → WaitingWoL / WaitingReboot → Booting
InventoryCheck → Firmware → SpecValidate → SMART
CPUStress → Storage → Network → Burn → GPU → PSU
→ Reporting → Completed
any stage → Failed → FailedHolding → Released
any active state → Cancelled
```
Key points:
@@ -97,7 +99,10 @@ POST /api/v1/runs/{id}/result → stage result; response says next_state
POST /api/v1/runs/{id}/hold → on FailedHolding, receive authorized_key
```
Auth on every `/api/v1/*` call: the bearer token is stored as a bcrypt
See [api-reference.md](api-reference.md) for full request/response
schemas and SSE event types.
Auth on every `/api/v1/runs/*` call: the bearer token is stored as a bcrypt
hash in `runs.agent_token_hash` and compared in constant time. The
plaintext is in the kernel cmdline — unforgeable by anyone not on the
trusted bridge, because the iPXE script is issued per-MAC and the MAC
@@ -165,6 +170,56 @@ The janitor goroutine (`internal/janitor`) runs a sweep every
**never** deleted by the janitor — host histories and aggregate
metrics survive cleanups.
## Threshold engine
Every `/sensor` batch is evaluated against rules seeded per-run at
creation time from the `ProfileRegistry` + per-host overrides. Rules
are immutable for the life of a run — a late config edit can't
retroactively pass or fail an in-flight run.
Operators: `lt`, `lte`, `gt`, `gte`, `within_pct`. Key matching is
glob-ish: `*` matches all keys, `cpu/*` matches any key starting with
`cpu/`, exact strings for specific keys. Stage matching works the same
way (`*` for global, exact name for stage-specific).
Severity drives the action:
- **critical** — fail the run immediately. The current stage is marked
failed, the run enters `FailedHolding`, and a `StageFailed`
notification fires.
- **warning** — record the breach for the report. The stage continues.
Every evaluation (pass or fail) is persisted as a
`threshold_evaluations` row so the report can render per-sample
verdict badges. See [configuration.md § thresholds](configuration.md#vettingthresholds)
for the config-level reference.
## Host-mode agent
The `vetting-agent host` binary runs as a systemd service on
installed hosts. It heartbeats to `POST /api/v1/hosts/{mac}/heartbeat`
every 30 s so the dashboard shows online/offline status.
The quick-register one-liner (`GET /register/quick.sh`) downloads the
agent binary from `/assets/vetting-agent-linux-amd64`, installs it as
a systemd service, and auto-POSTs to `POST /api/v1/hosts` to register
the host — no manual MAC entry needed.
When the operator clicks **Start Vetting**, the orchestrator's
dispatcher sets `cmd=reboot_for_vetting` on the next heartbeat
response. The host-mode agent reboots the host, which PXE-boots into
the live image and enters the normal vetting flow.
## Host API
These endpoints are LAN-trusted (no bearer token) and share the same
threat model as the browser UI:
```
POST /api/v1/hosts → JSON host registration (quick-register)
POST /api/v1/hosts/{mac}/heartbeat → host-mode liveness + command channel
```
## Reproducible builds
The orchestrator and agent are pure Go; `make orchestrator-linux`
+353
View File
@@ -0,0 +1,353 @@
# Configuration reference
The orchestrator reads a single YAML file at startup. Production
installs use `/etc/vetting/vetting.yaml`; the dev default is
`deploy/vetting.example.yaml`. Pass the path with `--config`:
```
vetting --config /etc/vetting/vetting.yaml
```
Every key has a compile-time default (see `internal/config/config.go`),
so an empty file produces a working orchestrator bound to
`127.0.0.1:8080` with PXE disabled.
---
## `server`
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `bind` | string | `127.0.0.1:8080` | Address and port the HTTP server listens on. |
| `public_url` | string | *(empty)* | External URL the orchestrator is reachable at from a browser. Used in notification click-throughs (e.g. `https://vetting.lan:8443`). |
| `tls.enabled` | bool | `false` | Terminate TLS at the orchestrator. |
| `tls.cert_file` | string | *(empty)* | Path to the PEM-encoded certificate. |
| `tls.key_file` | string | *(empty)* | Path to the PEM-encoded private key. |
## `database`
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `path` | string | `./var/vetting.db` | SQLite database file. Created on first run. |
## `artifacts`
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `dir` | string | `./var/artifacts` | Directory for per-run files (reports, fio logs, iperf logs, hold keys). |
| `retention_days` | int | `30` | Days to keep artifact files before the janitor prunes them. `0` = keep forever. DB rows are never pruned. |
## `logs`
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `dir` | string | `./var/logs` | Directory for per-run append-only log files. |
| `retention_days` | int | `30` | Days to keep log files. `0` = keep forever. |
## `janitor`
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `interval_minutes` | int | `60` | Minutes between cleanup sweeps. `0` defaults to `60`. |
## `dispatcher`
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `max_concurrent_runs` | int | `3` | Semaphore limiting how many vetting runs execute in parallel. |
## `network`
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `iperf_port` | int | `5201` | Port the orchestrator-supervised `iperf3 -s` binds to. The agent connects here during the Network stage. |
## `pxe`
PXE is disabled by default. Enable it after running
[`vetting-pxe-setup`](operations.md#pxe-enablement).
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | `false` | Enable dnsmasq + iPXE serving. |
| `interface` | string | *(empty)* | LAN NIC the dnsmasq proxy-DHCP binds to (e.g. `eth0`). |
| `subnet` | string | *(empty)* | LAN CIDR (e.g. `192.168.1.0/24`). Scopes the proxy-DHCP responses. |
| `orchestrator_url` | string | *(empty)* | URL the live-image agent uses to reach the orchestrator (e.g. `http://192.168.1.135:8080`). Baked into the iPXE kernel cmdline. |
| `tftp_root` | string | *(empty)* | Directory containing `ipxe.efi` + `undionly.kpxe`. |
| `live_dir` | string | *(empty)* | Directory containing `vmlinuz` + `initrd.img`. Served at `/live/*`. |
dnsmasq runs in **proxy-DHCP mode**: it coexists with your existing
router's DHCP server and only supplements PXE options. See
[operations.md](operations.md#pxe-enablement) for the full setup
walkthrough.
## `agent`
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `asset_dir` | string | `<database.dir>/../assets` | Directory containing `vetting-agent-linux-amd64`. Served at `/assets/*` so the quick-register one-liner can download the agent binary. Empty string disables the route. |
## `notifiers`
An array of notification targets. Each entry declares a named notifier
with a type-specific set of fields. Delivery is fire-and-forget (one
attempt per event, 10 s timeout, failures logged).
### ntfy
```yaml
notifiers:
- name: ops-ntfy
type: ntfy
server: https://ntfy.sh
topic: vetting-YOUR-TOPIC
```
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Identifier referenced by `routes[].notifier`. |
| `type` | string | `ntfy` |
| `server` | string | ntfy server URL. |
| `topic` | string | Topic to publish to. |
### Discord
```yaml
notifiers:
- name: ops-discord
type: discord
webhook_url: https://discord.com/api/webhooks/XXX/YYY
```
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Identifier referenced by `routes[].notifier`. |
| `type` | string | `discord` |
| `webhook_url` | string | Discord webhook URL. |
### SMTP
```yaml
notifiers:
- name: ops-email
type: smtp
smtp:
host: mail.lan
port: 25
from: vetting@lan.local
to: [ops@lan.local]
```
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Identifier referenced by `routes[].notifier`. |
| `type` | string | `smtp` |
| `smtp.host` | string | SMTP server hostname. |
| `smtp.port` | int | SMTP server port. |
| `smtp.from` | string | Sender address. |
| `smtp.to` | string[] | Recipient addresses. |
## `routes`
Routes map notification events to notifiers by kind and severity.
Each route is evaluated independently; an event can match multiple
routes and fire on multiple notifiers.
```yaml
routes:
- match_severity: [critical]
notifier: ops-ntfy
- match_severity: [critical]
notifier: ops-discord
- match_kind: [RunCompleted]
notifier: ops-ntfy
```
| Field | Type | Description |
|-------|------|-------------|
| `match_kind` | string[] | Event kinds to match: `StageFailed`, `SpecMismatch`, `HoldingOpened`, `RunCompleted`. Omit to match all kinds. |
| `match_severity` | string[] | Severities to match: `critical`, `warning`, `info`. Omit to match all severities. |
| `notifier` | string | Name of a declared notifier to deliver to. |
## `vetting`
Shared pipeline defaults that apply to all profiles.
### `vetting.stages`
Ordered list of stage names the pipeline walks. Default:
```yaml
vetting:
stages:
- Inventory
- Firmware
- SpecValidate
- SMART
- CPUStress
- Storage
- Network
- Burn
- GPU
- PSU
- Reporting
```
### `vetting.thresholds`
Array of threshold rules evaluated against every `/sensor` batch.
Rules apply across all profiles — a 92 C CPU limit fails both a
2-minute quick run and a 12-hour soak.
| Field | Type | Description |
|-------|------|-------------|
| `stage` | string | Stage selector. `*` matches any stage; exact name (e.g. `PSU`) limits to that stage. |
| `kind` | string | Measurement kind to match: `temp`, `psu_volt`, `iperf`, `fio_p99_us`, `nic_retrans`, `edac_ue`, `edac_ce`, `mce`, `smart_attr`, `fan`. |
| `key` | string | Key selector. Glob-ish matching: `*` matches all, `cpu/*` matches keys starting with `cpu/`, exact string for specific keys. |
| `op` | string | Comparison operator (see table below). |
| `value` | float | Threshold limit. |
| `nominal` | float | Reference value, only used by `within_pct` (e.g. `12.0` for a +12 V rail). |
| `unit` | string | Display unit (e.g. `C`, `V`, `Mbps`). Informational only. |
| `severity` | string | `critical` = fail the run immediately. `warning` = record for the report only. |
**Threshold operators:**
| Operator | Pass condition | Typical use |
|----------|---------------|-------------|
| `lt` | `observed < value` | CPU temp < 92 C |
| `lte` | `observed <= value` | EDAC UE count <= 0 |
| `gt` | `observed > value` | — |
| `gte` | `observed >= value` | iperf throughput >= 900 Mbps |
| `within_pct` | `abs(observed - nominal) / nominal * 100 <= value` | +12 V rail within 5 % of 12.0 V |
**Default thresholds** (from `deploy/vetting.example.yaml`):
```yaml
thresholds:
- { stage: "*", kind: temp, key: "cpu/*", op: lt, value: 92, unit: C, severity: critical }
- { stage: PSU, kind: psu_volt, key: "+12V", op: within_pct, value: 5, nominal: 12.0, severity: critical }
- { stage: PSU, kind: psu_volt, key: "+5V", op: within_pct, value: 5, nominal: 5.0, severity: critical }
- { stage: PSU, kind: psu_volt, key: "+3.3V", op: within_pct, value: 5, nominal: 3.3, severity: critical }
- { stage: Storage, kind: fio_p99_us, key: "*", op: lt, value: 50000, severity: warning }
- { stage: Network, kind: iperf, key: throughput_mbps, op: gte, value: 900, severity: critical }
- { stage: Network, kind: nic_retrans, key: "*/rate", op: lt, value: 0.001, severity: warning }
- { stage: CPUStress, kind: edac_ue, key: "*", op: lte, value: 0, severity: critical }
- { stage: CPUStress, kind: mce, key: "*", op: lte, value: 0, severity: critical }
```
## `profiles`
Three built-in profiles control per-stage durations and probe knobs.
Every profile exercises every probe and gate — only the durations
scale. Quick is a ~10-minute same-day sanity check; deep is the
8-12 hour overnight soak; soak is the opt-in 36-40 hour extreme run.
### Profile inheritance
A profile can declare `inherit: <parent>` to merge the parent's
timeouts and defaults before applying its own overrides. Child keys
win. The default `soak` profile inherits from `deep`.
### `stage_timeouts`
Per-stage time limits. The orchestrator kills the agent's stage
subprocess when a timeout fires.
| Stage | quick | deep | soak |
|-------|-------|------|------|
| CPUStress | 5 m | 2 h | 14 h |
| Storage | 5 m | 4 h | 8 h |
| Network | 2 m | 35 m | 2 h 30 m |
| Burn | 3 m | 3 h | 20 h |
| PSU | 1 m | 10 m | 15 m |
### `defaults`
Per-stage probe knobs shipped to the agent on `/claim`. Empty values
mean "fall back to the agent's compile-time default".
#### `cpustress`
| Knob | Type | Description | quick | deep | soak |
|------|------|-------------|-------|------|------|
| `cpu_pass` | duration | `stress-ng --cpu` duration | 2 m | 60 m | 12 h |
| `mem_pass` | duration | `stress-ng --vm` duration | 2 m | 60 m | *(inherit)* |
| `edac_poll` | duration | EDAC error counter polling interval | 10 s | 10 s | *(inherit)* |
#### `storage`
| Knob | Type | Description | quick | deep | soak |
|------|------|-------------|-------|------|------|
| `mode` | string | `fio_sample` (skip badblocks) or `full_disk` (badblocks + fio) | fio_sample | full_disk | full_disk |
| `fio_size` | string | fio test file size (only in `fio_sample` mode) | 1 GiB | *(inherit)* | *(inherit)* |
| `fio_time` | duration | fio runtime | 3 m | 2 h | 6 h |
| `fio_bs` | string | fio block size | 4 k | 4 k | *(inherit)* |
| `fio_rw` | string | fio I/O pattern | randrw | randrw | *(inherit)* |
| `verify` | string | fio integrity mode (`md5` or empty) | md5 | md5 | *(inherit)* |
#### `network`
| Knob | Type | Description | quick | deep | soak |
|------|------|-------------|-------|------|------|
| `duration` | duration | `iperf3` test duration | 60 s | 30 m | 2 h |
#### `burn`
| Knob | Type | Description | quick | deep | soak |
|------|------|-------------|-------|------|------|
| `duration` | duration | Total burn-in window (CPU + mem + disk + net simultaneously) | 2 m | 2 h | 18 h |
| `cpu_workers` | string | `all` (= `runtime.NumCPU()`) or a numeric string | all | all | *(inherit)* |
| `mem_pct` | int | Percentage of MemAvailable to stress | 50 | 70 | *(inherit)* |
| `fio_on_spare` | bool | Run fio inside Burn (requires a spare partition) | true | true | *(inherit)* |
| `iperf_parallel` | int | Parallel stream count fed to `iperf3 -P` | 2 | 4 | 8 |
### Example profile block
```yaml
profiles:
quick:
stage_timeouts:
CPUStress: 5m
Storage: 5m
Network: 2m
defaults:
cpustress: { cpu_pass: 2m, mem_pass: 2m, edac_poll: 10s }
storage: { mode: fio_sample, fio_size: 1GiB, fio_time: 3m, fio_bs: 4k, fio_rw: randrw, verify: md5 }
network: { duration: 60s }
burn: { duration: 2m, cpu_workers: all, mem_pct: 50, fio_on_spare: true, iperf_parallel: 2 }
deep:
stage_timeouts:
CPUStress: 2h
Storage: 4h
Network: 35m
defaults:
cpustress: { cpu_pass: 60m, mem_pass: 60m, edac_poll: 10s }
storage: { mode: full_disk, fio_time: 2h, fio_bs: 4k, fio_rw: randrw, verify: md5 }
network: { duration: 30m }
burn: { duration: 2h, cpu_workers: all, mem_pct: 70, fio_on_spare: true, iperf_parallel: 4 }
soak:
inherit: deep
stage_timeouts:
CPUStress: 14h
Storage: 8h
Network: 2h30m
defaults:
cpustress: { cpu_pass: 12h }
storage: { mode: full_disk, fio_time: 6h }
network: { duration: 2h }
burn: { duration: 18h, iperf_parallel: 8 }
```
---
## Host-mode agent config
The persistent host-mode agent reads a separate file at
`/etc/vetting/host-agent.yaml`. This is installed by the
quick-register one-liner and is distinct from the orchestrator config.
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `orchestrator_url` | string | *(required)* | URL of the orchestrator (e.g. `http://192.168.1.135:8080`). |
| `mac` | string | *(auto-detected)* | MAC address to heartbeat as. Auto-detected from the default route NIC if omitted. |
| `interval` | duration | `30s` | Heartbeat interval. |
+279
View File
@@ -0,0 +1,279 @@
# Database schema
The orchestrator uses SQLite via
[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) — a pure
Go driver with no cgo dependency. The database file is created on
first startup at the path in `database.path`
(default `./var/vetting.db`).
**Pragmas set at open time:**
- `PRAGMA journal_mode = WAL` — write-ahead logging for concurrent
readers.
- `PRAGMA foreign_keys = ON` — enforced referential integrity.
**Migrations** are embedded via `go:embed` in `internal/db/` and
applied in filename order at startup. A `schema_migrations` table
tracks which migrations have run.
---
## Tables
### `hosts`
Registered hardware nodes in the vetting cluster.
| Column | Type | Constraints | Default | Description |
|--------|------|-------------|---------|-------------|
| `id` | INTEGER | PK AUTOINCREMENT | | |
| `name` | TEXT | NOT NULL UNIQUE | | Human-readable host name. |
| `mac` | TEXT | NOT NULL UNIQUE | | Lowercase colon form (e.g. `aa:bb:cc:dd:ee:ff`). |
| `wol_broadcast_ip` | TEXT | NOT NULL | | LAN broadcast IP for Wake-on-LAN magic packets. |
| `wol_port` | INTEGER | NOT NULL | `9` | WoL UDP port. |
| `expected_spec_yaml` | TEXT | NOT NULL | | YAML describing expected hardware (CPU, memory, disks, firmware). |
| `pdu_config_json` | TEXT | | | PDU power control config (future use). |
| `ipmi_config_json` | TEXT | | | IPMI config (future use). |
| `notes` | TEXT | NOT NULL | `''` | Operator notes. |
| `created_at` | TIMESTAMP | NOT NULL | `CURRENT_TIMESTAMP` | |
| `updated_at` | TIMESTAMP | NOT NULL | `CURRENT_TIMESTAMP` | |
| `last_seen_at` | TIMESTAMP | | | Host-mode agent heartbeat timestamp. NULL = never seen. |
### `runs`
Vetting run instances. Each run belongs to one host and walks through
the state machine.
| Column | Type | Constraints | Default | Description |
|--------|------|-------------|---------|-------------|
| `id` | INTEGER | PK AUTOINCREMENT | | |
| `host_id` | INTEGER | NOT NULL FK → hosts(id) CASCADE | | |
| `state` | TEXT | NOT NULL | | Current `RunState` (see `internal/model`). |
| `result` | TEXT | | | `pass` or `fail` once terminal. |
| `failed_stage` | TEXT | | | Stage name that halted the pipeline. |
| `next_boot_target` | TEXT | | | `linux`, `memtest`, etc. (future use). |
| `agent_token_hash` | TEXT | NOT NULL | | SHA-256 hash of the bearer token. |
| `started_at` | TIMESTAMP | NOT NULL | `CURRENT_TIMESTAMP` | |
| `completed_at` | TIMESTAMP | | | Set when run reaches a terminal state. |
| `report_path` | TEXT | | | Path to `report.json` on disk. |
| `hold_ip` | TEXT | | | Agent IP during FailedHolding (for SSH command). |
| `override_flags_json` | TEXT | | | JSON blob (e.g. `{"wipe": true}`). |
| `non_destructive` | INTEGER | NOT NULL | `0` | `1` = skip badblocks + wipe probe. |
| `profile` | TEXT | NOT NULL | `'quick'` | `quick`, `deep`, or `soak`. |
**Indices:**
- `idx_runs_host` on `(host_id)`
- `idx_runs_state` on `(state)`
### `stages`
Per-stage results within a run. Seeded at `/claim` time with one row
per stage in `DefaultStageOrder`.
| Column | Type | Constraints | Default | Description |
|--------|------|-------------|---------|-------------|
| `id` | INTEGER | PK AUTOINCREMENT | | |
| `run_id` | INTEGER | NOT NULL FK → runs(id) CASCADE | | |
| `name` | TEXT | NOT NULL | | Stage name (e.g. `SMART`, `CPUStress`). |
| `ordinal` | INTEGER | NOT NULL | | 0-based position in the pipeline. |
| `state` | TEXT | NOT NULL | | `pending`, `running`, `passed`, `failed`, `skipped`. |
| `started_at` | TIMESTAMP | | | Set when the stage begins. |
| `completed_at` | TIMESTAMP | | | Set when the stage finishes. |
| `summary_json` | TEXT | | | Arbitrary JSON from the agent's result. |
**Indices:**
- `idx_stages_run_ordinal` on `(run_id, ordinal)`
### `sub_steps`
Finer-grained units within a stage (per-disk SMART, per-NIC iperf,
CPU/memory pass, per-GPU run). Not every stage has sub-steps.
| Column | Type | Constraints | Default | Description |
|--------|------|-------------|---------|-------------|
| `id` | INTEGER | PK AUTOINCREMENT | | |
| `run_id` | INTEGER | NOT NULL FK → runs(id) CASCADE | | |
| `stage_name` | TEXT | NOT NULL | | Parent stage name. |
| `ordinal` | INTEGER | NOT NULL | | 0-based within `(run_id, stage_name)`. |
| `name` | TEXT | NOT NULL | | Human label (e.g. `sda SMART`, `eth0 iperf`). |
| `state` | TEXT | NOT NULL | `'pending'` | `pending`, `running`, `passed`, `failed`, `skipped`. |
| `started_at` | TIMESTAMP | | | |
| `completed_at` | TIMESTAMP | | | |
| `summary_json` | TEXT | NOT NULL | `'{}'` | |
**Constraints:** `UNIQUE (run_id, stage_name, ordinal)`
**Indices:** `idx_sub_steps_run` on `(run_id, stage_name, ordinal)`
### `measurements`
Time-series sensor data from the thermal sidecar and stage executors.
| Column | Type | Constraints | Default | Description |
|--------|------|-------------|---------|-------------|
| `id` | INTEGER | PK AUTOINCREMENT | | |
| `run_id` | INTEGER | NOT NULL FK → runs(id) CASCADE | | |
| `stage_id` | INTEGER | FK → stages(id) SET NULL | | Optional link to a specific stage. |
| `ts` | TIMESTAMP | NOT NULL | | Sample timestamp. |
| `kind` | TEXT | NOT NULL | | `temp`, `power`, `iperf`, `fio`, `smart_attr`, `psu_volt`, `fan`, etc. |
| `key` | TEXT | NOT NULL | | Source identifier (e.g. `cpu/0`, `+12V`). |
| `value` | REAL | | | Numeric sample. |
| `unit` | TEXT | | | Display unit. |
**Indices:** `idx_measurements_run_kind_ts` on `(run_id, kind, ts)`
### `artifacts`
On-disk file references (reports, fio logs, iperf logs, hold keys).
| Column | Type | Constraints | Default | Description |
|--------|------|-------------|---------|-------------|
| `id` | INTEGER | PK AUTOINCREMENT | | |
| `run_id` | INTEGER | NOT NULL FK → runs(id) CASCADE | | |
| `stage_id` | INTEGER | FK → stages(id) SET NULL | | |
| `kind` | TEXT | NOT NULL | | `inventory`, `report`, `report_html`, `hold_key`, `fio`, `iperf`. |
| `path` | TEXT | NOT NULL | | Absolute path on disk. |
| `sha256` | TEXT | NOT NULL | | SHA-256 hex digest. |
| `size_bytes` | INTEGER | NOT NULL | | File size. |
### `spec_diffs`
Expected-vs-actual hardware divergences from SpecValidate.
| Column | Type | Constraints | Default | Description |
|--------|------|-------------|---------|-------------|
| `id` | INTEGER | PK AUTOINCREMENT | | |
| `run_id` | INTEGER | NOT NULL FK → runs(id) CASCADE | | |
| `field` | TEXT | NOT NULL | | Dotted path (e.g. `memory.total_gib`, `cpu.logical_cores`). |
| `expected` | TEXT | | | Expected value from the host's spec YAML. |
| `actual` | TEXT | | | Observed value from the inventory probe. |
| `severity` | TEXT | NOT NULL | | `critical`, `warning`, `info`. |
| `ignored` | INTEGER | NOT NULL | `0` | `1` = operator chose to ignore this diff. |
### `thresholds`
Per-run threshold rules, seeded from the `ProfileRegistry` + per-host
overrides at run creation. Immutable for the run's lifetime.
| Column | Type | Constraints | Default | Description |
|--------|------|-------------|---------|-------------|
| `id` | INTEGER | PK AUTOINCREMENT | | |
| `run_id` | INTEGER | NOT NULL FK → runs(id) CASCADE | | |
| `stage_name` | TEXT | NOT NULL | | `*` matches any stage. |
| `kind` | TEXT | NOT NULL | | Measurement kind to match. |
| `key` | TEXT | NOT NULL | | Key selector (glob-ish). |
| `op` | TEXT | NOT NULL | | `lt`, `lte`, `gt`, `gte`, `within_pct`. |
| `threshold` | REAL | NOT NULL | | Limit value. |
| `nominal` | REAL | NOT NULL | `0` | Reference for `within_pct`. |
| `unit` | TEXT | NOT NULL | `''` | Display unit. |
| `severity` | TEXT | NOT NULL | | `critical` or `warning`. |
| `source` | TEXT | NOT NULL | | `profile` or `host_override`. |
**Indices:**
- `idx_thresholds_run` on `(run_id)`
- `idx_thresholds_kind` on `(run_id, stage_name, kind)`
### `threshold_evaluations`
Per-sample pass/fail results from threshold evaluation. Drives
report badges and pipeline verdict rendering.
| Column | Type | Constraints | Default | Description |
|--------|------|-------------|---------|-------------|
| `id` | INTEGER | PK AUTOINCREMENT | | |
| `run_id` | INTEGER | NOT NULL FK → runs(id) CASCADE | | |
| `threshold_id` | INTEGER | NOT NULL FK → thresholds(id) CASCADE | | |
| `stage_name` | TEXT | NOT NULL | | Stage the sample belongs to. |
| `kind` | TEXT | NOT NULL | | Measurement kind. |
| `key` | TEXT | NOT NULL | | Source key. |
| `ts` | TIMESTAMP | NOT NULL | | Sample timestamp. |
| `observed` | REAL | NOT NULL | | Observed value. |
| `passed` | INTEGER | NOT NULL | | `1` = within threshold, `0` = breach. |
**Indices:** `idx_threshold_evals_run` on `(run_id, passed)`
### `firmware_snapshots`
Per-run firmware version captures (BIOS, BMC, NIC, HBA, microcode,
NVMe). Populated by the Firmware stage; consumed by SpecValidate for
firmware version diffing.
| Column | Type | Constraints | Default | Description |
|--------|------|-------------|---------|-------------|
| `id` | INTEGER | PK AUTOINCREMENT | | |
| `run_id` | INTEGER | NOT NULL FK → runs(id) CASCADE | | |
| `component` | TEXT | NOT NULL | | `bios`, `bmc`, `nic`, `hba`, `microcode`, `nvme_fw`. |
| `identifier` | TEXT | NOT NULL | | Slot, serial, or device path distinguishing this component. |
| `version` | TEXT | NOT NULL | | Firmware version string. |
| `vendor` | TEXT | NOT NULL | `''` | |
| `raw_json` | TEXT | NOT NULL | `'{}'` | Additional metadata. |
**Indices:** `idx_firmware_run` on `(run_id, component)`
### `events`
Event log table. Reserved for future use.
| Column | Type | Constraints | Default | Description |
|--------|------|-------------|---------|-------------|
| `id` | INTEGER | PK AUTOINCREMENT | | |
| `run_id` | INTEGER | FK → runs(id) CASCADE | | |
| `host_id` | INTEGER | FK → hosts(id) CASCADE | | |
| `ts` | TIMESTAMP | NOT NULL | | |
| `level` | TEXT | NOT NULL | | |
| `kind` | TEXT | NOT NULL | | |
| `message` | TEXT | NOT NULL | | |
| `data_json` | TEXT | | | |
### `settings`
Key-value store for orchestrator-level settings.
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| `key` | TEXT | PK | |
| `value` | TEXT | NOT NULL | |
---
## Entity relationships
```
hosts 1───N runs
├──N stages
│ └──(FK) measurements (stage_id, SET NULL)
│ └──(FK) artifacts (stage_id, SET NULL)
├──N sub_steps
├──N measurements (run_id)
├──N artifacts (run_id)
├──N spec_diffs
├──N thresholds
│ └──N threshold_evaluations
└──N firmware_snapshots
```
All foreign keys use `ON DELETE CASCADE` (except `stage_id` references
which use `SET NULL`). Deleting a host cascades through its runs and
all dependent rows.
## Data retention
The janitor goroutine prunes **on-disk files** (artifacts, logs) based
on `artifacts.retention_days` and `logs.retention_days`. **Database
rows are never deleted** by the janitor — run histories, measurement
time-series, spec diffs, and threshold evaluations survive cleanups
indefinitely.
See [architecture.md § Data retention](architecture.md#data-retention)
and [configuration.md § janitor](configuration.md#janitor).
## Migration history
| File | What it adds |
|------|-------------|
| `0001_init.sql` | Core schema: `hosts`, `runs`, `stages`, `measurements`, `artifacts`, `spec_diffs`, `events`, `settings`. |
| `0002_add_hosts_last_seen_at.sql` | `hosts.last_seen_at` column for host-mode agent heartbeats. |
| `0003_add_runs_non_destructive.sql` | `runs.non_destructive` boolean flag. |
| `0004_add_sub_steps.sql` | `sub_steps` table for per-disk/per-NIC granular stage detail. |
| `0005_profiles_thresholds_firmware.sql` | `runs.profile` column, `thresholds` + `threshold_evaluations` tables, `firmware_snapshots` table. |
All migrations are additive — no schema deletions or renames.
+193
View File
@@ -0,0 +1,193 @@
# Development guide
How to build, test, and contribute to the vetting orchestrator and
agent.
## Prerequisites
| Tool | Version | Notes |
|------|---------|-------|
| Go | 1.22+ | Pure Go — no cgo required. |
| templ | latest | `go install github.com/a-h/templ/cmd/templ@latest` |
| make | any | GNU Make on Linux/macOS/WSL; `make` ships with Git for Windows. |
| mkosi | 25.3+ | Only needed for `make live-image`. Linux/WSL only. |
Windows hosts can build and test everything except `live-image` and
`e2e`. Those targets require a real Linux userspace — use WSL:
`wsl make live-image`.
## Repository structure
```
cmd/
vetting/ orchestrator binary — HTTP server, dispatcher, runner
vetting-agent/ agent binary — dual-mode (live-image + host-mode)
internal/
config/ YAML loader, ProfileRegistry (quick/deep/soak)
db/ SQLite open + embedded migrations (pure Go via modernc.org/sqlite)
model/ Plain structs: Host, Run, Stage, SubStep, Measurement, SpecDiff
store/ Repository layer — hand-written SQL, no ORM
orchestrator/ State machine, dispatcher, runner, WoL, HMAC tokens, iperf supervisor
api/ HTTP handlers — agent_handlers.go + ui_handlers.go
httpserver/ chi router assembly (exists to break api ↔ orchestrator import cycle)
web/ Embedded static assets + compiled Templ templates
pxe/ dnsmasq subprocess supervisor + per-MAC iPXE script generator
events/ In-process SSE hub (fan-out to browser clients)
logs/ Per-run flat-file writer + SSE fan-out
spec/ Expected-vs-actual hardware diff engine
notify/ Pluggable notifier registry (ntfy, Discord, SMTP)
report/ HTML + JSON report generation
hold/ Per-run SSH key issuance for FailedHolding
janitor/ Retention-based cleanup (artifact + log files)
agent/
runner.go In-image agent: claim loop, stage dispatch, heartbeat, log forwarder
client.go HTTP client for orchestrator API
sensor_mux.go Thermal + performance metric sidecar
bootstate/ Kernel cmdline parser (run_id, mac, orchestrator_url, token)
hostmode/ Persistent host-mode reporter (systemd service)
probes/ Hardware interrogation (lshw, dmidecode, smartctl, etc.)
tests/ Per-stage test implementations
live-image/ mkosi config + scripts for Debian live image
deploy/ systemd unit, install.sh, pxe-setup.sh, example config
docs/ You are here
test/e2e/ Build-tagged QEMU + PXE full-stack integration test
```
**Key architectural insight:** `internal/httpserver` exists solely to
break the `api ↔ orchestrator` import cycle. The `internal/` tree is
the orchestrator binary's code; the `agent/` tree is the agent
binary's code. They share only `internal/model` (plain structs) and
`internal/spec` (diff engine, used by the agent's inventory probe and
the orchestrator's SpecValidate resolver).
## Building
| Target | Command | Description |
|--------|---------|-------------|
| Everything | `make all` | Build orchestrator + agent for host OS. |
| Orchestrator | `make orchestrator` | Host OS binary (`bin/vetting`). |
| Orchestrator (Linux) | `make orchestrator-linux` | Cross-compile to `bin/vetting-linux-amd64`. |
| Agent | `make agent` | Host OS binary (dev/testing only). |
| Agent (Linux) | `make agent-linux` | Cross-compile to `bin/vetting-agent.linux-amd64`. |
| Templates | `make templ` | Regenerate `.templ``.go` files. Run before build if templates changed. |
| Live image | `make live-image` | Build Debian live image via mkosi (Linux/WSL only). |
| Release bundle | `make release` | Slim tarball: binaries + deploy scripts + VERSION pointer. |
| Tidy | `make tidy` | `go mod tidy`. |
| Format | `make fmt` | `go fmt ./...`. |
| Lint | `make vet` | `go vet ./...`. |
| Clean | `make clean` | Remove `bin/`, `build/`, `tmp/`, `out/`, `dist/`. |
Build flags: the git SHA is baked into the binary via
`-ldflags -X vetting/internal/version.GitSHA=<sha>`.
## Running locally
```bash
make run
# → builds orchestrator, launches with deploy/vetting.example.yaml
# → http://localhost:8080
```
The example config binds to `127.0.0.1:8080`, disables PXE, and uses
`./var/` relative paths for the database, artifacts, and logs. Edit
`deploy/vetting.example.yaml` to tune for your dev environment.
For a QEMU walkthrough (register a host, PXE-boot a VM, watch the
pipeline), see [operations.md § First vetting run](operations.md#first-vetting-run).
## Testing
| Command | What it does |
|---------|--------------|
| `make test` | Unit + smoke tests across all packages. Cross-platform. |
| `make test-race` | Same tests with Go's race detector (`-race -count=1`). |
| `make vet` | `go vet ./...` — catches common mistakes. |
| `make e2e` | QEMU + PXE full-stack integration test. Requires Linux root, a built live image, and a running orchestrator with a registered host and queued run. |
**Test design:**
- Tests use real SQLite (in-memory or temp file) — no mocking the
database.
- The `agent/tests/fakes/` directory contains mock binaries
(`dmidecode`, `stress-ng`, etc.) used by agent probe tests.
- E2E tests are build-tagged with `-tags=e2e` and live in
`test/e2e/qemu_test.go`.
## Adding a new test stage
1. Add a `State<Name>` constant to `internal/model/model.go`.
2. Wire it into `internal/orchestrator/statemachine.go` — both the
forward transition table and the stage-for-state lookup.
3. Add the stage name to `DefaultStages()` in
`internal/config/profiles.go`.
4. Add a `case "<Name>":` to the `runStage` switch in
`agent/runner.go`.
5. Drop the implementation into `agent/tests/<name>.go`.
6. If the stage is **orchestrator-owned** (like SpecValidate or
Reporting), add a `resolve<Name>` helper to
`internal/api/agent_handlers.go` and call it from `resultAdvance`.
7. Add the stage to `vetting.stages` in
`deploy/vetting.example.yaml`.
See [test-suite.md](test-suite.md) for what each existing stage
measures and its pass/fail criteria.
## Adding a new notifier
1. Implement the `notify.Notifier` interface (single `Send` method)
in a new file under `internal/notify/`.
2. Register the new type in the notifier builder (the switch in
`internal/notify/build.go` or equivalent factory).
3. Add the type-specific config fields to the `Notifier` struct in
`internal/config/config.go`.
4. Document the new notifier type in
[configuration.md § notifiers](configuration.md#notifiers).
## Code conventions
- **No cgo** — the SQLite driver is `modernc.org/sqlite` (pure Go).
Builds cross-compile to Linux from Windows/macOS without a C
toolchain.
- **Hand-written SQL** — no ORM. Queries are explicit and testable.
Each store method is a single SQL statement or a short transaction.
- **Templ for UI** — `.templ` files compile to type-safe Go functions.
The report module uses `html/template` instead (self-contained HTML
with inlined CSS).
- **chi for routing** — `github.com/go-chi/chi/v5`. Standard
middleware stack: `RealIP`, `Recoverer`, `Logger`.
- **Error handling** — fail-soft in SSE/tile paths (log and skip),
fail-hard in store/migration paths (return error up).
- **Log convention** — `log.Printf` with a context prefix
(e.g. `"claim: seed stages run %d: %v"`).
## CI/CD
Three Gitea Actions workflows in `.gitea/workflows/`:
| Workflow | Trigger | What it does |
|----------|---------|--------------|
| `ci.yml` | Push to main + PRs | Templ generate, tidy check, vet, build (native + linux), test with race detector + coverage. |
| `release.yml` | Push to main (skips doc/test paths) | Detects `live-image/VERSION` changes → builds + publishes live image to registry. Always builds slim bundle → publishes to `vetting/latest/`. |
| `e2e.yml` | Manual dispatch | Builds live image + orchestrator, installs QEMU + deps, runs `make e2e`. |
**Release bundle structure:**
```
vetting-bundle/
bin/
vetting-linux-amd64
vetting-agent.linux-amd64
live-image/
VERSION # pointer — actual vmlinuz/initrd.img fetched on install
install.sh
pxe-setup.sh
vetting.service
vetting.production.yaml
ipxe-shas.txt
VERSION # git SHA
```
The ~30 MB bundle is published on every push to main. The ~300 MB live
image (`vmlinuz` + `initrd.img`) is published separately under
`live-image/<version>/` and only rebuilds when `live-image/VERSION`
changes.
+73 -2
View File
@@ -8,8 +8,8 @@ to fix, override, or abandon.
## Stage order
```
Inventory → SpecValidate → SMART → CPUStress → Storage
→ Network → GPU → PSU → Reporting
Inventory → Firmware → SpecValidate → SMART → CPUStress → Storage
→ Network → Burn → GPU → PSU → Reporting
```
Stages marked *orchestrator-owned* resolve inside `/result` and never
@@ -27,6 +27,20 @@ merged into a single JSON blob.
`nvidia-smi` on a GPU-less host) are tolerated.
**Artifacts:** `inventory.json` under `artifacts/run-<N>/`.
## Firmware
**Owner:** agent.
**What it does:** probes firmware versions across all discoverable
components: BIOS (`dmidecode -t bios`), BMC (`ipmitool mc info`), NIC
firmware (`ethtool -i` per interface), NVMe firmware (`nvme id-ctrl`),
HBA firmware (`lspci -vv`), and CPU microcode (`/proc/cpuinfo`).
Missing tools are tolerated — a GPU-less server won't have
`nvidia-smi`, a consumer board won't have `ipmitool`.
**Pass:** always passes. Firmware is advisory-only; SpecValidate is the
gate that fails on version mismatches.
**Artifacts:** `firmware_snapshots` table rows (one per component,
keyed by `(run_id, component, identifier)`).
## SpecValidate *(orchestrator-owned)*
**Owner:** orchestrator (resolves inline inside the `/result` for the
@@ -93,6 +107,40 @@ binds to the configured `network.iperf_port`.
for 10GbE).
**Artifacts:** `iperf-<nic>.json`.
## Burn
**Owner:** agent.
**What it does:** runs CPU stress, memory stress, disk I/O, and
network throughput **simultaneously** for the profile's burn duration.
The goal is to stress every subsystem at once and surface failures that
only appear under combined load (thermal throttling, PSU voltage sag,
memory errors under thermal pressure).
Sub-workloads run as parallel goroutines:
- **CPU** — `stress-ng --cpu <workers>` for the burn duration.
- **Memory** — `stress-ng --vm --vm-bytes <mem_pct>%` for the burn
duration.
- **Disk** — `fio` against a spare partition (when `fio_on_spare` is
enabled).
- **Network** — `iperf3 -c <orchestrator> -P <parallel>` for the burn
duration.
**Pass:** all four sub-workloads exit 0 and no critical threshold
breach fires during the window.
**Configurable knobs** (per profile):
| Knob | Description |
|------|-------------|
| `duration` | Total burn-in window. |
| `cpu_workers` | `all` = `runtime.NumCPU()`, or a fixed count. |
| `mem_pct` | Percentage of MemAvailable to stress. |
| `fio_on_spare` | Run fio inside Burn (requires a spare partition). |
| `iperf_parallel` | Parallel stream count for `iperf3 -P`. |
See [configuration.md § burn](configuration.md#burn) for per-profile
default values.
## GPU
**Owner:** agent.
@@ -153,6 +201,29 @@ the next batch.
- `artifacts` — on-disk files (report, fio logs, iperf logs, etc).
- `spec_diffs` — one row per expected-vs-actual divergence.
## Profile duration summary
Three profiles scale every stage's duration. Probes and gates are
identical across profiles — only the work size changes. See
[configuration.md § profiles](configuration.md#profiles) for the full
knob reference.
| Stage | quick (~10 min) | deep (~8-12 h) | soak (~36-40 h) |
|-------|----------------|----------------|-----------------|
| Inventory | seconds | seconds | seconds |
| Firmware | seconds | seconds | seconds |
| SpecValidate | instant (server) | instant (server) | instant (server) |
| SMART | seconds per disk | seconds per disk | seconds per disk |
| CPUStress | 2 m cpu + 2 m mem | 60 m cpu + 60 m mem | 12 h cpu + 12 h mem |
| Storage | 3 m fio (sample) | badblocks + 2 h fio | badblocks + 6 h fio |
| Network | 60 s iperf | 30 m iperf | 2 h iperf |
| Burn | 2 m all-at-once | 2 h all-at-once | 18 h all-at-once |
| GPU | seconds | seconds | seconds |
| PSU | 1 m load burst | 10 m load burst | 15 m load burst |
| Reporting | instant (server) | instant (server) | instant (server) |
---
## Adding a new stage
1. Add the name to `store.DefaultStageOrder`.
+84 -75
View File
@@ -1,3 +1,5 @@
// Package api contains the HTTP handlers for both the agent-facing
// endpoints (/api/v1/runs/:id/*) and the browser-facing UI routes.
package api
import (
@@ -170,7 +172,7 @@ func (a *Agent) Claim(w http.ResponseWriter, r *http.Request) {
if len(mustListStages(a.Stages, r, runID)) == 0 {
if err := a.Stages.Seed(r.Context(), runID); err != nil {
log.Printf("claim: seed stages run %d: %v", runID, err)
http.Error(w, "seed stages", http.StatusInternalServerError)
writeJSONErr(w, http.StatusInternalServerError, "seed stages")
return
}
}
@@ -180,7 +182,7 @@ func (a *Agent) Claim(w http.ResponseWriter, r *http.Request) {
if run.State == model.StateWaitingWoL || run.State == model.StateBooting {
if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerAgentClaimed); err != nil {
log.Printf("claim: transition run %d: %v", runID, err)
http.Error(w, "transition", http.StatusConflict)
writeJSONErr(w, http.StatusConflict, "transition")
return
}
}
@@ -369,6 +371,10 @@ func writeJSON(w http.ResponseWriter, status int, body any) {
_ = json.NewEncoder(w).Encode(body)
}
func writeJSONErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]any{"ok": false, "error": msg})
}
// mustListStages is a small wrapper that hides the error path from
// /claim — a DB read failure just pretends there are zero stages, and
// the subsequent Seed will surface the real error.
@@ -408,12 +414,12 @@ func (a *Agent) Log(w http.ResponseWriter, r *http.Request) {
}
var batch LogBatch
if err := json.NewDecoder(r.Body).Decode(&batch); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
writeJSONErr(w, http.StatusBadRequest, "bad json")
return
}
writer, err := a.Logs.WriterFor(runID)
if err != nil {
http.Error(w, "open log: "+err.Error(), http.StatusInternalServerError)
writeJSONErr(w, http.StatusInternalServerError, "open log: "+err.Error())
return
}
for _, l := range batch.Lines {
@@ -470,9 +476,7 @@ type SubStepResultLine struct {
// Result receives a stage's outcome. Flow:
// 1. Mark the stage row passed/failed + record summary JSON.
// 2. For Inventory: persist the inventory artifact.
// 3. For Inventory (on pass): run spec diff server-side, persist rows,
// bump the run into SpecValidate and immediately resolve SpecValidate
// from that diff — the agent isn't involved in SpecValidate at all.
// 3. For Firmware: persist firmware snapshots.
// 4. Transition the run via StageCompleted/StageFailed.
func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
runID, ok := runIDFromURL(w, r)
@@ -485,28 +489,55 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
}
var body StageResult
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
writeJSONErr(w, http.StatusBadRequest, "bad json")
return
}
body.Stage = strings.TrimSpace(body.Stage)
if _, ok := orchestrator.StateForStage(body.Stage); !ok {
http.Error(w, "unknown stage: "+body.Stage, http.StatusBadRequest)
writeJSONErr(w, http.StatusBadRequest, "unknown stage: "+body.Stage)
return
}
// Silent-skip guard. Orchestrator advances the run state via
// TriggerStageCompleted against the *current* state, not against
// body.Stage — so an Inventory result posted while the run is in
// StateCPUStress would silently advance CPUStress → Storage and mark
// CPUStress as passed without it ever running. That's exactly what
// happened on Orion when the agent OOM-crashed mid-CPUStress,
// systemd restarted it, and the restarted agent (which hardcoded
// "Inventory" as its first stage) re-ran Inventory and reported it.
// Guard: if body.Stage doesn't match the stage the run is currently
// in, park the run in FailedHolding so the operator can investigate
// rather than trusting the claim and cascading silent passes.
if a.resultStageMismatch(w, r, runID, run, &body) {
return
}
thresholdDetail := a.resultCheckThresholds(r.Context(), runID, &body)
stageState := model.StagePassed
if !body.Passed {
stageState = model.StageFailed
}
summaryJSON := ""
if len(body.Summary) > 0 {
summaryJSON = string(body.Summary)
}
if err := a.Runner.CompleteStage(r.Context(), runID, body.Stage, stageState, summaryJSON); err != nil {
writeJSONErr(w, http.StatusInternalServerError, "complete stage: "+err.Error())
return
}
if thresholdDetail != "" && body.Message == "" {
body.Message = thresholdDetail
}
a.persistSubSteps(r.Context(), runID, body.Stage, body.SubSteps)
a.resultPersistArtifacts(r, run, runID, &body)
if !body.Passed {
a.resultHandleFailed(w, r, runID, run, &body)
return
}
a.resultAdvance(w, r, runID, &body)
}
// resultStageMismatch parks the run in FailedHolding when the reported
// stage doesn't match what the orchestrator expects. Returns true if the
// response has been written (caller should return).
func (a *Agent) resultStageMismatch(w http.ResponseWriter, r *http.Request, runID int64, run *model.Run, body *StageResult) bool {
expectedStage := orchestrator.StageNameForState(run.State)
if expectedStage != "" && body.Stage != expectedStage {
if expectedStage == "" || body.Stage == expectedStage {
return false
}
failedLabel := fmt.Sprintf("%s (expected %s)", body.Stage, expectedStage)
if err := a.Runs.SetFailedStage(r.Context(), runID, failedLabel); err != nil {
log.Printf("result: set failed stage on mismatch run %d: %v", runID, err)
@@ -526,68 +557,49 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
URL: a.runLinkURL(runID),
})
log.Printf("result: stage mismatch run=%d got=%s expected=%s — parked", runID, body.Stage, expectedStage)
http.Error(w, "stage mismatch: got "+body.Stage+", expected "+expectedStage, http.StatusConflict)
return
}
writeJSONErr(w, http.StatusConflict, "stage mismatch: got "+body.Stage+", expected "+expectedStage)
return true
}
// Aggregate threshold gate: flip Passed=false server-side when any
// critical breach landed for this stage. The agent's verdict is
// advisory — a stage-executor can miss a runaway sample that the
// sidecar caught. We check this *before* writing the stage state
// so the DB reflects the server-side decision.
thresholdDetail := ""
if body.Passed {
if breached, detail := a.stageHadCriticalBreach(r.Context(), runID, body.Stage); breached {
body.Passed = false
thresholdDetail = detail
a.appendLog(runID, "error", fmt.Sprintf("%s reported passed but %s — flipping to failed", body.Stage, detail))
}
}
stageState := model.StagePassed
// resultCheckThresholds flips body.Passed to false when the server-side
// threshold sidecar recorded a critical breach the agent missed.
func (a *Agent) resultCheckThresholds(ctx context.Context, runID int64, body *StageResult) string {
if !body.Passed {
stageState = model.StageFailed
return ""
}
summaryJSON := ""
if len(body.Summary) > 0 {
summaryJSON = string(body.Summary)
}
if err := a.Runner.CompleteStage(r.Context(), runID, body.Stage, stageState, summaryJSON); err != nil {
http.Error(w, "complete stage: "+err.Error(), http.StatusInternalServerError)
return
}
if thresholdDetail != "" && body.Message == "" {
body.Message = thresholdDetail
breached, detail := a.stageHadCriticalBreach(ctx, runID, body.Stage)
if !breached {
return ""
}
body.Passed = false
a.appendLog(runID, "error", fmt.Sprintf("%s reported passed but %s — flipping to failed", body.Stage, detail))
return detail
}
// Agent-authored sub-steps: persist in slice order (ordinal = index)
// and fan out a per-row SSE event each so the detail pane shows them
// without a reload. Best-effort — a persistence error is logged but
// doesn't fail the whole /result.
a.persistSubSteps(r.Context(), runID, body.Stage, body.SubSteps)
// Inventory-specific: persist artifact + compute spec diff.
// resultPersistArtifacts handles stage-specific artifact persistence
// (inventory JSON, firmware snapshots). Best-effort — errors are logged.
func (a *Agent) resultPersistArtifacts(r *http.Request, run *model.Run, runID int64, body *StageResult) {
if body.Stage == "Inventory" && body.Inventory != nil {
if err := a.persistInventory(r, run, body.Inventory); err != nil {
log.Printf("persist inventory run %d: %v", runID, err)
}
}
// Firmware-specific: persist each snapshot into firmware_snapshots.
// SpecValidate reads them back to diff against expected_firmware.
if body.Stage == "Firmware" && len(body.Firmware) > 0 {
if err := a.persistFirmware(r.Context(), runID, body.Firmware); err != nil {
log.Printf("persist firmware run %d: %v", runID, err)
}
}
}
if !body.Passed {
// resultHandleFailed transitions a failed stage into FailedHolding and
// fires the failure notification.
func (a *Agent) resultHandleFailed(w http.ResponseWriter, r *http.Request, runID int64, run *model.Run, body *StageResult) {
if err := a.Runs.SetFailedStage(r.Context(), runID, body.Stage); err != nil {
log.Printf("set failed stage: %v", err)
}
if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageFailed); err != nil {
log.Printf("result: failed-transition run %d: %v", runID, err)
http.Error(w, "transition", http.StatusConflict)
writeJSONErr(w, http.StatusConflict, "transition")
return
}
hostName := a.hostNameFor(r.Context(), run.HostID)
@@ -605,21 +617,18 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
URL: a.runLinkURL(runID),
})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "next_state": "FailedHolding"})
return
}
}
// Passed: advance to the next stage in the pipeline.
// resultAdvance transitions a passed stage to the next pipeline state,
// auto-resolving server-owned stages (SpecValidate, Reporting).
func (a *Agent) resultAdvance(w http.ResponseWriter, r *http.Request, runID int64, body *StageResult) {
next, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageCompleted)
if err != nil {
http.Error(w, "advance: "+err.Error(), http.StatusConflict)
writeJSONErr(w, http.StatusConflict, "advance: "+err.Error())
return
}
log.Printf("result: run %d stage %s passed → %s", runID, body.Stage, next)
// If the just-advanced-into state is SpecValidate or Reporting, the
// orchestrator owns those stages entirely. The resolve function may
// transition further (→ next stage on pass, → FailedHolding on fail,
// → Completed for Reporting), so we re-read the run after each.
if next == model.StateSpecValidate {
a.resolveSpecValidate(r, runID)
if after, err := a.Runs.Get(r.Context(), runID); err == nil {
@@ -912,13 +921,13 @@ func (a *Agent) Hold(w http.ResponseWriter, r *http.Request) {
kp, err := hold.Issue(runID)
if err != nil {
http.Error(w, "generate key: "+err.Error(), http.StatusInternalServerError)
writeJSONErr(w, http.StatusInternalServerError, "generate key: "+err.Error())
return
}
keyPath := filepath.Join(a.ArtifactsDir, fmt.Sprintf("run-%d", runID), "hold.key")
abs, err := kp.WritePrivateTo(keyPath)
if err != nil {
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
writeJSONErr(w, http.StatusInternalServerError, "write key: "+err.Error())
return
}
sum := sha256.Sum256(kp.PrivatePEM)
@@ -1021,12 +1030,12 @@ func (a *Agent) Sensor(w http.ResponseWriter, r *http.Request) {
return
}
if a.Measurements == nil {
http.Error(w, "measurements store not wired", http.StatusInternalServerError)
writeJSONErr(w, http.StatusInternalServerError, "measurements store not wired")
return
}
var body SensorBatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
writeJSONErr(w, http.StatusBadRequest, "bad json")
return
}
rows := make([]model.Measurement, 0, len(body.Samples))
@@ -1050,7 +1059,7 @@ func (a *Agent) Sensor(w http.ResponseWriter, r *http.Request) {
sampleStages = append(sampleStages, orchestrator.StageNameForState(run.State))
}
if err := a.Measurements.CreateBatch(r.Context(), rows); err != nil {
http.Error(w, "write samples: "+err.Error(), http.StatusInternalServerError)
writeJSONErr(w, http.StatusInternalServerError, "write samples: "+err.Error())
return
}
critical := a.evaluateSensorBatch(r.Context(), runID, rows, sampleStages)
+10 -5
View File
@@ -70,6 +70,11 @@ func (u *UI) reloadPXE(ctx context.Context) {
}
}
func renderNotFound(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_ = templates.NotFound().Render(r.Context(), w)
}
var macRe = regexp.MustCompile(`^[0-9a-f]{2}(:[0-9a-f]{2}){5}$`)
// quickRegisterTmpl is parsed once at startup — a malformed template
@@ -126,7 +131,7 @@ func (u *UI) HostPage(w http.ResponseWriter, r *http.Request) {
data, err := u.LoadHostPageData(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
renderNotFound(w, r)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -190,7 +195,7 @@ func (u *UI) RunPage(w http.ResponseWriter, r *http.Request) {
data, err := u.LoadRunPageData(r.Context(), runID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
renderNotFound(w, r)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -293,7 +298,7 @@ func (u *UI) StartRun(w http.ResponseWriter, r *http.Request) {
host, err := u.Hosts.Get(r.Context(), hostID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
renderNotFound(w, r)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -685,7 +690,7 @@ func (u *UI) DeleteHost(w http.ResponseWriter, r *http.Request) {
}
if err := u.Hosts.Delete(r.Context(), id); err != nil {
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
renderNotFound(w, r)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -722,7 +727,7 @@ func (u *UI) Report(w http.ResponseWriter, r *http.Request) {
}
}
if path == "" {
http.NotFound(w, r)
renderNotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
+4
View File
@@ -1,3 +1,7 @@
// Package config loads the orchestrator's YAML configuration file and
// exposes typed structs for every config block. The ProfileRegistry
// (quick/deep/soak) is built during Load from the vetting: and
// profiles: top-level blocks.
package config
import (
+3
View File
@@ -1,3 +1,6 @@
// Package db opens the SQLite database and applies embedded SQL
// migrations in filename order at startup. Uses modernc.org/sqlite
// (pure Go, no cgo).
package db
import (
+32 -5
View File
@@ -1,3 +1,6 @@
// Package events provides an in-process SSE fan-out hub. Browser
// clients subscribe via GET /events; the orchestrator publishes
// pre-rendered HTML fragments that HTMX swaps into the DOM.
package events
import (
@@ -22,6 +25,11 @@ type subscriber struct {
ch chan Event
}
const (
defaultSubscriberBuffer = 32
heartbeatInterval = 15 * time.Second
)
// Hub is an in-process fan-out for SSE subscribers.
type Hub struct {
mu sync.RWMutex
@@ -29,13 +37,16 @@ type Hub struct {
subs map[int64]*subscriber
buffer int
heartbeat time.Duration
done chan struct{}
closeOnce sync.Once
}
func NewHub() *Hub {
h := &Hub{
subs: map[int64]*subscriber{},
buffer: 32,
heartbeat: 15 * time.Second,
buffer: defaultSubscriberBuffer,
heartbeat: heartbeatInterval,
done: make(chan struct{}),
}
go h.heartbeatLoop()
return h
@@ -70,12 +81,17 @@ func (h *Hub) Subscribe() (id int64, ch <-chan Event, cancel func()) {
func (h *Hub) heartbeatLoop() {
t := time.NewTicker(h.heartbeat)
defer t.Stop()
for range t.C {
for {
select {
case <-h.done:
return
case <-t.C:
h.Publish(Event{
Name: "heartbeat",
Payload: fmt.Sprintf(`<span data-heartbeat="%d"></span>`, time.Now().Unix()),
})
}
}
}
// ServeSSE writes server-sent events for a single subscriber for the
@@ -140,5 +156,16 @@ func splitLines(s string) []string {
return out
}
// Shutdown is a no-op placeholder wired into graceful shutdown.
func (h *Hub) Shutdown(_ context.Context) error { return nil }
// Shutdown stops the heartbeat goroutine and closes all subscriber channels.
func (h *Hub) Shutdown(_ context.Context) error {
h.closeOnce.Do(func() {
close(h.done)
h.mu.Lock()
for id, s := range h.subs {
close(s.ch)
delete(h.subs, id)
}
h.mu.Unlock()
})
return nil
}
+4 -3
View File
@@ -4,6 +4,7 @@
package httpserver
import (
"fmt"
"io/fs"
"net/http"
@@ -21,7 +22,7 @@ type Deps struct {
AgentAssetDir string // directory containing vetting-agent-linux-amd64; "" disables /assets
}
func NewRouter(d Deps) http.Handler {
func NewRouter(d Deps) (http.Handler, error) {
r := chi.NewRouter()
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
@@ -29,7 +30,7 @@ func NewRouter(d Deps) http.Handler {
staticFS, err := fs.Sub(web.Static, "static")
if err != nil {
panic(err)
return nil, fmt.Errorf("extract static assets: %w", err)
}
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
@@ -80,5 +81,5 @@ func NewRouter(d Deps) http.Handler {
r.Get("/register/quick.sh", d.UI.QuickRegisterScript)
r.Get("/events", d.UI.SSE)
return r
return r, nil
}
+8 -2
View File
@@ -72,7 +72,10 @@ func TestSSE_EndToEnd(t *testing.T) {
t.Fatalf("create host: %v", err)
}
router := NewRouter(Deps{UI: ui, Agent: agent})
router, err := NewRouter(Deps{UI: ui, Agent: agent})
if err != nil {
t.Fatalf("router: %v", err)
}
srv := httptest.NewServer(router)
t.Cleanup(srv.Close)
@@ -178,7 +181,10 @@ func TestSSE_SubStepEvent(t *testing.T) {
SpecDiffs: diffs, Runner: runner, EventHub: hub,
}
router := NewRouter(Deps{UI: ui, Agent: agent})
router, err := NewRouter(Deps{UI: ui, Agent: agent})
if err != nil {
t.Fatalf("router: %v", err)
}
srv := httptest.NewServer(router)
t.Cleanup(srv.Close)
+10
View File
@@ -1,7 +1,11 @@
// Package model defines the domain value types shared across the
// orchestrator: Host, Run, Stage, SubStep, Measurement, and SpecDiff.
// These are plain structs with no behaviour beyond state classification.
package model
import "time"
// Host is a registered hardware node in the vetting cluster.
type Host struct {
ID int64
Name string
@@ -17,6 +21,7 @@ type Host struct {
LastSeenAt *time.Time // host-mode agent heartbeat; nil = never seen
}
// RunState is the current position of a run in the state machine.
type RunState string
const (
@@ -51,6 +56,7 @@ func (s RunState) IsTerminal() bool {
return false
}
// Run is a single vetting pass on a host, walking through the stage pipeline.
type Run struct {
ID int64
HostID int64
@@ -68,6 +74,7 @@ type Run struct {
Profile string // quick|deep|soak; empty is treated as "quick"
}
// StageState tracks whether a stage is pending, running, passed, failed, or skipped.
type StageState string
const (
@@ -78,6 +85,7 @@ const (
StageSkipped StageState = "skipped"
)
// Stage is a single test step within a run (e.g. SMART, CPUStress, Storage).
type Stage struct {
ID int64
RunID int64
@@ -107,6 +115,7 @@ type SubStep struct {
SummaryJSON string
}
// Measurement is a single time-series sample from the thermal sidecar or a stage executor.
type Measurement struct {
ID int64
RunID int64
@@ -118,6 +127,7 @@ type Measurement struct {
Unit string
}
// SpecDiff records a single expected-vs-actual hardware divergence from SpecValidate.
type SpecDiff struct {
ID int64
RunID int64
+5 -2
View File
@@ -18,7 +18,10 @@ import (
// doesn't block dispatch. Used by the StartRun preflight and the
// dispatcher itself — both must agree or the operator's click-time
// validation wouldn't match the dispatch-time check.
const HostHeartbeatStaleAfter = 60 * time.Second
const (
HostHeartbeatStaleAfter = 60 * time.Second
dispatchTickInterval = 2 * time.Second
)
// Dispatcher picks Queued runs off the DB and drives them to
// WaitingReboot — the happy path is heartbeat-first: we transition and
@@ -76,7 +79,7 @@ func (d *Dispatcher) Stop() {
}
func (d *Dispatcher) loop(ctx context.Context) {
t := time.NewTicker(2 * time.Second)
t := time.NewTicker(dispatchTickInterval)
defer t.Stop()
for {
select {
+3
View File
@@ -1,3 +1,6 @@
// Package orchestrator contains the run state machine, dispatcher,
// per-run runner, WoL sender, HMAC token issuer, threshold evaluator,
// and iperf3 supervisor.
package orchestrator
import (
+3
View File
@@ -1,3 +1,6 @@
// Package pxe supervises a dnsmasq subprocess for proxy-DHCP PXE
// boot and generates per-MAC iPXE scripts that chainload the live
// image with run-specific kernel cmdline parameters.
package pxe
import (
+3
View File
@@ -1,3 +1,6 @@
// Package store is the repository layer for the orchestrator's SQLite
// database. Each store type (Hosts, Runs, Stages, etc.) wraps a
// *sql.DB and exposes hand-written SQL queries — no ORM.
package store
import (
+3 -1
View File
@@ -10,6 +10,8 @@ import (
"vetting/internal/model"
)
const defaultRunListLimit = 20
type Runs struct {
DB *sql.DB
}
@@ -182,7 +184,7 @@ func (r *Runs) LatestForHost(ctx context.Context, hostID int64) (*model.Run, err
// can't scan the whole history into memory.
func (r *Runs) ListForHost(ctx context.Context, hostID int64, limit int) ([]model.Run, error) {
if limit <= 0 {
limit = 20
limit = defaultRunListLimit
}
rows, err := r.DB.QueryContext(ctx, `
SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''),
+2
View File
@@ -1,3 +1,5 @@
// Package web embeds the static assets (CSS, JS) and compiled Templ
// templates served by the orchestrator's HTTP routes.
package web
import "embed"
+143 -156
View File
@@ -117,14 +117,8 @@ button.danger:hover { background: rgba(229,100,102,.1); }
text-decoration: none;
}
.tile > *:not(.tile-link) { position: relative; z-index: 1; }
.tile-primary-action { display: flex; gap: 8px; }
.tile-primary-action .inline { margin: 0; }
.tile-primary-action:empty { display: none; }
.tile-head { display: flex; justify-content: space-between; align-items: center; }
.tile-name { font-weight: 600; }
.tile-header-right { display: flex; align-items: center; gap: 10px; }
.tile-status { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .5px; }
.tile-idle .tile-status { color: var(--text-dim); }
.tile-last-seen {
font-family: var(--mono);
@@ -146,58 +140,6 @@ button.danger:hover { background: rgba(229,100,102,.1); }
.tile-last-seen.stale::before { background: var(--warn); }
.tile-last-seen.offline::before { background: var(--text-dim); opacity: .5; }
.tile-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 16px; margin: 0; font-size: 13px; }
.tile-meta div { display: flex; justify-content: space-between; align-items: baseline; }
.tile-meta dt { color: var(--text-dim); }
.tile-meta dd { margin: 0; font-family: var(--mono); }
.tile-actions { display: flex; gap: 8px; }
.tile-actions .inline { margin: 0; flex: 0; }
.tile-meta dd.bad { color: var(--danger); }
.tile-hold {
background: rgba(229,100,102,.08);
border: 1px solid rgba(229,100,102,.35);
border-radius: var(--radius);
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.tile-hold .hold-title {
font-size: 12px;
color: var(--danger);
text-transform: uppercase;
letter-spacing: .5px;
}
.tile-hold .hold-ssh {
font-family: var(--mono);
font-size: 12px;
color: var(--text);
word-break: break-all;
user-select: all;
}
.tile-log {
background: #0b0d12;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 10px;
font-family: var(--mono);
font-size: 12px;
color: var(--text-dim);
max-height: 160px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.tile-log:empty { display: none; }
.tile-log .log-line { white-space: pre-wrap; }
.tile-log .log-warn { color: var(--warn); }
.tile-log .log-error { color: var(--danger); }
.tile-fail { border-color: rgba(229,100,102,.6); }
.tile-pass { border-color: rgba(53,194,123,.5); }
.tile-active { border-color: var(--accent); }
@@ -273,31 +215,14 @@ body.bare main { max-width: none; }
.quick-register .one-liner code { white-space: pre; }
.manual-register-card { padding-top: 10px; padding-bottom: 14px; }
.manual-register summary {
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.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;
.manual-register-card h2 {
margin: 0 0 12px;
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 ===== */
.detail { display: flex; flex-direction: column; gap: 20px; }
@@ -320,7 +245,6 @@ body.bare main { max-width: none; }
.detail-summary.tile-active { border-color: var(--accent); }
.detail-summary-head { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; flex-wrap: wrap; }
.detail-name { margin: 0; font-size: 22px; }
.detail-status-row { display: flex; align-items: center; gap: 12px; }
.detail-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
@@ -345,11 +269,6 @@ body.bare main { max-width: none; }
.detail-section details[open] > summary::before { transform: rotate(90deg); }
.detail-section details > summary h2 { margin: 0; }
.detail-hold {
background: rgba(229,100,102,.08);
border-color: rgba(229,100,102,.35);
}
.detail-hold h2 { color: var(--danger); }
.hold-ssh {
font-family: var(--mono);
font-size: 13px;
@@ -366,25 +285,6 @@ body.bare main { max-width: none; }
.detail-actions-row { display: flex; flex-wrap: wrap; gap: 10px; }
.detail-actions-row .inline { margin: 0; }
.detail-log {
background: #0b0d12;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
font-family: var(--mono);
font-size: 12px;
color: var(--text-dim);
max-height: 500px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.detail-log:empty::before { content: "(no log output yet)"; color: var(--text-dim); opacity: .5; }
.detail-log .log-line { white-space: pre-wrap; }
.detail-log .log-warn { color: var(--warn); }
.detail-log .log-error { color: var(--danger); }
/* ===== Log tabs (CSS-only radio switch) ===== */
/* Radios are visually hidden but still functional: checked state is read
by sibling selectors below to flip the active label + pane. */
@@ -570,37 +470,6 @@ body.bare main { max-width: none; }
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);
@@ -679,25 +548,7 @@ body.bare main { max-width: none; }
}
.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);
@@ -909,14 +760,14 @@ body.bare main { max-width: none; }
.host-profile-picker {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 10px;
display: inline-flex;
gap: 12px;
align-items: center;
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 6px;
margin: 0 8px 0 0;
}
.host-profile-picker legend { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; padding: 0 4px; }
.host-profile-picker label { display: inline-flex; gap: 4px; align-items: center; font-family: var(--mono); font-size: 13px; cursor: pointer; }
.host-profile-picker label { cursor: pointer; }
.in-flight-banner-wrap { display: contents; }
.in-flight-banner {
@@ -996,3 +847,139 @@ body.bare main { max-width: none; }
.run-page { display: flex; flex-direction: column; gap: 12px; }
.run-body { display: flex; flex-direction: column; gap: 10px; }
.run-header-name { margin: 0; font-size: 20px; font-weight: 600; }
/* ---------- UX fixes ------------------------------------------------ */
/* #1: Active nav indicator */
.topbar nav a.nav-active { color: var(--text); }
/* #3: Copy button for code blocks */
.copyable-wrap { position: relative; display: flex; align-items: stretch; gap: 0; }
.copyable-wrap .one-liner,
.copyable-wrap .hold-ssh { flex: 1; margin: 0; border-top-right-radius: 0; border-bottom-right-radius: 0; }
.copy-btn {
padding: 6px 12px;
font-size: 11px;
font-family: var(--mono);
text-transform: uppercase;
letter-spacing: .5px;
background: var(--bg-elev-2);
border: 1px solid var(--border);
border-left: none;
border-radius: 0 var(--radius) var(--radius) 0;
color: var(--text-dim);
cursor: pointer;
white-space: nowrap;
}
.copy-btn:hover { color: var(--text); background: var(--bg-elev); }
.copy-btn.copied { color: var(--success); }
/* #4: Profile picker descriptions */
.host-profile-picker label {
display: flex;
flex-direction: column;
gap: 2px;
align-items: flex-start;
font-family: var(--mono);
font-size: 13px;
cursor: pointer;
}
.host-profile-picker label > .profile-label { display: inline-flex; align-items: center; gap: 4px; }
.host-profile-picker label > .profile-desc { font-family: var(--font); font-size: 11px; color: var(--text-dim); padding-left: 18px; }
/* #5: Non-destructive hint */
.nd-hint {
display: block;
font-size: 11px;
color: var(--text-dim);
padding-left: 20px;
margin-top: 2px;
}
/* #6: Offline guidance */
.offline-hint {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.offline-hint span { font-size: 13px; color: var(--text-dim); }
.offline-hint a { color: var(--accent); }
/* #7: SSE connection indicator */
.heartbeat { transition: color .3s ease; }
.heartbeat-live { color: var(--success) !important; }
.heartbeat-stale { color: var(--danger) !important; }
/* #9: Diff badge on collapsed spec diffs */
.diff-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
font-family: var(--mono);
line-height: 1;
}
.diff-badge-critical { background: rgba(229,100,102,.2); color: var(--danger); border: 1px solid rgba(229,100,102,.5); }
.diff-badge-warn { background: rgba(228,169,75,.15); color: var(--warn); border: 1px solid rgba(228,169,75,.4); }
/* #10: Cancelled run state */
.run-status-cancelled { background: rgba(154,162,177,.12); border-color: rgba(154,162,177,.4); color: var(--text-dim); }
.tile-cancelled { border-color: rgba(154,162,177,.3); }
/* #11: Small status badge on tiles */
.run-status-badge-sm { font-size: 10px; padding: 2px 7px; }
.tile-status { display: flex; }
/* #12: Log search match count */
.log-match-count {
font-family: var(--mono);
font-size: 11px;
color: var(--text-dim);
padding: 6px 8px;
white-space: nowrap;
align-self: center;
}
.log-match-count:empty { display: none; }
/* #8: Inline confirm */
.confirm-overlay {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: rgba(229,100,102,.1);
border: 1px solid rgba(229,100,102,.5);
border-radius: var(--radius);
font-size: 13px;
color: var(--danger);
}
.confirm-overlay .confirm-msg { flex: 1; }
.confirm-overlay .confirm-yes {
background: var(--danger);
border-color: var(--danger);
color: #fff;
font-weight: 600;
padding: 6px 14px;
}
.confirm-overlay .confirm-no {
background: transparent;
border-color: var(--border);
color: var(--text-dim);
padding: 6px 14px;
}
/* #13: 404 page */
.not-found {
text-align: center;
padding: 80px 24px;
color: var(--text-dim);
}
.not-found h1 { font-size: 24px; color: var(--text); margin: 0 0 8px; }
.not-found p { margin: 0 0 20px; }
.not-found .button { display: inline-block; }
+119 -31
View File
@@ -1,16 +1,5 @@
// 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.
// Client behaviors for the Vetting UI. Loaded in layout.templ with `defer`
// so the DOM is parsed before any listeners fire.
(function () {
'use strict';
@@ -21,9 +10,6 @@
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);
@@ -40,24 +26,19 @@
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 ----------------------------------------------
// --- 2. in-step search + match count --------------------------------
document.body.addEventListener('input', function (ev) {
var el = ev.target;
@@ -67,6 +48,7 @@
var step = el.closest('.step');
if (!step) { return; }
var query = el.value.trim().toLowerCase();
var matchCount = 0;
step.querySelectorAll('.log-line').forEach(function (line) {
if (!query) {
line.style.display = '';
@@ -80,17 +62,22 @@
} else {
line.style.display = '';
line.classList.add('log-hit');
matchCount++;
}
});
var counter = el.closest('.log-search-wrap').querySelector('.log-match-count');
if (counter) {
if (!query) {
counter.textContent = '';
} else if (matchCount === 0) {
counter.textContent = 'No matches';
} else {
counter.textContent = matchCount + (matchCount === 1 ? ' match' : ' matches');
}
}
});
// --- 3. live duration tick ------------------------------------------
//
// .run-duration spans carry data-started-at (RFC3339) while the run is
// non-terminal. Every second we rewrite their text with the current
// elapsed so the header timer ticks between SSE pushes. When an SSE
// swap drops the attribute (run finished), the tick silently skips it
// and the server-rendered final value stays put.
function formatDuration(ms) {
if (ms < 0) { ms = 0; }
@@ -126,7 +113,6 @@
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' });
@@ -135,8 +121,6 @@
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; }
@@ -147,4 +131,108 @@
scrollToHash();
}
});
// --- 5. active nav indicator ----------------------------------------
(function setActiveNav() {
var path = location.pathname;
var nav = document.querySelector('[data-nav]');
if (!nav) { return; }
nav.querySelectorAll('a').forEach(function (a) {
var href = a.getAttribute('href');
if (href === '/hosts/new' && path === '/hosts/new') {
a.classList.add('nav-active');
} else if (href === '/' && path !== '/hosts/new') {
a.classList.add('nav-active');
}
});
})();
// --- 6. copy-to-clipboard buttons -----------------------------------
document.body.addEventListener('click', function (ev) {
var btn = ev.target.closest && ev.target.closest('.copy-btn');
if (!btn) { return; }
var wrap = btn.closest('.copyable-wrap');
if (!wrap) { return; }
var source = wrap.querySelector('.one-liner code, .hold-ssh');
if (!source) { return; }
var text = source.textContent || '';
navigator.clipboard.writeText(text.trim()).then(function () {
btn.textContent = 'Copied';
btn.classList.add('copied');
setTimeout(function () {
btn.textContent = 'Copy';
btn.classList.remove('copied');
}, 2000);
});
});
// --- 7. SSE connection health indicator ------------------------------
var lastSSE = 0;
var HEARTBEAT_INTERVAL = 15000;
var STALE_THRESHOLD = HEARTBEAT_INTERVAL * 2.5;
document.body.addEventListener('htmx:sseMessage', function () {
lastSSE = Date.now();
var hb = document.querySelector('.heartbeat');
if (hb) {
hb.classList.add('heartbeat-live');
hb.classList.remove('heartbeat-stale');
}
});
setInterval(function () {
if (!lastSSE) { return; }
var hb = document.querySelector('.heartbeat');
if (!hb) { return; }
if (Date.now() - lastSSE > STALE_THRESHOLD) {
hb.classList.remove('heartbeat-live');
hb.classList.add('heartbeat-stale');
}
}, 5000);
// --- 8. inline confirmation for destructive actions ------------------
document.body.addEventListener('submit', function (ev) {
var form = ev.target;
if (!form || !form.hasAttribute('data-confirm')) { return; }
if (form.dataset.confirmed === 'yes') {
form.removeAttribute('data-confirmed');
return;
}
ev.preventDefault();
if (form.querySelector('.confirm-overlay')) { return; }
var msg = form.getAttribute('data-confirm');
var btn = form.querySelector('button[type="submit"]');
if (!btn) { return; }
btn.style.display = 'none';
var overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
overlay.innerHTML =
'<span class="confirm-msg">' + escapeHtml(msg) + '</span>' +
'<button type="button" class="confirm-yes">Confirm</button>' +
'<button type="button" class="confirm-no">Cancel</button>';
form.appendChild(overlay);
overlay.querySelector('.confirm-yes').addEventListener('click', function () {
form.dataset.confirmed = 'yes';
form.requestSubmit();
});
overlay.querySelector('.confirm-no').addEventListener('click', function () {
overlay.remove();
btn.style.display = '';
});
});
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
})();
+5 -26
View File
@@ -2,7 +2,6 @@ package templates
import (
"fmt"
"time"
"vetting/internal/model"
)
@@ -40,7 +39,8 @@ templ ActiveStep(d ActiveStepData) {
</ol>
}
<div class="log-search-wrap">
<input class="log-search" type="search" placeholder="Search this step" data-step={ d.Stage.Name }/>
<input class="log-search" type="search" placeholder="Search this step" data-step={ d.Stage.Name } aria-label={ "Search " + d.Stage.Name + " logs" }/>
<span class="log-match-count"></span>
</div>
<div
class="log-pane"
@@ -67,30 +67,9 @@ func SubStepsForStage(all []model.SubStep, stageName string) []model.SubStep {
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 {
if d := elapsed(s.StartedAt, s.CompletedAt); d >= 0 {
return fmtElapsed(d, false)
}
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))
}
}
+28 -37
View File
@@ -10,7 +10,6 @@ import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
"time"
"vetting/internal/model"
)
@@ -88,7 +87,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
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}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 27, Col: 102}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -123,7 +122,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
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}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 29, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -136,7 +135,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
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}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 30, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -149,7 +148,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
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}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 31, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -182,39 +181,52 @@ func ActiveStep(d ActiveStepData) templ.Component {
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}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 42, 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=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" aria-label=\"")
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))
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs("Search " + d.Stage.Name + " logs")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 47, Col: 56}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 42, Col: 149}
}
_, 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=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"> <span class=\"log-match-count\"></span></div><div class=\"log-pane\" id=\"")
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}
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_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" hx-swap=\"beforeend show:bottom\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, 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_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" hx-swap=\"beforeend show:bottom\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -222,7 +234,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div></div></details>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div></div></details>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -243,32 +255,11 @@ func SubStepsForStage(all []model.SubStep, stageName string) []model.SubStep {
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 {
if d := elapsed(s.StartedAt, s.CompletedAt); d >= 0 {
return fmtElapsed(d, false)
}
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
+42
View File
@@ -0,0 +1,42 @@
package templates
import (
"fmt"
"time"
)
func elapsed(start, end *time.Time) time.Duration {
if start == nil {
return -1
}
e := time.Now()
if end != nil {
e = *end
}
d := e.Sub(*start)
if d < 0 {
return 0
}
return d
}
func fmtElapsed(d time.Duration, long bool) string {
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:
if long {
return fmt.Sprintf("%dm %ds", int(d/time.Minute), int((d%time.Minute)/time.Second))
}
return fmt.Sprintf("%dm", int(d/time.Minute))
default:
if long {
return fmt.Sprintf("%dh %dm", int(d/time.Hour), int((d%time.Hour)/time.Minute))
}
return fmt.Sprintf("%dh", int(d/time.Hour))
}
}
+30 -25
View File
@@ -104,31 +104,38 @@ templ HostActions(d HostPageData) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)) } class="inline host-start-form">
<fieldset class="host-profile-picker">
<legend>Profile</legend>
<label title="~10 min — post-repair sanity: all probes + gates, short budgets">
<label>
<input type="radio" name="profile" value="quick" checked/>
quick
<span class="profile-label">quick</span>
<span class="profile-desc">~10 min — post-repair sanity check</span>
</label>
<label title="~812 h — overnight soak: long CPU/RAM, full-disk fio verify, 30 min network">
<label>
<input type="radio" name="profile" value="deep"/>
deep
<span class="profile-label">deep</span>
<span class="profile-desc">~812 h — overnight full-disk verify</span>
</label>
<label title="≥24 h — week-long burn-in; opt-in when you suspect intermittent faults">
<label>
<input type="radio" name="profile" value="soak"/>
soak
<span class="profile-label">soak</span>
<span class="profile-desc">≥24 h — burn-in for intermittent faults</span>
</label>
</fieldset>
<label class="host-nd-toggle">
<input type="checkbox" name="non_destructive" value="1"/>
Non-destructive (skip wipe-probe + disk writes)
<span>Non-destructive</span>
<span class="nd-hint">Skips the Storage wipe-and-verify stage. All other stages run normally.</span>
</label>
<button type="submit" class="btn-primary">Start vetting</button>
</form>
} else if hostCanStartIfOnline(d) {
<button type="button" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button>
<div class="offline-hint">
<button type="button" disabled>Start vetting</button>
<span>Host is offline — <a href="/hosts/new">run the reporter script</a> on the target host to bring it online.</span>
</div>
} else {
<button type="button" disabled>Run in flight</button>
}
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Host.ID)) } class="inline" onsubmit="return confirm('Delete host and all its runs?');">
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Host.ID)) } class="inline" data-confirm="Delete this host and all its runs?">
<button type="submit" class="btn-danger">Delete host</button>
</form>
</div>
@@ -168,7 +175,10 @@ templ HostEmptyState(d HostPageData) {
<button type="submit" class="btn-primary big">Start vetting</button>
</form>
} else {
<button type="button" class="btn-primary big" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button>
<div class="offline-hint">
<button type="button" class="btn-primary big" disabled>Start vetting</button>
<span>Host is offline — <a href="/hosts/new">run the reporter script</a> on the target host to bring it online.</span>
</div>
}
</section>
}
@@ -283,9 +293,6 @@ func profileChipValue(p string) string {
return p
}
// runDuration formats the elapsed time for a run using the same buckets
// as stageDuration. In-flight runs clock from StartedAt to now so the
// run-page header + runs-table row keep ticking on each SSE push.
func runDuration(r *model.Run) string {
if r == nil || r.StartedAt.IsZero() {
return ""
@@ -298,18 +305,7 @@ func runDuration(r *model.Run) string {
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))
}
return fmtElapsed(d, true)
}
// stageForName returns the persisted Stage row for a given name, or a
@@ -336,6 +332,15 @@ func hasCriticalDiff(diffs []model.SpecDiff) bool {
return false
}
func diffBadgeClass(diffs []model.SpecDiff) string {
for _, d := range diffs {
if d.Severity == "critical" && !d.Ignored {
return "diff-badge-critical"
}
}
return "diff-badge-warn"
}
// relativeTime renders a past time as "2m ago" / "1h ago" / "3d ago".
// Future times (clock skew) render as "now" so the runs table never
// shows nonsense when a host's clock is ahead of the orchestrator.
+30 -35
View File
@@ -361,12 +361,12 @@ func HostActions(d HostPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"inline host-start-form\"><fieldset class=\"host-profile-picker\"><legend>Profile</legend> <label title=\"~10 min — post-repair sanity: all probes + gates, short budgets\"><input type=\"radio\" name=\"profile\" value=\"quick\" checked> quick</label> <label title=\"~812 h — overnight soak: long CPU/RAM, full-disk fio verify, 30 min network\"><input type=\"radio\" name=\"profile\" value=\"deep\"> deep</label> <label title=\"≥24 h — week-long burn-in; opt-in when you suspect intermittent faults\"><input type=\"radio\" name=\"profile\" value=\"soak\"> soak</label></fieldset><label class=\"host-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> Non-destructive (skip wipe-probe + disk writes)</label> <button type=\"submit\" class=\"btn-primary\">Start vetting</button></form>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"inline host-start-form\"><fieldset class=\"host-profile-picker\"><legend>Profile</legend> <label><input type=\"radio\" name=\"profile\" value=\"quick\" checked> <span class=\"profile-label\">quick</span> <span class=\"profile-desc\">~10 min — post-repair sanity check</span></label> <label><input type=\"radio\" name=\"profile\" value=\"deep\"> <span class=\"profile-label\">deep</span> <span class=\"profile-desc\">~812 h — overnight full-disk verify</span></label> <label><input type=\"radio\" name=\"profile\" value=\"soak\"> <span class=\"profile-label\">soak</span> <span class=\"profile-desc\">≥24 h — burn-in for intermittent faults</span></label></fieldset><label class=\"host-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> <span>Non-destructive</span> <span class=\"nd-hint\">Skips the Storage wipe-and-verify stage. All other stages run normally.</span></label> <button type=\"submit\" class=\"btn-primary\">Start vetting</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if hostCanStartIfOnline(d) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<button type=\"button\" disabled title=\"host is not heartbeating — install the reporter via /register/quick.sh on the target host\">Start vetting</button>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"offline-hint\"><button type=\"button\" disabled>Start vetting</button> <span>Host is offline — <a href=\"/hosts/new\">run the reporter script</a> on the target host to bring it online.</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -383,13 +383,13 @@ func HostActions(d HostPageData) templ.Component {
var templ_7745c5c3_Var19 templ.SafeURL
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 131, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 138, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" class=\"inline\" onsubmit=\"return confirm('Delete host and all its runs?');\"><button type=\"submit\" class=\"btn-danger\">Delete host</button></form></div></section>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" class=\"inline\" data-confirm=\"Delete this host and all its runs?\"><button type=\"submit\" class=\"btn-danger\">Delete host</button></form></div></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -428,7 +428,7 @@ func InFlightBanner(d HostPageData) templ.Component {
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-inflight-%d", d.Host.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 143, Col: 51}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 150, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
@@ -441,7 +441,7 @@ func InFlightBanner(d HostPageData) templ.Component {
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-inflight-%d", d.Host.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 145, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 152, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
@@ -459,7 +459,7 @@ func InFlightBanner(d HostPageData) templ.Component {
var templ_7745c5c3_Var23 templ.SafeURL
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/runs/%d", d.ActiveRun.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 149, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 156, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
@@ -472,7 +472,7 @@ func InFlightBanner(d HostPageData) templ.Component {
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ActiveRun.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 150, Col: 74}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 157, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
@@ -485,7 +485,7 @@ func InFlightBanner(d HostPageData) templ.Component {
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(d.ActiveRun))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 151, Col: 59}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 158, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
@@ -541,7 +541,7 @@ func HostEmptyState(d HostPageData) templ.Component {
var templ_7745c5c3_Var27 templ.SafeURL
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 167, Col: 88}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 174, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
@@ -552,7 +552,7 @@ func HostEmptyState(d HostPageData) templ.Component {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<button type=\"button\" class=\"btn-primary big\" disabled title=\"host is not heartbeating — install the reporter via /register/quick.sh on the target host\">Start vetting</button>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"offline-hint\"><button type=\"button\" class=\"btn-primary big\" disabled>Start vetting</button> <span>Host is offline — <a href=\"/hosts/new\">run the reporter script</a> on the target host to bring it online.</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -655,7 +655,7 @@ func RunRow(d RunRowData) templ.Component {
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("runrow-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 219, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 229, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
@@ -681,7 +681,7 @@ func RunRow(d RunRowData) templ.Component {
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("runrow-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 221, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 231, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
@@ -694,7 +694,7 @@ func RunRow(d RunRowData) templ.Component {
var templ_7745c5c3_Var34 templ.SafeURL
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/runs/%d", d.Run.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 225, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 235, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
@@ -707,7 +707,7 @@ func RunRow(d RunRowData) templ.Component {
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("#%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 225, Col: 94}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 235, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
@@ -742,7 +742,7 @@ func RunRow(d RunRowData) templ.Component {
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(&d.Run))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 228, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 238, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
@@ -755,7 +755,7 @@ func RunRow(d RunRowData) templ.Component {
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(relativeTime(d.Run.StartedAt))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 230, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 240, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
@@ -768,7 +768,7 @@ func RunRow(d RunRowData) templ.Component {
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(runDuration(&d.Run))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 231, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 241, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {
@@ -805,7 +805,7 @@ func RunRow(d RunRowData) templ.Component {
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 236, Col: 94}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 246, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
@@ -823,7 +823,7 @@ func RunRow(d RunRowData) templ.Component {
var templ_7745c5c3_Var44 templ.SafeURL
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/runs/%d", d.Run.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 241, Col: 84}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 251, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
@@ -877,9 +877,6 @@ func profileChipValue(p string) string {
return p
}
// runDuration formats the elapsed time for a run using the same buckets
// as stageDuration. In-flight runs clock from StartedAt to now so the
// run-page header + runs-table row keep ticking on each SSE push.
func runDuration(r *model.Run) string {
if r == nil || r.StartedAt.IsZero() {
return ""
@@ -892,18 +889,7 @@ func runDuration(r *model.Run) string {
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))
}
return fmtElapsed(d, true)
}
// stageForName returns the persisted Stage row for a given name, or a
@@ -930,6 +916,15 @@ func hasCriticalDiff(diffs []model.SpecDiff) bool {
return false
}
func diffBadgeClass(diffs []model.SpecDiff) string {
for _, d := range diffs {
if d.Severity == "critical" && !d.Ignored {
return "diff-badge-critical"
}
}
return "diff-badge-warn"
}
// relativeTime renders a past time as "2m ago" / "1h ago" / "3d ago".
// Future times (clock skew) render as "now" so the runs table never
// shows nonsense when a host's clock is ahead of the orchestrator.
+10 -50
View File
@@ -9,10 +9,9 @@ import (
"vetting/internal/model"
)
// HostTile renders a single dashboard card: hostname, heartbeat badge,
// latest run status, and the primary action (Start / Cancel / View
// report). The whole tile is a link to /hosts/{id} via a CSS-overlay
// <a>; every deeper control lives on the host page or the run page.
// HostTile renders a single dashboard card: hostname + heartbeat badge
// only. Everything else (run status, controls, reports) lives on the
// host page — the whole tile is a link there via a CSS-overlay <a>.
// It's the SSE-swap target for per-host tile refreshes (`tile-N`).
templ HostTile(t TileData) {
<article
@@ -24,30 +23,13 @@ templ HostTile(t TileData) {
<a class="tile-link" href={ templ.SafeURL(fmt.Sprintf("/hosts/%d", t.Host.ID)) } aria-label={ "Open " + t.Host.Name }></a>
<header class="tile-head">
<div class="tile-name">{ t.Host.Name }</div>
<div class="tile-header-right">
<span class={ "tile-last-seen", lastSeenClass(t.LastSeenAt) }>{ lastSeenLabel(t.LastSeenAt) }</span>
<div class="tile-status">{ tileStatus(t.Latest) }</div>
</div>
</header>
<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">
<label class="tile-nd-toggle">
<input type="checkbox" name="non_destructive" value="1"/>
Non-destructive
</label>
<button type="submit">Start vetting</button>
</form>
} else if canStartIfOnline(t.Latest) {
<button type="button" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button>
} else if canCancel(t.Latest) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", t.Host.ID)) } 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>
} else if hasReport(t.Latest) {
<a class="button-like" href={ templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)) } target="_blank" rel="noopener">View report</a>
}
if t.Latest != nil {
<div class="tile-status">
<span class={ "run-status-badge", "run-status-badge-sm", "run-status-" + tileMood(t.Latest) }>{ tileStatus(t.Latest) }</span>
</div>
}
</article>
}
@@ -65,30 +47,6 @@ func hasReport(r *model.Run) bool {
return r != nil && r.State == model.StateCompleted
}
// canStart gates the Start button on two things: the run is in a state
// that accepts a fresh start, AND the host is currently heartbeating.
// The heartbeat check mirrors the StartRun handler's preflight so the
// button never offers a click that the server would reject with 409.
func canStart(t TileData) bool {
if !canStartIfOnline(t.Latest) {
return false
}
if t.LastSeenAt == nil {
return false
}
return time.Since(*t.LastSeenAt) <= 60*time.Second
}
// canStartIfOnline is the run-state half of canStart, split out so the
// template can distinguish "waiting on run to end" (no button) from
// "run is done but host is offline" (disabled button with tooltip).
func canStartIfOnline(r *model.Run) bool {
if r == nil {
return true
}
return r.State.IsTerminal()
}
// canCancel is true for any non-terminal run, plus FailedHolding —
// a held run technically classifies as terminal for the pipeline but
// the host is still live on the SSH hold prompt, and the operator
@@ -131,7 +89,9 @@ func tileMood(r *model.Run) string {
return "pass"
case model.StateFailed, model.StateFailedHolding:
return "fail"
case model.StateReleased, model.StateCancelled:
case model.StateCancelled:
return "cancelled"
case model.StateReleased:
return "idle"
}
return "active"
+28 -83
View File
@@ -17,10 +17,9 @@ import (
"vetting/internal/model"
)
// HostTile renders a single dashboard card: hostname, heartbeat badge,
// latest run status, and the primary action (Start / Cancel / View
// report). The whole tile is a link to /hosts/{id} via a CSS-overlay
// <a>; every deeper control lives on the host page or the run page.
// HostTile renders a single dashboard card: hostname + heartbeat badge
// only. Everything else (run status, controls, reports) lives on the
// host page — the whole tile is a link there via a CSS-overlay <a>.
// It's the SSE-swap target for per-host tile refreshes (`tile-N`).
func HostTile(t TileData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
@@ -55,7 +54,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: 19, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 18, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -81,7 +80,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: 21, Col: 46}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 20, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -94,7 +93,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: 24, Col: 80}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 23, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -107,7 +106,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: 24, Col: 117}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 23, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -120,13 +119,13 @@ 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: 26, Col: 39}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 25, Col: 39}
}
_, 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, "</div><div class=\"tile-header-right\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -155,90 +154,58 @@ 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: 28, Col: 95}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 26, Col: 94}
}
_, 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, 10, "</span><div class=\"tile-status\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span></header>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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: 29, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if t.Latest != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"tile-status\">")
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\">")
var templ_7745c5c3_Var12 = []any{"run-status-badge", "run-status-badge-sm", "run-status-" + tileMood(t.Latest)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...)
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=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<span class=\"")
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(templ.CSSClasses(templ_7745c5c3_Var12).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 34, Col: 89}
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_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, "\">")
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(tileStatus(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: 44, Col: 90}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 30, Col: 120}
}
_, 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>")
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=\"")
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)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 48, Col: 88}
}
_, 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, 14, "</span></div>")
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, 15, "</article>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -260,30 +227,6 @@ func hasReport(r *model.Run) bool {
return r != nil && r.State == model.StateCompleted
}
// canStart gates the Start button on two things: the run is in a state
// that accepts a fresh start, AND the host is currently heartbeating.
// The heartbeat check mirrors the StartRun handler's preflight so the
// button never offers a click that the server would reject with 409.
func canStart(t TileData) bool {
if !canStartIfOnline(t.Latest) {
return false
}
if t.LastSeenAt == nil {
return false
}
return time.Since(*t.LastSeenAt) <= 60*time.Second
}
// canStartIfOnline is the run-state half of canStart, split out so the
// template can distinguish "waiting on run to end" (no button) from
// "run is done but host is offline" (disabled button with tooltip).
func canStartIfOnline(r *model.Run) bool {
if r == nil {
return true
}
return r.State.IsTerminal()
}
// canCancel is true for any non-terminal run, plus FailedHolding —
// a held run technically classifies as terminal for the pipeline but
// the host is still live on the SSH hold prompt, and the operator
@@ -326,7 +269,9 @@ func tileMood(r *model.Run) string {
return "pass"
case model.StateFailed, model.StateFailedHolding:
return "fail"
case model.StateReleased, model.StateCancelled:
case model.StateCancelled:
return "cancelled"
case model.StateReleased:
return "idle"
}
return "active"
+16 -35
View File
@@ -40,17 +40,15 @@ func TestHumanAgoFrom(t *testing.T) {
}
// TestHostTile_OverlayLink asserts the tile includes the tile-link <a>
// that makes the whole card clickable. The action button stays a
// sibling element, so CSS (z-index) keeps it on top of the overlay.
//
// Heartbeat must be fresh because canStart now gates on LastSeenAt —
// an offline host renders a disabled button (no form), which is
// covered by TestHostTile_DisabledStartWhenOffline below.
// that makes the whole card clickable, and that the dashboard tile is
// stripped down to just hostname + last-seen badge — no action controls
// or run-state UI clogging the dashboard at scale.
func TestHostTile_OverlayLink(t *testing.T) {
now := time.Now()
data := TileData{
Host: model.Host{ID: 42, Name: "tile-test", MAC: "aa:bb:cc:dd:ee:ff"},
LastSeenAt: &now,
Latest: &model.Run{State: model.StateCompleted},
}
var buf strings.Builder
if err := HostTile(data).Render(context.Background(), &buf); err != nil {
@@ -63,39 +61,22 @@ func TestHostTile_OverlayLink(t *testing.T) {
if !strings.Contains(html, `class="tile-link"`) {
t.Fatalf("tile missing tile-link class: %s", html)
}
// Fresh heartbeat + no run → Start form must render.
if !strings.Contains(html, `/hosts/42/start`) {
t.Fatalf("expected Start vetting form in tile: %s", html)
}
// Dropped content that used to live on the tile — confirm it has
// actually moved off so the slim-down is real.
for _, dropped := range []string{`tile-meta`, `tile-log`, `tile-actions`, `tile-hold`} {
// actually moved off so the slim-down is real. tile-status is
// intentionally re-added as a minimal status badge (issue #11).
for _, dropped := range []string{
`tile-meta`, `tile-log`, `tile-actions`, `tile-hold`,
`tile-primary-action`, `tile-start-form`,
`tile-nd-toggle`, `tile-cancel-form`,
`/hosts/42/start`, `/hosts/42/cancel`,
`Start vetting`, `Non-destructive`, `Cancel run`, `View report`,
} {
if strings.Contains(html, dropped) {
t.Errorf("slim tile still contains dropped class %q", dropped)
t.Errorf("slim tile still contains dropped content %q", dropped)
}
}
}
// TestHostTile_DisabledStartWhenOffline: no heartbeat → disabled button
// with the quick.sh tooltip, not a submittable form. Mirrors the
// server-side StartRun 409 so the UI matches the handler.
func TestHostTile_DisabledStartWhenOffline(t *testing.T) {
data := TileData{
Host: model.Host{ID: 42, Name: "tile-test", MAC: "aa:bb:cc:dd:ee:ff"},
}
var buf strings.Builder
if err := HostTile(data).Render(context.Background(), &buf); err != nil {
t.Fatalf("render: %v", err)
}
html := buf.String()
if strings.Contains(html, `/hosts/42/start`) {
t.Fatalf("offline host should not expose a Start form: %s", html)
}
if !strings.Contains(html, `disabled`) || !strings.Contains(html, `quick.sh`) {
t.Fatalf("expected disabled Start button with quick.sh tooltip: %s", html)
}
}
// TestHostTile_NoStageStrip: the tile no longer carries the Phase 3
// per-stage mini run-view — the runs-table on /hosts/{id} owns the
// stage-strip now. Guards against the regression that would bring
@@ -149,10 +130,10 @@ func TestTileStatusCancelledFromHold(t *testing.T) {
wantMood: "fail",
},
{
name: "mid-stage cancel stays plain cancelled",
name: "mid-stage cancel gets cancelled mood",
run: &model.Run{State: model.StateCancelled},
wantStatus: "Cancelled",
wantMood: "idle",
wantMood: "cancelled",
},
{
name: "failed-holding itself still reads as FailedHolding",
+2 -2
View File
@@ -15,12 +15,12 @@ templ Layout(title string) {
<body hx-boost="true">
<header class="topbar">
<div class="brand">Vetting</div>
<nav>
<nav data-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>
<span class="heartbeat" hx-ext="sse" sse-connect="/events" sse-swap="heartbeat" title="Server connection">·</span>
</div>
</header>
<main>
+1 -1
View File
@@ -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><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>")
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 data-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\" title=\"Server connection\">·</span></div></header><main>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+11
View File
@@ -0,0 +1,11 @@
package templates
templ NotFound() {
@Layout("Not found") {
<section class="not-found">
<h1>Page not found</h1>
<p>The host or run you're looking for doesn't exist or has been deleted.</p>
<a class="button" href="/">Back to dashboard</a>
</section>
}
}
+58
View File
@@ -0,0 +1,58 @@
// 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"
func NotFound() 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)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section class=\"not-found\"><h1>Page not found</h1><p>The host or run you're looking for doesn't exist or has been deleted.</p><a class=\"button\" href=\"/\">Back to dashboard</a></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = Layout("Not found").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+3 -24
View File
@@ -223,32 +223,11 @@ func stageStateByName(name string) (model.RunState, bool) {
return s, ok
}
// stageDuration renders node timing as "1.2s" / "12s" / "4m". Empty
// string when the node hasn't started or hasn't finished.
func stageDuration(n PipelineNode) string {
if n.StartedAt == nil {
if d := elapsed(n.StartedAt, n.CompletedAt); d >= 0 {
return fmtElapsed(d, false)
}
return ""
}
end := time.Now()
if n.CompletedAt != nil {
end = *n.CompletedAt
}
d := end.Sub(*n.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))
}
}
// stageDisplayName turns the internal single-word state/stage identifier
+8 -29
View File
@@ -231,32 +231,11 @@ func stageStateByName(name string) (model.RunState, bool) {
return s, ok
}
// stageDuration renders node timing as "1.2s" / "12s" / "4m". Empty
// string when the node hasn't started or hasn't finished.
func stageDuration(n PipelineNode) string {
if n.StartedAt == nil {
if d := elapsed(n.StartedAt, n.CompletedAt); d >= 0 {
return fmtElapsed(d, false)
}
return ""
}
end := time.Now()
if n.CompletedAt != nil {
end = *n.CompletedAt
}
d := end.Sub(*n.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))
}
}
// stageDisplayName turns the internal single-word state/stage identifier
@@ -406,7 +385,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(n.State))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 307, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 286, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -419,7 +398,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(stageDisplayName(n.Name))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 308, Col: 54}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 287, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -432,7 +411,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(stageDuration(n))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 309, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 288, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -486,7 +465,7 @@ func PipelineSection(run *model.Run, nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 324, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 303, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -499,7 +478,7 @@ func PipelineSection(run *model.Run, nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 326, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 305, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
+4 -3
View File
@@ -25,13 +25,15 @@ templ Registration(form RegistrationForm) {
<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>
<div class="copyable-wrap">
<pre class="one-liner"><code>{ "curl -fsSL " + form.QuickRegisterURL + "/register/quick.sh | sudo bash" }</code></pre>
<button type="button" class="copy-btn" data-copy-target="previousSibling">Copy</button>
</div>
<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>
}
<section class="detail-section manual-register-card">
<details class="manual-register">
<summary><h2>Register manually</h2></summary>
<h2>Register manually</h2>
<form method="post" action="/hosts" class="host-form">
<label>
Name
@@ -64,7 +66,6 @@ templ Registration(form RegistrationForm) {
<a class="button-secondary" href="/">Cancel</a>
</div>
</form>
</details>
</section>
</section>
}
+11 -11
View File
@@ -76,32 +76,32 @@ func Registration(form RegistrationForm) templ.Component {
}
}
if form.QuickRegisterURL != "" {
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>")
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><div class=\"copyable-wrap\"><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: 28, Col: 108}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 29, Col: 109}
}
_, 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></section>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</code></pre><button type=\"button\" class=\"copy-btn\" data-copy-target=\"previousSibling\">Copy</button></div><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, "<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=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<section class=\"detail-section manual-register-card\"><h2>Register manually</h2><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: 38, Col: 55}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 40, 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: 42, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 44, 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: 47, Col: 78}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 49, 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: 51, Col: 78}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 53, 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: 56, Col: 127}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 58, 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: 60, Col: 51}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 62, 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\" class=\"btn-primary\">Register</button> <a class=\"button-secondary\" href=\"/\">Cancel</a></div></form></details></section></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></section></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+9 -3
View File
@@ -101,11 +101,11 @@ templ RunHeader(d RunPageData) {
<div class="run-header-right">
if canCancel(&d.Run) {
if d.Run.State == model.StateFailedHolding {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Host.ID)) } class="inline" onsubmit="return confirm('Cancel held run? The host will reboot to local disk.');">
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Host.ID)) } class="inline" data-confirm="Cancel held run? The host will reboot to local disk.">
<button type="submit" class="btn-danger">Cancel &amp; reboot</button>
</form>
} else {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Host.ID)) } class="inline" onsubmit="return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');">
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Host.ID)) } class="inline" data-confirm="Cancel run? Destructive stages may leave the host mid-operation.">
<button type="submit" class="btn-danger">Cancel run</button>
</form>
}
@@ -140,7 +140,10 @@ templ HoldBanner(d RunPageData) {
hx-swap="outerHTML"
>
<span class="hold-banner-label">Host is holding — SSH available:</span>
<div class="copyable-wrap">
<code class="hold-ssh">{ sshInvocation(d.HoldKeyPath, d.Run.HoldIP) }</code>
<button type="button" class="copy-btn" data-copy-target="previousSibling">Copy</button>
</div>
</section>
} else {
<section
@@ -164,7 +167,10 @@ templ RunSpecDiffs(d RunPageData) {
>
if len(d.SpecDiffs) > 0 {
<details open?={ hasCriticalDiff(d.SpecDiffs) }>
<summary><h2>Spec diffs ({ fmt.Sprintf("%d", len(d.SpecDiffs)) })</h2></summary>
<summary>
<h2>Spec diffs</h2>
<span class={ "diff-badge", diffBadgeClass(d.SpecDiffs) }>{ fmt.Sprintf("%d", len(d.SpecDiffs)) }</span>
</summary>
<ul class="diff-list">
for _, diff := range d.SpecDiffs {
<li class={ "diff-row", "diff-" + diff.Severity }>
+61 -39
View File
@@ -419,7 +419,7 @@ func RunHeader(d RunPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"inline\" onsubmit=\"return confirm('Cancel held run? The host will reboot to local disk.');\"><button type=\"submit\" class=\"btn-danger\">Cancel &amp; reboot</button></form>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"inline\" data-confirm=\"Cancel held run? The host will reboot to local disk.\"><button type=\"submit\" class=\"btn-danger\">Cancel &amp; reboot</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -437,7 +437,7 @@ func RunHeader(d RunPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" class=\"inline\" onsubmit=\"return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');\"><button type=\"submit\" class=\"btn-danger\">Cancel run</button></form>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" class=\"inline\" data-confirm=\"Cancel run? Destructive stages may leave the host mid-operation.\"><button type=\"submit\" class=\"btn-danger\">Cancel run</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -560,20 +560,20 @@ func HoldBanner(d RunPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" hx-swap=\"outerHTML\"><span class=\"hold-banner-label\">Host is holding — SSH available:</span> <code class=\"hold-ssh\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" hx-swap=\"outerHTML\"><span class=\"hold-banner-label\">Host is holding — SSH available:</span><div class=\"copyable-wrap\"><code class=\"hold-ssh\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(d.HoldKeyPath, d.Run.HoldIP))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 143, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 144, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</code></section>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</code> <button type=\"button\" class=\"copy-btn\" data-copy-target=\"previousSibling\">Copy</button></div></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -585,7 +585,7 @@ func HoldBanner(d RunPageData) templ.Component {
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-hold-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 147, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 150, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
@@ -598,7 +598,7 @@ func HoldBanner(d RunPageData) templ.Component {
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-hold-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 149, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 152, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
@@ -644,7 +644,7 @@ func RunSpecDiffs(d RunPageData) templ.Component {
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-specdiffs-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 160, Col: 51}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 163, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
@@ -657,7 +657,7 @@ func RunSpecDiffs(d RunPageData) templ.Component {
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-specdiffs-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 162, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 165, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
@@ -678,92 +678,114 @@ func RunSpecDiffs(d RunPageData) templ.Component {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "><summary><h2>Spec diffs (")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "><summary><h2>Spec diffs</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 167, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
var templ_7745c5c3_Var36 = []any{"diff-badge", diffBadgeClass(d.SpecDiffs)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var36...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, ")</h2></summary><ul class=\"diff-list\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, diff := range d.SpecDiffs {
var templ_7745c5c3_Var37 = []any{"diff-row", "diff-" + diff.Severity}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var37...)
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var36).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<li class=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var37).String())
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 172, Col: 100}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\"><div class=\"diff-field\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</span></summary><ul class=\"diff-list\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 171, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
for _, diff := range d.SpecDiffs {
var templ_7745c5c3_Var39 = []any{"diff-row", "diff-" + diff.Severity}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var39...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</div><div class=\"diff-expected\">expected: <code>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<li class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected)
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var39).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 172, Col: 65}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</code></div><div class=\"diff-actual\">actual: <code>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\"><div class=\"diff-field\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual)
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 173, Col: 59}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 177, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</code></div></li>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</div><div class=\"diff-expected\">expected: <code>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 178, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</code></div><div class=\"diff-actual\">actual: <code>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 179, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</code></div></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</ul></details>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</ul></details>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</section>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+3 -25
View File
@@ -4,37 +4,15 @@ 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 {
if d := elapsed(ss.StartedAt, ss.CompletedAt); d >= 0 {
return fmtElapsed(d, false)
}
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
+8 -30
View File
@@ -12,37 +12,15 @@ 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 {
if d := elapsed(ss.StartedAt, ss.CompletedAt); d >= 0 {
return fmtElapsed(d, false)
}
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
@@ -99,7 +77,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
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}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 41, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -125,7 +103,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
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}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 43, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -160,7 +138,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
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}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 46, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -173,7 +151,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
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}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 47, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -186,7 +164,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
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}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 48, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
+1 -1
View File
@@ -1 +1 @@
v0.1.0
v0.1.1