summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/goprecords/integration_test_runner.go2
-rw-r--r--internal/goprecords/order.go3
-rw-r--r--internal/goprecords/order_test.go2
-rw-r--r--internal/goprecords/report.go23
-rw-r--r--internal/goprecords/report_config.go2
-rw-r--r--internal/goprecords/report_test.go80
-rw-r--r--internal/goprecords/types.go7
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)
}