diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-27 11:33:49 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-27 11:33:49 +0300 |
| commit | 9e8609c5133e5889b33b2f0a4dfffdb3ac4f1e6d (patch) | |
| tree | 3c9485dea96dbf2ac905e5480c1418317d74ca1d /internal | |
| parent | 8c540b6bdc745ee0c0eab7cb6075d5289ec45873 (diff) | |
Add last-updated records table at bottom of All report
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/goprecords/integration_test_runner.go | 2 | ||||
| -rw-r--r-- | internal/goprecords/order.go | 3 | ||||
| -rw-r--r-- | internal/goprecords/order_test.go | 2 | ||||
| -rw-r--r-- | internal/goprecords/report.go | 23 | ||||
| -rw-r--r-- | internal/goprecords/report_config.go | 2 | ||||
| -rw-r--r-- | internal/goprecords/report_test.go | 80 | ||||
| -rw-r--r-- | internal/goprecords/types.go | 7 |
7 files changed, 114 insertions, 5 deletions
diff --git a/internal/goprecords/integration_test_runner.go b/internal/goprecords/integration_test_runner.go index 448dcd7..215ea96 100644 --- a/internal/goprecords/integration_test_runner.go +++ b/internal/goprecords/integration_test_runner.go @@ -75,7 +75,7 @@ func testStatsOrder() int { fmt.Printf("FAIL: stats-order custom first entry\n") failed++ } - for _, bad := range []string{"Host", "Bad:Uptime", "Kernel:Downtime", "Host:Nope"} { + for _, bad := range []string{"Host", "Bad:Uptime", "Kernel:Downtime", "Kernel:LastUpdated", "Host:Nope"} { if _, err := ParseStatsOrder(bad); err == nil { fmt.Printf("FAIL: parse %q should error\n", bad) failed++ diff --git a/internal/goprecords/order.go b/internal/goprecords/order.go index 6397e06..c7b6726 100644 --- a/internal/goprecords/order.go +++ b/internal/goprecords/order.go @@ -44,7 +44,7 @@ func ParseStatsOrder(s string) ([]CategoryMetric, error) { if err != nil { return nil, fmt.Errorf("invalid -stats-order metric %q", metName) } - if cat != CategoryHost && (met == MetricDowntime || met == MetricLifespan) { + if cat != CategoryHost && (met == MetricDowntime || met == MetricLifespan || met == MetricLastUpdated) { return nil, fmt.Errorf("invalid -stats-order entry %q (metric %s not supported for category %s)", entry, metName, catName) } key := cat.String() + ":" + met.String() @@ -98,5 +98,6 @@ func defaultStatsOrder() []CategoryMetric { {CategoryKernel, MetricBoots}, {CategoryKernel, MetricUptime}, {CategoryKernel, MetricScore}, + {CategoryHost, MetricLastUpdated}, } } diff --git a/internal/goprecords/order_test.go b/internal/goprecords/order_test.go index 0a7d5cb..74cbc92 100644 --- a/internal/goprecords/order_test.go +++ b/internal/goprecords/order_test.go @@ -23,6 +23,7 @@ func TestParseStatsOrder(t *testing.T) { {in: "Host", valid: false}, {in: "Bad:Uptime", valid: false}, {in: "Kernel:Downtime", valid: false}, + {in: "Kernel:LastUpdated", valid: false}, {in: "Host:Nope", valid: false}, {in: "", valid: false}, {in: " , ", valid: false}, @@ -65,6 +66,7 @@ func TestStatsOrderList(t *testing.T) { {CategoryKernel, MetricBoots}, {CategoryKernel, MetricUptime}, {CategoryKernel, MetricScore}, + {CategoryHost, MetricLastUpdated}, } got, err := StatsOrderList("") if err != nil { diff --git a/internal/goprecords/report.go b/internal/goprecords/report.go index 8916caa..531cfc3 100644 --- a/internal/goprecords/report.go +++ b/internal/goprecords/report.go @@ -47,6 +47,22 @@ var metricExtractors = map[Metric]metricExtractor{ hostHuman: func(h *HostAggregate) string { return formatDuration(h.Lifespan()) }, aggHuman: func(a *Aggregate) string { return formatDuration(a.Uptime) }, }, + MetricLastUpdated: { + hostSortKey: func(h *HostAggregate) uint64 { + if h.LastUpdated.IsZero() { + return 0 + } + return uint64(h.LastUpdated.UTC().Unix()) + }, + aggSortKey: func(a *Aggregate) uint64 { return 0 }, + hostHuman: func(h *HostAggregate) string { + if h.LastUpdated.IsZero() { + return "" + } + return h.LastUpdated.UTC().Format("2006-01-02 15:04") + }, + aggHuman: func(a *Aggregate) string { return "" }, + }, } func extractorFor(m Metric) metricExtractor { @@ -59,7 +75,7 @@ func extractorFor(m Metric) metricExtractor { // WriteReports renders reports to w based on the given config. func WriteReports(w io.Writer, aggregates *Aggregates, cfg ReportConfig) error { if !cfg.All { - if cfg.Category != CategoryHost && (cfg.Metric == MetricDowntime || cfg.Metric == MetricLifespan) { + if cfg.Category != CategoryHost && (cfg.Metric == MetricDowntime || cfg.Metric == MetricLifespan || cfg.Metric == MetricLastUpdated) { return fmt.Errorf("Category %s only supports: Boots, Uptime, Score", cfg.Category) } s := reportForPair(aggregates, cfg.Category, cfg.Metric, cfg.Limit, 1, cfg.OutputFormat) @@ -108,7 +124,7 @@ func skipPair(cfg ReportConfig, c Category, m Metric) bool { if !cfg.IncludeKernel && c == CategoryKernel { return true } - if c != CategoryHost && (m == MetricDowntime || m == MetricLifespan) { + if c != CategoryHost && (m == MetricDowntime || m == MetricLifespan || m == MetricLastUpdated) { return true } return false @@ -323,6 +339,9 @@ func (r reportBuilder) buildHostTable() ([]tableRow, bool, bool) { LastUpdated: lastUpdated, }) } + if r.metric == MetricLastUpdated { + hasLastUpdated = false + } return rows, true, hasLastUpdated } diff --git a/internal/goprecords/report_config.go b/internal/goprecords/report_config.go index 8dc43d8..f181dba 100644 --- a/internal/goprecords/report_config.go +++ b/internal/goprecords/report_config.go @@ -34,7 +34,7 @@ type ReportFlags struct { func RegisterReportFlags(fs *flag.FlagSet) *ReportFlags { return &ReportFlags{ category: fs.String("category", "Host", "Category: Host, Kernel, KernelMajor, KernelName"), - metric: fs.String("metric", "Uptime", "Metric: Boots, Uptime, Score, Downtime, Lifespan"), + metric: fs.String("metric", "Uptime", "Metric: Boots, Uptime, Score, Downtime, Lifespan, LastUpdated"), limit: fs.Uint("limit", 20, "Limit output to num of entries"), outputFormat: fs.String("output-format", "Plaintext", "Output format: Plaintext, Markdown, Gemtext, HTML"), all: fs.Bool("all", false, "Generate all possible stats but Kernel"), diff --git a/internal/goprecords/report_test.go b/internal/goprecords/report_test.go index 962afdb..68371ee 100644 --- a/internal/goprecords/report_test.go +++ b/internal/goprecords/report_test.go @@ -122,6 +122,86 @@ func TestReportHTML(t *testing.T) { } } +func TestReportLastUpdated(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), + } + + old := NewHostAggregate("oldhost", "Linux 5.10") + old.Stats.Uptime = 86400000 + old.Stats.Boots = 1 + old.Stats.FirstBoot = 1000 + old.Stats.LastSeen = 86401000 + old.LastUpdated = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + aggs.Host["oldhost"] = old + + recent := NewHostAggregate("recenthost", "Linux 6.0") + recent.Stats.Uptime = 1000 + recent.Stats.Boots = 1 + recent.Stats.FirstBoot = 1000 + recent.Stats.LastSeen = 2000 + recent.LastUpdated = time.Date(2026, 6, 27, 12, 0, 0, 0, time.UTC) + aggs.Host["recenthost"] = recent + + unknown := NewHostAggregate("unknownhost", "Linux 6.1") + unknown.Stats.Uptime = 100 + unknown.Stats.Boots = 1 + unknown.Stats.FirstBoot = 1000 + unknown.Stats.LastSeen = 1100 + aggs.Host["unknownhost"] = unknown + + report := NewHostReporter(aggs, 20, MetricLastUpdated, FormatPlaintext, 1).Report() + if report == "" { + t.Fatal("expected non-empty report") + } + if !strings.Contains(report, "LastUpdated") { + t.Error("expected report to contain LastUpdated header") + } + if !strings.Contains(report, "Last Kernel") { + t.Error("expected report to contain Last Kernel column") + } + if strings.Contains(report, " Updated |") { + t.Errorf("expected no redundant Updated column; got %q", report) + } + recentPos := strings.Index(report, "recenthost") + oldPos := strings.Index(report, "oldhost") + unknownPos := strings.Index(report, "unknownhost") + if recentPos == -1 || oldPos == -1 || unknownPos == -1 { + t.Fatalf("missing hosts in report: %q", report) + } + if recentPos > oldPos { + t.Errorf("expected recenthost before oldhost (descending LastUpdated); got %q", report) + } + if unknownPos < oldPos { + t.Errorf("expected zero-LastUpdated host sorted to bottom; got %q", report) + } + if !strings.Contains(report, "2026-06-27 12:00") || !strings.Contains(report, "2024-01-01 00:00") { + t.Errorf("expected datestamps in report; got %q", report) + } + + htmlReport := NewHostReporter(aggs, 20, MetricLastUpdated, FormatHTML, 2).Report() + if !strings.Contains(htmlReport, "<table>") { + t.Fatalf("expected HTML fragment with table, got %q", htmlReport) + } + if !strings.Contains(htmlReport, "<th>Last Kernel</th>") { + t.Error("expected HTML to contain Last Kernel column") + } + if strings.Contains(htmlReport, "<th>Updated</th>") { + t.Errorf("expected no redundant Updated column in HTML; got %q", htmlReport) + } + + mdReport := NewHostReporter(aggs, 20, MetricLastUpdated, FormatMarkdown, 2).Report() + if !strings.Contains(mdReport, "##") || !strings.Contains(mdReport, "```") { + t.Fatalf("expected markdown code block, got %q", mdReport) + } + if !strings.Contains(mdReport, "Last Kernel") { + t.Error("expected markdown report to contain Last Kernel column") + } +} + 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 487119f..7b32c1f 100644 --- a/internal/goprecords/types.go +++ b/internal/goprecords/types.go @@ -33,6 +33,7 @@ const ( MetricScore MetricDowntime MetricLifespan + MetricLastUpdated ) // OutputFormat is the report output format. @@ -123,6 +124,8 @@ func (m Metric) String() string { return "Downtime" case MetricLifespan: return "Lifespan" + case MetricLastUpdated: + return "LastUpdated" default: return "?" } @@ -221,6 +224,8 @@ func MetricDescription(m Metric) string { return "Lifespan is the total uptime + the total downtime of a host." case MetricScore: return "Score is calculated by combining all other metrics." + case MetricLastUpdated: + return "LastUpdated is the date a host's records file was last updated." default: return "" } @@ -255,6 +260,8 @@ func ParseMetric(s string) (Metric, error) { return MetricDowntime, nil case "lifespan": return MetricLifespan, nil + case "lastupdated": + return MetricLastUpdated, nil default: return 0, fmt.Errorf("invalid metric %q", s) } |
