Skip to content
Browse files

Initial commit

  • Loading branch information...
0 parents commit e47752137f276c20efc7570c78b40b4dc376d52d @p-e-w committed Apr 28, 2013
Showing with 411 additions and 0 deletions.
  1. +25 −0 README.md
  2. BIN Screenshot.png
  3. +386 −0 ranwhen.py
25 README.md
@@ -0,0 +1,25 @@
+# ranwhen – Visualize when your system was running
+
+ranwhen is a Python script that graphically shows **in your terminal** when your system was running in the past. Have a look:
+
+![Screenshot](../blob/master/Screenshot.png?raw=true)
+
+
+# Requirements
+
+* *nix system with [last(1)](http://linux.die.net/man/1/last) installed and supporting the -R and -F flags
+* [Python 3](http://www.python.org/)
+* Terminal emulator with support for Unicode and xterm's 256 color mode
+
+The above requirements should be fulfilled by default on the majority of modern Linux distributions, where the only thing that needs to be done is usually to install Python 3.
+
+
+# License
+
+Copyright © 2013 Philipp Emanuel Weidmann (<pew@worldwidemann.com>)
+
+ranwhen 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 3 of the License, or (at your option) any later version.
+
+ranwhen 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.
+
+You should have received a copy of the GNU General Public License along with ranwhen. If not, see <http://www.gnu.org/licenses/>.
BIN Screenshot.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
386 ranwhen.py
@@ -0,0 +1,386 @@
+#!/usr/bin/env python3
+
+# ranwhen – Visualize when your system was running
+#
+# Requirements:
+# - *nix system with last(1) installed and supporting the -R and -F flags
+# - Python 3
+# - Terminal emulator with support for Unicode and xterm's 256 color mode
+#
+#
+# Copyright © 2013 Philipp Emanuel Weidmann <pew@worldwidemann.com>
+#
+# Nemo vir est qui mundum non reddat meliorem.
+#
+#
+# ranwhen 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 3 of the License, or
+# (at your option) any later version.
+#
+# ranwhen 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.
+#
+# You should have received a copy of the GNU General Public License
+# along with ranwhen. If not, see <http://www.gnu.org/licenses/>.
+
+import subprocess
+import re
+from datetime import datetime, timedelta
+import sys
+
+
+# Line format of last's output
+# e.g. "reboot system boot Wed Jan 16 21:36:54 2013 - Wed Jan 16 22:05:50 2013 (00:28)"
+line_pattern = re.compile("reboot\s+system\s+boot\s+\w{3}\s+([\d\w\s:]{20})\s+-\s+\w{3}\s+([\d\w\s:]{20})")
+
+# Date format used by last
+# e.g. "Jan 16 21:36:54 2013"
+time_format = "%b %d %H:%M:%S %Y"
+
+# Extracts start and end time from a line of last's output
+def parse_line(line):
+ result = line_pattern.match(line)
+ if result is None:
+ return None
+ else:
+ from_time = datetime.strptime(result.group(1), time_format)
+ to_time = datetime.strptime(result.group(2), time_format)
+ return { "from" : from_time, "to" : to_time }
+
+
+# Returns the length of time for which two time spans overlap
+# (i.e. the length of their intersection)
+def time_overlap(time_span_1, time_span_2):
+ if max(time_span_1["from"], time_span_2["from"]) >= \
+ min(time_span_1["to"], time_span_2["to"]):
+ # No overlap
+ return timedelta()
+ return min(time_span_1["to"], time_span_2["to"]) - \
+ max(time_span_1["from"], time_span_2["from"])
+
+
+# Do not use escape sequences if output is piped
+use_escape_sequences = sys.stdout.isatty()
+
+# Returns an xterm escape sequence that, if printed to the terminal,
+# will reset all character attributes to the default
+def get_reset_sequence():
+ if use_escape_sequences:
+ return "\033[0m"
+ return ""
+
+# Returns an xterm escape sequence that, if printed to the terminal,
+# will set the specified character attributes
+def get_escape_sequence(fgcolor = None, bgcolor = None, bold = False):
+ if use_escape_sequences:
+ return get_reset_sequence() + \
+ "\033[%s%s%sm" % \
+ ("" if fgcolor is None else ("38;5;%d" % fgcolor), \
+ "" if bgcolor is None else (";48;5;%d" % bgcolor), \
+ ";1" if bold else "")
+ return ""
+
+# Output colors as xterm color codes
+# (see e.g. http://www.calmar.ws/vim/256-xterm-24bit-rgb-color-chart.html)
+foreground_color = 251
+heading_color = 208
+heading_line_color = 246
+time_color = 231
+time_text_color = 246
+histogram_color = [ 57, 56, 126, 197 ]
+histogram_color_grid = [ 99, 97, 169, 204 ]
+weekday_color = 245
+weekend_color = 231
+bar_color = 28
+bar_weekend_color = 82
+bar_color_grid = 77
+bar_weekend_color_grid = 156
+night_color = 51
+sunrise_color = 228
+noon_color = 226
+sunset_color = 214
+grid_color = 238
+
+# Returns a string that, if printed to the terminal,
+# will display the specified text with the specified attributes
+def style_text(text, fgcolor = foreground_color, bgcolor = None, bold = False):
+ return get_escape_sequence(fgcolor, bgcolor, bold) + text + \
+ get_escape_sequence(fgcolor = foreground_color)
+
+
+# Computes the number of hours (total), minutes and seconds
+# in the specified timedelta object
+def get_delta_fields(delta):
+ fields = {}
+ fields["hours"], remainder = divmod(round(delta.total_seconds()), 3600)
+ fields["minutes"], fields["seconds"] = divmod(remainder, 60)
+ return fields
+
+# Formats the specified timedelta object into a string
+# of the form "X hours Y minutes"
+def format_delta(delta):
+ fields = get_delta_fields(delta)
+ return style_text("%4d" % fields["hours"], fgcolor = time_color) + \
+ style_text(" hours ", fgcolor = time_text_color) + \
+ style_text("%2d" % fields["minutes"], fgcolor = time_color) + \
+ style_text(" minutes", fgcolor = time_text_color)
+
+# Formats the specified timedelta object into a string
+# of the form "XX:YY"
+def format_delta_short(delta):
+ fields = get_delta_fields(delta)
+ if fields["hours"] == 0 and fields["minutes"] == 0:
+ return ""
+ if fields["hours"] == 0:
+ return style_text(" :", fgcolor = time_text_color) + \
+ style_text("%02d" % fields["minutes"], fgcolor = time_color)
+ return style_text("%2d" % fields["hours"], fgcolor = time_color) + \
+ style_text(":", fgcolor = time_text_color) + \
+ style_text("%02d" % fields["minutes"], fgcolor = time_color)
+
+output_width = 61
+
+# Returns a styled section separator used to frame a calendar month
+def format_heading(heading):
+ padded_heading = " " + heading + " "
+ heading_pos = int((output_width - len(padded_heading)) / 2)
+ full_heading = style_text("" + ("" * heading_pos), fgcolor = heading_line_color) + \
+ style_text(padded_heading, fgcolor = heading_color)
+ full_heading += style_text(("" * (output_width - heading_pos - len(padded_heading))) + "", \
+ fgcolor = heading_line_color)
+ return full_heading
+
+
+
+##### Program logic starts here #####
+
+
+
+# ranwhen parses the output of this command
+command = "last -R -F reboot"
+
+
+# Major time granularity (lines)
+day = timedelta(days = 1)
+
+# Minor time granularity (columns)
+half_hour = timedelta(minutes = 30)
+
+# Ratio of granularities (columns per line)
+half_hours_in_day = round(day / half_hour)
+
+
+output = subprocess.check_output(command.split(), universal_newlines = True)
+
+lines = output.splitlines()
+
+time_spans = []
+
+### Parse output
+for line in lines:
+ result = parse_line(line)
+ if result is not None:
+ time_spans.append(result)
+
+if not time_spans:
+ sys.exit("Error: '%s' returned no parsable output" % command)
+
+
+### Merge overlapping time spans (for unknown reasons, last sometimes outputs them)
+time_spans_merged = True
+while time_spans_merged:
+ time_spans_merged = False
+ time_spans_new = []
+ i = 0
+ while i < len(time_spans):
+ if i < len(time_spans) - 1 and time_overlap(time_spans[i], time_spans[i + 1]) > timedelta():
+ # Time spans overlap
+ time_spans_new.append({ "from" : min(time_spans[i]["from"], time_spans[i + 1]["from"]), \
+ "to" : max(time_spans[i]["to"], time_spans[i + 1]["to"]) })
+ i += 1
+ time_spans_merged = True
+ else:
+ time_spans_new.append(time_spans[i])
+ i += 1
+ time_spans = time_spans_new
+
+
+### Compute period
+latest_time = time_spans[0]["to"]
+earliest_time = time_spans[-1]["from"]
+
+latest_time = latest_time.replace(hour = 0, minute = 0, second = 0) + day
+earliest_time = earliest_time.replace(hour = 0, minute = 0, second = 0)
+
+
+### Compute runtime for each half hour time slot in period
+time_slots = []
+
+aggregated_time_slots = [ timedelta() ] * half_hours_in_day
+
+total_time = timedelta()
+
+current_time = latest_time
+while current_time > earliest_time:
+ current_time -= half_hour
+ time_slot = { "from" : current_time, "to" : current_time + half_hour }
+ time_in_slot = timedelta()
+ for time_span in time_spans:
+ time_in_slot += time_overlap(time_slot, time_span)
+ time_slots.append({ "time" : current_time, "time_in_slot" : time_in_slot })
+ half_hour_index = current_time.hour * 2 + (1 if current_time.minute >= 30 else 0)
+ aggregated_time_slots[half_hour_index] += time_in_slot
+ total_time += time_in_slot
+
+
+# Required because we want to process the individual days from the start of each one
+time_slots.reverse()
+
+
+time_header = " 0:00 " + style_text("", fgcolor = night_color) + \
+ " 6:00 " + style_text("", fgcolor = sunrise_color) + \
+ " 12:00 " + style_text("", fgcolor = noon_color) + \
+ " 18:00 " + style_text("", fgcolor = sunset_color) + \
+ " 24:00 " + style_text("", fgcolor = night_color)
+
+grid_header = style_text("▆ ▆ ▆ ▆ ▆", fgcolor = grid_color)
+grid_footer = style_text("▀ ▀ ▀ ▀ ▀", fgcolor = grid_color)
+
+level_characters = [ " ", "", "", "", "", "", "", "", "" ]
+
+
+# Set default foreground color for output
+print(get_escape_sequence(fgcolor = foreground_color), end = "")
+
+
+### Print summary
+number_of_days = (latest_time - earliest_time).days
+
+print(style_text("Period: ", bold = True) + \
+ earliest_time.strftime("%B %d %Y") + "" + \
+ (latest_time - day).strftime("%B %d %Y") + \
+ " (" + \
+ style_text("%d" % number_of_days, fgcolor = time_color) + \
+ style_text(" days", fgcolor = time_text_color) + ")")
+
+print()
+
+print(style_text("Total time running: ", bold = True) + format_delta(total_time))
+print(style_text("Daily average: ", bold = True) + format_delta(total_time / number_of_days))
+
+print()
+print()
+
+
+### Print histogram
+print(style_text("Histogram:", bold = True))
+print()
+print(time_header)
+print(" max " + grid_header)
+
+number_of_lines = 4
+
+levels = len(level_characters) - 1
+
+min_level = (min(aggregated_time_slots) / number_of_days) / half_hour
+max_level = (max(aggregated_time_slots) / number_of_days) / half_hour
+
+def format_histogram_line(label, index):
+ line = label + " "
+ slot_index = 0
+ for time_slot in aggregated_time_slots:
+ level = (time_slot / number_of_days) / half_hour
+ # Normalize level to increase resolution
+ level = (level - min_level) / (max_level - min_level)
+ level = round(level * (levels * number_of_lines)) - (index * levels)
+ # Clamp level to permissible range
+ level = max(0, min(levels, level))
+ grid = slot_index % 12 == 0
+ slot_index += 1
+ line += style_text(level_characters[level], \
+ fgcolor = histogram_color_grid[index] if grid else histogram_color[index],
+ bgcolor = grid_color if grid else None)
+ line += style_text(" ", bgcolor = grid_color)
+ return line
+
+print(format_histogram_line(" ", 3))
+print(format_histogram_line(" ", 2))
+print(format_histogram_line(" ", 1))
+print(format_histogram_line(" min", 0))
+
+print(" " + grid_footer)
+
+print()
+
+
+### Print month views
+current_time = latest_time
+
+# 0 is not a valid month index, so the "month changed" condition
+# will always be fulfilled in the first iteration
+current_month = 0
+
+# Do not use full block character here to keep separation between lines
+levels = len(level_characters) - 2
+
+while current_time > earliest_time:
+ current_time -= day
+
+ month_changed = current_time.month != current_month
+
+ if month_changed:
+ current_month = current_time.month
+ print()
+ print()
+ print(format_heading(current_time.strftime("%B %Y")))
+ print()
+ print(time_header)
+ print(" " + grid_header)
+
+ weekend = current_time.weekday() in [5, 6]
+ sunday = current_time.weekday() == 6
+
+ time_text = style_text(current_time.strftime("%a"), \
+ fgcolor = weekend_color if weekend else weekday_color, \
+ bold = sunday)
+ time_text += current_time.strftime(" %d").replace(" 0", " ")
+
+ output_line = time_text + " "
+
+ time_sum = timedelta()
+
+ bar_text = ""
+
+ slot_index = 0
+
+ ### Build chart for day
+ for time_slot in time_slots:
+ if time_slot["time"] >= current_time and time_slot["time"] < current_time + day:
+ time_sum += time_slot["time_in_slot"]
+ level = round((time_slot["time_in_slot"] / half_hour) * levels)
+ grid = slot_index % 12 == 0
+ slot_index += 1
+ bar_text += style_text(level_characters[level], \
+ fgcolor = (bar_weekend_color_grid if grid else bar_weekend_color) if weekend \
+ else (bar_color_grid if grid else bar_color),
+ bgcolor = grid_color if grid else None)
+
+ output_line += bar_text
+
+ output_line += style_text(" ", bgcolor = grid_color)
+
+ output_line += " " + format_delta_short(time_sum)
+
+ print(output_line)
+
+ if (current_time.day == 1) or \
+ (current_time <= earliest_time):
+ # End of month
+ print(" " + grid_footer)
+
+
+# Reset text attributes
+print(get_reset_sequence(), end = "")

0 comments on commit e477521

Please sign in to comment.
Something went wrong with that request. Please try again.