summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--README.md2
-rw-r--r--cmd/goprecords/main.go37
-rw-r--r--guprecords.raku383
-rw-r--r--internal/goprecords/aggregate.go94
-rw-r--r--internal/goprecords/report.go26
-rw-r--r--internal/goprecords/types.go134
-rwxr-xr-xscripts/compare-with-raku.sh72
8 files changed, 155 insertions, 596 deletions
diff --git a/.gitignore b/.gitignore
index 83dd821..b60628f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,6 @@
# Built binary
/goprecords
+
+# Temporary test database
+/fixtures/test_import.db
diff --git a/README.md b/README.md
index 36dff64..4841a6b 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# goprecords - Global uptime records
-`goprecords` is a Go command-line program that generates uptime reports for hosts based on the input record files from `uptimed`. It supports importing records into SQLite and querying for reports, or reporting directly from a stats directory. (A Raku implementation, `guprecords.raku`, is also available in this repo for reference.)
+`goprecords` is a Go command-line program that generates uptime reports for hosts based on the input record files from `uptimed`. It supports importing records into SQLite and querying for reports, or reporting directly from a stats directory.
## Features
diff --git a/cmd/goprecords/main.go b/cmd/goprecords/main.go
index 7807d1f..c608f78 100644
--- a/cmd/goprecords/main.go
+++ b/cmd/goprecords/main.go
@@ -14,28 +14,26 @@ import (
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) > 1 && (os.Args[1] == "-version" || os.Args[1] == "--version") {
+ fmt.Println(version.Version)
+ return
}
- 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
- }
+ if len(os.Args) < 2 {
+ runReportFromFiles(nil)
+ return
}
- runReportFromFiles(os.Args[1:])
+ switch os.Args[1] {
+ case "import":
+ runImport(os.Args[2:])
+ case "query":
+ runQuery(os.Args[2:])
+ case "test":
+ runTests()
+ default:
+ runReportFromFiles(os.Args[1:])
+ }
}
func runImport(args []string) {
@@ -146,6 +144,9 @@ func runQuery(args []string) {
}
func runReportFromFiles(args []string) {
+ if args == nil {
+ 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")
diff --git a/guprecords.raku b/guprecords.raku
deleted file mode 100644
index acd92ca..0000000
--- a/guprecords.raku
+++ /dev/null
@@ -1,383 +0,0 @@
-#!/usr/bin/env raku
-
-use v6.d;
-
-enum Category <Host Kernel KernelMajor KernelName>;
-enum Metric <Boots Uptime Score Downtime Lifespan>;
-enum OutputFormat <Plaintext Markdown Gemtext>;
-subset MetricSubset of Metric where * ne any (Downtime, Lifespan);
-
-our constant %DESCRIPTION = {
- Boots => 'Boots is the total number of host boots over the entire lifespan.',
- Uptime => 'Uptime is the total uptime of a host over the entire lifespan.',
- Downtime => 'Downtime is the total downtime of a host over the entire lifespan.',
- Lifespan => 'Lifespan is the total uptime + the total downtime of a host.',
- Score => 'Score is calculated by combining all other metrics.',
-};
-
-our UInt constant DAY = 1 * 24 * 3600;
-our UInt constant MONTH = 30 * DAY;
-
-sub default-stats-order() {
- (Category.^enum_value_list X Metric.^enum_value_list).map({ $_[0] => $_[1] }).List;
-}
-
-sub parse-stats-order(Str:D $stats-order --> List) {
- my @entries = $stats-order.split(',').map(*.trim).grep(*.chars);
- die "Invalid --stats-order: empty list."
- if @entries.elems == 0;
-
- my @order;
- my %seen;
- for @entries -> $entry {
- my ($category-name, $metric-name) = $entry.split(':', 2);
- die "Invalid --stats-order entry '$entry' (expected Category:Metric)."
- if !$category-name.defined || !$metric-name.defined
- || $category-name.chars == 0 || $metric-name.chars == 0;
-
- my $category = ::("Category::$category-name");
- die "Invalid --stats-order category '$category-name'."
- unless $category.defined;
- my $metric = ::("Metric::$metric-name");
- die "Invalid --stats-order metric '$metric-name'."
- unless $metric.defined;
- die "Invalid --stats-order entry '$entry' (metric $metric-name not supported for category $category-name)."
- if $category !~~ Host && $metric !~~ MetricSubset;
-
- my $key = "{$category.Str}:{$metric.Str}";
- next if %seen{$key}++;
- @order.push: $category => $metric;
- }
-
- return @order;
-}
-
-sub stats-order(Str $stats-order? --> List) {
- my @default-order = default-stats-order();
- return @default-order unless $stats-order.defined;
-
- my @order = parse-stats-order($stats-order);
- my %seen;
- for @order -> $pair {
- %seen{"{$pair.key.Str}:{$pair.value.Str}"} = True;
- }
- for @default-order -> $pair {
- my $key = "{$pair.key.Str}:{$pair.value.Str}";
- next if %seen{$key}++;
- @order.push: $pair;
- }
-
- return @order;
-}
-
-class Epoch {
- has UInt $.value is required;
-
- submethod new (UInt $value) { self.bless: :$value }
-
- method human-duration returns Str {
- my DateTime \dt .= new: Instant.from-posix: $!value;
- "{dt.year-1970} years, {dt.month} months, {dt.day} days";
- }
-
- method human-date returns Str {
- DateTime.new(Instant.from-posix: $!value).yyyy-mm-dd;
- }
-
- method newer-than(UInt:D \limit --> Bool) {
- (DateTime.now - DateTime.new: Instant.from-posix: $!value) < limit * DAY;
- }
-}
-
-class Aggregate {
- has Str $.name is required;
- has UInt $.uptime;
- has UInt $.first-boot;
- has UInt $.last-seen;
- has UInt $.boots;
-
- method new (Str:D $name) { self.bless: :$name }
-
- method add-record(Str:D :$uptime, Str:D :$boot-time) {
- my $last-seen = $uptime + $boot-time;
- $!uptime += $uptime;
- $!boots++;
-
- $!first-boot = +$boot-time if not defined $!first-boot or $!first-boot > $boot-time;
- $!last-seen = $last-seen if not defined $!last-seen or $!last-seen < $last-seen;
- }
-
- method meta-score returns UInt {
- UInt((($!uptime * 2) + ($!boots * DAY) + (self.is-active ?? MONTH !! 0))/1000000)
- }
-
- method is-active(UInt:D \limit = 90 --> Bool) {
- Epoch.new($!last-seen).newer-than: limit;
- }
-}
-
-class HostAggregate is Aggregate {
- has Str $.last-kernel;
-
- method new (Str:D $name, Str:D $last-kernel) { self.bless: :$name, :$last-kernel }
- method lifespan returns UInt { $.last-seen - $.first-boot }
- method downtime returns UInt { self.lifespan - $.uptime }
- method meta-score returns UInt { UInt(self.downtime / 2000000) + callsame }
-}
-
-class Aggregator {
- has Hash %!aggregates = { Host => {}, Kernel => {}, KernelName => {}, KernelMajor => {} }
- has Str $.stats-dir is required;
-
- submethod new (Str:D $stats-dir) { self.bless: :$stats-dir }
-
- method aggregate () {
- self!add-file: $_ for dir $!stats-dir, test => { /.records$/ };
- return %!aggregates;
- }
-
- method !add-file(IO::Path:D $file) {
- return if $file.s == 0;
- my $host = $file.IO.basename.split('.').first;
-
- die "Record file for $host already processed - duplicate inputs?"
- if %!aggregates<Host>{$host}:exists;
-
- %!aggregates<Host>{$host} = HostAggregate.new: $host, self!last-kernel($file);
- self!add-line: :line($_), :$host for $file.IO.lines;
- }
-
- method !last-kernel(IO::Path:D $file) {
- $file.lines.map({ [.split(':')] }).max({ $_[1] }).[2]
- }
-
- method !add-line(Str:D :$line, Str:D :$host) {
- my ($uptime, $boot-time, $os) = $line.trim.split: ':';
- my $uname = $os.split(' ').first;
- my $os-major = "$uname {$os.split(' ')[1].split('.').first}...";
-
- %!aggregates<Kernel>{$os} //= Aggregate.new: $os;
- %!aggregates<KernelName>{$uname} //= Aggregate.new: $uname;
- %!aggregates<KernelMajor>{$os-major} //= Aggregate.new: $os-major;
-
- .add-record: :$uptime, :$boot-time
- for %!aggregates<Host>{$host}, %!aggregates<Kernel>{$os},
- %!aggregates<KernelName>{$uname}, %!aggregates<KernelMajor>{$os-major};
- }
-}
-
-role OutputHelper {
- has OutputFormat $.output-format is required;
- has UInt $.header-indent = 1;
-
- method output-header {
- ($.output-format ~~ any Markdown, Gemtext) ?? '#' x $.header-indent ~ ' ' !! ''
- }
-
- method output-trim(Str \str, UInt \line-limit --> Str) {
- if $.output-format ~~ Plaintext and str.chars > line-limit {
- return join '', gather {
- my $chars = 0;
- for str.split(' ') -> \word {
- if ($chars += word.chars + 1) > line-limit {
- take "\n" ~ word;
- $chars = word.chars;
- } else {
- take ' ' ~ word;
- }
- }
- }
- }
- return str;
- }
-
- method output-block {
- ($.output-format ~~ any Markdown, Gemtext) ?? "```\n" !! ''
- }
-}
-
-class Reporter does OutputHelper {
- has Hash %.aggregates is required;
- has UInt $.limit is required;
- has Category $.category = Host;
- has Metric $.metric is required;
-
- method report returns Str {
- join '', gather {
- with self!table -> (@table, %size) {
- my $format = '|' ~ join '|',
- " %{%size<count>}s "," %{%size<name>}s "," %{%size<value>}s ";
- my $border = '+' ~ join '+',
- '-' x (2+%size<count>), '-' x (2+%size<name>), '-' x (2+%size<value>);
-
- # Works only for HostReporter reports
- my \last-kernel-header = 'Last Kernel';
- if %size<last-kernel> > 0 {
- %size<last-kernel> = last-kernel-header.chars
- if %size<last-kernel> < last-kernel-header.chars;
- $format ~= "| %{%size<last-kernel>}s ";
- $border ~= '+' ~ '-' x (2+%size<last-kernel>);
- }
-
- $format ~= "|\n" and $border ~= "+\n";
-
- take
- "{self.output-header}Top {$.limit} {$.metric}'s by {$.category}\n\n",
- self.output-trim(%DESCRIPTION{$.metric}, $border.chars), "\n\n",
- self.output-block, $border,
- (%size<last-kernel> > 0
- ?? sprintf($format, 'Pos', $.category, $.metric, last-kernel-header)
- !! sprintf($format, 'Pos', $.category, $.metric)),
- $border;
-
- for @table -> \position, \name, \value, \last-kernel {
- if last-kernel.chars > 0 {
- take sprintf $format, position, name, value, last-kernel;
- } else {
- take sprintf $format, position, name, value;
- }
- }
-
- take $border, self.output-block;
- }
- }
- }
-
- method !table returns List {
- my $count = 0;
- my @table;
-
- # Initial table size
- my %size =
- :count('Pos'.chars), :name($.category.chars),
- :value($.metric.chars), :last-kernel(0);
-
- for self.sort-by($.metric) -> Aggregate \what {
- my \active = what.is-active ?? '*' !! ' ';
- my \name = active ~ what.name;
- my \value = self.human-str($.metric, what).Str;
- my $last-kernel = '';
-
- if what ~~ HostAggregate {
- my HostAggregate $ha = what;
- $last-kernel = $ha.last-kernel;
- }
-
- # Adjust size
- %size{.key} = .value if %size{.key} < .value
- for :count($count.Str.chars+1), :name(name.chars),
- :value(value.chars), :last-kernel($last-kernel.chars);
-
- @table.push: "{$count+1}.", name, value, $last-kernel;
- last if ++$count == $.limit;
- }
-
- return @table, %size;
- }
- multi method sort-by(Uptime) { self.sort-by: *.uptime }
- multi method sort-by(Boots) { self.sort-by: *.boots }
- multi method sort-by(Score) { self.sort-by: *.meta-score }
-
- multi method sort-by(Code:D $sort-by) {
- %!aggregates{$!category}.values.sort(&$sort-by).reverse;
- }
-
- multi method human-str(Uptime, Aggregate:D $what) { Epoch.new($what.uptime).human-duration }
- multi method human-str(Boots, Aggregate:D $what) { $what.boots }
- multi method human-str(Score, Aggregate:D $what) { $what.meta-score }
-}
-
-class HostReporter is Reporter {
- multi method sort-by(Downtime) { self.sort-by: *.downtime }
- multi method sort-by(Lifespan) { self.sort-by: *.lifespan }
-
- multi method human-str(Downtime, Aggregate:D $what) { Epoch.new($what.downtime).human-duration }
- multi method human-str(Lifespan, Aggregate:D $what) { Epoch.new($what.lifespan).human-duration }
-}
-
-multi MAIN(
- Str :$stats-dir is required, #= The uptimed raw record input dir.
- Category :$category = Host, #= The category, one of Host, Kernel, KernelMajor, KernelName [default: 'Host']
- Metric :$metric = Uptime, #= The metric, one of Boots, Uptime, Score, Downtime, Lifespan
- UInt :$limit = 20, #= Limit output to num of entries.
- OutputFormat :$output-format = Plaintext, #= Output format.
-) {
- my Hash %aggregates = Aggregator.new($stats-dir).aggregate;
-
- if $category ~~ Host {
- print HostReporter.new(:%aggregates, :$metric, :$limit, :$output-format).report;
- } elsif $metric ~~ MetricSubset {
- print Reporter.new(:%aggregates, :$category, :$metric, :$limit, :$output-format).report;
- } else {
- die "Category $category only supports the following metrics: {Metric.^enum_value_list.grep: * ~~ MetricSubset}";
- }
-}
-
-multi MAIN(
- Str :$stats-dir is required,
- Bool :$all, #= Generate all possible stats but Kernel (too verbose)
- Bool :$include-kernel, #= Also include Kernel
- Str :$stats-order, #= Comma-separated Category:Metric order for --all
- UInt :$limit = 20,
- OutputFormat :$output-format = Plaintext,
-) {
- my $header-indent = 2;
- my %aggregates = Aggregator.new($stats-dir).aggregate;
-
- my @stats-order = stats-order($stats-order);
-
- for @stats-order -> $entry {
- my $category = $entry.key;
- my $metric = $entry.value;
- next if !$include-kernel and $category ~~ Kernel;
- next if $category !~~ Host and $metric !~~ MetricSubset;
- if $category ~~ Host {
- print HostReporter.new(:%aggregates, :$metric, :$limit, :$output-format, :$header-indent).report
- } else {
- print Reporter.new(:%aggregates, :$category, :$metric, :$limit, :$output-format, :$header-indent).report;
- }
- say '';
- }
-}
-
-multi MAIN('test') {
- use Test;
-
- my @cross-product = gather {
- for Category.^enum_value_list X Metric.^enum_value_list X OutputFormat.^enum_value_list
- -> ($category, $metric, $output-format) {
- next if $category !~~ Host and $metric !~~ MetricSubset;
- take $category, $metric, $output-format;
- }
- }
-
- plan @cross-product + 1;
- my $limit = 3;
- my %aggregates = Aggregator.new('./fixtures').aggregate;
-
- for @cross-product -> ($category, $metric, $output-format) {
- my \reporter = $category ~~ Host
- ?? HostReporter.new: :%aggregates, :$metric, :$limit, :$output-format
- !! Reporter.new: :%aggregates, :$category, :$metric, :$limit, :$output-format;
- #my $fh = open "./fixtures/$category.$metric.$output-format.expected", :w;
- #$fh.print(reporter.report);
- #$fh.close;
- is reporter.report, "./fixtures/$category.$metric.$output-format.expected".IO.slurp;
- }
-
- subtest 'stats-order parsing' => {
- plan 6;
- my @order = parse-stats-order('Host:Uptime,Host:Boots');
- is-deeply @order.map({ [.key, .value] }).Array, [[Host, Uptime], [Host, Boots]],
- 'parses order list';
- my @merged = stats-order('Host:Uptime');
- is-deeply [@merged[0].key, @merged[0].value], [Host, Uptime],
- 'custom order first entry';
- dies-ok { parse-stats-order('Host') }, 'invalid format';
- dies-ok { parse-stats-order('Bad:Uptime') }, 'invalid category';
- dies-ok { parse-stats-order('Kernel:Downtime') }, 'invalid metric for category';
- dies-ok { parse-stats-order('Host:Nope') }, 'invalid metric';
- }
-
- done-testing;
-}
diff --git a/internal/goprecords/aggregate.go b/internal/goprecords/aggregate.go
index 3fb9144..ba446cb 100644
--- a/internal/goprecords/aggregate.go
+++ b/internal/goprecords/aggregate.go
@@ -61,53 +61,61 @@ func (ag *Aggregator) Aggregate(ctx context.Context) (*Aggregates, error) {
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)
+ if err := processRecordsFile(ctx, path, host, out); err != nil {
+ return nil, 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)
+ }
+ return out, nil
+}
+
+func processRecordsFile(ctx context.Context, path, host string, out *Aggregates) error {
+ f, err := os.Open(path)
+ if err != nil {
+ return fmt.Errorf("open %s: %w", path, err)
+ }
+ defer f.Close()
+
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
}
- if err := sc.Err(); err != nil {
- return nil, fmt.Errorf("scan %s: %w", path, err)
+ 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)
}
- return out, nil
+ if err := sc.Err(); err != nil {
+ return fmt.Errorf("scan %s: %w", path, err)
+ }
+ return nil
}
func getOrNewAggregate(m map[string]*Aggregate, name string) *Aggregate {
diff --git a/internal/goprecords/report.go b/internal/goprecords/report.go
index af61b29..01ca570 100644
--- a/internal/goprecords/report.go
+++ b/internal/goprecords/report.go
@@ -34,7 +34,7 @@ func NewHostReporter(aggregates *Aggregates, limit uint, metric Metric, outputFo
}
// Report returns the formatted report string.
-func (r *Reporter) Report() string {
+func (r Reporter) Report() string {
var rows []tableRow
var hasLastKernel bool
if r.category == CategoryHost {
@@ -48,7 +48,7 @@ func (r *Reporter) Report() string {
return r.formatReport(rows, hasLastKernel)
}
-func (r *Reporter) buildHostTable() ([]tableRow, bool) {
+func (r Reporter) buildHostTable() ([]tableRow, bool) {
type keyVal struct {
agg *HostAggregate
key uint64
@@ -93,7 +93,7 @@ func (r *Reporter) buildHostTable() ([]tableRow, bool) {
return rows, true
}
-func (r *Reporter) buildCategoryTable() ([]tableRow, bool) {
+func (r Reporter) buildCategoryTable() ([]tableRow, bool) {
m := r.aggregates.Kernel
switch r.category {
case CategoryKernelMajor:
@@ -140,7 +140,7 @@ func (r *Reporter) buildCategoryTable() ([]tableRow, bool) {
return rows, false
}
-func (r *Reporter) humanStrHost(h *HostAggregate) string {
+func (r Reporter) humanStrHost(h *HostAggregate) string {
switch r.metric {
case MetricUptime:
return formatDuration(h.Uptime)
@@ -157,7 +157,7 @@ func (r *Reporter) humanStrHost(h *HostAggregate) string {
}
}
-func (r *Reporter) humanStrAgg(a *Aggregate) string {
+func (r Reporter) humanStrAgg(a *Aggregate) string {
switch r.metric {
case MetricUptime:
return formatDuration(a.Uptime)
@@ -170,7 +170,7 @@ func (r *Reporter) humanStrAgg(a *Aggregate) string {
}
}
-func (r *Reporter) formatReport(rows []tableRow, hasLastKernel bool) string {
+func (r Reporter) formatReport(rows []tableRow, hasLastKernel bool) string {
cW, nW, vW, lkW := r.reportWidths(rows, hasLastKernel)
border := r.buildBorder(cW, nW, vW, lkW, hasLastKernel)
header := r.buildReportHeader(cW, nW, vW, lkW, hasLastKernel, border)
@@ -183,7 +183,7 @@ func (r *Reporter) formatReport(rows []tableRow, hasLastKernel bool) string {
return out
}
-func (r *Reporter) reportWidths(rows []tableRow, hasLastKernel bool) (countW, nameW, valueW, lastKernelW int) {
+func (r Reporter) reportWidths(rows []tableRow, hasLastKernel bool) (countW, nameW, valueW, lastKernelW int) {
countW = 3
nameW = len(r.category.String())
valueW = len(r.metric.String())
@@ -207,7 +207,7 @@ func (r *Reporter) reportWidths(rows []tableRow, hasLastKernel bool) (countW, na
return countW, nameW, valueW, lastKernelW
}
-func (r *Reporter) buildBorder(countW, nameW, valueW, lastKernelW int, hasLastKernel bool) string {
+func (r Reporter) buildBorder(countW, nameW, valueW, lastKernelW int, hasLastKernel bool) string {
parts := []string{
"+" + strings.Repeat("-", 2+countW),
"+" + strings.Repeat("-", 2+nameW),
@@ -219,7 +219,7 @@ func (r *Reporter) buildBorder(countW, nameW, valueW, lastKernelW int, hasLastKe
return strings.Join(parts, "") + "+\n"
}
-func (r *Reporter) buildReportHeader(countW, nameW, valueW, lastKernelW int, hasLastKernel bool, border string) string {
+func (r Reporter) buildReportHeader(countW, nameW, valueW, lastKernelW int, hasLastKernel bool, border string) string {
var h string
if r.outputFormat == FormatMarkdown || r.outputFormat == FormatGemtext {
h = strings.Repeat("#", int(r.headerIndent)) + " "
@@ -227,8 +227,8 @@ func (r *Reporter) buildReportHeader(countW, nameW, valueW, lastKernelW int, has
h += fmt.Sprintf("Top %d %s's by %s\n\n", r.limit, r.metric, r.category)
desc := MetricDescription(r.metric)
lineLimit := len(border)
- if r.outputFormat == FormatPlaintext && lineLimit > 0 && len(desc) > lineLimit {
- desc = " " + wordWrap(desc, lineLimit)
+ if r.outputFormat == FormatPlaintext && lineLimit > 0 && len(desc) > lineLimit-1 {
+ desc = " " + wordWrap(desc, lineLimit-1)
}
h += desc + "\n\n"
if r.outputFormat == FormatMarkdown || r.outputFormat == FormatGemtext {
@@ -245,14 +245,14 @@ func (r *Reporter) buildReportHeader(countW, nameW, valueW, lastKernelW int, has
return h
}
-func (r *Reporter) buildFormatStr(countW, nameW, valueW, lastKernelW int, hasLastKernel bool) string {
+func (r Reporter) buildFormatStr(countW, nameW, valueW, lastKernelW int, hasLastKernel bool) string {
if hasLastKernel {
return fmt.Sprintf("| %%%ds | %%%ds | %%%ds | %%%ds |", countW, nameW, valueW, lastKernelW)
}
return fmt.Sprintf("| %%%ds | %%%ds | %%%ds |", countW, nameW, valueW)
}
-func (r *Reporter) buildReportBody(rows []tableRow, fmtStr string, hasLastKernel bool) string {
+func (r Reporter) buildReportBody(rows []tableRow, fmtStr string, hasLastKernel bool) string {
var b strings.Builder
for _, row := range rows {
if hasLastKernel {
diff --git a/internal/goprecords/types.go b/internal/goprecords/types.go
index ce44df8..05a68f8 100644
--- a/internal/goprecords/types.go
+++ b/internal/goprecords/types.go
@@ -24,6 +24,65 @@ const (
CategoryKernelName
)
+// Metric is the value to rank by (Boots, Uptime, etc.).
+type Metric int
+
+const (
+ MetricBoots Metric = iota
+ MetricUptime
+ MetricScore
+ MetricDowntime
+ MetricLifespan
+)
+
+// OutputFormat is the report output format.
+type OutputFormat int
+
+const (
+ FormatPlaintext OutputFormat = iota
+ FormatMarkdown
+ FormatGemtext
+)
+
+// Epoch is a Unix timestamp for duration/date formatting.
+type Epoch uint64
+
+// Aggregate holds per-entity stats (Host, Kernel, etc.).
+type Aggregate struct {
+ Name string
+ Uptime uint64
+ FirstBoot uint64
+ LastSeen uint64
+ Boots uint64
+}
+
+// NewAggregate constructs an Aggregate with the given name.
+func NewAggregate(name string) *Aggregate {
+ return &Aggregate{Name: name}
+}
+
+// HostAggregate adds last-kernel and lifespan/downtime for host reports.
+type HostAggregate struct {
+ Aggregate
+ LastKernel string
+}
+
+// NewHostAggregate constructs a HostAggregate.
+func NewHostAggregate(name, lastKernel string) *HostAggregate {
+ return &HostAggregate{
+ Aggregate: Aggregate{Name: name},
+ LastKernel: lastKernel,
+ }
+}
+
+// tableRow is one row in the report table.
+type tableRow struct {
+ Pos string
+ Name string
+ Value string
+ LastKernel string
+}
+
// String returns the category name.
func (c Category) String() string {
switch c {
@@ -40,17 +99,6 @@ func (c Category) String() string {
}
}
-// Metric is the value to rank by (Boots, Uptime, etc.).
-type Metric int
-
-const (
- MetricBoots Metric = iota
- MetricUptime
- MetricScore
- MetricDowntime
- MetricLifespan
-)
-
// String returns the metric name.
func (m Metric) String() string {
switch m {
@@ -69,15 +117,6 @@ func (m Metric) String() string {
}
}
-// OutputFormat is the report output format.
-type OutputFormat int
-
-const (
- FormatPlaintext OutputFormat = iota
- FormatMarkdown
- FormatGemtext
-)
-
// String returns the format name.
func (f OutputFormat) String() string {
switch f {
@@ -92,9 +131,6 @@ func (f OutputFormat) String() string {
}
}
-// Epoch is a Unix timestamp for duration/date formatting.
-type Epoch uint64
-
// HumanDuration returns a human-readable duration from epoch (e.g. "1 years, 2 months, 3 days").
func (e Epoch) HumanDuration() string {
t := time.Unix(int64(e), 0).UTC()
@@ -110,20 +146,6 @@ func (e Epoch) NewerThan(limitDays uint) bool {
return time.Since(then) < time.Duration(limitDays)*24*time.Hour
}
-// Aggregate holds per-entity stats (Host, Kernel, etc.).
-type Aggregate struct {
- Name string
- Uptime uint64
- FirstBoot uint64
- LastSeen uint64
- Boots uint64
-}
-
-// NewAggregate constructs an Aggregate with the given name.
-func NewAggregate(name string) *Aggregate {
- return &Aggregate{Name: name}
-}
-
// AddRecord adds one uptime record.
func (a *Aggregate) AddRecord(uptimeSec, bootTime uint64) {
lastSeen := uptimeSec + bootTime
@@ -151,20 +173,6 @@ func (a *Aggregate) MetaScore() uint64 {
return ((a.Uptime*2 + a.Boots*uint64(Day) + activeBonus) / 1000000)
}
-// HostAggregate adds last-kernel and lifespan/downtime for host reports.
-type HostAggregate struct {
- Aggregate
- LastKernel string
-}
-
-// NewHostAggregate constructs a HostAggregate.
-func NewHostAggregate(name, lastKernel string) *HostAggregate {
- return &HostAggregate{
- Aggregate: Aggregate{Name: name},
- LastKernel: lastKernel,
- }
-}
-
// Lifespan returns last-seen minus first-boot.
func (h *HostAggregate) Lifespan() uint64 { return h.LastSeen - h.FirstBoot }
@@ -176,14 +184,6 @@ func (h *HostAggregate) MetaScore() uint64 {
return uint64(h.Downtime()/2000000) + h.Aggregate.MetaScore()
}
-// tableRow is one row in the report table.
-type tableRow struct {
- Pos string
- Name string
- Value string
- LastKernel string
-}
-
// MetricDescription returns the description text for a metric.
func MetricDescription(m Metric) string {
switch m {
@@ -257,22 +257,24 @@ func wordWrap(s string, lineLimit int) string {
var b strings.Builder
chars := 0
for _, word := range strings.Fields(s) {
- wlen := len(word)
- if chars > 0 {
- wlen++
+ wordLen := len(word)
+ needsSpace := chars > 0
+ totalLen := wordLen
+ if needsSpace {
+ totalLen++
}
- if chars+wlen > lineLimit {
+ if chars+totalLen > lineLimit {
if chars > 0 {
b.WriteByte('\n')
}
b.WriteString(word)
- chars = len(word)
+ chars = wordLen
} else {
- if chars > 0 {
+ if needsSpace {
b.WriteByte(' ')
}
b.WriteString(word)
- chars += wlen
+ chars += totalLen
}
}
return b.String()
diff --git a/scripts/compare-with-raku.sh b/scripts/compare-with-raku.sh
deleted file mode 100755
index 924cc85..0000000
--- a/scripts/compare-with-raku.sh
+++ /dev/null
@@ -1,72 +0,0 @@
-#!/bin/sh
-# Compare goprecords (Go) output with guprecords (Raku) on the same stats dir.
-# Usage: ./scripts/compare-with-raku.sh [stats-dir]
-# Default stats-dir: ../uprecords/stats (relative to repo root) or set UPRECORDS_STATS.
-
-set -e
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
-STATS="${1:-${UPRECORDS_STATS:-$REPO_ROOT/../uprecords/stats}}"
-RAKU_REPO="${GUPRECORDS_RAKU:-$REPO_ROOT/../guprecords}"
-
-if [ ! -d "$STATS" ]; then
- echo "Stats dir not found: $STATS" >&2
- echo "Usage: $0 [stats-dir]" >&2
- echo " or set UPRECORDS_STATS" >&2
- exit 1
-fi
-if [ ! -f "$RAKU_REPO/guprecords.raku" ]; then
- echo "Raku guprecords not found: $RAKU_REPO/guprecords.raku" >&2
- echo "Set GUPRECORDS_RAKU to the guprecords (Raku) repo." >&2
- exit 1
-fi
-
-GO_BIN="$REPO_ROOT/goprecords"
-if [ ! -x "$GO_BIN" ]; then
- echo "Build goprecords first (e.g. mage build)" >&2
- exit 1
-fi
-
-mkdir -p /tmp/goprecords-compare
-R=/tmp/goprecords-compare/raku
-G=/tmp/goprecords-compare/go
-
-echo "Stats dir: $STATS"
-echo "Raku repo: $RAKU_REPO"
-echo ""
-
-# Single report: Host Uptime
-echo "=== Host Uptime (limit 10) ==="
-raku "$RAKU_REPO/guprecords.raku" --stats-dir="$STATS" --category=Host --metric=Uptime --limit=10 --output-format=Plaintext 2>/dev/null > "$R.host_uptime.txt"
-"$GO_BIN" -stats-dir="$STATS" -category=Host -metric=Uptime -limit=10 -output-format=Plaintext 2>/dev/null > "$G.host_uptime.txt"
-if diff -q "$R.host_uptime.txt" "$G.host_uptime.txt" >/dev/null; then
- echo "OK (identical)"
-else
- diff -u "$R.host_uptime.txt" "$G.host_uptime.txt" || true
-fi
-
-# Single report: Host Boots, Markdown
-echo ""
-echo "=== Host Boots Markdown (limit 5) ==="
-raku "$RAKU_REPO/guprecords.raku" --stats-dir="$STATS" --category=Host --metric=Boots --limit=5 --output-format=Markdown 2>/dev/null > "$R.host_boots_md.txt"
-"$GO_BIN" -stats-dir="$STATS" -category=Host -metric=Boots -limit=5 -output-format=Markdown 2>/dev/null > "$G.host_boots_md.txt"
-if diff -q "$R.host_boots_md.txt" "$G.host_boots_md.txt" >/dev/null; then
- echo "OK (identical)"
-else
- diff -u "$R.host_boots_md.txt" "$G.host_boots_md.txt" || true
-fi
-
-# --all (known: description word-wrap may differ in one section)
-echo ""
-echo "=== --all limit 5 ==="
-raku "$RAKU_REPO/guprecords.raku" --stats-dir="$STATS" --all --limit=5 --output-format=Plaintext 2>/dev/null > "$R.all.txt"
-"$GO_BIN" -stats-dir="$STATS" -all -limit=5 -output-format=Plaintext 2>/dev/null > "$G.all.txt"
-if diff -q "$R.all.txt" "$G.all.txt" >/dev/null; then
- echo "OK (identical)"
-else
- echo "Differences (often only description word-wrap):"
- diff -u "$R.all.txt" "$G.all.txt" | head -40 || true
-fi
-
-echo ""
-echo "Done. Raku output: $R.*.txt Go output: $G.*.txt"