diff --git a/internal/cli/dataset.go b/internal/cli/dataset.go index 6da567f..5ec33a6 100644 --- a/internal/cli/dataset.go +++ b/internal/cli/dataset.go @@ -577,6 +577,7 @@ func runDatasetPush(ctx context.Context, out, errOut io.Writer, a runDatasetPush ImageDigest: a.ImageDigest, Detach: a.Detach, Out: out, + Printer: a.Printer, }) if err != nil { switch { diff --git a/internal/submit/submit.go b/internal/submit/submit.go index d4ccab3..7611d90 100644 --- a/internal/submit/submit.go +++ b/internal/submit/submit.go @@ -7,6 +7,8 @@ import ( "io" "k8s.io/client-go/kubernetes" + + "github.com/tracebloc/cli/internal/ui" ) // Options bundles every dependency Run needs. The CLI builds one @@ -47,9 +49,14 @@ type Options struct { // Out is the customer-facing log stream. Submit writes the // 201 announcement here, then either streams the Pod's logs - // to it (live watch) or prints the Job name (detach). The - // rendered summary panel also goes here. + // to it (live watch) or prints the Job name (detach). Out io.Writer + + // Printer renders the final ingestion summary (RenderSummary). + // nil is fine — Run falls back to ui.New(Out), so callers that + // don't thread the --plain decision still get a sensible + // (auto-detected) rendering. + Printer *ui.Printer } // Result is what Run reports back to the CLI orchestrator. @@ -162,8 +169,12 @@ func Run(ctx context.Context, opts Options) (*Result, error) { // Both Succeeded and Failed paths print it — on Failed, the // banner tells the customer what got partially through. if wr.Summary != nil { + p := opts.Printer + if p == nil { + p = ui.New(opts.Out) + } _, _ = fmt.Fprintln(opts.Out) - _, _ = fmt.Fprint(opts.Out, RenderPanel(wr.Summary)) + RenderSummary(p, wr.Summary) } return &Result{Submit: resp, Watch: wr}, nil diff --git a/internal/submit/summary.go b/internal/submit/summary.go index 23fd375..32d2adb 100644 --- a/internal/submit/summary.go +++ b/internal/submit/summary.go @@ -8,6 +8,8 @@ import ( "regexp" "strconv" "strings" + + "github.com/tracebloc/cli/internal/ui" ) // Summary is the parsed contents of the ingestor's 📊 INGESTION @@ -318,45 +320,46 @@ func (p *SummaryParser) Result() *Summary { return p.summary } -// RenderPanel returns a multi-line, customer-facing rendering of -// the Summary for display in the orchestrator's success/failure -// message. Format: -// -// ┌─ Ingestion summary ──────────────────────────┐ -// │ Ingestor ID: │ -// │ Total records: 1,234 │ -// │ Inserted: 1,200 │ -// │ Skipped: 4 │ -// │ File transfer failures: 0 │ -// │ DB-insert failures: 30 │ -// │ Success rate: 97.2% │ -// └──────────────────────────────────────────────┘ +// RenderSummary prints the installer-style ingestion summary through +// p: an outcome-colored headline (green when clean, yellow on skips, +// red on failures), the per-stage counts as Section/Field rows, and a +// short "what's next". It replaces the old box-drawing panel — +// Section/Field is plain-ASCII friendly, so no Unicode-box fallback is +// needed (that was the v0.2 TODO). // -// Uses box-drawing characters for visual structure. Plain ASCII -// fallback could be added in v0.2 for terminals that don't render -// Unicode (rare on modern OS X/Linux/Windows-Terminal). -func RenderPanel(s *Summary) string { +// No-op on a nil summary: an early failure (OOM before the ingestor +// printed its banner) produces no Summary, so the orchestrator can +// call this unconditionally. +func RenderSummary(p *ui.Printer, s *Summary) { if s == nil { - return "" + return } - const labelWidth = 26 - var b strings.Builder - b.WriteString("┌─ Ingestion summary ──────────────────────────┐\n") - row := func(label, value string) { - fmt.Fprintf(&b, "│ %-*s %s\n", labelWidth, label, value) + headline := fmt.Sprintf("ingested %s of %s records (%.1f%%)", + commaSep(s.InsertedRecords), commaSep(s.TotalRecords), s.SuccessRate()) + switch { + case s.HasFailures(): + p.Errorf("Ingestion completed with failures — %s", headline) + case s.SkippedRecords > 0: + p.Warnf("Ingestion completed with skips — %s", headline) + default: + p.Successf("Ingestion complete — %s", headline) } + + p.Section("Ingestion summary") if s.IngestorID != "" { - row("Ingestor ID:", s.IngestorID) + p.Field("ingestor ID", s.IngestorID) } - row("Total records:", commaSep(s.TotalRecords)) - row("Inserted:", commaSep(s.InsertedRecords)) - row("Sent to API:", commaSep(s.APISentRecords)) - row("Skipped:", commaSep(s.SkippedRecords)) - row("File transfer failures:", commaSep(s.FileTransferFailures)) - row("DB-insert failures:", commaSep(s.FailedRecords)) - row("Success rate:", fmt.Sprintf("%.1f%%", s.SuccessRate())) - b.WriteString("└──────────────────────────────────────────────┘\n") - return b.String() + p.Field("total records", commaSep(s.TotalRecords)) + p.Field("inserted", commaSep(s.InsertedRecords)) + p.Field("sent to API", commaSep(s.APISentRecords)) + p.Field("skipped", commaSep(s.SkippedRecords)) + p.Field("file failures", commaSep(s.FileTransferFailures)) + p.Field("DB failures", commaSep(s.FailedRecords)) + p.Field("success rate", fmt.Sprintf("%.1f%%", s.SuccessRate())) + + p.Section("What's next") + p.Infof("View it in the dashboard: https://ai.tracebloc.io") + p.Hintf("The table is staged and ready for training jobs.") } // commaSep formats an int64 with thousands-separator commas to diff --git a/internal/submit/summary_test.go b/internal/submit/summary_test.go index 5bfe950..6df9546 100644 --- a/internal/submit/summary_test.go +++ b/internal/submit/summary_test.go @@ -1,8 +1,11 @@ package submit import ( + "bytes" "strings" "testing" + + "github.com/tracebloc/cli/internal/ui" ) // realIngestorBanner mirrors what @@ -182,10 +185,10 @@ func TestStripANSI(t *testing.T) { } } -// TestRenderPanel_BasicShape: the panel rendering is what the -// customer sees on success; pin a few key lines so a refactor -// breaks the test rather than silently producing weird output. -func TestRenderPanel_BasicShape(t *testing.T) { +// TestRenderSummary_BasicShape pins the key facts the customer sees; +// a refactor that drops one breaks the test rather than silently +// producing weird output. Color off so we assert plain text. +func TestRenderSummary_BasicShape(t *testing.T) { s := &Summary{ IngestorID: "run-abc", TotalRecords: 1234567, @@ -195,26 +198,53 @@ func TestRenderPanel_BasicShape(t *testing.T) { FileTransferFailures: 30, FailedRecords: 5, } - got := RenderPanel(s) + var buf bytes.Buffer + RenderSummary(ui.New(&buf, ui.WithColor(false)), s) + got := buf.String() for _, want := range []string{ "Ingestion summary", "run-abc", "1,234,567", // commaSep formatting - "1,200,000", // commaSep formatting - "30", // file transfer failures - "DB-insert failures:", + "1,200,000", + "30", // file failures } { if !strings.Contains(got, want) { - t.Errorf("RenderPanel missing %q in:\n%s", want, got) + t.Errorf("RenderSummary missing %q in:\n%s", want, got) } } } -// TestRenderPanel_Nil: nil summary returns empty string so the -// orchestrator can blind-print without a guard. -func TestRenderPanel_Nil(t *testing.T) { - if got := RenderPanel(nil); got != "" { - t.Errorf("RenderPanel(nil) = %q, want empty", got) +// TestRenderSummary_Nil: a nil summary writes nothing, so the +// orchestrator can call it unconditionally. +func TestRenderSummary_Nil(t *testing.T) { + var buf bytes.Buffer + RenderSummary(ui.New(&buf, ui.WithColor(false)), nil) + if buf.Len() != 0 { + t.Errorf("RenderSummary(nil) wrote %q, want nothing", buf.String()) + } +} + +// TestRenderSummary_OutcomeHeadline: the headline reflects the outcome +// derived from the counts — clean / skips / failures. Table-driven, +// one sub-test per row via t.Run. +func TestRenderSummary_OutcomeHeadline(t *testing.T) { + cases := []struct { + name string + s *Summary + want string + }{ + {"clean", &Summary{TotalRecords: 10, ProcessedRecords: 10, InsertedRecords: 10}, "complete —"}, + {"skips", &Summary{TotalRecords: 10, InsertedRecords: 8, SkippedRecords: 2}, "skips"}, + {"failures", &Summary{TotalRecords: 10, InsertedRecords: 7, FailedRecords: 3}, "failures"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var buf bytes.Buffer + RenderSummary(ui.New(&buf, ui.WithColor(false)), c.s) + if !strings.Contains(buf.String(), c.want) { + t.Errorf("headline missing %q in:\n%s", c.want, buf.String()) + } + }) } }