#!/usr/bin/perl
#  dormando's awesome memcached top utility!
#
#  Copyright 2009 Dormando (dormando@rydia.net).  All rights reserved.
#
#  Use and distribution licensed under the BSD license.  See
#  the COPYING file for full text.

use strict;
use warnings FATAL => 'all';

use AnyEvent;
use AnyEvent::Socket;
use AnyEvent::Handle;
use Getopt::Long;
use YAML qw/Dump Load LoadFile/;
use Term::ReadKey qw/ReadMode ReadKey GetTerminalSize/;

our $VERSION = '0.1';

my $CLEAR     = `clear`;
my @TERM_SIZE = ();
$|++;

my %opts = ();
GetOptions(\%opts, 'help|h', 'config=s');

if ($opts{help}) {
    show_help(); exit;
}

$SIG{INT} = sub {
    ReadMode('normal');
    print "\n";
    exit;
};

# TODO: make this load from central location, and merge in homedir changes.
# then merge Getopt::Long stuff on top of that
# TODO: Set a bunch of defaults and merge in.
my $CONF = load_config();
my %CONS    = ();
my $LAST_RUN = time; # time after the last loop cycle.
my $TIME_SINCE_LAST_RUN = time; # time since last loop cycle.
my $loop_timer;
my $main_cond;
my $prev_stats_results;

my %display_modes = (
    't' => \&display_top_mode,
    '?' => \&display_help_mode,
    'h' => \&display_help_mode,
);

my %column_compute = (
    'hostname' => { stats => [], code => \&compute_hostname},
    'hit_rate' => { stats => ['get_hits', 'get_misses'],
                    code  => \&compute_hit_rate },
    'fill_rate' => { stats => ['bytes', 'limit_maxbytes'],
                    code => \&compute_fill_rate },
);

my %column_format = (
    'hit_rate' => \&format_percent,
    'fill_rate' => \&format_percent,
);

# This can collapse into %column_compute
my %column_format_totals = (
    'hit_rate' => 0,
    'fill_rate' => 0,
);

ReadMode('cbreak');
my $LAST_KEY = '';
my $read_keys = AnyEvent->io (
    fh => \*STDIN, poll => 'r',
    cb => sub {
        $LAST_KEY = ReadKey(-1);
        # If there is a running timer, cancel it.
        # Don't want to interrupt a main loop run.
        # fire_main_loop()'s iteration will pick up the keypress.
        if ($loop_timer) {
            $loop_timer = undef;
            $main_cond->send;
        }
    }
);

# start main loop
fire_main_loop();

### AnyEvent related code.

sub fire_main_loop {
    for (;;) {
        $loop_timer = undef;
        $main_cond = AnyEvent->condvar;
        my $time_taken = main_loop();
        my $delay = $CONF->{delay} - $time_taken;
        $delay = 0 if $delay < 0;
        $loop_timer = AnyEvent->timer(
            after => $delay,
            cb    => $main_cond,
        );
        $main_cond->recv;
    }
}

sub main_loop {
    my $start = AnyEvent->now; # use ->time to find the end.
    maintain_connections();

    my $cv = AnyEvent->condvar;

    # FIXME: Need to dump early if there're no connected conns
    # FIXME: Make this only fetch stats from cons we care to visualize?
    # maybe keep everything anyway to maintain averages?
    my %stats_results = ();
    while (my ($hostname, $con) = each %CONS) {
        $cv->begin;
        call_stats($con, ['', 'items', 'slabs'], sub {
            $stats_results{$hostname} = shift;
            $cv->end;
        });
    }
    $cv->recv;

    # Short circuit since we don't have anything to compare to.
    unless ($prev_stats_results) {
        $prev_stats_results = \%stats_results;
        return $CONF->{delay};
    }

    # Semi-exact global time diff for stats that want to average
    # themselves per-second.
    my $this_run = AnyEvent->time;
    $TIME_SINCE_LAST_RUN = $this_run - $LAST_RUN;
    $LAST_RUN = $this_run;

    # Done all our fetches. Drive the display.
    display_run($prev_stats_results, \%stats_results);
    $prev_stats_results = \%stats_results;

    my $end  = AnyEvent->time;
    my $diff = $LAST_RUN - $start;
    print "loop took: $diff";
    return $diff;
}

sub maintain_connections {
    my $cv    = AnyEvent->condvar;

    $cv->begin (sub { shift->send });
    for my $host (@{$CONF->{servers}}) {
        next if $CONS{$host};
        $cv->begin;
        $CONS{$host} = connect_memcached($host, sub {
            if ($_[0] eq 'err') {
                print "Failed connecting to $host: ", $_[1], "\n";
                delete $CONS{$host};
            }
            $cv->end;
        });
    }
    $cv->end;

    $cv->recv;
}

sub connect_memcached {
    my ($fullhost, $cb)   = @_;
    my ($host, $port) = split /:/, $fullhost;

    my $con; $con = AnyEvent::Handle->new (
        connect => [$host => $port],
        on_connect => sub {
            $cb->('con');
        },
        on_connect_error => sub {
            $cb->('err', $!);
            $con->destroy;
        },
        on_eof   => sub {
            $cb->('err', $!);
            $con->destroy;
        },
    );
    return $con;
}

# Function's getting a little weird since I started optimizing it.
# As of my first set of production tests, this routine is where we spend
# almost all of our processing time.
sub call_stats {
    my ($con, $cmds, $cb) = @_;

    my $stats = {};
    my $num_types = @$cmds;

    my $reader; $reader = sub {
        my ($con, $results) = @_;
        {
            my %temp = ();
            for my $line (split(/\n/, $results)) {
                my ($k, $v) = (split(/\s+/, $line))[1,2];
                $temp{$k} = $v;
            }
            $stats->{$cmds->[0]} = \%temp;
        }
        shift @$cmds;
        unless (@$cmds) {
            # Out of commands to process, return goodies.
            $cb->($stats);
            return;
        }
    };

    for my $cmd (@$cmds) {
        $con->push_write('stats ' . $cmd . "\n");
        $stats->{$cmd} = {};
        $con->push_read(line => "END\r\n", $reader);
    }
}

### Compute routines

sub compute_hostname {
    return $_[0];
}

sub compute_hit_rate {
    my $s = $_[1];
    my $total = $s->{get_hits} + $s->{get_misses};
    return 'NA' unless $total;
    return $s->{get_hits} / $total;
}

sub compute_fill_rate {
    my $s = $_[1];
    return $s->{bytes} / $s->{limit_maxbytes};
}

sub format_column {
    my ($col, $val) = @_;
    my $res;
    $col =~ s/^all_//;
    if ($column_format{$col}) {
        if (ref($column_format{$col}) eq 'CODE') {
            return $column_format{$col}->($val);
        } else {
            return $val .= $column_format{$col};
        }
    } else {
        return format_commas($val);
    }
}

sub column_can_total {
    my $col = shift;
    $col =~ s/^all_//;
    return 1 unless exists $column_format_totals{$col};
    return $column_format_totals{$col};
}

### Display routines

# If there isn't a specific column type computer, see if we just want to
# look at the specific stat and return it.
# If column is a generic type and of 'all_cmd_get' format, return the more
# complete stat instead of the diffed stat.
sub compute_column {
    my ($col, $host, $prev_stats, $curr_stats) = @_;
    my $diff_stats = 1;
    $diff_stats    = 0 if ($col =~ s/^all_//);

    # Really should decide on whether or not to flatten the hash :/
    my $find_stat = sub {
        for my $type (keys %{$_[0]}) {
            return $_[0]->{$type}->{$_[1]} if exists $_[0]->{$type}->{$_[1]};
        }
    };

    my $diff_stat = sub {
        my $stat = shift;
        return 'NA' unless defined $find_stat->($curr_stats, $stat);
        if ($diff_stats) {
            my $diff = eval {
                return ($find_stat->($curr_stats, $stat)
                       - $find_stat->($prev_stats, $stat))
                       / $TIME_SINCE_LAST_RUN;
            };
            return 'NA' if ($@);
            return $diff;
        } else {
            return $find_stat->($curr_stats, $stat);
        }
    };

    if (my $comp = $column_compute{$col}) {
        my %s = ();
        for my $stat (@{$comp->{stats}}) {
            $s{$stat} = $diff_stat->($stat);
        }
        return $comp->{code}->($host, \%s);
    } else {
        return $diff_stat->($col);
    }
    return 'NA';
}

# We have a bunch of stats from a bunch of connections.
# At this point we run a particular display mode, capture the lines, then
# truncate and display them.
sub display_run {
    my $prev_stats = shift;
    my $curr_stats = shift;
    @TERM_SIZE = GetTerminalSize;
    die "cannot detect terminal size" unless $TERM_SIZE[0] && $TERM_SIZE[1];

    if ($LAST_KEY eq 'q') {
        print "\n";
        ReadMode('normal'); exit;
    }

    if ($LAST_KEY ne $CONF->{mode} && exists $display_modes{$LAST_KEY}) {
        $CONF->{prev_mode} = $CONF->{mode};
        $CONF->{mode} = $LAST_KEY;
    } elsif ($CONF->{mode} eq 'h' || $CONF->{mode} eq '?') {
        # Bust out of help mode on any key.
        $CONF->{mode} = $CONF->{prev_mode};
    }
    my $lines = $display_modes{$CONF->{mode}}->($prev_stats, $curr_stats);
    display_lines($lines) if $lines;
}

# Default "top" mode.
# create a set of computed columns as requested by the config.
# this has gotten a little out of hand... needs more cleanup/abstraction.
sub display_top_mode {
    my $prev_stats = shift;
    my $curr_stats = shift;

    my @columns = @{$CONF->{top_mode}->{columns}};
    my @rows    = ();
    my @tot_row = ();

    # Round one.
    for my $host (sort keys %{$curr_stats}) {
        my @row = ();
        for my $colnum (0 .. @columns-1) {
            my $col = $columns[$colnum];
            my $res = compute_column($col, $host, $prev_stats->{$host},
                      $curr_stats->{$host});
            $tot_row[$colnum] += $res if is_numeric($res);
            push @row, $res;
        }
        push(@rows, \@row);
    }

    # Sort rows by sort column (ascending or descending)
    if (my $sort = $CONF->{top_mode}->{sort_column}) {
        my $order  = $CONF->{top_mode}->{sort_order} || 'asc';
        my $colnum = 0;
        for (0 .. @columns-1) { $colnum = $_ if $columns[$_] eq $sort; }
        my @newrows;
        if ($order eq 'asc') {
            if (is_numeric($rows[0]->[$colnum])) {
                @newrows = sort { $a->[$colnum] <=> $b->[$colnum] } @rows;
            } else {
                @newrows = sort { $a->[$colnum] cmp $b->[$colnum] } @rows;
            }
        } else {
            if (is_numeric($rows[0]->[$colnum])) {
                @newrows = sort { $b->[$colnum] <=> $a->[$colnum] } @rows;
            } else {
                @newrows = sort { $b->[$colnum] cmp $a->[$colnum] } @rows;
            }
        }
        @rows = @newrows;
    }

    # Format each column after the sort...
    {
        my @newrows = ();
        for my $row (@rows) {
            my @newrow = ();
            for my $colnum (0 .. @columns-1) {
                push @newrow, is_numeric($row->[$colnum]) ?
                            format_column($columns[$colnum], $row->[$colnum]) :
                            $row->[$colnum];
            }
            push @newrows, \@newrow;
        }
        @rows = @newrows;
    }

    # Create average and total rows.
    my @avg_row = ();
    for my $col (0 .. @columns-1) {
        if (is_numeric($tot_row[$col])) {
            my $countable_rows = 0;
            for my $row (@rows) {
                next unless $row->[$col];
                $countable_rows++ unless $row->[$col] eq 'NA';
            }
            $countable_rows = 1 unless $countable_rows;
            push @avg_row, format_column($columns[$col],
                 sprintf('%.2f', $tot_row[$col] / $countable_rows));
        } else {
            push @avg_row, 'NA';
        }
        $tot_row[$col] = 'NA' unless defined $tot_row[$col];
        $tot_row[$col] = 'NA' unless (column_can_total($columns[$col]));
        $tot_row[$col] = format_column($columns[$col], $tot_row[$col])
                         unless $tot_row[$col] eq 'NA';
    }
    unshift @rows, \@avg_row;
    unshift @rows, ['AVERAGE:'];
    unshift @rows, \@tot_row;
    unshift @rows, ['TOTAL:'];

    # Round two. Pass @rows into a function which returns an array with the
    # desired format spacing for each column.
    unshift @rows, \@columns;
    my $spacing = find_optimal_spacing(\@rows);

    my @display_lines = ();
    for my $row (@rows) {
        my $line = '';
        for my $col (0 .. @$row-1) {
            my $space = $spacing->[$col];
            $line .= sprintf("%-${space}s ", $row->[$col]);
        }
        push @display_lines, $line;
    }

    return \@display_lines;
}

sub display_help_mode {
    my $help = <<"ENDHELP";

dormando's awesome memcached top utility version v$VERSION

This early version requires you to edit the ~/.damemtop/damemtop.yaml
(or /etc/damemtop.yaml) file in order to change options.
See --help for more info.

Hit any key to exit help.
ENDHELP
    my @lines = split /\n/, $help;
    display_lines(\@lines);
    $LAST_KEY = ReadKey(0);
    return;
}

# Takes a set of lines, clears screen, dumps header, trims lines, etc
# MAYBE: mode to wrap lines instead of trim them?
sub display_lines {
    my $lines = shift;

    my $width         = $TERM_SIZE[0];
    my $height_remain = $TERM_SIZE[1];

    unshift @$lines, display_header($width);
    clear_screen() unless $CONF->{no_clear};

    while (--$height_remain && @$lines) {
        # truncate too long lines.
        my $line = shift @$lines;
        $line = substr $line, 0, $width-1;
        print $line, "\n";
    }
}

sub display_header {
    my $topbar = 'damemtop: ' . scalar localtime;
    if ($CONF->{mode} eq 't' && $CONF->{top_mode}->{sort_column}) {
        $topbar .= ' [sort: ' . $CONF->{top_mode}->{sort_column} . ']';
    }
    $topbar .= ' [delay: ' . $CONF->{delay} . 's]';
    return $topbar;
}

### Utilities

# find the optimal format spacing for each column, which is:
# longest length of item in col + 2 (whitespace).
sub find_optimal_spacing {
    my $rows  = shift;
    my @maxes = ();

    my $num_cols = @{$rows->[0]};
    for my $row (@$rows) {
        for my $col (0 .. $num_cols-1) {
            $maxes[$col] = 0 unless $maxes[$col];
            next unless $row->[$col];
            $maxes[$col] = length($row->[$col])
                if length($row->[$col]) > $maxes[$col];
        }
    }
    for my $col (0 .. $num_cols) {
        $maxes[$col] += 1;
    }

    return \@maxes;
}

# doesn't try too hard to identify numbers...
sub is_numeric {
    return 0 unless $_[0];
    return 1 if $_[0] =~ m/^\d+(\.\d*)?(\w+)?$/;
    return 0;
}

sub format_percent {
    return sprintf("%.2f%%", $_[0] * 100);
}

sub format_commas {
    my $num = shift;
    $num = int($num);
    $num =~ s/(^[-+]?\d+?(?=(?>(?:\d{3})+)(?!\d))|\G\d{3}(?=\d))/$1,/g;
    return $num;
}

# Can tick counters/etc here as well.
sub clear_screen {
    print $CLEAR;
}

# tries minimally to find a localized config file.
# TODO: Handle the YAML error and make it prettier.
sub load_config {
    my $config = $opts{config} if $opts{config};
    my $homedir = "$ENV{HOME}/.damemtop/damemtop.yaml";
    if (-e $homedir) {
        $config = $homedir;
    } else {
        $config = '/etc/damemtop.yaml';
    }
    return LoadFile($config);
}

sub show_help {
    print <<"ENDHELP";
dormando's awesome memcached top utility version v$VERSION

This program is copyright (c) 2009 Dormando.
Use and distribution licensed under the BSD license.  See
the COPYING file for full text.

contact: dormando\@rydia.net or memcached\@googlegroups.com.

This early version requires you to edit the ~/.damemtop/damemtop.yaml
(or /etc/damemtop.yaml) file in order to change options.

You may display any column that is in the output of
'stats', 'stats items', or 'stats slabs' from memcached's ASCII protocol.
Start a column with 'all_' (ie; 'all_get_hits') to display the current stat,
otherwise the stat is displayed as an average per second.

Specify a "sort_column" under "top_mode" to sort the output by any column.

Some special "computed" columns exist:
hit_rate (get/miss hit ratio)
fill_rate (% bytes used out of the maximum memory limit)
ENDHELP
    exit;
}
