ui: GitHub-Actions-style detail page, sub-steps, mini-tile run-view
CI / Lint + build + test (push) Successful in 1m26s
Release / release (push) Successful in 6m47s

Reshapes the detail page into a run-view: hybrid horizontal pipeline
+ expanded active-step pane with sub-steps, a per-step log pane with
line-numbered permalinks and client-side search, and a runs-history
sidebar that navigates via ?run=N. Default step is server-picked
(running → failed → Reporting) so the operator lands on the thing
that's moving.

Adds a sub_steps table + SSE topic (substep-{run}-{stage}-{ordinal})
so per-disk and per-pass work (SMART, CPUStress CPU/RAM, Storage,
GPU) is visible in the UI instead of buried in stage summary JSON.
Agent emits sub-step reports from existing per-iteration loops.

Dashboard tiles become a mini run-view with a 9-dot step strip so
the operator reads run health across the whole grid at a glance.
Register page gets the same card shell + button styling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 19:00:11 -04:00
parent 5c00edd7b6
commit f79fe0f0db
38 changed files with 3972 additions and 936 deletions
+39
View File
@@ -154,6 +154,45 @@ func (r *Runs) LatestForHost(ctx context.Context, hostID int64) (*model.Run, err
return &run, nil
}
// ListForHost returns the most recent `limit` runs for a host, newest
// first. Caller uses this to drive the host-detail runs sidebar (last 20
// by default, Phase 2). Zero/negative limit falls back to a safe cap so
// a mistaken call 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
}
rows, err := r.DB.QueryContext(ctx, `
SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''),
COALESCE(next_boot_target,''), agent_token_hash, started_at,
completed_at, COALESCE(report_path,''), COALESCE(hold_ip,''),
COALESCE(override_flags_json,''), COALESCE(non_destructive,0)
FROM runs
WHERE host_id = ?
ORDER BY id DESC
LIMIT ?
`, hostID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Run
for rows.Next() {
var run model.Run
var completedAt sql.NullTime
if err := rows.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage,
&run.NextBootTarget, &run.AgentTokenHash, &run.StartedAt,
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive); err != nil {
return nil, err
}
if completedAt.Valid {
run.CompletedAt = &completedAt.Time
}
out = append(out, run)
}
return out, rows.Err()
}
// Active returns all runs in non-terminal states.
func (r *Runs) Active(ctx context.Context) ([]model.Run, error) {
rows, err := r.DB.QueryContext(ctx, `
+140
View File
@@ -0,0 +1,140 @@
package store
import (
"context"
"database/sql"
"fmt"
"time"
"vetting/internal/model"
)
// SubSteps is the persistence layer for the sub_steps table. Sub-steps
// are authored by the agent (one per disk in SMART, one per disk-operation
// in Storage, CPU+Memory passes in CPUStress, etc.) and surfaced in the
// detail page as a collapsible list under each stage.
type SubSteps struct {
DB *sql.DB
}
// Upsert inserts-or-replaces a sub-step row keyed by (run_id, stage_name,
// ordinal). Replace semantics let the agent push a row twice — once when
// the sub-step starts (state=running, completed_at=nil) and again when
// it finishes (state=passed|failed, completed_at set) — without the
// caller needing to track whether the row already exists.
func (s *SubSteps) Upsert(ctx context.Context, ss model.SubStep) error {
// Preserve the original started_at if the caller didn't supply one
// (a "complete" call that only knows the end time still needs to
// leave the start time alone). ON CONFLICT DO UPDATE with COALESCE
// handles the merge in one round-trip.
_, err := s.DB.ExecContext(ctx, `
INSERT INTO sub_steps(run_id, stage_name, ordinal, name, state, started_at, completed_at, summary_json)
VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(run_id, stage_name, ordinal) DO UPDATE SET
name = excluded.name,
state = excluded.state,
started_at = COALESCE(excluded.started_at, sub_steps.started_at),
completed_at = COALESCE(excluded.completed_at, sub_steps.completed_at),
summary_json = CASE WHEN excluded.summary_json = '{}' THEN sub_steps.summary_json ELSE excluded.summary_json END
`,
ss.RunID, ss.StageName, ss.Ordinal, ss.Name, string(ss.State),
nullTime(ss.StartedAt), nullTime(ss.CompletedAt), jsonOrDefault(ss.SummaryJSON))
if err != nil {
return fmt.Errorf("upsert sub-step: %w", err)
}
return nil
}
// Get returns a single sub-step by its natural key. Returns ErrNotFound
// if none exists.
func (s *SubSteps) Get(ctx context.Context, runID int64, stageName string, ordinal int) (*model.SubStep, error) {
row := s.DB.QueryRowContext(ctx, `
SELECT id, run_id, stage_name, ordinal, name, state, started_at, completed_at, summary_json
FROM sub_steps WHERE run_id = ? AND stage_name = ? AND ordinal = ?
`, runID, stageName, ordinal)
ss, err := scanSubStep(row)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
return ss, err
}
// ListForRun returns every sub-step for a run, ordered by (stage_name,
// ordinal). The caller groups by StageName to build the UI.
func (s *SubSteps) ListForRun(ctx context.Context, runID int64) ([]model.SubStep, error) {
rows, err := s.DB.QueryContext(ctx, `
SELECT id, run_id, stage_name, ordinal, name, state, started_at, completed_at, summary_json
FROM sub_steps WHERE run_id = ? ORDER BY stage_name, ordinal
`, runID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.SubStep
for rows.Next() {
ss, err := scanSubStep(rows)
if err != nil {
return nil, err
}
out = append(out, *ss)
}
return out, rows.Err()
}
// ListForStage returns every sub-step for one stage of a run, in ordinal
// order. Convenience over filtering ListForRun client-side.
func (s *SubSteps) ListForStage(ctx context.Context, runID int64, stageName string) ([]model.SubStep, error) {
rows, err := s.DB.QueryContext(ctx, `
SELECT id, run_id, stage_name, ordinal, name, state, started_at, completed_at, summary_json
FROM sub_steps WHERE run_id = ? AND stage_name = ? ORDER BY ordinal
`, runID, stageName)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.SubStep
for rows.Next() {
ss, err := scanSubStep(rows)
if err != nil {
return nil, err
}
out = append(out, *ss)
}
return out, rows.Err()
}
type scanner interface {
Scan(dest ...any) error
}
func scanSubStep(r scanner) (*model.SubStep, error) {
var ss model.SubStep
var started, completed sql.NullTime
if err := r.Scan(&ss.ID, &ss.RunID, &ss.StageName, &ss.Ordinal, &ss.Name,
&ss.State, &started, &completed, &ss.SummaryJSON); err != nil {
return nil, err
}
if started.Valid {
t := started.Time
ss.StartedAt = &t
}
if completed.Valid {
t := completed.Time
ss.CompletedAt = &t
}
return &ss, nil
}
func nullTime(t *time.Time) any {
if t == nil || t.IsZero() {
return nil
}
return t.UTC()
}
func jsonOrDefault(s string) string {
if s == "" {
return "{}"
}
return s
}
+126
View File
@@ -0,0 +1,126 @@
package store_test
import (
"context"
"testing"
"time"
"vetting/internal/model"
"vetting/internal/store"
)
// TestSubStepsUpsertAndList covers the two-phase life cycle the agent
// is meant to exercise: an initial "started" upsert with state=running
// and started_at set, then a terminal upsert that flips state to passed
// and fills completed_at without clobbering started_at. Ordering falls
// out of the (stage_name, ordinal) index — ListForRun must return rows
// in that deterministic order even when they were inserted out of order.
func TestSubStepsUpsertAndList(t *testing.T) {
runs := newDB(t)
_, runID := seedRun(t, runs)
ss := &store.SubSteps{DB: runs.DB}
ctx := context.Background()
start := time.Date(2026, 4, 18, 12, 0, 0, 0, time.UTC)
end := start.Add(3 * time.Minute)
// Insert CPUStress ordinals out of order to prove ListForRun orders
// by (stage, ordinal) rather than insertion time.
if err := ss.Upsert(ctx, model.SubStep{
RunID: runID, StageName: "CPUStress", Ordinal: 1,
Name: "Memory pass", State: model.StageRunning, StartedAt: &start,
}); err != nil {
t.Fatalf("upsert CPUStress/1 running: %v", err)
}
if err := ss.Upsert(ctx, model.SubStep{
RunID: runID, StageName: "CPUStress", Ordinal: 0,
Name: "CPU pass", State: model.StagePassed, StartedAt: &start, CompletedAt: &end,
SummaryJSON: `{"elapsed_secs":180}`,
}); err != nil {
t.Fatalf("upsert CPUStress/0 passed: %v", err)
}
// Terminal update for ordinal 1 — only completed_at + state; started_at
// intentionally omitted so COALESCE preserves the original value.
if err := ss.Upsert(ctx, model.SubStep{
RunID: runID, StageName: "CPUStress", Ordinal: 1,
Name: "Memory pass", State: model.StagePassed, CompletedAt: &end,
}); err != nil {
t.Fatalf("upsert CPUStress/1 passed: %v", err)
}
list, err := ss.ListForRun(ctx, runID)
if err != nil {
t.Fatalf("ListForRun: %v", err)
}
if len(list) != 2 {
t.Fatalf("got %d rows, want 2", len(list))
}
if list[0].Name != "CPU pass" || list[1].Name != "Memory pass" {
t.Fatalf("order: %+v", list)
}
if list[1].State != model.StagePassed {
t.Fatalf("memory pass state = %q, want passed", list[1].State)
}
if list[1].StartedAt == nil {
t.Fatalf("memory pass started_at was wiped by terminal upsert")
}
if !list[1].StartedAt.Equal(start) {
t.Fatalf("started_at = %v, want %v", list[1].StartedAt, start)
}
if list[0].SummaryJSON != `{"elapsed_secs":180}` {
t.Fatalf("CPU pass summary = %q, want {\"elapsed_secs\":180}", list[0].SummaryJSON)
}
// ListForStage scopes to a single stage.
only, err := ss.ListForStage(ctx, runID, "CPUStress")
if err != nil {
t.Fatalf("ListForStage: %v", err)
}
if len(only) != 2 {
t.Fatalf("ListForStage CPUStress: got %d, want 2", len(only))
}
other, err := ss.ListForStage(ctx, runID, "Storage")
if err != nil {
t.Fatalf("ListForStage Storage: %v", err)
}
if len(other) != 0 {
t.Fatalf("ListForStage Storage should be empty, got %d", len(other))
}
}
// TestSubStepsSummaryPreserveOnDefault verifies the intentional special-
// case: when the second upsert supplies the default empty-object summary
// ('{}'), the prior non-default summary is kept. This lets a "complete"
// call that only carries timing update the row without wiping the rich
// summary the "start" call persisted.
func TestSubStepsSummaryPreserveOnDefault(t *testing.T) {
runs := newDB(t)
_, runID := seedRun(t, runs)
ss := &store.SubSteps{DB: runs.DB}
ctx := context.Background()
if err := ss.Upsert(ctx, model.SubStep{
RunID: runID, StageName: "SMART", Ordinal: 0,
Name: "smartctl /dev/sda", State: model.StageRunning,
SummaryJSON: `{"device":"/dev/sda"}`,
}); err != nil {
t.Fatalf("first upsert: %v", err)
}
// Terminal with empty summary — should not blow away the device field.
if err := ss.Upsert(ctx, model.SubStep{
RunID: runID, StageName: "SMART", Ordinal: 0,
Name: "smartctl /dev/sda", State: model.StagePassed,
}); err != nil {
t.Fatalf("terminal upsert: %v", err)
}
got, err := ss.Get(ctx, runID, "SMART", 0)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.SummaryJSON != `{"device":"/dev/sda"}` {
t.Fatalf("summary wiped: %q", got.SummaryJSON)
}
if got.State != model.StagePassed {
t.Fatalf("state = %q, want passed", got.State)
}
}