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 --- fixtures/Host.Boots.HTML.expected | 19 ++++++++ fixtures/Host.Downtime.HTML.expected | 19 ++++++++ fixtures/Host.Lifespan.HTML.expected | 19 ++++++++ fixtures/Host.Score.HTML.expected | 19 ++++++++ fixtures/Host.Uptime.HTML.expected | 19 ++++++++ fixtures/Kernel.Boots.HTML.expected | 19 ++++++++ fixtures/Kernel.Score.HTML.expected | 19 ++++++++ fixtures/Kernel.Uptime.HTML.expected | 19 ++++++++ fixtures/KernelMajor.Boots.HTML.expected | 19 ++++++++ fixtures/KernelMajor.Score.HTML.expected | 19 ++++++++ fixtures/KernelMajor.Uptime.HTML.expected | 19 ++++++++ fixtures/KernelName.Boots.HTML.expected | 19 ++++++++ fixtures/KernelName.Score.HTML.expected | 19 ++++++++ fixtures/KernelName.Uptime.HTML.expected | 19 ++++++++ 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 +++++++-------- 22 files changed, 404 insertions(+), 20 deletions(-) create mode 100644 fixtures/Host.Boots.HTML.expected create mode 100644 fixtures/Host.Downtime.HTML.expected create mode 100644 fixtures/Host.Lifespan.HTML.expected create mode 100644 fixtures/Host.Score.HTML.expected create mode 100644 fixtures/Host.Uptime.HTML.expected create mode 100644 fixtures/Kernel.Boots.HTML.expected create mode 100644 fixtures/Kernel.Score.HTML.expected create mode 100644 fixtures/Kernel.Uptime.HTML.expected create mode 100644 fixtures/KernelMajor.Boots.HTML.expected create mode 100644 fixtures/KernelMajor.Score.HTML.expected create mode 100644 fixtures/KernelMajor.Uptime.HTML.expected create mode 100644 fixtures/KernelName.Boots.HTML.expected create mode 100644 fixtures/KernelName.Score.HTML.expected create mode 100644 fixtures/KernelName.Uptime.HTML.expected diff --git a/fixtures/Host.Boots.HTML.expected b/fixtures/Host.Boots.HTML.expected new file mode 100644 index 0000000..e0b78a3 --- /dev/null +++ b/fixtures/Host.Boots.HTML.expected @@ -0,0 +1,19 @@ + + + + +Top 3 Boots's by Host + + +

Top 3 Boots's by Host

+

Boots is the total number of host boots over the entire lifespan.

+
+-----+----------------+-------+-----------------------------+
+| Pos |           Host | Boots |                 Last Kernel |
++-----+----------------+-------+-----------------------------+
+|  1. |  alphacentauri |   671 |     FreeBSD 11.4-RELEASE-p7 |
+|  2. |           mars |   207 |         Linux 3.2.0-4-amd64 |
+|  3. |       callisto |   153 | Linux 4.0.4-303.fc22.x86_64 |
++-----+----------------+-------+-----------------------------+
+
+ + diff --git a/fixtures/Host.Downtime.HTML.expected b/fixtures/Host.Downtime.HTML.expected new file mode 100644 index 0000000..72bdd43 --- /dev/null +++ b/fixtures/Host.Downtime.HTML.expected @@ -0,0 +1,19 @@ + + + + +Top 3 Downtime's by Host + + +

Top 3 Downtime's by Host

+

Downtime is the total downtime of a host over the entire lifespan.

+
+-----+----------------+-----------------------------+--------------------------+
+| Pos |           Host |                    Downtime |              Last Kernel |
++-----+----------------+-----------------------------+--------------------------+
+|  1. |       dionysus |  8 years, 3 months, 16 days | FreeBSD 13.0-RELEASE-p11 |
+|  2. |  alphacentauri | 5 years, 11 months, 18 days |  FreeBSD 11.4-RELEASE-p7 |
+|  3. |         uranus |  3 years, 3 months, 28 days |    Linux 5.16.14-arch1-1 |
++-----+----------------+-----------------------------+--------------------------+
+
+ + diff --git a/fixtures/Host.Lifespan.HTML.expected b/fixtures/Host.Lifespan.HTML.expected new file mode 100644 index 0000000..12bfe4d --- /dev/null +++ b/fixtures/Host.Lifespan.HTML.expected @@ -0,0 +1,19 @@ + + + + +Top 3 Lifespan's by Host + + +

Top 3 Lifespan's by Host

+

Lifespan is the total uptime + the total downtime of a host.

+
+-----+----------------+----------------------------+--------------------------+
+| Pos |           Host |                   Lifespan |              Last Kernel |
++-----+----------------+----------------------------+--------------------------+
+|  1. |       dionysus | 8 years, 6 months, 17 days | FreeBSD 13.0-RELEASE-p11 |
+|  2. |         uranus | 7 years, 2 months, 16 days |    Linux 5.16.14-arch1-1 |
+|  3. |  alphacentauri | 6 years, 9 months, 13 days |  FreeBSD 11.4-RELEASE-p7 |
++-----+----------------+----------------------------+--------------------------+
+
+ + diff --git a/fixtures/Host.Score.HTML.expected b/fixtures/Host.Score.HTML.expected new file mode 100644 index 0000000..e42adf9 --- /dev/null +++ b/fixtures/Host.Score.HTML.expected @@ -0,0 +1,19 @@ + + + + +Top 3 Score's by Host + + +

Top 3 Score's by Host

+

Score is calculated by combining all other metrics.

+
+-----+---------+-------+-----------------------------------+
+| Pos |    Host | Score |                       Last Kernel |
++-----+---------+-------+-----------------------------------+
+|  1. |  uranus |   309 |             Linux 5.16.14-arch1-1 |
+|  2. |  vulcan |   270 | Linux 3.10.0-1160.81.1.el7.x86_64 |
+|  3. |     sun |   238 |          FreeBSD 10.3-RELEASE-p24 |
++-----+---------+-------+-----------------------------------+
+
+ + diff --git a/fixtures/Host.Uptime.HTML.expected b/fixtures/Host.Uptime.HTML.expected new file mode 100644 index 0000000..484aef6 --- /dev/null +++ b/fixtures/Host.Uptime.HTML.expected @@ -0,0 +1,19 @@ + + + + +Top 3 Uptime's by Host + + +

Top 3 Uptime's by Host

+

Uptime is the total uptime of a host over the entire lifespan.

+
+-----+---------+-----------------------------+-----------------------------------+
+| Pos |    Host |                      Uptime |                       Last Kernel |
++-----+---------+-----------------------------+-----------------------------------+
+|  1. |  vulcan |   4 years, 4 months, 7 days | Linux 3.10.0-1160.81.1.el7.x86_64 |
+|  2. |  uranus | 3 years, 11 months, 21 days |             Linux 5.16.14-arch1-1 |
+|  3. |     sun |  3 years, 9 months, 26 days |          FreeBSD 10.3-RELEASE-p24 |
++-----+---------+-----------------------------+-----------------------------------+
+
+ + diff --git a/fixtures/Kernel.Boots.HTML.expected b/fixtures/Kernel.Boots.HTML.expected new file mode 100644 index 0000000..d4e7435 --- /dev/null +++ b/fixtures/Kernel.Boots.HTML.expected @@ -0,0 +1,19 @@ + + + + +Top 3 Boots's by Kernel + + +

Top 3 Boots's by Kernel

+

Boots is the total number of host boots over the entire lifespan.

+
+-----+------------------------------+-------+
+| Pos |                       Kernel | Boots |
++-----+------------------------------+-------+
+|  1. |          Linux 3.2.0-4-amd64 |   321 |
+|  2. |  Linux 4.0.4-303.fc22.x86_64 |   103 |
+|  3. |         FreeBSD 10.1-RELEASE |    76 |
++-----+------------------------------+-------+
+
+ + diff --git a/fixtures/Kernel.Score.HTML.expected b/fixtures/Kernel.Score.HTML.expected new file mode 100644 index 0000000..2446cfe --- /dev/null +++ b/fixtures/Kernel.Score.HTML.expected @@ -0,0 +1,19 @@ + + + + +Top 3 Score's by Kernel + + +

Top 3 Score's by Kernel

+

Score is calculated by combining all other metrics.

+
+-----+------------------------------------+-------+
+| Pos |                             Kernel | Score |
++-----+------------------------------------+-------+
+|  1. |                Linux 3.2.0-4-amd64 |   241 |
+|  2. |  Linux 3.10.0-1160.15.2.el7.x86_64 |   123 |
+|  3. |   Linux 3.10.0-957.21.3.el7.x86_64 |    96 |
++-----+------------------------------------+-------+
+
+ + diff --git a/fixtures/Kernel.Uptime.HTML.expected b/fixtures/Kernel.Uptime.HTML.expected new file mode 100644 index 0000000..d57421a --- /dev/null +++ b/fixtures/Kernel.Uptime.HTML.expected @@ -0,0 +1,19 @@ + + + + +Top 3 Uptime's by Kernel + + +

Top 3 Uptime's by Kernel

+

Uptime is the total uptime of a host over the entire lifespan.

+
+-----+------------------------------------+-----------------------------+
+| Pos |                             Kernel |                      Uptime |
++-----+------------------------------------+-----------------------------+
+|  1. |                Linux 3.2.0-4-amd64 |  3 years, 5 months, 23 days |
+|  2. |  Linux 3.10.0-1160.15.2.el7.x86_64 | 1 years, 12 months, 15 days |
+|  3. |   Linux 3.10.0-957.21.3.el7.x86_64 |  1 years, 7 months, 13 days |
++-----+------------------------------------+-----------------------------+
+
+ + diff --git a/fixtures/KernelMajor.Boots.HTML.expected b/fixtures/KernelMajor.Boots.HTML.expected new file mode 100644 index 0000000..298f5d0 --- /dev/null +++ b/fixtures/KernelMajor.Boots.HTML.expected @@ -0,0 +1,19 @@ + + + + +Top 3 Boots's by KernelMajor + + +

Top 3 Boots's by KernelMajor

+

Boots is the total number of host boots over the entire lifespan.

+
+-----+----------------+-------+
+| Pos |    KernelMajor | Boots |
++-----+----------------+-------+
+|  1. |  FreeBSD 10... |   551 |
+|  2. |     Linux 3... |   410 |
+|  3. |     Linux 5... |   249 |
++-----+----------------+-------+
+
+ + diff --git a/fixtures/KernelMajor.Score.HTML.expected b/fixtures/KernelMajor.Score.HTML.expected new file mode 100644 index 0000000..9aa35d0 --- /dev/null +++ b/fixtures/KernelMajor.Score.HTML.expected @@ -0,0 +1,19 @@ + + + + +Top 3 Score's by KernelMajor + + +

Top 3 Score's by KernelMajor

+

Score is calculated by combining all other metrics.

+
+-----+----------------+-------+
+| Pos |    KernelMajor | Score |
++-----+----------------+-------+
+|  1. |     Linux 3... |   736 |
+|  2. |  FreeBSD 10... |   406 |
+|  3. |     Linux 5... |   268 |
++-----+----------------+-------+
+
+ + diff --git a/fixtures/KernelMajor.Uptime.HTML.expected b/fixtures/KernelMajor.Uptime.HTML.expected new file mode 100644 index 0000000..c77248e --- /dev/null +++ b/fixtures/KernelMajor.Uptime.HTML.expected @@ -0,0 +1,19 @@ + + + + +Top 3 Uptime's by KernelMajor + + +

Top 3 Uptime's by KernelMajor

+

Uptime is the total uptime of a host over the entire lifespan.

+
+-----+----------------+-----------------------------+
+| Pos |    KernelMajor |                      Uptime |
++-----+----------------+-----------------------------+
+|  1. |     Linux 3... | 11 years, 2 months, 11 days |
+|  2. |  FreeBSD 10... |   5 years, 9 months, 9 days |
+|  3. |     Linux 5... |  3 years, 12 months, 2 days |
++-----+----------------+-----------------------------+
+
+ + diff --git a/fixtures/KernelName.Boots.HTML.expected b/fixtures/KernelName.Boots.HTML.expected new file mode 100644 index 0000000..298d9b7 --- /dev/null +++ b/fixtures/KernelName.Boots.HTML.expected @@ -0,0 +1,19 @@ + + + + +Top 3 Boots's by KernelName + + +

Top 3 Boots's by KernelName

+

Boots is the total number of host boots over the entire lifespan.

+
+-----+------------+-------+
+| Pos | KernelName | Boots |
++-----+------------+-------+
+|  1. |    FreeBSD |   872 |
+|  2. |      Linux |   867 |
+|  3. |    OpenBSD |    44 |
++-----+------------+-------+
+
+ + diff --git a/fixtures/KernelName.Score.HTML.expected b/fixtures/KernelName.Score.HTML.expected new file mode 100644 index 0000000..483414a --- /dev/null +++ b/fixtures/KernelName.Score.HTML.expected @@ -0,0 +1,19 @@ + + + + +Top 3 Score's by KernelName + + +

Top 3 Score's by KernelName

+

Score is calculated by combining all other metrics.

+
+-----+------------+-------+
+| Pos | KernelName | Score |
++-----+------------+-------+
+|  1. |      Linux |  1292 |
+|  2. |    FreeBSD |   700 |
+|  3. |    OpenBSD |   196 |
++-----+------------+-------+
+
+ + diff --git a/fixtures/KernelName.Uptime.HTML.expected b/fixtures/KernelName.Uptime.HTML.expected new file mode 100644 index 0000000..862be60 --- /dev/null +++ b/fixtures/KernelName.Uptime.HTML.expected @@ -0,0 +1,19 @@ + + + + +Top 3 Uptime's by KernelName + + +

Top 3 Uptime's by KernelName

+

Uptime is the total uptime of a host over the entire lifespan.

+
+-----+------------+-----------------------------+
+| Pos | KernelName |                      Uptime |
++-----+------------+-----------------------------+
+|  1. |      Linux | 19 years, 4 months, 18 days |
+|  2. |    FreeBSD | 9 years, 11 months, 29 days |
+|  3. |    OpenBSD |  3 years, 1 months, 18 days |
++-----+------------+-----------------------------+
+
+ + 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