f79fe0f0db
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>
141 lines
4.3 KiB
Go
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
|
|
}
|