summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-20 21:06:35 +0200
committerPaul Buetow <paul@buetow.org>2026-02-20 21:06:35 +0200
commit59e86c9fd39308bc6b632e02ecf4d37265dabc91 (patch)
tree20e865fa680df4cff10056fa238ad07d7c58f458
parent0441f47fd585812b9c1f98b8a3dbeee03aa70b03 (diff)
Add Go implementation (goprecords)v0.1.0
- cmd/goprecords: CLI with import, query, report-from-files, test - internal/goprecords: types, order, aggregate, db, report - internal/version: version constant (0.1.0) - SQLite import (repeatable: clears record table then inserts) - Magefile: Build, Test, Install, Uninstall - Table-driven unit tests; comparison script vs Raku guprecords - .gitignore: *.db, /goprecords binary Co-authored-by: Cursor <cursoragent@cursor.com>
-rw-r--r--.gitignore5
-rw-r--r--Magefile.go65
-rw-r--r--cmd/goprecords/main.go312
-rw-r--r--go.mod22
-rw-r--r--go.sum41
-rw-r--r--internal/goprecords/aggregate.go147
-rw-r--r--internal/goprecords/db.go175
-rw-r--r--internal/goprecords/order.go93
-rw-r--r--internal/goprecords/order_test.go72
-rw-r--r--internal/goprecords/parse_test.go73
-rw-r--r--internal/goprecords/report.go265
-rw-r--r--internal/goprecords/types.go287
-rw-r--r--internal/version/version.go4
-rwxr-xr-xscripts/compare-with-raku.sh72
14 files changed, 1633 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..83dd821
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+# SQLite databases
+*.db
+
+# Built binary
+/goprecords
diff --git a/Magefile.go b/Magefile.go
new file mode 100644
index 0000000..07bfe1c
--- /dev/null
+++ b/Magefile.go
@@ -0,0 +1,65 @@
+//go:build mage
+
+package main
+
+// Magefile for goprecords. Targets follow the same style as other Go projects (e.g. hexai).
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/magefile/mage/mg"
+ "github.com/magefile/mage/sh"
+)
+
+const binaryName = "goprecords"
+
+// Default builds the binary.
+func Default() { mg.Deps(Build) }
+
+// Build builds the goprecords binary.
+func Build() error {
+ return sh.RunV("go", "build", "-o", binaryName, "./cmd/goprecords")
+}
+
+// Test runs all tests.
+func Test() error {
+ return sh.RunV("go", "test", "./...")
+}
+
+// Install builds and installs the binary to GOPATH/bin.
+func Install() error {
+ mg.Deps(Build)
+ gopath := os.Getenv("GOPATH")
+ if gopath == "" {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return fmt.Errorf("GOPATH unset and home: %w", err)
+ }
+ gopath = filepath.Join(home, "go")
+ }
+ binDir := filepath.Join(gopath, "bin")
+ if err := os.MkdirAll(binDir, 0755); err != nil {
+ return fmt.Errorf("mkdir %s: %w", binDir, err)
+ }
+ dest := filepath.Join(binDir, binaryName)
+ return sh.RunV("cp", "-v", binaryName, dest)
+}
+
+// Uninstall removes the binary from GOPATH/bin.
+func Uninstall() error {
+ gopath := os.Getenv("GOPATH")
+ if gopath == "" {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return fmt.Errorf("GOPATH unset and home: %w", err)
+ }
+ gopath = filepath.Join(home, "go")
+ }
+ dest := filepath.Join(gopath, "bin", binaryName)
+ if err := os.Remove(dest); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ return nil
+}
diff --git a/cmd/goprecords/main.go b/cmd/goprecords/main.go
new file mode 100644
index 0000000..7807d1f
--- /dev/null
+++ b/cmd/goprecords/main.go
@@ -0,0 +1,312 @@
+// Program goprecords generates uptime reports from uptimed record files or a SQLite database.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "os"
+
+ "github.com/goprecords/internal/goprecords"
+ "github.com/goprecords/internal/version"
+)
+
+const defaultDB = "goprecords.db"
+
+func main() {
+ for _, arg := range os.Args[1:] {
+ if arg == "-version" || arg == "--version" {
+ fmt.Println(version.Version)
+ os.Exit(0)
+ }
+ }
+
+ if len(os.Args) >= 2 {
+ switch os.Args[1] {
+ case "import":
+ runImport(os.Args[2:])
+ return
+ case "query":
+ runQuery(os.Args[2:])
+ return
+ case "test":
+ runTests()
+ return
+ }
+ }
+
+ runReportFromFiles(os.Args[1:])
+}
+
+func runImport(args []string) {
+ fs := flag.NewFlagSet("import", flag.ExitOnError)
+ statsDir := fs.String("stats-dir", "", "Directory containing .records files (required)")
+ dbPath := fs.String("db", defaultDB, "SQLite database path")
+ fs.Parse(args)
+
+ if *statsDir == "" {
+ fmt.Fprintln(os.Stderr, "import: missing required flag: -stats-dir")
+ fs.Usage()
+ os.Exit(1)
+ }
+ db, err := goprecords.OpenDB(*dbPath)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "open db:", err)
+ os.Exit(1)
+ }
+ defer db.Close()
+ ctx := context.Background()
+ if err := goprecords.CreateSchema(ctx, db); err != nil {
+ fmt.Fprintln(os.Stderr, "schema:", err)
+ os.Exit(1)
+ }
+ if err := goprecords.ImportFromDir(ctx, db, *statsDir); err != nil {
+ fmt.Fprintln(os.Stderr, "import:", err)
+ os.Exit(1)
+ }
+ fmt.Fprintf(os.Stderr, "imported %s into %s\n", *statsDir, *dbPath)
+}
+
+func runQuery(args []string) {
+ fs := flag.NewFlagSet("query", flag.ExitOnError)
+ dbPath := fs.String("db", defaultDB, "SQLite database path")
+ 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")
+ 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")
+ fs.Parse(args)
+
+ db, err := goprecords.OpenDB(*dbPath)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "open db:", err)
+ os.Exit(1)
+ }
+ defer db.Close()
+
+ ctx := context.Background()
+ aggregates, err := goprecords.LoadAggregates(ctx, db)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "load:", err)
+ os.Exit(1)
+ }
+
+ cat, err := goprecords.ParseCategory(*category)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ met, err := goprecords.ParseMetric(*metric)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ outFmt, err := goprecords.ParseOutputFormat(*outputFormat)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+
+ if !*all {
+ if cat != goprecords.CategoryHost && (met == goprecords.MetricDowntime || met == goprecords.MetricLifespan) {
+ fmt.Fprintf(os.Stderr, "Category %s only supports: Boots, Uptime, Score\n", *category)
+ os.Exit(1)
+ }
+ if cat == goprecords.CategoryHost {
+ os.Stdout.WriteString(goprecords.NewHostReporter(aggregates, *limit, met, outFmt, 1).Report())
+ } else {
+ os.Stdout.WriteString(goprecords.NewReporter(aggregates, cat, *limit, met, outFmt, 1).Report())
+ }
+ return
+ }
+
+ order, err := goprecords.StatsOrderList(*statsOrder)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ headerIndent := uint(2)
+ for _, pair := range order {
+ c, m := pair.Category, pair.Metric
+ if !*includeKernel && c == goprecords.CategoryKernel {
+ continue
+ }
+ if c != goprecords.CategoryHost && (m == goprecords.MetricDowntime || m == goprecords.MetricLifespan) {
+ continue
+ }
+ if c == goprecords.CategoryHost {
+ os.Stdout.WriteString(goprecords.NewHostReporter(aggregates, *limit, m, outFmt, headerIndent).Report())
+ } else {
+ os.Stdout.WriteString(goprecords.NewReporter(aggregates, c, *limit, m, outFmt, headerIndent).Report())
+ }
+ os.Stdout.WriteString("\n")
+ }
+}
+
+func runReportFromFiles(args []string) {
+ fs := flag.NewFlagSet("goprecords", flag.ExitOnError)
+ statsDir := fs.String("stats-dir", "", "The uptimed raw record input dir (required)")
+ 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")
+ 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")
+ fs.Parse(args)
+
+ if *statsDir == "" {
+ fmt.Fprintln(os.Stderr, "missing required flag: -stats-dir")
+ fs.Usage()
+ os.Exit(1)
+ }
+
+ cat, err := goprecords.ParseCategory(*category)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ met, err := goprecords.ParseMetric(*metric)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ outFmt, err := goprecords.ParseOutputFormat(*outputFormat)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+
+ ctx := context.Background()
+ aggr := goprecords.NewAggregator(*statsDir)
+ aggregates, err := aggr.Aggregate(ctx)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+
+ if !*all {
+ if cat != goprecords.CategoryHost && (met == goprecords.MetricDowntime || met == goprecords.MetricLifespan) {
+ fmt.Fprintf(os.Stderr, "Category %s only supports: Boots, Uptime, Score\n", *category)
+ os.Exit(1)
+ }
+ if cat == goprecords.CategoryHost {
+ os.Stdout.WriteString(goprecords.NewHostReporter(aggregates, *limit, met, outFmt, 1).Report())
+ } else {
+ os.Stdout.WriteString(goprecords.NewReporter(aggregates, cat, *limit, met, outFmt, 1).Report())
+ }
+ return
+ }
+
+ order, err := goprecords.StatsOrderList(*statsOrder)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ headerIndent := uint(2)
+ for _, pair := range order {
+ c, m := pair.Category, pair.Metric
+ if !*includeKernel && c == goprecords.CategoryKernel {
+ continue
+ }
+ if c != goprecords.CategoryHost && (m == goprecords.MetricDowntime || m == goprecords.MetricLifespan) {
+ continue
+ }
+ if c == goprecords.CategoryHost {
+ os.Stdout.WriteString(goprecords.NewHostReporter(aggregates, *limit, m, outFmt, headerIndent).Report())
+ } else {
+ os.Stdout.WriteString(goprecords.NewReporter(aggregates, c, *limit, m, outFmt, headerIndent).Report())
+ }
+ os.Stdout.WriteString("\n")
+ }
+}
+
+func runTests() {
+ ctx := context.Background()
+ aggr := goprecords.NewAggregator("./fixtures")
+ aggregates, err := aggr.Aggregate(ctx)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ limit := uint(3)
+ categories := []goprecords.Category{goprecords.CategoryHost, goprecords.CategoryKernel, goprecords.CategoryKernelMajor, goprecords.CategoryKernelName}
+ metrics := []goprecords.Metric{goprecords.MetricBoots, goprecords.MetricUptime, goprecords.MetricScore, goprecords.MetricDowntime, goprecords.MetricLifespan}
+ formats := []goprecords.OutputFormat{goprecords.FormatPlaintext, goprecords.FormatMarkdown, goprecords.FormatGemtext}
+ failed := 0
+ for _, cat := range categories {
+ for _, met := range metrics {
+ if cat != goprecords.CategoryHost && (met == goprecords.MetricDowntime || met == goprecords.MetricLifespan) {
+ continue
+ }
+ for _, outFmt := range formats {
+ var report string
+ if cat == goprecords.CategoryHost {
+ report = goprecords.NewHostReporter(aggregates, limit, met, outFmt, 1).Report()
+ } else {
+ report = goprecords.NewReporter(aggregates, cat, limit, met, outFmt, 1).Report()
+ }
+ expectedPath := fmt.Sprintf("./fixtures/%s.%s.%s.expected", cat, met, outFmt)
+ expected, err := os.ReadFile(expectedPath)
+ if err != nil {
+ fmt.Printf("FAIL: read %s: %v\n", expectedPath, err)
+ failed++
+ continue
+ }
+ if report != string(expected) {
+ fmt.Printf("FAIL: %s\n--- got:\n%s--- expected:\n%s\n", expectedPath, report, string(expected))
+ failed++
+ }
+ }
+ }
+ }
+ if _, err := goprecords.ParseStatsOrder("Host:Uptime,Host:Boots"); err != nil {
+ fmt.Printf("FAIL: parse Host:Uptime,Host:Boots: %v\n", err)
+ failed++
+ }
+ merged, _ := goprecords.StatsOrderList("Host:Uptime")
+ if len(merged) == 0 || merged[0].Category != goprecords.CategoryHost || merged[0].Metric != goprecords.MetricUptime {
+ fmt.Printf("FAIL: stats-order custom first entry\n")
+ failed++
+ }
+ for _, bad := range []string{"Host", "Bad:Uptime", "Kernel:Downtime", "Host:Nope"} {
+ if _, err := goprecords.ParseStatsOrder(bad); err == nil {
+ fmt.Printf("FAIL: parse %q should error\n", bad)
+ failed++
+ }
+ }
+ tmpDB := "./fixtures/test_import.db"
+ os.Remove(tmpDB)
+ db, err := goprecords.OpenDB(tmpDB)
+ if err != nil {
+ fmt.Printf("FAIL: open tmp db: %v\n", err)
+ failed++
+ } else {
+ goprecords.CreateSchema(ctx, db)
+ if err := goprecords.ImportFromDir(ctx, db, "./fixtures"); err != nil {
+ fmt.Printf("FAIL: import: %v\n", err)
+ failed++
+ } else {
+ aggFromDB, err := goprecords.LoadAggregates(ctx, db)
+ if err != nil {
+ fmt.Printf("FAIL: load: %v\n", err)
+ failed++
+ } else {
+ reportFromDB := goprecords.NewHostReporter(aggFromDB, limit, goprecords.MetricUptime, goprecords.FormatPlaintext, 1).Report()
+ reportFromMem := goprecords.NewHostReporter(aggregates, limit, goprecords.MetricUptime, goprecords.FormatPlaintext, 1).Report()
+ if reportFromDB != reportFromMem {
+ fmt.Printf("FAIL: import/query report differs from in-memory\n--- from DB:\n%s--- from memory:\n%s\n", reportFromDB, reportFromMem)
+ failed++
+ }
+ }
+ }
+ db.Close()
+ os.Remove(tmpDB)
+ }
+ if failed > 0 {
+ os.Exit(1)
+ }
+ fmt.Println("ok")
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..b8513c2
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,22 @@
+module github.com/goprecords
+
+go 1.21
+
+require modernc.org/sqlite v1.29.1
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.3.0 // indirect
+ github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
+ github.com/magefile/mage v1.15.0 // indirect
+ github.com/mattn/go-isatty v0.0.16 // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/sys v0.16.0 // indirect
+ modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
+ modernc.org/libc v1.41.0 // indirect
+ modernc.org/mathutil v1.6.0 // indirect
+ modernc.org/memory v1.7.2 // indirect
+ modernc.org/strutil v1.2.0 // indirect
+ modernc.org/token v1.1.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..83dc90d
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,41 @@
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
+github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
+github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
+github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
+github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
+golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
+golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
+golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
+modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk=
+modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
+modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
+modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
+modernc.org/sqlite v1.29.1 h1:19GY2qvWB4VPw0HppFlZCPAbmxFU41r+qjKZQdQ1ryA=
+modernc.org/sqlite v1.29.1/go.mod h1:hG41jCYxOAOoO6BRK66AdRlmOcDzXf7qnwlwjUIOqa0=
+modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
+modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/internal/goprecords/aggregate.go b/internal/goprecords/aggregate.go
new file mode 100644
index 0000000..3fb9144
--- /dev/null
+++ b/internal/goprecords/aggregate.go
@@ -0,0 +1,147 @@
+package goprecords
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+// Aggregates holds all category maps. Host uses HostAggregate; others use Aggregate.
+type Aggregates struct {
+ Host map[string]*HostAggregate
+ Kernel map[string]*Aggregate
+ KernelMajor map[string]*Aggregate
+ KernelName map[string]*Aggregate
+}
+
+// Aggregator reads .records files from a directory and builds Aggregates.
+type Aggregator struct {
+ statsDir string
+}
+
+// NewAggregator returns an Aggregator for the given stats directory.
+func NewAggregator(statsDir string) *Aggregator {
+ return &Aggregator{statsDir: statsDir}
+}
+
+// Aggregate reads all .records files and returns aggregated data.
+func (ag *Aggregator) Aggregate(ctx context.Context) (*Aggregates, error) {
+ out := &Aggregates{
+ Host: make(map[string]*HostAggregate),
+ Kernel: make(map[string]*Aggregate),
+ KernelMajor: make(map[string]*Aggregate),
+ KernelName: make(map[string]*Aggregate),
+ }
+ entries, err := os.ReadDir(ag.statsDir)
+ if err != nil {
+ return nil, fmt.Errorf("read stats dir: %w", err)
+ }
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".records") {
+ continue
+ }
+ path := filepath.Join(ag.statsDir, e.Name())
+ info, err := os.Stat(path)
+ if err != nil || info.Size() == 0 {
+ continue
+ }
+ host := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
+ if idx := strings.Index(host, "."); idx > 0 {
+ host = host[:idx]
+ }
+ if _, exists := out.Host[host]; exists {
+ return nil, fmt.Errorf("record file for %s already processed - duplicate inputs?", host)
+ }
+ lastKernel, err := lastKernelFromFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("last kernel %s: %w", path, err)
+ }
+ out.Host[host] = NewHostAggregate(host, lastKernel)
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("open %s: %w", path, err)
+ }
+ defer f.Close()
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ default:
+ }
+ line := strings.TrimSpace(sc.Text())
+ if line == "" {
+ continue
+ }
+ parts := strings.SplitN(line, ":", 3)
+ if len(parts) != 3 {
+ continue
+ }
+ uptime, _ := strconv.ParseUint(parts[0], 10, 64)
+ bootTime, _ := strconv.ParseUint(parts[1], 10, 64)
+ osStr := parts[2]
+ uname := osStr
+ if i := strings.Index(osStr, " "); i > 0 {
+ uname = osStr[:i]
+ }
+ osMajor := uname + " "
+ rest := osStr
+ if i := strings.Index(osStr, " "); i >= 0 {
+ rest = osStr[i+1:]
+ }
+ if j := strings.Index(rest, "."); j >= 0 {
+ osMajor += rest[:j] + "..."
+ } else {
+ osMajor += rest + "..."
+ }
+ out.Host[host].AddRecord(uptime, bootTime)
+ getOrNewAggregate(out.Kernel, osStr).AddRecord(uptime, bootTime)
+ getOrNewAggregate(out.KernelName, uname).AddRecord(uptime, bootTime)
+ getOrNewAggregate(out.KernelMajor, osMajor).AddRecord(uptime, bootTime)
+ }
+ if err := sc.Err(); err != nil {
+ return nil, fmt.Errorf("scan %s: %w", path, err)
+ }
+ }
+ return out, nil
+}
+
+func getOrNewAggregate(m map[string]*Aggregate, name string) *Aggregate {
+ if a, ok := m[name]; ok {
+ return a
+ }
+ a := NewAggregate(name)
+ m[name] = a
+ return a
+}
+
+func lastKernelFromFile(path string) (string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+ var maxBoot uint64
+ var lastOS string
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ line := strings.TrimSpace(sc.Text())
+ if line == "" {
+ continue
+ }
+ parts := strings.SplitN(line, ":", 3)
+ if len(parts) != 3 {
+ continue
+ }
+ bootTime, _ := strconv.ParseUint(parts[1], 10, 64)
+ if bootTime >= maxBoot {
+ maxBoot = bootTime
+ lastOS = parts[2]
+ }
+ }
+ return lastOS, sc.Err()
+}
diff --git a/internal/goprecords/db.go b/internal/goprecords/db.go
new file mode 100644
index 0000000..3afe970
--- /dev/null
+++ b/internal/goprecords/db.go
@@ -0,0 +1,175 @@
+package goprecords
+
+import (
+ "bufio"
+ "context"
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ _ "modernc.org/sqlite"
+)
+
+const schemaSQL = `
+CREATE TABLE IF NOT EXISTS record (
+ host TEXT NOT NULL,
+ uptime_sec INTEGER NOT NULL,
+ boot_time INTEGER NOT NULL,
+ os TEXT NOT NULL,
+ os_kernel_name TEXT NOT NULL,
+ os_kernel_major TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_record_host ON record(host);
+CREATE INDEX IF NOT EXISTS idx_record_os ON record(os);
+CREATE INDEX IF NOT EXISTS idx_record_os_kernel_name ON record(os_kernel_name);
+CREATE INDEX IF NOT EXISTS idx_record_os_kernel_major ON record(os_kernel_major);
+`
+
+// OpenDB opens the SQLite database at path, creating the file if needed.
+func OpenDB(path string) (*sql.DB, error) {
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := db.Exec("PRAGMA foreign_keys = OFF"); err != nil {
+ db.Close()
+ return nil, err
+ }
+ return db, nil
+}
+
+// CreateSchema creates the record table and indexes (idempotent).
+func CreateSchema(ctx context.Context, db *sql.DB) error {
+ _, err := db.ExecContext(ctx, schemaSQL)
+ return err
+}
+
+// ResetRecords removes all rows so import is repeatable.
+func ResetRecords(ctx context.Context, db *sql.DB) error {
+ _, err := db.ExecContext(ctx, "DELETE FROM record")
+ return err
+}
+
+// ImportFromDir reads all .records files from statsDir and inserts into the DB.
+// Resets the record table first so the run is repeatable.
+func ImportFromDir(ctx context.Context, db *sql.DB, statsDir string) error {
+ if err := ResetRecords(ctx, db); err != nil {
+ return fmt.Errorf("reset records: %w", err)
+ }
+ entries, err := os.ReadDir(statsDir)
+ if err != nil {
+ return fmt.Errorf("read dir: %w", err)
+ }
+ insert, err := db.PrepareContext(ctx, "INSERT INTO record (host, uptime_sec, boot_time, os, os_kernel_name, os_kernel_major) VALUES (?, ?, ?, ?, ?, ?)")
+ if err != nil {
+ return fmt.Errorf("prepare insert: %w", err)
+ }
+ defer insert.Close()
+
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".records") {
+ continue
+ }
+ path := filepath.Join(statsDir, e.Name())
+ info, err := os.Stat(path)
+ if err != nil || info.Size() == 0 {
+ continue
+ }
+ host := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
+ if idx := strings.Index(host, "."); idx > 0 {
+ host = host[:idx]
+ }
+ f, err := os.Open(path)
+ if err != nil {
+ return fmt.Errorf("open %s: %w", path, err)
+ }
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ line := strings.TrimSpace(sc.Text())
+ if line == "" {
+ continue
+ }
+ parts := strings.SplitN(line, ":", 3)
+ if len(parts) != 3 {
+ continue
+ }
+ uptimeSec, _ := strconv.ParseInt(parts[0], 10, 64)
+ bootTime, _ := strconv.ParseInt(parts[1], 10, 64)
+ osStr := parts[2]
+ osKernelName := osStr
+ if i := strings.Index(osStr, " "); i > 0 {
+ osKernelName = osStr[:i]
+ }
+ osMajor := osKernelName + " "
+ rest := osStr
+ if i := strings.Index(osStr, " "); i >= 0 {
+ rest = osStr[i+1:]
+ }
+ if j := strings.Index(rest, "."); j >= 0 {
+ osMajor += rest[:j] + "..."
+ } else {
+ osMajor += rest + "..."
+ }
+ _, err := insert.ExecContext(ctx, host, uptimeSec, bootTime, osStr, osKernelName, osMajor)
+ if err != nil {
+ f.Close()
+ return fmt.Errorf("insert: %w", err)
+ }
+ }
+ f.Close()
+ if err := sc.Err(); err != nil {
+ return fmt.Errorf("scan %s: %w", path, err)
+ }
+ }
+ return nil
+}
+
+// LoadAggregates reads all rows from the DB and builds Aggregates (same shape as file-based aggregation).
+func LoadAggregates(ctx context.Context, db *sql.DB) (*Aggregates, error) {
+ rows, err := db.QueryContext(ctx, "SELECT host, uptime_sec, boot_time, os, os_kernel_name, os_kernel_major FROM record ORDER BY host, boot_time")
+ if err != nil {
+ return nil, fmt.Errorf("query: %w", err)
+ }
+ defer rows.Close()
+
+ out := &Aggregates{
+ Host: make(map[string]*HostAggregate),
+ Kernel: make(map[string]*Aggregate),
+ KernelMajor: make(map[string]*Aggregate),
+ KernelName: make(map[string]*Aggregate),
+ }
+ hostMaxBoot := make(map[string]int64)
+ hostLastKernel := make(map[string]string)
+
+ for rows.Next() {
+ var host string
+ var uptimeSec, bootTime int64
+ var osStr, osKernelName, osKernelMajor string
+ if err := rows.Scan(&host, &uptimeSec, &bootTime, &osStr, &osKernelName, &osKernelMajor); err != nil {
+ return nil, fmt.Errorf("scan row: %w", err)
+ }
+ uptime := uint64(uptimeSec)
+ boot := uint64(bootTime)
+ if boot >= uint64(hostMaxBoot[host]) {
+ hostMaxBoot[host] = int64(boot)
+ hostLastKernel[host] = osStr
+ }
+ if _, ok := out.Host[host]; !ok {
+ out.Host[host] = NewHostAggregate(host, "")
+ }
+ out.Host[host].AddRecord(uptime, boot)
+ getOrNewAggregate(out.Kernel, osStr).AddRecord(uptime, boot)
+ getOrNewAggregate(out.KernelName, osKernelName).AddRecord(uptime, boot)
+ getOrNewAggregate(out.KernelMajor, osKernelMajor).AddRecord(uptime, boot)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("rows: %w", err)
+ }
+ for host, h := range out.Host {
+ h.LastKernel = hostLastKernel[host]
+ }
+ return out, nil
+}
diff --git a/internal/goprecords/order.go b/internal/goprecords/order.go
new file mode 100644
index 0000000..a20e5f9
--- /dev/null
+++ b/internal/goprecords/order.go
@@ -0,0 +1,93 @@
+package goprecords
+
+import (
+ "fmt"
+ "strings"
+)
+
+// CategoryMetric pairs a category with a metric for stats order.
+type CategoryMetric struct {
+ Category Category
+ Metric Metric
+}
+
+// ParseStatsOrder parses a comma-separated "Category:Metric" list.
+func ParseStatsOrder(s string) ([]CategoryMetric, error) {
+ parts := strings.Split(s, ",")
+ var entries []string
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p != "" {
+ entries = append(entries, p)
+ }
+ }
+ if len(entries) == 0 {
+ return nil, fmt.Errorf("invalid -stats-order: empty list")
+ }
+ var order []CategoryMetric
+ seen := make(map[string]bool)
+ for _, entry := range entries {
+ idx := strings.Index(entry, ":")
+ if idx <= 0 || idx == len(entry)-1 {
+ return nil, fmt.Errorf("invalid -stats-order entry %q (expected Category:Metric)", entry)
+ }
+ catName := strings.TrimSpace(entry[:idx])
+ metName := strings.TrimSpace(entry[idx+1:])
+ if catName == "" || metName == "" {
+ return nil, fmt.Errorf("invalid -stats-order entry %q (expected Category:Metric)", entry)
+ }
+ cat, err := ParseCategory(catName)
+ if err != nil {
+ return nil, fmt.Errorf("invalid -stats-order category %q", catName)
+ }
+ met, err := ParseMetric(metName)
+ if err != nil {
+ return nil, fmt.Errorf("invalid -stats-order metric %q", metName)
+ }
+ if cat != CategoryHost && (met == MetricDowntime || met == MetricLifespan) {
+ return nil, fmt.Errorf("invalid -stats-order entry %q (metric %s not supported for category %s)", entry, metName, catName)
+ }
+ key := cat.String() + ":" + met.String()
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ order = append(order, CategoryMetric{cat, met})
+ }
+ return order, nil
+}
+
+// StatsOrderList returns the full order (custom entries first, then default remainder).
+func StatsOrderList(statsOrder string) ([]CategoryMetric, error) {
+ defaultOrder := defaultStatsOrder()
+ if statsOrder == "" {
+ return defaultOrder, nil
+ }
+ order, err := ParseStatsOrder(statsOrder)
+ if err != nil {
+ return nil, err
+ }
+ seen := make(map[string]bool)
+ for _, p := range order {
+ seen[p.Category.String()+":"+p.Metric.String()] = true
+ }
+ for _, p := range defaultOrder {
+ key := p.Category.String() + ":" + p.Metric.String()
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ order = append(order, p)
+ }
+ return order, nil
+}
+
+func defaultStatsOrder() []CategoryMetric {
+ var out []CategoryMetric
+ for _, c := range []Category{CategoryHost, CategoryKernel, CategoryKernelMajor, CategoryKernelName} {
+ for _, m := range []Metric{MetricBoots, MetricUptime, MetricScore, MetricDowntime, MetricLifespan} {
+ out = append(out, CategoryMetric{c, m})
+ }
+ }
+ return out
+}
diff --git a/internal/goprecords/order_test.go b/internal/goprecords/order_test.go
new file mode 100644
index 0000000..c085723
--- /dev/null
+++ b/internal/goprecords/order_test.go
@@ -0,0 +1,72 @@
+package goprecords
+
+import (
+ "testing"
+)
+
+func TestParseStatsOrder(t *testing.T) {
+ tests := []struct {
+ in string
+ want []CategoryMetric
+ valid bool
+ }{
+ {
+ in: "Host:Uptime,Host:Boots",
+ want: []CategoryMetric{{CategoryHost, MetricUptime}, {CategoryHost, MetricBoots}},
+ valid: true,
+ },
+ {
+ in: "Host:Uptime",
+ want: []CategoryMetric{{CategoryHost, MetricUptime}},
+ valid: true,
+ },
+ {in: "Host", valid: false},
+ {in: "Bad:Uptime", valid: false},
+ {in: "Kernel:Downtime", valid: false},
+ {in: "Host:Nope", valid: false},
+ {in: "", valid: false},
+ {in: " , ", valid: false},
+ }
+ for _, tt := range tests {
+ got, err := ParseStatsOrder(tt.in)
+ valid := err == nil
+ if valid != tt.valid {
+ t.Errorf("ParseStatsOrder(%q) err=%v; valid=%v want %v", tt.in, err, valid, tt.valid)
+ continue
+ }
+ if !tt.valid {
+ continue
+ }
+ if len(got) != len(tt.want) {
+ t.Errorf("ParseStatsOrder(%q) len=%d want %d", tt.in, len(got), len(tt.want))
+ continue
+ }
+ for i := range got {
+ if got[i].Category != tt.want[i].Category || got[i].Metric != tt.want[i].Metric {
+ t.Errorf("ParseStatsOrder(%q)[%d] = %v; want %v", tt.in, i, got[i], tt.want[i])
+ }
+ }
+ }
+}
+
+func TestStatsOrderList(t *testing.T) {
+ // Empty string returns default order (all category×metric pairs).
+ got, err := StatsOrderList("")
+ if err != nil {
+ t.Fatalf("StatsOrderList(\"\"): %v", err)
+ }
+ if len(got) == 0 {
+ t.Error("StatsOrderList(\"\"): got empty order")
+ }
+ // Custom order: Host:Uptime first, then rest of default.
+ got, err = StatsOrderList("Host:Uptime")
+ if err != nil {
+ t.Fatalf("StatsOrderList(\"Host:Uptime\"): %v", err)
+ }
+ if len(got) == 0 {
+ t.Fatal("StatsOrderList(\"Host:Uptime\"): got empty")
+ }
+ if got[0].Category != CategoryHost || got[0].Metric != MetricUptime {
+ t.Errorf("StatsOrderList(\"Host:Uptime\")[0] = %v; want Host:Uptime", got[0])
+ }
+}
diff --git a/internal/goprecords/parse_test.go b/internal/goprecords/parse_test.go
new file mode 10064