diff options
Diffstat (limited to 'internal/reporter')
| -rw-r--r-- | internal/reporter/reporter.go | 117 | ||||
| -rw-r--r-- | internal/reporter/reporter_test.go | 114 |
2 files changed, 231 insertions, 0 deletions
diff --git a/internal/reporter/reporter.go b/internal/reporter/reporter.go new file mode 100644 index 0000000..4ba66e0 --- /dev/null +++ b/internal/reporter/reporter.go @@ -0,0 +1,117 @@ +package reporter + +import ( + "encoding/json" + "fmt" + "io" + "text/tabwriter" + "time" + + "github.com/claudomator/claudomator/internal/storage" +) + +// Reporter generates reports from execution data. +type Reporter interface { + Generate(w io.Writer, executions []*storage.Execution) error +} + +// ConsoleReporter outputs a formatted table. +type ConsoleReporter struct{} + +func (r *ConsoleReporter) Generate(w io.Writer, executions []*storage.Execution) error { + if len(executions) == 0 { + fmt.Fprintln(w, "No executions found.") + return nil + } + + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "ID\tTASK\tSTATUS\tEXIT\tCOST\tDURATION\tSTARTED") + + var totalCost float64 + var completed, failed int + + for _, e := range executions { + dur := e.EndTime.Sub(e.StartTime) + totalCost += e.CostUSD + if e.Status == "COMPLETED" { + completed++ + } else { + failed++ + } + + fmt.Fprintf(tw, "%.8s\t%.8s\t%s\t%d\t$%.4f\t%v\t%s\n", + e.ID, e.TaskID, e.Status, e.ExitCode, e.CostUSD, + dur.Round(time.Second), e.StartTime.Format("2006-01-02 15:04")) + } + tw.Flush() + + fmt.Fprintf(w, "\nSummary: %d completed, %d failed, total cost $%.4f\n", completed, failed, totalCost) + return nil +} + +// JSONReporter outputs JSON. +type JSONReporter struct { + Pretty bool +} + +func (r *JSONReporter) Generate(w io.Writer, executions []*storage.Execution) error { + enc := json.NewEncoder(w) + if r.Pretty { + enc.SetIndent("", " ") + } + return enc.Encode(executions) +} + +// HTMLReporter generates a standalone HTML report. +type HTMLReporter struct{} + +func (r *HTMLReporter) Generate(w io.Writer, executions []*storage.Execution) error { + var totalCost float64 + var completed, failed int + for _, e := range executions { + totalCost += e.CostUSD + if e.Status == "COMPLETED" { + completed++ + } else { + failed++ + } + } + + fmt.Fprint(w, `<!DOCTYPE html> +<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Claudomator Report</title> +<style> + * { box-sizing: border-box; margin: 0; padding: 0; } + body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #0f172a; color: #e2e8f0; padding: 1rem; } + .container { max-width: 960px; margin: 0 auto; } + h1 { font-size: 1.5rem; margin-bottom: 1rem; color: #7dd3fc; } + .summary { display: flex; gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap; } + .stat { background: #1e293b; padding: 1rem; border-radius: 0.5rem; flex: 1; min-width: 120px; } + .stat .label { font-size: 0.75rem; color: #94a3b8; text-transform: uppercase; } + .stat .value { font-size: 1.5rem; font-weight: bold; margin-top: 0.25rem; } + .ok { color: #4ade80; } .fail { color: #f87171; } .cost { color: #fbbf24; } + table { width: 100%; border-collapse: collapse; background: #1e293b; border-radius: 0.5rem; overflow: hidden; } + th { background: #334155; padding: 0.75rem; text-align: left; font-size: 0.75rem; text-transform: uppercase; color: #94a3b8; } + td { padding: 0.75rem; border-top: 1px solid #334155; font-size: 0.875rem; } + tr:hover { background: #334155; } + .status-COMPLETED { color: #4ade80; } .status-FAILED { color: #f87171; } .status-TIMED_OUT { color: #fbbf24; } +</style></head><body><div class="container"> +<h1>Claudomator Report</h1> +<div class="summary">`) + + fmt.Fprintf(w, `<div class="stat"><div class="label">Completed</div><div class="value ok">%d</div></div>`, completed) + fmt.Fprintf(w, `<div class="stat"><div class="label">Failed</div><div class="value fail">%d</div></div>`, failed) + fmt.Fprintf(w, `<div class="stat"><div class="label">Total Cost</div><div class="value cost">$%.4f</div></div>`, totalCost) + fmt.Fprintf(w, `<div class="stat"><div class="label">Total</div><div class="value">%d</div></div>`, len(executions)) + + fmt.Fprint(w, `</div><table><thead><tr><th>ID</th><th>Task</th><th>Status</th><th>Exit</th><th>Cost</th><th>Duration</th><th>Started</th></tr></thead><tbody>`) + + for _, e := range executions { + dur := e.EndTime.Sub(e.StartTime).Round(time.Second) + fmt.Fprintf(w, `<tr><td>%.8s</td><td>%.8s</td><td class="status-%s">%s</td><td>%d</td><td>$%.4f</td><td>%v</td><td>%s</td></tr>`, + e.ID, e.TaskID, e.Status, e.Status, e.ExitCode, e.CostUSD, dur, e.StartTime.Format("2006-01-02 15:04")) + } + + fmt.Fprint(w, `</tbody></table></div></body></html>`) + return nil +} diff --git a/internal/reporter/reporter_test.go b/internal/reporter/reporter_test.go new file mode 100644 index 0000000..1ddce23 --- /dev/null +++ b/internal/reporter/reporter_test.go @@ -0,0 +1,114 @@ +package reporter + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/claudomator/claudomator/internal/storage" +) + +func sampleExecutions() []*storage.Execution { + now := time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) + return []*storage.Execution{ + { + ID: "exec-1", TaskID: "task-1", Status: "COMPLETED", + StartTime: now, EndTime: now.Add(2 * time.Minute), + ExitCode: 0, CostUSD: 0.25, + }, + { + ID: "exec-2", TaskID: "task-2", Status: "FAILED", + StartTime: now, EndTime: now.Add(30 * time.Second), + ExitCode: 1, CostUSD: 0.10, ErrorMsg: "something broke", + }, + } +} + +func TestConsoleReporter_WithExecutions(t *testing.T) { + r := &ConsoleReporter{} + var buf bytes.Buffer + err := r.Generate(&buf, sampleExecutions()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "COMPLETED") { + t.Error("missing COMPLETED status") + } + if !strings.Contains(output, "FAILED") { + t.Error("missing FAILED status") + } + if !strings.Contains(output, "1 completed, 1 failed") { + t.Errorf("missing summary in output: %s", output) + } + if !strings.Contains(output, "$0.3500") { + t.Errorf("missing total cost in output: %s", output) + } +} + +func TestConsoleReporter_Empty(t *testing.T) { + r := &ConsoleReporter{} + var buf bytes.Buffer + r.Generate(&buf, []*storage.Execution{}) + if !strings.Contains(buf.String(), "No executions") { + t.Error("expected 'No executions' message") + } +} + +func TestJSONReporter(t *testing.T) { + r := &JSONReporter{Pretty: false} + var buf bytes.Buffer + err := r.Generate(&buf, sampleExecutions()) + if err != nil { + t.Fatal(err) + } + + var result []storage.Execution + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(result) != 2 { + t.Errorf("want 2 results, got %d", len(result)) + } + if result[0].Status != "COMPLETED" { + t.Errorf("want COMPLETED, got %q", result[0].Status) + } +} + +func TestJSONReporter_Pretty(t *testing.T) { + r := &JSONReporter{Pretty: true} + var buf bytes.Buffer + r.Generate(&buf, sampleExecutions()) + if !strings.Contains(buf.String(), " ") { + t.Error("expected indented JSON") + } +} + +func TestHTMLReporter(t *testing.T) { + r := &HTMLReporter{} + var buf bytes.Buffer + err := r.Generate(&buf, sampleExecutions()) + if err != nil { + t.Fatal(err) + } + + html := buf.String() + if !strings.Contains(html, "<!DOCTYPE html>") { + t.Error("missing DOCTYPE") + } + if !strings.Contains(html, "Claudomator Report") { + t.Error("missing title") + } + if !strings.Contains(html, "COMPLETED") { + t.Error("missing COMPLETED status") + } + if !strings.Contains(html, "FAILED") { + t.Error("missing FAILED status") + } + if !strings.Contains(html, "$0.3500") { + t.Error("missing total cost") + } +} |
