ui: GitHub-Actions-style detail page, sub-steps, mini-tile run-view
Reshapes the detail page into a run-view: hybrid horizontal pipeline
+ expanded active-step pane with sub-steps, a per-step log pane with
line-numbered permalinks and client-side search, and a runs-history
sidebar that navigates via ?run=N. Default step is server-picked
(running → failed → Reporting) so the operator lands on the thing
that's moving.
Adds a sub_steps table + SSE topic (substep-{run}-{stage}-{ordinal})
so per-disk and per-pass work (SMART, CPUStress CPU/RAM, Storage,
GPU) is visible in the UI instead of buried in stage summary JSON.
Agent emits sub-step reports from existing per-iteration loops.
Dashboard tiles become a mini run-view with a 9-dot step strip so
the operator reads run health across the whole grid at a glance.
Register page gets the same card shell + button styling.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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, `
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user