#!/usr/bin/perl -w

# mysqlreport v2.5 Sep 1 2006
# http://hackmysql.com/mysqlreport

# mysqlreport makes a friendly report of important MySQL status values.
# Copyright (C) 2006  Daniel Nichter
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# The GNU General Public License is available at:
# http://www.gnu.org/copyleft/gpl.html

use strict;
use File::Temp qw(tempfile);
use DBI;
use Getopt::Long;
eval { require Term::ReadKey; };
my $RK = ($@ ? 0 : 1);

sub have_op;

my $WIN = ($^O eq 'MSWin32' ? 1 : 0);
my %op;
my %mycnf; # ~/.my.cnf
   $mycnf{'user'} = $ENV{USER};
   $mycnf{'pass'} = '';
my ($tmpfile_fh, $tmpfile);
my ($stat_name, $stat_val, $stat_label);
my ($major, $minor, $patch, $x); # MySQL version
my (%stats, %vars); # SHOW STATUS, SHOW VARIABLES
my (%DMS_vals, %Com_vals);
my ($dbh, $query);
my ($questions, $key_read_ratio, $key_write_ratio, $dms);
my ($key_cache_block_size, $key_buffer_used, $key_buffer_usage);
my ($qc_mem_used, $qc_hi_r, $qc_ip_r); # Query Cache

GetOptions (
   \%op,
   "user=s",
   "password:s",
   "host=s",
   "port=s",
   "socket=s",
   "no-mycnf",
   "dtq|tdq|dq",
   "dms",
   "com:i",
   "sas",
   "tab",
   "qcache",
   "all",
   "infile=s",
   "outfile=s",
   "flush-status",
   "email=s",
   "help|?"
);

show_help_and_exit() if $op{'help'};

$op{'host'} = 'localhost' if !have_op 'host';
$op{'port'} ||= 3306;
$op{'socket'} ||= '/var/run/mysqld/mysqld.sock';

get_user_mycnf() unless $op{'no-mycnf'};

$mycnf{'user'} = $op{'user'} if have_op 'user';

if(exists $op{'password'})
{
   if($op{'password'} eq '') # Prompt for password
   {
      Term::ReadKey::ReadMode(2) if $RK;
      print "Password for database user $mycnf{'user'}: ";
      chomp($mycnf{'pass'} = <STDIN>);
      Term::ReadKey::ReadMode(0), print "\n" if $RK;
   }
   else { $mycnf{'pass'} = $op{'password'}; } # Use password given on command line
}

if($op{'all'} and not defined $op{'com'}) { $op{'com'} = 3; };
if(defined $op{'com'} and $op{'com'} == 0) { $op{'com'} = 3; }

# Connect to MySQL
if(!have_op 'infile') {
   $dbh = DBI->connect("dbi:mysql::$op{host};port=$op{port};mysql_socket=$op{socket}", $mycnf{'user'}, $mycnf{'pass'}) or die;
}

# The report is written to a tmp file first.
# Later it will be moved to $op{'outfile'} or emailed $op{'email'} if needed.
($tmpfile_fh, $tmpfile) = tempfile() or die("Can't open temporary file for writing: $!\n");

# Get status values and server system variables from MySQL or -infile
get_vals_vars();

$questions = $stats{'Questions'};
$key_read_ratio  = sprintf "%.2f", ($stats{'Key_read_requests'} ? $stats{'Key_reads'} / $stats{'Key_read_requests'} : 0);
$key_write_ratio = sprintf "%.2f", ($stats{'Key_write_requests'} ? $stats{'Key_writes'} / $stats{'Key_write_requests'} : 0);

$key_cache_block_size = (defined $vars{'key_cache_block_size'} ? $vars{'key_cache_block_size'} : 1024);
$key_buffer_used = $stats{'Key_blocks_used'} * $key_cache_block_size;

if(defined $stats{'Key_blocks_unused'}) { # MySQL 4.1.2+
   $key_buffer_usage =  $vars{'key_buffer_size'} - ($stats{'Key_blocks_unused'} * $key_cache_block_size);
}
else { $key_buffer_usage = -1; }

# Data Manipulation Statements: http://dev.mysql.com/doc/mysql/en/Data_Manipulation.html
%DMS_vals = (
   SELECT  => $stats{'Com_select'},
   INSERT  => $stats{'Com_insert'}  + $stats{'Com_insert_select'},
   REPLACE => $stats{'Com_replace'} + $stats{'Com_replace_select'},
   UPDATE  => $stats{'Com_update'}  + (exists $stats{'Com_update_multi'} ? $stats{'Com_update_multi'} : 0),
   DELETE  => $stats{'Com_delete'}  + (exists $stats{'Com_delete_multi'} ? $stats{'Com_delete_multi'} : 0)
);

$dms = $DMS_vals{SELECT} + $DMS_vals{INSERT} + $DMS_vals{REPLACE} + $DMS_vals{UPDATE} + $DMS_vals{DELETE};

# Write the report
select $tmpfile_fh;
$~ = 'KEY_BUFF_MAX', write;
if($key_buffer_usage != -1)     { $~ = 'KEY_BUFF_USAGE', write }
$~ = 'KEY_RATIOS', write;
if($op{'dtq'}    or $op{'all'}) { write_DTQ(); }
$~ = 'SLOW_DMS', write;
if($op{'dms'}    or $op{'all'}) { write_DMS(); }
if($op{'com'}    or $op{'all'}) { write_Com(); }
if($op{'sas'}    or $op{'all'}) { $~ = 'SAS', write; }
if($op{'qcache'} or $op{'all'}) { write_qcache(); }
$~ = 'REPORT_END', write;
$~ = 'TAB', write if $op{'tab'};

close $tmpfile_fh and select STDOUT;

email_report($tmpfile) if have_op 'email';

cat_report($tmpfile);

if(have_op 'outfile')
{
   if($WIN) { `move $tmpfile $op{outfile}`; }
   else     { `mv $tmpfile $op{outfile}`;   }
}
else
{
   if($WIN) { `del $tmpfile`;   }
   else     { `rm -f $tmpfile`; }
}

if(!have_op 'infile')
{
   if($op{'flush-status'})
   {
      $query = $dbh->prepare("FLUSH STATUS;");
      $query->execute();
   }

   $query->finish();
   $dbh->disconnect();
}

exit;

#
# Subroutines
#
sub show_help_and_exit
{
   print <<"HELP";
mysqlreport v2.5 Sep 1 2006
mysqlreport makes a friendly report of important MySQL status values.

Command line options (abbreviations work):
   --user USER     Connect to MySQL as USER
   --password PASS Use PASS or prompt for MySQL user's password
   --host ADDRESS  Connect to MySQL at ADDRESS
   --port PORT     Connect to MySQL at PORT
   --socket SOCKET Connect to MySQL at SOCKET
   --no-mycnf      Don't read ~/.my.cnf
   --dtq           Show Distribution of Total Questions
   --dms           Show DMS details
   --com N         Show top N number of non-DMS questions
   --sas           Show SELECT and Sort report
   --tab           Show Thread, Aborts, and Bytes reports
   --qcache        Show Query Cache report
   --all           Show --dms --com 3 --sas --qcache
   --infile FILE   Read status values from FILE instead of MySQL
   --outfile FILE  Write report to FILE
   --email ADDRESS Email report to ADDRESS (doesn't work on Windows)
   --flush-status  Issue FLUSH STATUS; after getting current values
   --help          Prints this

Visit http://hackmysql.com/mysqlreport for more information.
HELP

   exit;
}

sub get_user_mycnf
{
   return if $WIN;
   open MYCNF, "$ENV{HOME}/.my.cnf" or return;
   while(<MYCNF>) { $mycnf{$1} = $2 if /^(.+?)\s*=\s*"?(.+?)"?\s*$/; }
   close MYCNF;
}

sub get_vals_vars
{
   if(not defined $op{'infile'})
   {
      my @row;

      # Determine MySQL version
      $query = $dbh->prepare("SHOW VARIABLES LIKE 'version';");
      $query->execute();
      @row = $query->fetchrow_array();

      ($major, $minor, $patch) = ($row[1] =~ /(\d{1,2})\.(\d{1,2})\.(\d{1,2})/);

      if($major == 5 && (($minor == 0 && $patch >= 2) || $minor > 0))
      {
             $query = $dbh->prepare("SHOW GLOBAL STATUS;");
      }
      else { $query = $dbh->prepare("SHOW STATUS;"); }
      
      # Get status values
      $query->execute();
      while(@row = $query->fetchrow_array()) { $stats{$row[0]} = $row[1]; }

      # Get server system variables
      $query = $dbh->prepare("SHOW VARIABLES;");
      $query->execute();
      while(@row = $query->fetchrow_array()) { $vars{$row[0]} = $row[1]; }
   }
   else
   {
      # Default values if not set in INFILE
      $vars{'version'} = "0.0.0";
      $vars{'table_cache'} = 64;
      $vars{'max_connections'} = 100;
      # key_cache_block_size
      $vars{'key_buffer_size'} = 8388600; # 8M
      # query_cache_size

      open INFILE, "< $op{infile}" or die;
      while(<INFILE>)
      {
         next if /^\+\-/;
         next if /^$/;

         chomp;

         if(/([A-Za-z_]+)[\s|]+(\d+)/)
         {
            $stats{$1} = $2;
            next; 
         }

         # Explicit var = val (e.g. key_buffer_size = 128M)
         $vars{$1} = ($3 ? $2 * 1024 * 1024 : $2) and next if(/^\s*(\w+)\s*=\s*([0-9.]+)(M*)\s*$/);
         
#         print "Unrecognized line in infile: $_\n";
      }
      close INFILE;
   }
}

sub sec_to_dhms # Seconds to days hours:minutes:seconds
{
   my $s = shift;
   my ($d, $h, $m) = (0, 0, 0);

   return '0 0:0:0' if $s <= 0;

   if($s >= 86400)
   {
      $d = int $s / 86400;
      $s -= $d * 86400;
   }

   if($s >= 3600)
   {
     $h = int $s / 3600;
     $s -= $h * 3600;
   }
   
   $m = int $s / 60;
   $s -= $m * 60;
   
   return "$d $h:$m:$s";
}

# Copied from mytop (http://jeremy.zawodny.com/mysql/mytop/) and modified a little
sub make_short
{
   my ($number, $kb) = @_;
   my $n = 0;
   my $short;

   if(defined $kb) { while ($number > 1023) { $number /= 1024; $n++; }; }
   else { while ($number > 999) { $number /= 1000; $n++; }; }

   $short = sprintf "%.2f%s", $number, ('','k','M','G','T')[$n];
   if($short =~ /^(.+)\.(00)$/) { return $1; } # 12.00 -> 12 but not 12.00k -> 12k
   return $short;
}

sub perc # Percentage
{
   my($is, $of) = @_;
   return sprintf "%.2f", ($is * 100) / ($of ||= 1);
}

sub t # Time average per second
{
   my $val = shift;
   return sprintf "%.2f", $val / $stats{'Uptime'};
}

sub email_report # Email given report to $op{'email'}
{
   return if $WIN;

   my $report = shift;

   open SENDMAIL, "|/usr/sbin/sendmail -t";
   print SENDMAIL "From: mysqlreport\n";
   print SENDMAIL "To: $op{email}\n";
   print SENDMAIL "Subject: MySQL status report\n\n";
   print SENDMAIL `cat $report`;
   close SENDMAIL;
}

sub cat_report # Print given report to screen
{
   my $report = shift;
   my @report;

   open REPORT, "< $report";
   @report = <REPORT>;
   close REPORT;
   print @report;
}

sub get_Com_values
{
   # Make copy of just the Com_ values
   for(keys %stats)
   {
      if(grep /^Com_/, $_ and $stats{$_} > 0)
      {
         /^Com_(.*)/;
         $Com_vals{$1} = $stats{$_};
      }
   }

   # Remove DMS values
   delete $Com_vals{'select'};
   delete $Com_vals{'insert'};
   delete $Com_vals{'insert_select'};
   delete $Com_vals{'replace'};
   delete $Com_vals{'replace_select'};
   delete $Com_vals{'update'};
   delete $Com_vals{'update_multi'} if exists $Com_vals{'update_multi'};
   delete $Com_vals{'delete'};
   delete $Com_vals{'delete_multi'} if exists $Com_vals{'delete_multi'};
}

sub write_DTQ # Write DTQ report in descending order by values
{
   $~ = 'DTQ';

   my %DTQ;
   my $first = 1;

   get_Com_values();

   # Total Com values
   $stat_val = 0;
   for(values %Com_vals) { $stat_val += $_; }
   $DTQ{'Com_'} = $stat_val;

   $DTQ{'DMS'}      = $dms;
   $DTQ{'QC Hits'}  = $stats{'Qcache_hits'} if $stats{'Qcache_hits'} != 0;
   $DTQ{'COM_QUIT'} = int (($stats{'Connections'} - 2) - ($stats{'Aborted_clients'} / 2));

   $stat_val = 0;
   for(values %DTQ) { $stat_val += $_; }
   if($questions != $stat_val) {
      $DTQ{($questions > $stat_val ? '+Unknown' : '-Unknown')} = abs $questions - $stat_val;
   }

   for(sort { $DTQ{$b} <=> $DTQ{$a} } keys(%DTQ))
   {
      if($first) { $stat_label = '%Total:'; $first = 0; }
      else       { $stat_label = ''; }

      $stat_name = $_;
      $stat_val  = $DTQ{$_};
      write;
   }
}

sub write_DMS # Write DMS report in descending order by values
{
   $~ = 'DMS';

   for(sort { $DMS_vals{$b} <=> $DMS_vals{$a} } keys(%DMS_vals))
   {
      $stat_name = $_;
      $stat_val  = $DMS_vals{$_};
      write;
   }
}

sub write_Com # Write COM report in descending order by values
{
   $~ = 'COM_1';

   get_Com_values() if !$op{'dtq'};

   # Total Com values and write first line of COM report
   $stat_label = '%Total:' unless $op{'dtq'};
   $stat_val   = 0;
   for(values %Com_vals) { $stat_val += $_; }
   write;

   $~ = 'COM_2';

   # Sort remaining Com values, print only the top $op{'com'} number of values
   for(sort { $Com_vals{$b} <=> $Com_vals{$a} } keys(%Com_vals))
   {
      $stat_name = $_;
      $stat_val  = $Com_vals{$_};
      write;

      last if !(--$op{'com'});
   }
}

sub write_qcache
{
   # Query cache was added in 4.0.1, but have_query_cache was added in 4.0.2,
   # ergo this method is slightly more reliable
   return if not exists $vars{'query_cache_size'};
   return if $vars{'query_cache_size'} == 0;

   $qc_mem_used = $vars{'query_cache_size'} - $stats{'Qcache_free_memory'};
   $qc_hi_r = sprintf "%.2f", $stats{'Qcache_hits'} / ($stats{'Qcache_inserts'} ||= 1);
   $qc_ip_r = sprintf "%.2f", $stats{'Qcache_inserts'} / ($stats{'Qcache_lowmem_prunes'} ||= 1);

   $~ = 'QCACHE';
   write;
}

sub have_op
{
   my $key = shift;
   return 1 if (exists $op{$key} && $op{$key} ne '');
   return 0;
}


#
# Formats
#

# First part of main report
format KEY_BUFF_MAX =
MySQL @<<<<<<<<<<<<<<<<  uptime @<<<<<<<<<<<   @>>>>>>>>>>>>>>>>>>>>>>>>
$vars{'version'}, sec_to_dhms($stats{'Uptime'}), scalar localtime

__ Key _________________________________________________________________
Buffer used   @>>>>>> of @>>>>>>  %Used: @>>>>>
make_short($key_buffer_used, 1), make_short($vars{'key_buffer_size'}, 1), perc($key_buffer_used, $vars{'key_buffer_size'})
.

format KEY_BUFF_USAGE =
  Current     @>>>>>>            %Usage: @>>>>>
make_short($key_buffer_usage, 1), perc($key_buffer_usage, $vars{'key_buffer_size'})
.

format KEY_RATIOS =
Write ratio @>>>>>>>>
$key_write_ratio
Read ratio  @>>>>>>>>
$key_read_ratio

__ Questions ___________________________________________________________
Total       @>>>>>>>>  @>>>>>/s
make_short($questions), t($questions)
.

# Distribution of Total Questions invoked by -dtq or -all
format DTQ =
  @<<<<<<<  @>>>>>>>>  @>>>>>/s  @>>>>>> @>>>>>
$stat_name, make_short($stat_val), t($stat_val), $stat_label, perc($stat_val, $questions)
.

# Second part of main report
format SLOW_DMS =
Slow        @>>>>>>>>  @>>>>>/s  @>>>>>> @>>>>>  %DMS: @>>>>>
make_short($stats{'Slow_queries'}), t($stats{'Slow_queries'}), ($op{'dtq'} || $op{'all'} ? '' : '%Total:'), perc($stats{'Slow_queries'}, $questions), perc($stats{'Slow_queries'}, $dms)
DMS         @>>>>>>>>  @>>>>>/s          @>>>>>
make_short($dms), t($dms), perc($dms, $questions)
.

# Data Manipulation Statements format invoked by -dms or -all
format DMS =
  @<<<<<<<  @>>>>>>>>  @>>>>>/s          @>>>>>        @>>>>>
$stat_name, make_short($stat_val), t($stat_val), perc($stat_val, $questions), perc($stat_val, $dms)
.

# Top Com_ format invoked by -com or -all
format COM_1 =
Com_        @>>>>>>>>  @>>>>>/s          @>>>>>
make_short($stat_val), t($stat_val), perc($stat_val, $questions)
.

format COM_2 =
  @<<<<<<<<<< @>>>>>>  @>>>>>/s          @>>>>>
$stat_name, make_short($stat_val), t($stat_val), perc($stat_val, $questions)
.

# SELECT and Sort format invoked by -sas or -all
format SAS =

__ SELECT and Sort _____________________________________________________
Scan          @>>>>>>   @>>>>/s %SELECT: @>>>>>
make_short($stats{'Select_scan'}), t($stats{'Select_scan'}), perc($stats{'Select_scan'}, $stats{'Com_select'})
Range         @>>>>>>   @>>>>/s          @>>>>>
make_short($stats{'Select_range'}), t($stats{'Select_range'}), perc($stats{'Select_range'}, $stats{'Com_select'})
Full join     @>>>>>>   @>>>>/s          @>>>>>
make_short($stats{'Select_full_join'}), t($stats{'Select_full_join'}), perc($stats{'Select_full_join'}, $stats{'Com_select'})
Range check   @>>>>>>   @>>>>/s          @>>>>>
make_short($stats{'Select_range_check'}), t($stats{'Select_range_check'}), perc($stats{'Select_range_check'}, $stats{'Com_select'})
Full rng join @>>>>>>   @>>>>/s          @>>>>>
make_short($stats{'Select_full_range_join'}), t($stats{'Select_full_range_join'}), perc($stats{'Select_full_range_join'}, $stats{'Com_select'})
Sort scan     @>>>>>>   @>>>>/s
make_short($stats{'Sort_scan'}), t($stats{'Sort_scan'})
Sort range    @>>>>>>   @>>>>/s
make_short($stats{'Sort_range'}), t($stats{'Sort_range'})
Sort mrg pass @>>>>>>   @>>>>/s
make_short($stats{'Sort_merge_passes'}), t($stats{'Sort_merge_passes'})
.

# Query Cache format invoked by -qcache or -all
format QCACHE =

__ Query Cache _________________________________________________________
Memory usage  @>>>>>> of @>>>>>>  %Used: @>>>>>
make_short($qc_mem_used, 1), make_short($vars{'query_cache_size'}, 1), perc($qc_mem_used, $vars{'query_cache_size'})
Block Fragmnt @>>>>>%
perc($stats{'Qcache_free_blocks'}, $stats{'Qcache_total_blocks'})
Hits          @>>>>>>   @>>>>/s
make_short($stats{'Qcache_hits'}), t($stats{'Qcache_hits'})
Inserts       @>>>>>>   @>>>>/s
make_short($stats{'Qcache_inserts'}), t($stats{'Qcache_inserts'})
Prunes        @>>>>>>   @>>>>/s
make_short($stats{'Qcache_lowmem_prunes'}), t($stats{'Qcache_lowmem_prunes'})
Insrt:Prune @>>>>>>:1   @>>>>/s
make_short($qc_ip_r), t($stats{'Qcache_inserts'} - $stats{'Qcache_lowmem_prunes'})
Hit:Insert  @>>>>>>:1
$qc_hi_r, t($qc_hi_r)
.

# Third part of main report
format REPORT_END =

__ Table Locks _________________________________________________________
Waited      @>>>>>>>>  @>>>>>/s  %Total: @>>>>>
make_short($stats{'Table_locks_waited'}), t($stats{'Table_locks_waited'}), perc($stats{'Table_locks_waited'}, $stats{'Table_locks_waited'} + $stats{'Table_locks_immediate'});
Immediate   @>>>>>>>>  @>>>>>/s
make_short($stats{'Table_locks_immediate'}), t($stats{'Table_locks_immediate'})

__ Tables ______________________________________________________________
Open        @>>>>>>>> of @>>>    %Cache: @>>>>>
make_short($stats{'Open_tables'}), $vars{'table_cache'}, perc($stats{'Open_tables'}, $vars{'table_cache'})
Opened      @>>>>>>>>  @>>>>>/s
make_short($stats{'Opened_tables'}), t($stats{'Opened_tables'})

__ Connections _________________________________________________________
Max used    @>>>>>>>> of @>>>      %Max: @>>>>>
$stats{'Max_used_connections'}, $vars{'max_connections'}, perc($stats{'Max_used_connections'}, $vars{'max_connections'})
Total       @>>>>>>>>  @>>>>>/s
make_short($stats{'Connections'}), t($stats{'Connections'})

__ Created Temp ________________________________________________________
Disk table  @>>>>>>>>  @>>>>>/s
make_short($stats{'Created_tmp_disk_tables'}), t($stats{'Created_tmp_disk_tables'})
Table       @>>>>>>>>  @>>>>>/s
make_short($stats{'Created_tmp_tables'}), t($stats{'Created_tmp_tables'})
File        @>>>>>>>>  @>>>>>/s
make_short($stats{'Created_tmp_files'}), t($stats{'Created_tmp_files'})
.

# Threads, Aborts, and Bytes format invoked by -tab
format TAB =

__ Threads _____________________________________________________________
Running     @>>>>>>>> of @>>>
$stats{'Threads_running'}, $stats{'Threads_connected'}
Cache       @>>>>>>>>              %Hit: @>>>>>
$stats{'Threads_cached'}, (100 - perc($stats{'Threads_created'}, $stats{'Connections'}))
Created     @>>>>>>>>  @>>>>>/s
make_short($stats{'Threads_created'}), t($stats{'Threads_created'})
Slow        @>>>>>>>>  @>>>>>/s
$stats{'Slow_launch_threads'}, t($stats{'Slow_launch_threads'})

__ Aborted _____________________________________________________________
Clients     @>>>>>>>>  @>>>>>/s
make_short($stats{'Aborted_clients'}), t($stats{'Aborted_clients'})
Connects    @>>>>>>>>  @>>>>>/s
make_short($stats{'Aborted_connects'}), t($stats{'Aborted_connects'})

__ Bytes _______________________________________________________________
Sent        @>>>>>>>>  @>>>>>/s
make_short($stats{'Bytes_sent'}), make_short(t($stats{'Bytes_sent'}))
Received    @>>>>>>>>  @>>>>>/s
make_short($stats{'Bytes_received'}), make_short(t($stats{'Bytes_received'}))
.
