#!/usr/bin/python
# coding=utf-8
"""
toppu - a small top-like utility for monitoring Heroku dyno response times
"""

import curses
import datetime
import optparse
import re
import shlex
import subprocess
import sys
from Controls import Gauge, HttpStatusGauge, ColorMinMaxGauge, AverageGauge, MinGauge, MaxGauge, Heading, Clock

banner = "\ntoppu - A top-like tool for Heroku: https://github.com/grobertson/toppu"
usage = "toppu -a <appname> [--solarized]" 

PARSER = optparse.OptionParser(usage=''.join([usage,banner]))

# app name
PARSER.add_option('-a', action="store", dest="app", help="Heroku app name")
PARSER.add_option('--app', action="store", dest="app", help="Heroku app name")
PARSER.add_option('--256', action="store_true", dest="colorful", help="Experimental support for 256 color terms")
# for tinkering
PARSER.add_option('--devel', action="store_true", dest="debug", help="For tinkering with the parser.")

# timeout declaration, unused after refactor,
#TODO: thresholds
#PARSER.add_option('-t', action="store", dest="timeout", type="int", help="timeout in milliseconds.")
#PARSER.add_option('--timeout', action="store", dest="timeout", type="int", help="timeout in milliseconds.")
#PARSER.set_defaults(timeout=2000)

PARSER.add_option('--solarized', action="store_true", dest="solarized", help="Easy on the eyes.")
PARSER.set_defaults(solarized=False)
OPTIONS, ARGS = PARSER.parse_args()


try:
    assert OPTIONS.app is not None
    APP_NAME = OPTIONS.app
    #assert OPTIONS.timeout is not None
    #TIMEOUT = OPTIONS.timeout
    TIMEOUT = 0
    assert OPTIONS.solarized is not None
    SOLARIZED = OPTIONS.solarized
    COLOR_256 = OPTIONS.colorful
    
except AssertionError:
    PARSER.error("No appname specified.")
    exit()


PARSE_ERRORS = []

class Toppu(object):
    ''' 
        This dedicated to my engineers that was down from day one.
        Welcome to the cli 
            ...like we always do about this time.
        
        Apologies to Dr. Dre
    '''
    def __init__(self):
        self.stream = LogStream(APP_NAME)
        self.logparser = LogParser()
        self.display = Display()
        self.dynos = {}
        try:
            self.run()
        except KeyboardInterrupt, Exception:
            del self.display
            if OPTIONS.debug:
                print >> sys.stderr, "\nUnparseable log lines:\n"
                for err in PARSE_ERRORS:
                    print >> sys.stderr, "{err}".format(err=err)


    def run(self):
        for line in self.stream:
            data =  self.logparser.parse(line)
            if not data:
                continue

            if data['log_type'] == 'router':
                try:
                    assert data['dyno']
                    assert data['connect']
                    assert data['service']
                    assert data['status']
                except KeyError:
                    # continue to the next line if necessary data isn't present
                    continue

                # data present, keep moving
                (d_type, d_number) = data['dyno'].split('.')
                #We build an array of dicts representing each dyno we've seen so far.
                #If our dict already exists, the Controls.Gauge has been created, so we just update
                if self.dynos.__contains__(data['dyno']):
                    self.dynos[data['dyno']]['dyno'].update(data['dyno'])
                    self.dynos[data['dyno']]['connect'].update(int(data['connect'].replace('ms','')))
                    self.dynos[data['dyno']]['connect_avg'].update(int(data['connect'].replace('ms','')))
                    self.dynos[data['dyno']]['connect_max'].update(int(data['connect'].replace('ms','')))
                    self.dynos[data['dyno']]['service'].update(int(data['service'].replace('ms','')))
                    self.dynos[data['dyno']]['service_avg'].update(int(data['service'].replace('ms','')))
                    self.dynos[data['dyno']]['service_max'].update(int(data['service'].replace('ms','')))
                    self.dynos[data['dyno']]['status'].update(data['status'])
                else:
                    #if the dict didn't exist, add it and make a new Controls.Gauge 
                    self.dynos[data['dyno']] = {}
                    self.dynos[data['dyno']]['dyno'] = Gauge(   self.display.STDSCR, 
                                                                self.display.rowrange[int(d_number)], 
                                                                self.display.colrange[0], 
                                                                data['dyno'], width=self.display.column_width)
                    
                    self.dynos[data['dyno']]['status'] = HttpStatusGauge( self.display.STDSCR, 
                                                                self.display.rowrange[int(d_number)], 
                                                                self.display.colrange[1], 
                                                                data['status'], width=self.display.column_width)
                    
                    self.dynos[data['dyno']]['connect'] = ColorMinMaxGauge(self.display.STDSCR, 
                                                                self.display.rowrange[int(d_number)], 
                                                                self.display.colrange[2], 
                                                                int(data['connect'].replace('ms','')), 
                                                                units='ms', width=self.display.column_width,
                                                                left=True)
                                                                
                    self.dynos[data['dyno']]['connect_avg'] = AverageGauge(self.display.STDSCR, 
                                                                self.display.rowrange[int(d_number)], 
                                                                self.display.colrange[3], 
                                                                int(data['connect'].replace('ms','')), 
                                                                units='ms', width=self.display.column_width,
                                                                left=True)
                    
                    self.dynos[data['dyno']]['connect_max'] = MaxGauge(self.display.STDSCR, 
                                                                self.display.rowrange[int(d_number)], 
                                                                self.display.colrange[4], 
                                                                int(data['connect'].replace('ms','')), 
                                                                units='ms', width=self.display.column_width,
                                                                left=True)
                                                                
                    self.dynos[data['dyno']]['service'] = ColorMinMaxGauge(self.display.STDSCR, 
                                                                self.display.rowrange[int(d_number)], 
                                                                self.display.colrange[5], 
                                                                int(data['service'].replace('ms','')), 
                                                                units='ms', width=self.display.column_width,
                                                                left=True)
                    
                    self.dynos[data['dyno']]['service_avg'] = AverageGauge(self.display.STDSCR, 
                                                                self.display.rowrange[int(d_number)], 
                                                                self.display.colrange[6], 
                                                                int(data['service'].replace('ms','')), 
                                                                units='ms', width=self.display.column_width,
                                                                left=True)
            
                    self.dynos[data['dyno']]['service_max'] = MaxGauge(self.display.STDSCR, 
                                                                self.display.rowrange[int(d_number)], 
                                                                self.display.colrange[7], 
                                                                int(data['service'].replace('ms','')), 
                                                                units='ms', width=self.display.column_width,
                                                                left=True)
                    
                    
                    self.dynos[data['dyno']]['dyno'].draw()
                    self.dynos[data['dyno']]['connect'].draw()
                    self.dynos[data['dyno']]['connect_avg'].draw()
                    self.dynos[data['dyno']]['connect_max'].draw()
                    self.dynos[data['dyno']]['service'].draw()
                    self.dynos[data['dyno']]['service_avg'].draw()
                    self.dynos[data['dyno']]['service_max'].draw()
                    self.dynos[data['dyno']]['status'].draw()
                    self.dynos[data['dyno']]['status'].draw()

            elif data['log_type'] == 'web':
                continue
            elif data['log_type'] == 'worker':
                continue
        
            self.display.wallclock.update()
            c = self.display.STDSCR.getch()
            if not c == -1:
                if c == ord('q'):
                    exit()
        
        
class LogStream(object):

    def __init__(self, app_name):
        self.app_name = app_name
        return None
        
    def __iter__(self):
        """Runs the `heroku logs` command with the appropriate app name and wraps it 
            up nicely in a familiar iterable object.
        """
        command = '/usr/bin/heroku logs -t --app ' + self.app_name
        args = shlex.split(command)
        heroku = subprocess.Popen(args,
                             stdout=subprocess.PIPE,
                             stderr=subprocess.STDOUT)
        return iter(heroku.stdout.readline, b'')
    

class LogParser(object):
    ''' Let's read some loglines, shall we? '''
    def __init__(self):
        # you want options, maybe?
        return None

    def parse(self, line):
        """Tries to parse a single Heroku logline"""
        try:
            (year, month, day, hour, minute, second, microseconds, offset_hour, offset_minute, source, process, logentry) = re.match('^(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)\.([\d]+)\+(\d\d):(\d\d) ([a-z]+)\[([a-zA-Z0-9_.]+)\]: ([0-9a-z-A-Z\-_\.\[\]:\?\#\",/\ ={}\'\(\)<>]+)$', line).groups()
        except:
            pass
        
        try:
            parsed_data = dict()
            parsed_data['timestamp'] = " ".join(["-".join([year, month, day]), ":".join([hour, minute, second])])
            parsed_data['log_time'] = datetime.datetime(int(year), int(month), int(day), int(hour), int(minute), int(second))
            parsed_data['log_source'] = source
            parsed_data['log_type'] = process
        except (AttributeError, UnboundLocalError):
            PARSE_ERRORS.append(line)
            return False

        #TODO: This still needs work on spaces in values surrounded by " " 
        if parsed_data['log_source'] == "heroku":
            if logentry.__len__() > 1:
                logentry = re.sub(', ', ',', logentry)
                line_chunks = re.split(' ', logentry)
                for chunk in line_chunks:
                    line_chunks = re.split('=', chunk)
                    if line_chunks.__len__() > 2:
                        #fwd and path are a little clunky to parse
                        pass
                    elif line_chunks.__len__() > 1:
                        parsed_data[line_chunks[0]] = line_chunks[1]
                    else:
                        pass
            else:
                return False
        else:
            # TODO: [app] 
            # Needs parsing. Do that here.
            return False

        return parsed_data


class Display(object):
    ''' Hat tip to https://github.com/mooseman/pd_curses_stuff/ '''
    def __init__(self):
        self.STDSCR = curses.initscr()
        curses.start_color()
        curses.noecho()
        curses.cbreak()
        self.STDSCR.nodelay(1)
        self.STDSCR.keypad(1)
        self.STDSCR.scrollok(1)
        self.STDSCR.idlok(1)      
        self.STDSCR.leaveok(0)
        
        self.palette()
        
        # The boundaries of the screen 
        (y, x) = self.STDSCR.getmaxyx() 
        self.maxrows = y  
        self.maxcols = x
        grid_margin = 1
        #scroll port
        #self.STDSCR.setscrreg(6, self.maxrows-2)
        
        #every row is a display row.
        self.rowrange = list(range(grid_margin, self.maxrows - grid_margin))  

        self.columns = 8
        self.column_width = self.maxcols / self.columns
        
        # leading spaces, width of screen, width of a column
        self.colrange = list(range( 0 , self.maxcols, self.maxcols / self.columns ) ) 

        self.colheadposlist = list( (y,x) 
            for y in range(grid_margin, grid_margin + 1) 
                for x in self.colrange )
                        
        # Draw the headings
        title = " ".join(["toppu","-",APP_NAME])
        
        self.STDSCR.addstr(0, self.maxcols - title.__len__(), title, curses.color_pair(7))
        self.wallclock = Clock(self.STDSCR, 0, 2)
        bar = ""
        for i in range(0, self.maxcols):
            bar = bar + " "
        self.STDSCR.addstr(grid_margin, 0, bar, curses.A_REVERSE) 
        self.coldata = ["Dyno", "Status", "Connect", "Avg", "Max", "Service", "Avg", "Max"]
        self.ch = Heading(self.colheadposlist, self.coldata, column_padding=4)  
        for label, pos in zip(self.coldata, self.colheadposlist):
            self.STDSCR.addstr(pos[0], pos[1], label, curses.A_REVERSE)
        self.STDSCR.refresh()
        return None

    def __del__(self):
        curses.nocbreak()
        self.STDSCR1.keypad(0)
        curses.echo()
        curses.endwin()

    def palette(self):
        colors = 15
        if COLOR_256:
            colors = 255
        
        for col in range(1, colors):
            curses.init_pair(col, col, 0)
        

if __name__ == "__main__":
    t = Toppu()

e04715e87ce5b9f794f57c846a667542a409d301
#!/usr/bin/python
# coding=utf-8
"""
toppu - a small top-like utility for monitoring Heroku dyno response times
"""

import curses
import datetime
import optparse
import re
import shlex
import subprocess
import sys
from Controls import Gauge, HttpStatusGauge, ColorMinMaxGauge, AverageGauge, MinGauge, MaxGauge, Heading, Clock

banner = "\ntoppu - A top-like tool for Heroku: https://github.com/grobertson/toppu"
usage = "toppu -a <appname> [--solarized]" 

PARSER = optparse.OptionParser(usage=''.join([usage,banner]))

# app name
PARSER.add_option('-a', action="store", dest="app", help="Heroku app name")
PARSER.add_option('--app', action="store", dest="app", help="Heroku app name")
PARSER.add_option('--256', action="store_true", dest="colorful", help="Experimental support for 256 color terms")
# for tinkering
PARSER.add_option('--devel', action="store_true", dest="debug", help="For tinkering with the parser.")

# timeout declaration, unused after refactor,
#TODO: thresholds
#PARSER.add_option('-t', action="store", dest="timeout", type="int", help="timeout in milliseconds.")
#PARSER.add_option('--timeout', action="store", dest="timeout", type="int", help="timeout in milliseconds.")
#PARSER.set_defaults(timeout=2000)

PARSER.add_option('--solarized', action="store_true", dest="solarized", help="Easy on the eyes.")
PARSER.set_defaults(solarized=False)
OPTIONS, ARGS = PARSER.parse_args()


try:
    assert OPTIONS.app is not None
    APP_NAME = OPTIONS.app
    #assert OPTIONS.timeout is not None
    #TIMEOUT = OPTIONS.timeout
    TIMEOUT = 0
    assert OPTIONS.solarized is not None
    SOLARIZED = OPTIONS.solarized
    COLOR_256 = OPTIONS.colorful
    
except AssertionError:
    PARSER.error("No appname specified.")
    exit()


PARSE_ERRORS = []

class Toppu(object):
    ''' 
        This dedicated to my engineers that was down from day one.
        Welcome to the cli 
            ...like we always do about this time.
        
        Apologies to Dr. Dre
    '''
    def __init__(self):
        self.stream = LogStream(APP_NAME)
        self.logparser = LogParser()
        self.display = Display()
        self.dynos = {}
        try:
            self.run()
        except KeyboardInterrupt, Exception:
            del self.display
            if OPTIONS.debug:
                print >> sys.stderr, "\nUnparseable log lines:\n"
                for err in PARSE_ERRORS:
                    print >> sys.stderr, "{err}".format(err=err)


    def run(self):
        for line in self.stream:
            data =  self.logparser.parse(line)
            if not data:
                continue

            if data['log_type'] == 'router':
                try:
                    assert data['dyno']
                    assert data['connect']
                    assert data['service']
                    assert data['status']
                except KeyError:
                    # continue to the next line if necessary data isn't present
                    continue

                # data present, keep moving
                (d_type, d_number) = data['dyno'].split('.')
                #We build an array of dicts representing each dyno we've seen so far.
                #If our dict already exists, the Controls.Gauge has been created, so we just update
                if self.dynos.__contains__(data['dyno']):
                    self.dynos[data['dyno']]['dyno'].update(data['dyno'])
                    self.dynos[data['dyno']]['connect'].update(int(data['connect'].replace('ms','')))
                    self.dynos[data['dyno']]['connect_avg'].update(int(data['connect'].replace('ms','')))
                    self.dynos[data['dyno']]['connect_max'].update(int(data['connect'].replace('ms','')))
                    self.dynos[data['dyno']]['service'].update(int(data['service'].replace('ms','')))
                    self.dynos[data['dyno']]['service_avg'].update(int(data['service'].replace('ms','')))
                    self.dynos[data['dyno']]['service_max'].update(int(data['service'].replace('ms','')))
                    self.dynos[data['dyno']]['status'].update(data['status'])
                else:
                    #if the dict didn't exist, add it and make a new Controls.Gauge 
                    self.dynos[data['dyno']] = {}
                    self.dynos[data['dyno']]['dyno'] = Gauge(   self.display.STDSCR, 
                                                                self.display.rowrange[int(d_number)], 
                                                                self.display.colrange[0], 
                                                                data['dyno'], width=self.display.column_width)
                    
                    self.dynos[data['dyno']]['status'] = HttpStatusGauge( self.display.STDSCR, 
                                                                self.display.rowrange[int(d_number)], 
                                                                self.display.colrange[1], 
                                                                data['status'], width=self.display.column_width)
                    
                    self.dynos[data['dyno']]['connect'] = ColorMinMaxGauge(self.display.STDSCR, 
                                                                self.display.rowrange[int(d_number)], 
                                                                self.display.colrange[2], 
                                                                int(data['connect'].replace('ms','')), 
                                                                units='ms', width=self.display.column_width,
                                                                left=True)
                                                                
                    self.dynos[data['dyno']]['connect_avg'] = AverageGauge(self.display.STDSCR, 
                                                                self.display.rowrange[int(d_number)], 
                                                                self.display.colrange[3], 
                                                                int(data['connect'].replace('ms','')), 
                                                                units='ms', width=self.display.column_width,
                                                                left=True)
                    
                    self.dynos[data['dyno']]['connect_max'] = MaxGauge(self.display.STDSCR, 
                                                                self.display.rowrange[int(d_number)], 
                                                                self.display.colrange[4], 
                                                                int(data['connect'].replace('ms','')), 
                                                                units='ms', width=self.display.column_width,
                                                                left=True)
                                                                
                    self.dynos[data['dyno']]['service'] = ColorMinMaxGauge(self.display.STDSCR, 
                                                                self.display.rowrange[int(d_number)], 
                                                                self.display.colrange[5], 
                                                                int(data['service'].replace('ms','')), 
                                                                units='ms', width=self.display.column_width,
                                                                left=True)
                    
                    self.dynos[data['dyno']]['service_avg'] = AverageGauge(self.display.STDSCR, 
                                                                self.display.rowrange[int(d_number)], 
                                                                self.display.colrange[6], 
                                                                int(data['service'].replace('ms','')), 
                                                                units='ms', width=self.display.column_width,
                                                                left=True)
            
                    self.dynos[data['dyno']]['service_max'] = MaxGauge(self.display.STDSCR, 
                                                                self.display.rowrange[int(d_number)], 
                                                                self.display.colrange[7], 
                                                                int(data['service'].replace('ms','')), 
                                                                units='ms', width=self.display.column_width,
                                                                left=True)
                    
                    
                    self.dynos[data['dyno']]['dyno'].draw()
                    self.dynos[data['dyno']]['connect'].draw()
                    self.dynos[data['dyno']]['connect_avg'].draw()
                    self.dynos[data['dyno']]['connect_max'].draw()
                    self.dynos[data['dyno']]['service'].draw()
                    self.dynos[data['dyno']]['service_avg'].draw()
                    self.dynos[data['dyno']]['service_max'].draw()
                    self.dynos[data['dyno']]['status'].draw()
                    self.dynos[data['dyno']]['status'].draw()

            elif data['log_type'] == 'web':
                continue
            elif data['log_type'] == 'worker':
                continue
        
            self.display.wallclock.update()
            c = self.display.STDSCR.getch()
            if not c == -1:
                if c == ord('q'):
                    exit()
        
        
class LogStream(object):

    def __init__(self, app_name):
        self.app_name = app_name
        return None
        
    def __iter__(self):
        """Runs the `heroku logs` command with the appropriate app name and wraps it 
            up nicely in a familiar iterable object.
        """
        command = '/usr/bin/heroku logs -t --app ' + self.app_name
        args = shlex.split(command)
        heroku = subprocess.Popen(args,
                             stdout=subprocess.PIPE,
                             stderr=subprocess.STDOUT)
        return iter(heroku.stdout.readline, b'')
    

class LogParser(object):
    ''' Let's read some loglines, shall we? '''
    def __init__(self):
        # you want options, maybe?
        return None

    def parse(self, line):
        """Tries to parse a single Heroku logline"""
        try:
            (year, month, day, hour, minute, second, microseconds, offset_hour, offset_minute, source, process, logentry) = re.match('^(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)\.([\d]+)\+(\d\d):(\d\d) ([a-z]+)\[([a-zA-Z0-9_.]+)\]: ([0-9a-z-A-Z\-_\.\[\]:\?\#\",/\ ={}\'\(\)<>]+)$', line).groups()
        except:
            pass
        
        try:
            parsed_data = dict()
            parsed_data['timestamp'] = " ".join(["-".join([year, month, day]), ":".join([hour, minute, second])])
            parsed_data['log_time'] = datetime.datetime(int(year), int(month), int(day), int(hour), int(minute), int(second))
            parsed_data['log_source'] = source
            parsed_data['log_type'] = process
        except (AttributeError, UnboundLocalError):
            PARSE_ERRORS.append(line)
            return False

        #TODO: This still needs work on spaces in values surrounded by " " 
        if parsed_data['log_source'] == "heroku":
            if logentry.__len__() > 1:
                logentry = re.sub(', ', ',', logentry)
                line_chunks = re.split(' ', logentry)
                for chunk in line_chunks:
                    line_chunks = re.split('=', chunk)
                    if line_chunks.__len__() > 2:
                        #fwd and path are a little clunky to parse
                        pass
                    elif line_chunks.__len__() > 1:
                        parsed_data[line_chunks[0]] = line_chunks[1]
                    else:
                        pass
            else:
                return False
        else:
            # TODO: [app] 
            # Needs parsing. Do that here.
            return False

        return parsed_data


class Display(object):
    ''' Hat tip to https://github.com/mooseman/pd_curses_stuff/ '''
    def __init__(self):
        self.STDSCR = curses.initscr()
        curses.start_color()
        curses.noecho()
        curses.cbreak()
        self.STDSCR.nodelay(1)
        self.STDSCR.keypad(1)
        self.STDSCR.scrollok(1)
        self.STDSCR.idlok(1)      
        self.STDSCR.leaveok(0)
        
        self.palette()
        
        # The boundaries of the screen 
        (y, x) = self.STDSCR.getmaxyx() 
        self.maxrows = y  
        self.maxcols = x
        grid_margin = 1
        #scroll port
        #self.STDSCR.setscrreg(6, self.maxrows-2)
        
        #every row is a display row.
        self.rowrange = list(range(grid_margin, self.maxrows - grid_margin))  

        self.columns = 8
        self.column_width = self.maxcols / self.columns
        
        # leading spaces, width of screen, width of a column
        self.colrange = list(range( 0 , self.maxcols, self.maxcols / self.columns ) ) 

        self.colheadposlist = list( (y,x) 
            for y in range(grid_margin, grid_margin + 1) 
                for x in self.colrange )
                        
        # Draw the headings
        title = " ".join(["toppu","-",APP_NAME])
        
        self.STDSCR.addstr(0, self.maxcols - title.__len__(), title, curses.color_pair(7))
        self.wallclock = Clock(self.STDSCR, 0, 2)
        bar = ""
        for i in range(0, self.maxcols):
            bar = bar + " "
        self.STDSCR.addstr(grid_margin, 0, bar, curses.A_REVERSE) 
        self.coldata = ["Dyno", "Status", "Connect", "Avg", "Max", "Service", "Avg", "Max"]
        self.ch = Heading(self.colheadposlist, self.coldata, column_padding=4)  
        for label, pos in zip(self.coldata, self.colheadposlist):
            self.STDSCR.addstr(pos[0], pos[1], label, curses.A_REVERSE)
        self.STDSCR.refresh()
        return None

    def __del__(self):
        curses.nocbreak()
        self.STDSCR1.keypad(0)
        curses.echo()
        curses.endwin()

    def palette(self):
        colors = 15
        if COLOR_256:
            colors = 255
        
        for col in range(1, colors):
            curses.init_pair(col, col, 0)
        

if __name__ == "__main__":
    t = Toppu()
