summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-14 10:10:50 +0300
committerPaul Buetow <paul@buetow.org>2026-04-14 10:10:50 +0300
commitc053f1e04ffb0fb89743cc7bc5154efaf6e8a0bf (patch)
tree282736aff772a899f1f1a0884c380cc82d1544c6
parent00a015a9642baee69def9a104602b4d59f980c63 (diff)
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
-rw-r--r--fixtures/Host.Boots.HTML.expected19
-rw-r--r--fixtures/Host.Downtime.HTML.expected19
-rw-r--r--fixtures/Host.Lifespan.HTML.expected19
-rw-r--r--fixtures/Host.Score.HTML.expected19
-rw-r--r--fixtures/Host.Uptime.HTML.expected19
-rw-r--r--fixtures/Kernel.Boots.HTML.expected19
-rw-r--r--fixtures/Kernel.Score.HTML.expected19
-rw-r--r--fixtures/Kernel.Uptime.HTML.expected19
-rw-r--r--fixtures/KernelMajor.Boots.HTML.expected19
-rw-r--r--fixtures/KernelMajor.Score.HTML.expected19
-rw-r--r--fixtures/KernelMajor.Uptime.HTML.expected19
-rw-r--r--fixtures/KernelName.Boots.HTML.expected19
-rw-r--r--fixtures/KernelName.Score.HTML.expected19
-rw-r--r--fixtures/KernelName.Uptime.HTML.expected19
-rw-r--r--internal/daemon/daemon.go2
-rw-r--r--internal/daemon/daemon_test.go22
-rw-r--r--internal/goprecords/integration_test_runner.go2
-rw-r--r--internal/goprecords/parse_test.go1
-rw-r--r--internal/goprecords/report.go60
-rw-r--r--internal/goprecords/report_test.go29
-rw-r--r--internal/goprecords/types.go7
-rw-r--r--internal/goprecords/types_test.go35
22 files changed, 404 insertions, 20 deletions
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Top 3 Boots&#39;s by Host</title>
+</head>
+<body>
+<h1>Top 3 Boots&#39;s by Host</h1>
+<p>Boots is the total number of host boots over the entire lifespan.</p>
+<pre>+-----+----------------+-------+-----------------------------+
+| 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 |
++-----+----------------+-------+-----------------------------+
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Top 3 Downtime&#39;s by Host</title>
+</head>
+<body>
+<h1>Top 3 Downtime&#39;s by Host</h1>
+<p>Downtime is the total downtime of a host over the entire lifespan.</p>
+<pre>+-----+----------------+-----------------------------+--------------------------+
+| 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 |
++-----+----------------+-----------------------------+--------------------------+
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Top 3 Lifespan&#39;s by Host</title>
+</head>
+<body>
+<h1>Top 3 Lifespan&#39;s by Host</h1>
+<p>Lifespan is the total uptime + the total downtime of a host.</p>
+<pre>+-----+----------------+----------------------------+--------------------------+
+| 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 |
++-----+----------------+----------------------------+--------------------------+
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Top 3 Score&#39;s by Host</title>
+</head>
+<body>
+<h1>Top 3 Score&#39;s by Host</h1>
+<p>Score is calculated by combining all other metrics.</p>
+<pre>+-----+---------+-------+-----------------------------------+
+| 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 |
++-----+---------+-------+-----------------------------------+
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Top 3 Uptime&#39;s by Host</title>
+</head>
+<body>
+<h1>Top 3 Uptime&#39;s by Host</h1>
+<p>Uptime is the total uptime of a host over the entire lifespan.</p>
+<pre>+-----+---------+-----------------------------+-----------------------------------+
+| 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 |
++-----+---------+-----------------------------+-----------------------------------+
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Top 3 Boots&#39;s by Kernel</title>
+</head>
+<body>
+<h1>Top 3 Boots&#39;s by Kernel</h1>
+<p>Boots is the total number of host boots over the entire lifespan.</p>
+<pre>+-----+------------------------------+-------+
+| 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 |
++-----+------------------------------+-------+
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Top 3 Score&#39;s by Kernel</title>
+</head>
+<body>
+<h1>Top 3 Score&#39;s by Kernel</h1>
+<p>Score is calculated by combining all other metrics.</p>
+<pre>+-----+------------------------------------+-------+
+| 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 |
++-----+------------------------------------+-------+
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Top 3 Uptime&#39;s by Kernel</title>
+</head>
+<body>
+<h1>Top 3 Uptime&#39;s by Kernel</h1>
+<p>Uptime is the total uptime of a host over the entire lifespan.</p>
+<pre>+-----+------------------------------------+-----------------------------+
+| 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 |
++-----+------------------------------------+-----------------------------+
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Top 3 Boots&#39;s by KernelMajor</title>
+</head>
+<body>
+<h1>Top 3 Boots&#39;s by KernelMajor</h1>
+<p>Boots is the total number of host boots over the entire lifespan.</p>
+<pre>+-----+----------------+-------+
+| Pos | KernelMajor | Boots |
++-----+----------------+-------+
+| 1. | FreeBSD 10... | 551 |
+| 2. | Linux 3... | 410 |
+| 3. | Linux 5... | 249 |
++-----+----------------+-------+
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Top 3 Score&#39;s by KernelMajor</title>
+</head>
+<body>
+<h1>Top 3 Score&#39;s by KernelMajor</h1>
+<p>Score is calculated by combining all other metrics.</p>
+<pre>+-----+----------------+-------+
+| Pos | KernelMajor | Score |
++-----+----------------+-------+
+| 1. | Linux 3... | 736 |
+| 2. | FreeBSD 10... | 406 |
+| 3. | Linux 5... | 268 |
++-----+----------------+-------+
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Top 3 Uptime&#39;s by KernelMajor</title>
+</head>
+<body>
+<h1>Top 3 Uptime&#39;s by KernelMajor</h1>
+<p>Uptime is the total uptime of a host over the entire lifespan.</p>
+<pre>+-----+----------------+-----------------------------+
+| 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 |
++-----+----------------+-----------------------------+
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Top 3 Boots&#39;s by KernelName</title>
+</head>
+<body>
+<h1>Top 3 Boots&#39;s by KernelName</h1>
+<p>Boots is the total number of host boots over the entire lifespan.</p>
+<pre>+-----+------------+-------+
+| Pos | KernelName | Boots |
++-----+------------+-------+
+| 1. | FreeBSD | 872 |
+| 2. | Linux | 867 |
+| 3. | OpenBSD | 44 |
++-----+------------+-------+
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Top 3 Score&#39;s by KernelName</title>
+</head>
+<body>
+<h1>Top 3 Score&#39;s by KernelName</h1>
+<p>Score is calculated by combining all other metrics.</p>
+<pre>+-----+------------+-------+
+| Pos | KernelName | Score |
++-----+------------+-------+
+| 1. | Linux | 1292 |
+| 2. | FreeBSD | 700 |
+| 3. | OpenBSD | 196 |
++-----+------------+-------+
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Top 3 Uptime&#39;s by KernelName</title>
+</head>
+<body>
+<h1>Top 3 Uptime&#39;s by KernelName</h1>
+<p>Uptime is the total uptime of a host over the entire lifespan.</p>
+<pre>+-----+------------+-----------------------------+
+| 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 |
++-----+------------+-----------------------------+
+</pre>
+</body>
+</html>
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, "<!DOCTYPE html>") || !strings.Contains(body, "<pre>") {
+ 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("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>")
+ b.WriteString(template.HTMLEscapeString(title))
+ b.WriteString("</title>\n</head>\n<body>\n<h")
+ b.WriteString(strconv.Itoa(hl))
+ b.WriteString(">")
+ b.WriteString(template.HTMLEscapeString(title))
+ b.WriteString("</h")
+ b.WriteString(strconv.Itoa(hl))
+ b.WriteString(">\n")
+ if desc != "" {
+ b.WriteString("<p>")
+ b.WriteString(template.HTMLEscapeString(desc))
+ b.WriteString("</p>\n")
+ }
+ b.WriteString("<pre>")
+ b.WriteString(template.HTMLEscapeString(ascii))
+ b.WriteString("</pre>\n</body>\n</html>\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, "<!DOCTYPE html>") || !strings.Contains(report, "<pre>") {
+ 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 {