summaryrefslogtreecommitdiff
path: root/guprecords.raku
diff options
context:
space:
mode:
Diffstat (limited to 'guprecords.raku')
-rw-r--r--guprecords.raku383
1 files changed, 0 insertions, 383 deletions
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;
-}