summaryrefslogtreecommitdiff
path: root/internal
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 /internal
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
Diffstat (limited to 'internal')
-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
8 files changed, 138 insertions, 20 deletions
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 {