From c053f1e04ffb0fb89743cc7bc5154efaf6e8a0bf Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 14 Apr 2026 10:10:50 +0300 Subject: Add HTML OutputFormat and report API support (ask task 03) - Add FormatHTML with parse/string and HTMLReporter on existing report path - Emit minimal HTML document with escaped title, description, and pre table - Daemon /report sets text/html Content-Type for HTML format - Integration fixtures and tests for HTML Made-with: Cursor --- internal/daemon/daemon.go | 2 + internal/daemon/daemon_test.go | 22 ++++++++++ internal/goprecords/integration_test_runner.go | 2 +- internal/goprecords/parse_test.go | 1 + internal/goprecords/report.go | 60 +++++++++++++++++++++++++- internal/goprecords/report_test.go | 29 +++++++++++++ internal/goprecords/types.go | 7 ++- internal/goprecords/types_test.go | 35 +++++++-------- 8 files changed, 138 insertions(+), 20 deletions(-) (limited to 'internal') diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index a7de45f..13e7311 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -156,6 +156,8 @@ func reportContentType(f goprecords.OutputFormat) string { return "text/markdown; charset=utf-8" case goprecords.FormatGemtext: return "text/gemini; charset=utf-8" + case goprecords.FormatHTML: + return "text/html; charset=utf-8" default: return "text/plain; charset=utf-8" } diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 9f25ca3..87b3dd8 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -91,6 +91,28 @@ func TestReportQueryAliases(t *testing.T) { } } +func TestReportHTMLContentType(t *testing.T) { + fixtures := filepath.Join("..", "..", "fixtures") + srv := httptest.NewServer(Handler(fixtures)) + defer srv.Close() + res, err := http.Get(srv.URL + "/report?OutputFormat=HTML&limit=2") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("status %d", res.StatusCode) + } + if ct := res.Header.Get("Content-Type"); ct != "text/html; charset=utf-8" { + t.Fatalf("Content-Type %q", ct) + } + b, _ := io.ReadAll(res.Body) + body := string(b) + if !strings.Contains(body, "") || !strings.Contains(body, "
") {
+		t.Fatalf("expected HTML body, got %q", body)
+	}
+}
+
 func TestReportGemtextContentType(t *testing.T) {
 	fixtures := filepath.Join("..", "..", "fixtures")
 	srv := httptest.NewServer(Handler(fixtures))
diff --git a/internal/goprecords/integration_test_runner.go b/internal/goprecords/integration_test_runner.go
index 276714d..ee76a1d 100644
--- a/internal/goprecords/integration_test_runner.go
+++ b/internal/goprecords/integration_test_runner.go
@@ -30,7 +30,7 @@ func testReportFixtures(aggregates *Aggregates, fixturesDir string) int {
 	limit := uint(3)
 	categories := []Category{CategoryHost, CategoryKernel, CategoryKernelMajor, CategoryKernelName}
 	metrics := []Metric{MetricBoots, MetricUptime, MetricScore, MetricDowntime, MetricLifespan}
-	formats := []OutputFormat{FormatPlaintext, FormatMarkdown, FormatGemtext}
+	formats := []OutputFormat{FormatPlaintext, FormatMarkdown, FormatGemtext, FormatHTML}
 	failed := 0
 	for _, cat := range categories {
 		for _, met := range metrics {
diff --git a/internal/goprecords/parse_test.go b/internal/goprecords/parse_test.go
index 700440d..ce63e7a 100644
--- a/internal/goprecords/parse_test.go
+++ b/internal/goprecords/parse_test.go
@@ -138,6 +138,7 @@ func TestParseOutputFormat(t *testing.T) {
 		{"Plaintext", FormatPlaintext, true},
 		{"Markdown", FormatMarkdown, true},
 		{"Gemtext", FormatGemtext, true},
+		{"HTML", FormatHTML, true},
 		{"", 0, false},
 		{"html", 0, false},
 	}
diff --git a/internal/goprecords/report.go b/internal/goprecords/report.go
index b0403e7..cd86fcd 100644
--- a/internal/goprecords/report.go
+++ b/internal/goprecords/report.go
@@ -3,6 +3,7 @@ package goprecords
 import (
 	"flag"
 	"fmt"
+	"html/template"
 	"io"
 	"net/url"
 	"sort"
@@ -38,7 +39,7 @@ func RegisterReportFlags(fs *flag.FlagSet) *ReportFlags {
 		category:      fs.String("category", "Host", "Category: Host, Kernel, KernelMajor, KernelName"),
 		metric:        fs.String("metric", "Uptime", "Metric: Boots, Uptime, Score, Downtime, Lifespan"),
 		limit:         fs.Uint("limit", 20, "Limit output to num of entries"),
-		outputFormat:  fs.String("output-format", "Plaintext", "Output format: Plaintext, Markdown, Gemtext"),
+		outputFormat:  fs.String("output-format", "Plaintext", "Output format: Plaintext, Markdown, Gemtext, HTML"),
 		all:           fs.Bool("all", false, "Generate all possible stats but Kernel"),
 		includeKernel: fs.Bool("include-kernel", false, "Also include Kernel when using -all"),
 		statsOrder:    fs.String("stats-order", "", "Comma-separated Category:Metric order for -all"),
@@ -212,6 +213,10 @@ type GemtextReporter struct {
 	builder reportBuilder
 }
 
+type HTMLReporter struct {
+	builder reportBuilder
+}
+
 func NewReporter(aggregates *Aggregates, category Category, limit uint, metric Metric, outputFormat OutputFormat, headerIndent uint) Reporter {
 	builder := reportBuilder{
 		aggregates:   aggregates,
@@ -225,6 +230,8 @@ func NewReporter(aggregates *Aggregates, category Category, limit uint, metric M
 		return &MarkdownReporter{builder: builder}
 	case FormatGemtext:
 		return &GemtextReporter{builder: builder}
+	case FormatHTML:
+		return &HTMLReporter{builder: builder}
 	default:
 		return &PlaintextReporter{builder: builder}
 	}
@@ -246,6 +253,10 @@ func (r *GemtextReporter) Report() string {
 	return r.builder.Report(FormatGemtext)
 }
 
+func (r *HTMLReporter) Report() string {
+	return r.builder.Report(FormatHTML)
+}
+
 func (r reportBuilder) Report(outputFormat OutputFormat) string {
 	var rows []tableRow
 	var hasLastKernel bool
@@ -257,6 +268,9 @@ func (r reportBuilder) Report(outputFormat OutputFormat) string {
 	if len(rows) == 0 {
 		return ""
 	}
+	if outputFormat == FormatHTML {
+		return r.formatReportHTML(rows, hasLastKernel)
+	}
 	return r.formatReport(rows, hasLastKernel, outputFormat)
 }
 
@@ -395,6 +409,50 @@ func (r reportBuilder) formatReport(rows []tableRow, hasLastKernel bool, outputF
 	return out
 }
 
+func (r reportBuilder) formatReportHTML(rows []tableRow, hasLastKernel bool) string {
+	cW, nW, vW, lkW := r.reportWidths(rows, hasLastKernel)
+	border := r.buildBorder(cW, nW, vW, lkW, hasLastKernel)
+	fmtStr := r.buildFormatStr(cW, nW, vW, lkW, hasLastKernel)
+	var headRow string
+	if hasLastKernel {
+		headRow = fmt.Sprintf(fmtStr+"\n", "Pos", r.category.String(), r.metric.String(), "Last Kernel")
+	} else {
+		headRow = fmt.Sprintf(fmtStr+"\n", "Pos", r.category.String(), r.metric.String())
+	}
+	body := r.buildReportBody(rows, fmtStr, hasLastKernel)
+	ascii := border + headRow + border + body + border
+
+	hl := int(r.headerIndent)
+	if hl < 1 {
+		hl = 1
+	}
+	if hl > 6 {
+		hl = 6
+	}
+	title := fmt.Sprintf("Top %d %s's by %s", r.limit, r.metric, r.category)
+	desc := MetricDescription(r.metric)
+
+	var b strings.Builder
+	b.WriteString("\n\n\n\n")
+	b.WriteString(template.HTMLEscapeString(title))
+	b.WriteString("\n\n\n")
+	b.WriteString(template.HTMLEscapeString(title))
+	b.WriteString("\n")
+	if desc != "" {
+		b.WriteString("

") + b.WriteString(template.HTMLEscapeString(desc)) + b.WriteString("

\n") + } + b.WriteString("
")
+	b.WriteString(template.HTMLEscapeString(ascii))
+	b.WriteString("
\n\n\n") + return b.String() +} + func (r reportBuilder) reportWidths(rows []tableRow, hasLastKernel bool) (countW, nameW, valueW, lastKernelW int) { countW = 3 nameW = len(r.category.String()) diff --git a/internal/goprecords/report_test.go b/internal/goprecords/report_test.go index 9715c12..a2ec5e9 100644 --- a/internal/goprecords/report_test.go +++ b/internal/goprecords/report_test.go @@ -92,6 +92,35 @@ func TestReportWithData(t *testing.T) { } } +func TestReportHTML(t *testing.T) { + aggs := &Aggregates{ + Host: make(map[string]*HostAggregate), + Kernel: make(map[string]*Aggregate), + KernelMajor: make(map[string]*Aggregate), + KernelName: make(map[string]*Aggregate), + } + + hagg := NewHostAggregate("host1", "Linux 5.10") + hagg.Uptime = 86400000 + hagg.Boots = 10 + hagg.FirstBoot = 1000 + hagg.LastSeen = 86401000 + aggs.Host["host1"] = hagg + + reporter := NewReporter(aggs, CategoryHost, 20, MetricUptime, FormatHTML, 2) + _, ok := reporter.(*HTMLReporter) + if !ok { + t.Fatalf("expected HTMLReporter, got %T", reporter) + } + report := reporter.Report() + if !strings.Contains(report, "") || !strings.Contains(report, "
") {
+		t.Fatalf("expected HTML document with pre, got %q", report)
+	}
+	if !strings.Contains(report, "host1") {
+		t.Error("expected report to contain host1")
+	}
+}
+
 func TestReportMarkdown(t *testing.T) {
 	aggs := &Aggregates{
 		Host:        make(map[string]*HostAggregate),
diff --git a/internal/goprecords/types.go b/internal/goprecords/types.go
index ae8a059..84e7f4f 100644
--- a/internal/goprecords/types.go
+++ b/internal/goprecords/types.go
@@ -9,7 +9,7 @@ import (
 
 const (
 	// Day is seconds in 24 hours.
-	Day   = 24 * 3600
+	Day = 24 * 3600
 	// Month is 30 days in seconds.
 	Month = 30 * Day
 )
@@ -42,6 +42,7 @@ const (
 	FormatPlaintext OutputFormat = iota
 	FormatMarkdown
 	FormatGemtext
+	FormatHTML
 )
 
 // Epoch is a Unix timestamp for duration/date formatting.
@@ -135,6 +136,8 @@ func (f OutputFormat) String() string {
 		return "Markdown"
 	case FormatGemtext:
 		return "Gemtext"
+	case FormatHTML:
+		return "HTML"
 	default:
 		return "?"
 	}
@@ -254,6 +257,8 @@ func ParseOutputFormat(s string) (OutputFormat, error) {
 		return FormatMarkdown, nil
 	case "Gemtext":
 		return FormatGemtext, nil
+	case "HTML":
+		return FormatHTML, nil
 	default:
 		return 0, fmt.Errorf("invalid output-format %q", s)
 	}
diff --git a/internal/goprecords/types_test.go b/internal/goprecords/types_test.go
index f872a69..a315784 100644
--- a/internal/goprecords/types_test.go
+++ b/internal/goprecords/types_test.go
@@ -117,7 +117,7 @@ func TestEpochHumanDuration(t *testing.T) {
 	// Unix epoch + 1 year + 2 months + 3 days
 	epoch := Epoch(31536000 + (60 * 24 * 3600) + (3 * 24 * 3600))
 	duration := epoch.HumanDuration()
-	
+
 	if duration == "" {
 		t.Error("expected non-empty duration string")
 	}
@@ -129,13 +129,13 @@ func TestEpochHumanDuration(t *testing.T) {
 
 func TestEpochNewerThan(t *testing.T) {
 	now := uint64(time.Now().Unix())
-	
+
 	// Recent epoch
 	recent := Epoch(now - 10*24*3600) // 10 days ago
 	if !recent.NewerThan(20) {
 		t.Error("expected recent epoch to be newer than 20 days")
 	}
-	
+
 	// Old epoch
 	old := Epoch(now - 100*24*3600) // 100 days ago
 	if old.NewerThan(90) {
@@ -145,7 +145,7 @@ func TestEpochNewerThan(t *testing.T) {
 
 func TestCategoryString(t *testing.T) {
 	tests := []struct {
-		cat Category
+		cat  Category
 		want string
 	}{
 		{CategoryHost, "Host"},
@@ -154,7 +154,7 @@ func TestCategoryString(t *testing.T) {
 		{CategoryKernelName, "KernelName"},
 		{Category(999), "?"},
 	}
-	
+
 	for _, tt := range tests {
 		got := tt.cat.String()
 		if got != tt.want {
@@ -165,7 +165,7 @@ func TestCategoryString(t *testing.T) {
 
 func TestMetricString(t *testing.T) {
 	tests := []struct {
-		met Metric
+		met  Metric
 		want string
 	}{
 		{MetricBoots, "Boots"},
@@ -175,7 +175,7 @@ func TestMetricString(t *testing.T) {
 		{MetricLifespan, "Lifespan"},
 		{Metric(999), "?"},
 	}
-	
+
 	for _, tt := range tests {
 		got := tt.met.String()
 		if got != tt.want {
@@ -186,15 +186,16 @@ func TestMetricString(t *testing.T) {
 
 func TestOutputFormatString(t *testing.T) {
 	tests := []struct {
-		fmt OutputFormat
+		fmt  OutputFormat
 		want string
 	}{
 		{FormatPlaintext, "Plaintext"},
 		{FormatMarkdown, "Markdown"},
 		{FormatGemtext, "Gemtext"},
+		{FormatHTML, "HTML"},
 		{OutputFormat(999), "?"},
 	}
-	
+
 	for _, tt := range tests {
 		got := tt.fmt.String()
 		if got != tt.want {
@@ -205,7 +206,7 @@ func TestOutputFormatString(t *testing.T) {
 
 func TestMetricDescription(t *testing.T) {
 	tests := []struct {
-		metric Metric
+		metric   Metric
 		contains string
 	}{
 		{MetricBoots, "boots"},
@@ -214,7 +215,7 @@ func TestMetricDescription(t *testing.T) {
 		{MetricDowntime, "downtime"},
 		{MetricLifespan, "uptime"},
 	}
-	
+
 	for _, tt := range tests {
 		desc := MetricDescription(tt.metric)
 		if desc == "" {
@@ -225,15 +226,15 @@ func TestMetricDescription(t *testing.T) {
 
 func TestWordWrap(t *testing.T) {
 	tests := []struct {
-		text string
+		text  string
 		limit int
-		name string
+		name  string
 	}{
 		{"short text", 100, "short text no wrap"},
 		{"this is a very long text that should be wrapped at some point because it exceeds the limit", 30, "long text wrap"},
 		{"", 50, "empty string"},
 	}
-	
+
 	for _, tt := range tests {
 		result := wordWrap(tt.text, tt.limit)
 		lines := 0
@@ -242,7 +243,7 @@ func TestWordWrap(t *testing.T) {
 				lines++
 			}
 		}
-		
+
 		// Just verify it doesn't crash and returns something reasonable
 		if len(result) == 0 && len(tt.text) > 0 {
 			t.Errorf("wordWrap(%q, %d): returned empty for non-empty input", tt.text, tt.limit)
@@ -259,14 +260,14 @@ func TestFormatDuration(t *testing.T) {
 
 func TestFormatInt(t *testing.T) {
 	tests := []struct {
-		n uint64
+		n    uint64
 		want string
 	}{
 		{0, "0"},
 		{123, "123"},
 		{9999999, "9999999"},
 	}
-	
+
 	for _, tt := range tests {
 		got := formatInt(tt.n)
 		if got != tt.want {
-- 
cgit v1.2.3