diff options
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | cmd/goprecords/main.go | 37 | ||||
| -rw-r--r-- | guprecords.raku | 383 | ||||
| -rw-r--r-- | internal/goprecords/aggregate.go | 94 | ||||
| -rw-r--r-- | internal/goprecords/report.go | 26 | ||||
| -rw-r--r-- | internal/goprecords/types.go | 134 | ||||
| -rwxr-xr-x | scripts/compare-with-raku.sh | 72 |
8 files changed, 155 insertions, 596 deletions
@@ -3,3 +3,6 @@ # Built binary /goprecords + +# Temporary test database +/fixtures/test_import.db @@ -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" |
