Files
Vetting/internal/store/substeps.go
T
josh f79fe0f0db
CI / Lint + build + test (push) Successful in 1m26s
Release / release (push) Successful in 6m47s
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>
2026-04-18 19:00:11 -04:00

141 lines
4.3 KiB
Go

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
}