diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-14 10:10:50 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-14 10:10:50 +0300 |
| commit | c053f1e04ffb0fb89743cc7bc5154efaf6e8a0bf (patch) | |
| tree | 282736aff772a899f1f1a0884c380cc82d1544c6 /internal | |
| parent | 00a015a9642baee69def9a104602b4d59f980c63 (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.go | 2 | ||||
| -rw-r--r-- | internal/daemon/daemon_test.go | 22 | ||||
| -rw-r--r-- | internal/goprecords/integration_test_runner.go | 2 | ||||
| -rw-r--r-- | internal/goprecords/parse_test.go | 1 | ||||
| -rw-r--r-- | internal/goprecords/report.go | 60 | ||||
| -rw-r--r-- | internal/goprecords/report_test.go | 29 | ||||
| -rw-r--r-- | internal/goprecords/types.go | 7 | ||||
| -rw-r--r-- | internal/goprecords/types_test.go | 35 |
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 { |
