gulik/gulik/__init__.py

3727 lines
118 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3.6
# -*- coding: utf-8 -*-
"""
Architecture
------------
::
+-----------------------------------------+
| |
| gulik process | collector processes
| |
| (main thread) (monitor threads) |
| Gulik----------- CPUMonitor <-----ø-----> CPUCollector
| | | `- MemoryMonitor <-----ø-----> MemoryCollector
| | |`-- NetworkMonitor <-----ø-----> NetworkCollector
| | `--- … <-----ø-----> …
| | | |
| | | |
| `-- visualizers | (visualizers |
| | `- Arc | access |
| |`-- Plot <´ monitors) |
| `--- … |
| |
+-----------------------------------------+
In ``gulik``, there is one central :class:`Gulik` object.
It manages :class:`Monitor`\s and :class:`Visualizer`\s.
Visualizers use the :func:`Monitor.normalize` and :func:`Monitor.caption`
functions to utilize the collected data.
Communication between the :class:`Monitor`\s within the gulik process and
:class:`Collector` processes is done via queues. Every monitor/collector
pair shares two queues. One "update queue" that monitors use to send update
requests to collectors and one "data queue" that collectors use to send the
next datapoint to their respective monitor.
Box model
---------
::
+-----------------------------------------------------------+ ↑
| | |
| margin | |
| | |
| +---------------------------------------------------+ | |
| | | |
| | padding | | h
| | | | e
| | +-----------------------------------+ ↑ | | i
| | | | | inner | | g
| | | inner drawing region | | height | | h
| | | | | | | t
| | +-----------------------------------+ ↓ | |
| | | | |
| | ←------------ inner width ----------→ | | |
| | | | |
| +---------------------------------------------------+ | |
| | |
+-----------------------------------------------------------+ ↓
←--------------------------- width -------------------------→
``gulik``\s box model is pretty similar to the one CSS has, with
one important distinction: margins and paddings are included in
``width`` and ``height`` in order to make layouting easier.
Concepts
--------
.. _visualizer:
visualizer
^^^^^^^^^^
Visualizers are instances of any subclass of :class:`Visualizer`.
A visualizer is assigned a :class:`Monitor` and a list of :ref:`element`\s.
:class:`Gulik` will periodically call all visualizers ``update`` methods
(see source of :func:`Visualizer.update`).
What exactly happens in the ``update`` function of a visualizer differs
between the different classes, but usually it queries the instance-assigned
monitor for the most recent data about its :ref:`element`\s by calling
:func:`Monitor.normalize` and then does some drawing on the passed
:class:`cairo.Context` to visualize the data in some manner.
Currently, there are 7 built-in visualizers:
* :class:`Text`
* :class:`Rect`
* :class:`Arc`
* :class:`Plot`
* :class:`MirrorRect`
* :class:`MirrorArc`
* :class:`MirrorPlot`
You are, however, welcome to implement your own visualizers by subclassing
:class:`Visualizer` and overriding :func:`Visualizer.draw`.
.. _monitor:
monitor
^^^^^^^
Monitors are instances of any subclass of :class:`Monitor`,
which itself is a subclass of :class:`multithreading.Thread`.
Every monitor acts as one half of a monitor-:ref:`collector` pair,
each of which collects and transforms data on a specific :ref:`component`.
The monitors responsibility in this pair is to take data from the collector
and offer it in a form that is usable by :ref:`Visualizer`\s. This is mainly
done through the functions :func:`Monitor.normalize` and :func:`Monitor.caption`.
Currently, there are 6 built-in monitors:
* :class:`CPUMonitor`
* :class:`MemoryMonitor`
* :class:`NetworkMonitor`
* :class:`BatteryMonitor`
* :class:`DiskMonitor`
* :class:`NetdataMonitor`
.. _collector:
collector
^^^^^^^^^
Collectors are instances of any subclass of :class:`Collector`,
which itself is a subclass of :class:`multiprocessing.Process`.
Every collector acts as one half of a :ref:`monitor`-collector pair.
The collectors responsibility in this pair is to collect system usage
data and send them to its associated :ref:`monitor`.
.. _component:
component
^^^^^^^^^
A string identifying a data source.
Valid values are:
* ``'cpu'``
* ``'memory'``
* ``'network'``
* ``'battery'``
* ``'disk'``
Besides that, ``'netdata-<hostname>'`` is valid, but only if ``<hostname>``
exists in the ``NETDATA_HOSTS`` configuration option.
.. _element:
element
^^^^^^^
A string identifying a (sub) element of a data source.
Valid values are defined within the respective :class:`Monitor`\s.
.. _alignment:
alignment
^^^^^^^^^
A string in the shape of ``<x-align>_<y-align>``, where:
* ``<x-align>`` can be any of ``left``, ``center``, ``right``
* ``<y-align>`` can be any of ``top``, ``center``, ``bottom``
alignments are used both for text positioning relative to its respective
allowed borders as well as positioning captions within :class:`Visualizer`\s.
.. _caption-description:
caption description
^^^^^^^^^^^^^^^^^^^
A dictionary describing a caption to be rendered by a :class:`Visualizer`.
Required items:
* ``text``: The text to be rendered
Optional items:
* ``position``: Either an ``(x,y)`` coordinate tuple or an :ref:`alignment`
* ``align``: An :ref:`alignment`
* ``font_size``: A number (int or float) denoting vertical font size in pixels
* ``font_weight``: A string. ``Normal``, ``Bold``, ``Light``, etc.
* ``operator``: One of cairos blending operators (See :class:`cairo.Operator`)
.. _pattern:
pattern
^^^^^^^
A function taking one :class:`Color` as parameter returning a cairo surface for
use as fill. See :func:`stripe45` for an example.
.. _palette:
palette
^^^^^^^
A function taking one :class:`Color` and one `int` parameter returning a
``list`` of :class:`Color` objects with its length being equal to the passed
`int` parameter.
.. note:: :func:`palette_hue` and :func:`palette_value` have extra parameters
you won't be able to use without wrapping them in :func:`functools.partial`
first!
.. _combination:
combination
^^^^^^^^^^^
A string denoting how multiple :ref:`element`\s are displayed within a :class:`Visualizer`.
Valid values are:
* ``separate``: separate elements visually
* ``cumulative``: show values cumulatively, assume data is normalized for that (i.e. all values added max out at 1.0)
* ``cumulative_force``: like ``cumulative``, but assumes every single value can go up to 1.0
"""
#__all__ = ['Gulik', 'Visualizer']
import os
# get version from _version file
from gulik._version import version as __version__
import sys
import math
import time
import random
import signal
import collections
import queue
import functools
import threading
import multiprocessing
import setproctitle
import psutil
import colorsys
import cairo
import gi
gi.require_version('Gtk', '3.0')
gi.require_version('PangoCairo', '1.0') # not sure if want
from gi.repository import Gtk, Gdk, GLib, Pango, PangoCairo
from . import netdata
Operator = cairo.Operator
PAGESIZE = os.sysconf('SC_PAGESIZE')
## Helpers ##
class Color(object):
"""
Magic color class implementing and supplying on-the-fly manipulation of
RGB and HSV (and alpha) attributes.
"""
def __init__(self, red=None, green=None, blue=None, alpha=None, hue=None, saturation=None, value=None):
rgb_passed = bool(red)|bool(green)|bool(blue)
hsv_passed = bool(hue)|bool(saturation)|bool(value)
if not alpha:
alpha = 0.0
if rgb_passed and hsv_passed:
raise ValueError("Color can't be initialized with RGB and HSV at the same time.")
elif hsv_passed:
if not hue:
hue = 0.0
if not saturation:
saturation = 0.0
if not value:
value = 0.0
super(Color, self).__setattr__('hue', hue)
super(Color, self).__setattr__('saturation', saturation)
super(Color, self).__setattr__('value', value)
self._update_rgb()
else:
if not red:
red = 0
if not green:
green = 0
if not blue:
blue = 0
super(Color, self).__setattr__('red', red)
super(Color, self).__setattr__('green', green)
super(Color, self).__setattr__('blue', blue)
self._update_hsv()
super(Color, self).__setattr__('alpha', alpha)
def __setattr__(self, key, value):
if key in ('red', 'green', 'blue'):
if value > 1.0:
value = value % 1.0
super(Color, self).__setattr__(key, value)
self._update_hsv()
elif key in ('hue', 'saturation', 'value'):
if key == 'hue' and (value >= 360.0 or value < 0):
value = value % 360.0
elif key != 'hue' and value > 1.0:
value = 1.0
super(Color, self).__setattr__(key, value)
self._update_rgb()
else:
if key == 'alpha' and value > 1.0: # TODO: Might this be more fitting in another place?
value = 1.0
super(Color, self).__setattr__(key, value)
def __repr__(self):
return '<%s: red %f, green %f, blue %f, hue %f, saturation %f, value %f, alpha %f>' % (
self.__class__.__name__,
self.red,
self.green,
self.blue,
self.hue,
self.saturation,
self.value,
self.alpha
)
def clone(self):
return Color(red=self.red, green=self.green, blue=self.blue, alpha=self.alpha)
def blend(self, other, mode='normal'):
if self.alpha != 1.0: # no clue how to blend with a translucent bottom layer
self.red = self.red * self.alpha
self.green = self.green * self.alpha
self.blue = self.blue * self.alpha
self.alpha = 1.0
if mode == 'normal':
own_influence = 1.0 - other.alpha
self.red = (self.red * own_influence) + (other.red * other.alpha)
self.green = (self.green * own_influence) + (other.green * other.alpha)
self.blue = (self.blue * own_influence) + (other.blue * other.alpha)
def lighten(self, other):
if isinstance(other, int) or isinstance(other, float):
other = Color(red=other, green=other, blue=other, alpha=1.0)
if self.alpha != 1.0:
self.red = self.red * self.alpha
self.green = self.green * self.alpha
self.blue = self.blue * self.alpha
self.alpha = 1.0
red = self.red + (other.red * other.alpha)
green = self.green + (other.green * other.alpha)
blue = self.blue + (other.blue * other.alpha)
if red > 1.0:
red = 1.0
if green > 1.0:
green = 1.0
if blue > 1.0:
blue = 1.0
self.red = red
self.green = green
self.blue = blue
def darken(self, other):
if isinstance(other, int) or isinstance(other, float):
other = Color(red=other, green=other, blue=other, alpha=1.0)
red = self.red - other.red
green = self.green - other.green
blue = self.blue - other.blue
if red < 0:
red = 0
if green < 0:
green = 0
if blue < 0:
blue = 0
self.red = red
self.green = green
self.blue = blue
def tuple_rgb(self):
""" return color (without alpha) as tuple, channels being float 0.0-1.0 """
return (self.red, self.green, self.blue)
def tuple_rgba(self):
""" return color (*with* alpha) as tuple, channels being float 0.0-1.0 """
return (self.red, self.green, self.blue, self.alpha)
def _update_hsv(self):
hue, saturation, value = colorsys.rgb_to_hsv(self.red, self.green, self.blue)
super(Color, self).__setattr__('hue', hue * 360.0)
super(Color, self).__setattr__('saturation', saturation)
super(Color, self).__setattr__('value', value)
def _update_rgb(self):
red, green, blue = colorsys.hsv_to_rgb(self.hue / 360.0, self.saturation, self.value)
super(Color, self).__setattr__('red', red)
super(Color, self).__setattr__('green', green)
super(Color, self).__setattr__('blue', blue)
class DotDict(dict):
"""
A dictionary with its data being readable through faked attributes.
Used to avoid [[[][][][][]] in caption formatting.
"""
def __getattribute__(self, name):
#data = super(DotDict, self).__getattribute__('data')
keys = super(DotDict, self).keys()
if name in keys:
return self.get(name)
return super(DotDict, self).__getattribute__(name)
def palette_hue(base, count, distance=180):
"""
Creates a hue-rotation palette.
Parameters:
base (:class:`Color`): Color on which the palette will be based (i.e. the starting point of the hue-rotation).
count (int): number of colors the palette should hold.
distance (int or float): angular distance on a 360° hue circle thingamabob.
Returns:
list: A list of length **count** of :class:`Color` objects.
"""
if count == 1:
return [base]
palette = []
for i in range(0, count):
color = base.clone()
color.hue += i/(count - 1) * distance
palette.append(color)
return palette
def palette_value(base, count, min=None, max=None):
"""
Creates a value-stepped palette
Parameters:
base (:class:`Color`): Color on which the palette will be based (i.e. source of hue and saturation)
count (int): number of colors the palette should hold
min (float >= 0 and <= 1): minimum value (the v in hsv)
max (float >= 0 and <= 1): maximum value
Returns:
list: A list of length **count** of :class:`Color` objects.
"""
if count == 1:
return [base]
if min is None:
if 0.2 > base.value:
min = base.value
else:
min = 0.2
if max is None:
if 0.6 < base.value:
max = base.value
else:
max = 0.6
span = max - min
step = span / (count - 1)
palette = []
for i in range(0, count):
color = base.clone()
color.value = max - i * step
palette.append(color)
return palette
def pretty_si(number):
"""
Return a SI-postfixed string representation of a number (int or float).
"""
postfixes = ['', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']
value = number
for postfix in postfixes:
if value / 1000.0 < 1:
break
value /= 1000.0
return "%.2f%s" % (value, postfix)
def pretty_bytes(bytecount):
"""
Return a human-readable representation given a size in bytes.
"""
units = ['Byte', 'kbyte', 'Mbyte', 'Gbyte', 'Tbyte']
value = bytecount
for unit in units:
if value / 1024.0 < 1:
break
value /= 1024.0
return "%.2f %s" % (value, unit)
def pretty_bits(bytecount):
"""
Return a human-readable representation in bits given a size in bytes.
"""
units = ['bit', 'kbit', 'Mbit', 'Gbit', 'Tbit']
value = bytecount * 8 # bytes to bits
for unit in units:
if value / 1024.0 < 1:
break
value /= 1024.0
return "%.2f %s" % (value, unit)
def ignore_none(*args):
"""
Return the first passed value that isn't ``None``.
"""
for arg in args:
if not arg is None:
return arg
def condense_addr_parts(items):
matching = []
max_match = min([len(x) for x in items])
for i in range(0, max_match):
s = set()
for item_idx in range(0, len(items)):
s.add(items[item_idx][i])
if len(s) > 1: # lazy hack, means not all items have the same part at index i
break
matching.append(items[item_idx][i])
return '.'.join(matching)
def alignment_offset(align, size):
x_align, y_align = align.split('_')
if x_align == 'left':
x_offset = 0
elif x_align == 'center':
x_offset = -size[0] / 2
elif x_align == 'right':
x_offset = -size[0]
else:
raise ValueError("unknown horizontal alignment: '%s', must be one of: left, center, right" % x_align)
if y_align == 'top':
y_offset = 0
elif y_align == 'center':
y_offset = -size[1] / 2
elif y_align == 'bottom':
y_offset = -size[1]
else:
raise ValueError("unknown horizontal alignment: '%s', must be one of: top, center, bottom" % y_align)
return (x_offset, y_offset)
## PATTERNS ##
def stripe45(color):
surface = cairo.ImageSurface(cairo.Format.ARGB32, 10, 10)
context = cairo.Context(surface)
context.set_source_rgba(*color.tuple_rgba())
context.move_to(5, 5)
context.line_to(10, 0)
context.line_to(10, 5)
context.line_to(5, 10)
context.line_to(0, 10)
context.line_to(5, 5)
context.close_path()
context.fill()
context.move_to(0, 0)
context.line_to(5, 0)
context.line_to(0, 5)
context.close_path()
context.fill()
return surface
##class PeriodicCall(threading.Thread):
#
# """ Periodically forces a window to redraw """
#
# def __init__(self, target, hz):
#
# super(PeriodicCall, self).__init__()
# self.daemon = True
#
# self.target = target
# self.interval = 1.0/hz
#
#
# def run(self):
#
# while True: # This thread will automatically die with its parent because of the daemon flag
#
# self.target()
# time.sleep(self.interval)
try:
_default_height = Gdk.Screen().get_default().get_height()
except Exception: # to avoid setup failures in sandboxes without graphical environment
_default_height = 0
DEFAULTS = {
'FPS': 1,
'WIDTH': 200,
'HEIGHT': _default_height,
'X': 0,
'Y': 0,
'NETDATA_HOSTS': [],
'NETDATA_RETRY': 5,
'BSD_ACCURATE_MEMORY': False, # use inaccurate but cheaper memory collection on bsd
# styling stuff below this
'MARGIN': 5,
'PADDING': 5,
'PADDING_BOTTOM': 40, # space for 2-rows of autolegend
'FONT': 'Orbitron',
'FONT_WEIGHT': 'Light',
'FONT_SIZE': 10,
'COLOR_WINDOW_BACKGROUND': Color(0.05, 0.05, 0.05, 0.8),
'COLOR_BACKGROUND': Color(1,1,1, 0.1), # background for visualizers
'COLOR_FOREGROUND': Color(0.5, 1, 0, 0.6),
'COLOR_CAPTION': Color(1,1,1, 0.6),
#'COLOR_CAPTION_MINOR': Color(1,1,1, 0.3),
'PALETTE': functools.partial(palette_hue, distance=-120), # mhh, curry…
'PATTERN': stripe45,
'CAPTION_PLACEMENT': 'inner', # allow captions to be properly centered in the inner region of visualizers, as opposed to 'padding'
#'CAPTION_PLACEMENT': 'padding', # allow captions to be placed within paddings, as opposed to 'inner'
'LEGEND': True,
'LEGEND_ORDER': 'normal', # other valid value: 'reverse'
'LEGEND_SIZE': 20, # not font size, but height of one legend cell, including margin and padding.
'LEGEND_PLACEMENT': 'padding',
'LEGEND_MARGIN': 2.5,
#'LEGEND_PADDING': 2.5,
'LEGEND_PADDING': 0,
'OPERATOR': Operator.OVER,
# class-specific default styles
'PADDING_TEXT': 0, # otherwise text will be tiny
'PADDING_RECT': 5,
'FONT_SIZE_RECT': 14,
'FONT_WEIGHT_RECT': 'Bold',
#'COLOR_RECT_CAPTION': Color(1,1,1, 1),
'PATTERN_RECT': None,
'OPERATOR_RECT_CAPTION': Operator.DIFFERENCE, # grants visibility no matter how much of the rect is filled
'CAPTION_PLACEMENT_RECT': 'inner', # to get padding around cut-out
'PATTERN_ARC': None,
'PATTERN_MIRRORARC': None,
'AUTOSCALE': False,
'LINE': True,
'MARKERS': False,
'GRID': True,
}
## Stuff I'd much rather do without a huge dependency like gtk ##
class Window(Gtk.Window):
def __init__(self):
super(Window, self).__init__()
self.set_title('gulik')
self.set_role('gulik')
self.stick() # show this window on every virtual desktop
self.set_app_paintable(True)
self.set_type_hint(Gdk.WindowTypeHint.DOCK)
self.set_keep_below(True)
screen = self.get_screen()
visual = screen.get_rgba_visual()
if visual != None and screen.is_composited():
self.set_visual(visual)
self.show_all()
@property
def width(self):
return self.get_size()[0]
@property
def height(self):
return self.get_size()[1]
## Collectors ##
class Collector(multiprocessing.Process):
def __init__(self, app, queue_update, queue_data):
super(Collector, self).__init__()
self.daemon = True
self.app = app
self.queue_update = queue_update
self.queue_data = queue_data
self.elements = []
def terminate(self):
#self.queue_data.close() # closing queues manually actually seems to mess stuff up
# Would've done this cleaner, but after half a day of chasing some
# retarded quantenbug I'm done with this shit. Just nuke the fucking
# things from orbit.
os.kill(self.pid, signal.SIGKILL)
#super(Collector, self).terminate()
def run(self):
setproctitle.setproctitle(f"gulik - {self.__class__.__name__}")
while True:
try:
msg = self.queue_update.get(block=True)
if msg == 'UPDATE':
self.update()
except KeyboardInterrupt: # so we don't randomly explode on ctrl+c
pass
def update(self):
raise NotImplementedError("%s.update not implemented!" % self.__class__.__name__)
class CPUCollector(Collector):
def update(self):
count = psutil.cpu_count()
aggregate = psutil.cpu_percent(percpu=False)
percpu = psutil.cpu_percent(percpu=True)
self.queue_data.put(
{
'count': count,
'aggregate': aggregate,
'percpu': percpu
},
block=True
)
# according to psutil docs, there should at least be 0.1 seconds
# between calls to cpu_percent without sampling interval
time.sleep(0.1)
class MemoryCollector(Collector):
def update(self):
vmem = psutil.virtual_memory()
processes = []
total_use = 0
for process in psutil.process_iter():
if psutil.LINUX or (psutil.BSD and not self.app.config['BSD_ACCURATE_MEMORY']):
if psutil.BSD:
key = 'rss'
else:
key = 'pss'
try:
pmem = process.memory_full_info()._asdict()
processes.append(DotDict({
'name': process.name(),
'size': pmem[key],
#'shared': pmem.shared,
'percent': pmem[key] / vmem.total * 100
}))
total_use += pmem[key]
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess) as e:
continue # skip to next process
elif psutil.BSD:
try:
resident = 0
size = 0
#shared = 0
try:
for mmap in process.memory_maps():
# assuming everything with a real path is
# "not really in ram", but no clue.
if mmap.path.startswith('['):
size += mmap.private * PAGESIZE
resident += mmap.rss * PAGESIZE
#shared += (mmap.rss - mmap.private) * PAGESIZE # FIXME: probably broken, can yield negative values
except OSError:
pass # probably "device not available"
processes.append(DotDict({
'name': process.name(),
'size': size,
#'shared': resident - size,
'percent': size / vmem.total * 100,
}))
total_use += size
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess) as e:
pass# TODO: add counter for processes we can't introspect
info = DotDict({
'total': vmem.total,
'percent': total_use / vmem.total * 100,
'available': vmem.total - total_use
})
processes_sorted = sorted(processes, key=lambda x: x['size'], reverse=True)
for i, process in enumerate(processes_sorted[:3]):
info['top_%d' % (i + 1)] = process
info['other'] = DotDict({
'name': 'other',
'size': 0,
#'shared': 0,
'count': 0
})
for process in processes_sorted[3:]:
info['other']['size'] += process['size']
#info['other']['shared'] += process['shared']
info['other']['count'] += 1
info['other']['percent'] = info['other']['size'] / vmem.total * 100
self.queue_data.put(info, block=True)
#time.sleep(1) # because this became horribly slow
class NetworkCollector(Collector):
def update(self):
stats = psutil.net_if_stats()
addrs = psutil.net_if_addrs()
counters = psutil.net_io_counters(pernic=True)
connections = psutil.net_connections(kind='all')
self.queue_data.put(
{
'stats': stats,
'addrs': addrs,
'counters': counters,
'connections': connections,
},
block=True
)
class BatteryCollector(Collector):
def update(self):
self.queue_data.put(psutil.sensors_battery(), block=True)
class DiskCollector(Collector):
def __init__(self, *args, **kwargs):
super(DiskCollector, self).__init__(*args, **kwargs)
self.previous_io = {}
def update(self):
data = {}
partitions = psutil.disk_partitions()
data['partitions'] = {}
for partition in partitions:
name = partition.device.split('/')[-1].replace('.', '-')
data['partitions'][name] = partition._asdict()
data['partitions'][name]['name'] = name
data['partitions'][name]['usage'] = psutil.disk_usage(partition.mountpoint)._asdict()
io = psutil.disk_io_counters(perdisk=True)
data['io'] = {}
for disk, info in io.items():
previous_info = self.previous_io.get(disk, None)
if previous_info is None:
data['io'][disk] = {
'read_count': 0,
'write_count': 0,
'read_bytes': 0,
'write_bytes': 0,
'read_time': 0,
'write_time': 0,
'busy_time': 0
}
else:
info = info._asdict()
previous_info = previous_info._asdict()
data['io'][disk] = {}
for k in ['read_count', 'write_count', 'read_bytes', 'write_bytes', 'read_time', 'write_time', 'busy_time']:
data['io'][disk][k] = (info[k] - previous_info[k]) * self.app.config['FPS']
#data['io'][disk] = info._asdict()
self.queue_data.put(data, block=True)
self.previous_io = io
class NetdataCollector(Collector):
def __init__(self, app, queue_update, queue_data, host, port):
super(NetdataCollector, self).__init__(app, queue_update, queue_data)
self.client = netdata.Netdata(host, port=port, timeout=1/self.app.config['FPS'])
def run(self):
setproctitle.setproctitle(f"gulik - {self.__class__.__name__}")
while True:
try:
msg = self.queue_update.get(block=True)
if msg.startswith('UPDATE '):
chart = msg[7:]
self.update(chart)
except KeyboardInterrupt: # so we don't randomly explode on ctrl+c
pass
def update(self, chart):
try:
# get the last second of data condensed to one point
data = self.client.data(chart, points=1, after=-1, options=['absolute'])
except netdata.NetdataException:
pass
else:
self.queue_data.put((chart, data), block=True)
## Monitors ##
class Monitor(threading.Thread):
"""
The base class for all :ref:`monitor`\s.
"""
collector_type = Collector
def __init__(self, app, component):
super(Monitor, self).__init__()
self.app = app
self.component = component
self.daemon = True
self.seppuku = False
self.queue_update = multiprocessing.Queue(1)
self.queue_data = multiprocessing.Queue(1)
self.collector = self.collector_type(self.app, self.queue_update, self.queue_data)
self.data = {}
self.defective = False # for future use, mostly for networked monitors (netdata, mpd, …)
def register_elements(self, elements):
pass
def tick(self):
if not self.queue_update.full():
self.queue_update.put('UPDATE', block=True)
def start(self):
self.collector.start()
super(Monitor, self).start()
def run(self):
#while self.collector.is_alive():
while not self.seppuku:
try:
self.data = self.queue_data.get(timeout=1)
except queue.Empty:
# try again, but give thread the ability to die without
# waiting on collector indefinitely
continue
self.commit_seppuku()
def commit_seppuku(self):
print(f"{self.__class__.__name__} committing glorious seppuku!")
#self.queue_update.close()
self.collector.terminate()
self.collector.join()
def normalize(self, element):
raise NotImplementedError("%s.normalize not implemented!" % self.__class__.__name__)
def caption(self, fmt):
raise NotImplementedError("%s.caption not implemented!" % self.__class__.__name__)
class CPUMonitor(Monitor):
"""
Memory for CPU usage.
"""
collector_type = CPUCollector
def normalize(self, element):
"""
Elements exposed:
* ``aggregate``: average cpu use, sum of all core loads divided by number of cores
* ``core_<n>``: load of core ``<n>``, with possible values of ``<n>`` being 0 to number of cores - 1
"""
if not self.data:
return 0
if element == 'aggregate':
return self.data['aggregate'] / 100.0
# assume core_<n> otherwise
idx = int(element.split('_')[1])
return self.data['percpu'][idx] / 100.0
def caption(self, fmt):
"""
Exposed keys:
* ``aggregate``: average cpu use, sum of all core loads divided by number of cores
* ``core_<n>``: load of core ``<n>``, with possible values of ``<n>`` being 0 to number of cores - 1
* ``count``: number of cores
"""
if not self.data:
return fmt
data = {}
data['count'] = self.data['count']
data['aggregate'] = self.data['aggregate']
for idx, perc in enumerate(self.data['percpu']):
data['core_%d' % idx] = perc
return fmt.format(**data)
class MemoryMonitor(Monitor):
"""
Monitor for memory usage
"""
collector_type = MemoryCollector
def normalize(self, element):
"""
Elements exposed:
* ``percent``: memory use of all processes.
* ``top_<n>``: memory use of the ``<n>``\th-biggest process. Valid values of ``<n>`` are 1-3.
* ``other``: memory use of all processes except the top 3
"""
if not self.data:
return 0
if element == 'percent':
return self.data.get('percent', 0) / 100.0
return self.data[element].get('percent', 0) / 100.0
def caption(self, fmt):
"""
Exposed keys:
* ``total``: how much memory this machine has in total,
* ``percent``: total memory usage in percent.
* ``available``: how much memory can be malloc'd without going into swap (roughly).
* ``top_<n>``: access information about the 3 "biggest" processes. possible subkeys are ``name``, ``size`` and ``percent``.
* ``other``: aggregate information for all processes *except* the top 3. Same subkeys as those, plus ``'count``.
"""
if not self.data:
return fmt
data = DotDict()#dict(self.data) # clone
data['total'] = pretty_bytes(self.data['total'])
data['available'] = pretty_bytes(self.data['available'])
data['percent'] = self.data['percent']
for k in ['top_1', 'top_2', 'top_3', 'other']:
data[k] = DotDict()
data[k]['name'] = self.data[k]['name']
data[k]['size'] = pretty_bytes(self.data[k]['size'])
#data[k]['shared'] = pretty_bytes(self.data[k]['shared'])
if k == 'other':
data[k]['count'] = self.data[k]['count']
return fmt.format(**data)
class NetworkMonitor(Monitor):
"""
Monitor for network interfaces.
"""
collector_type = NetworkCollector
def __init__(self, app, component):
super(NetworkMonitor, self).__init__(app, component)
self.interfaces = collections.OrderedDict()
if self.app.config['FPS'] < 2:
# we need a minimum of 2 samples so we can compute a difference
deque_len = 2
else:
# max size equal fps means this holds data of only the last second
deque_len = self.app.config['FPS']
keys = [
'bytes_sent',
'bytes_recv',
'packets_sent',
'packets_recv',
'errin',
'errout',
'dropin',
'dropout'
]
for if_name in psutil.net_io_counters(pernic=True).keys():
self.interfaces[if_name] = {
'addrs': {},
'stats': {},
'counters': {}
}
for key in keys:
self.interfaces[if_name]['counters'][key] = collections.deque([], deque_len)
self.aggregate = {
'if_count': len(self.interfaces),
'if_up': 0,
'speed': 0, # aggregate link speed
'counters': {}
}
for key in keys:
self.aggregate['counters'][key] = collections.deque([], deque_len)
def run(self):
while not self.seppuku:
try:
self.data = self.queue_data.get(timeout=1)
except queue.Empty:
# try again, but give thread the ability to die
# without waiting on collector indefinitely.
continue
aggregates = {}
for key in self.aggregate['counters']:
#self.aggregate['counters'][k] = []
aggregates[key] = 0
self.aggregate['speed'] = 0
for if_name, if_data in self.interfaces.items():
if_has_data = if_name in self.data['counters'] and\
if_name in self.data['stats'] and\
if_name in self.data['addrs']
if if_has_data:
for key, deque in if_data['counters'].items():
value = self.data['counters'][if_name]._asdict()[key]
deque.append(value)
aggregates[key] += value
self.interfaces[if_name]['stats'] = self.data['stats'][if_name]._asdict()
if self.interfaces[if_name]['stats']['speed'] == 0:
self.interfaces[if_name]['stats']['speed'] = 1000 # assume gbit speed per default
self.aggregate['speed'] += self.interfaces[if_name]['stats']['speed']
if if_name in self.data['addrs']:
self.interfaces[if_name]['addrs'] = self.data['addrs'][if_name]
else:
self.interfaces[if_name]['addrs'] = []
for key, value in aggregates.items():
self.aggregate['counters'][key].append(value)
self.commit_seppuku()
def count_sec(self, interface, key):
"""
get a specified count for a given interface
as calculated for the last second.
EXAMPLE: ``self.count_sec('eth0', 'bytes_sent')``
(will return count of bytes sent in the last second)
"""
if interface == 'aggregate':
deque = self.aggregate['counters'][key]
else:
deque = self.interfaces[interface]['counters'][key]
if len(deque) < 2: # not enough data
return 0
elif self.app.config['FPS'] < 2:
# fps < 1 means data covers 1/fps seconds
return (deque[-1] - deque[0]) / self.app.config['FPS']
else:
# last (most recent) minus first (oldest) item
return deque[-1] - deque[0]
def normalize(self, element):
"""
Exposed elements:
* ``<if>.bytes_sent``: upload of network interface ``<if>``.
* ``<if>.bytes_recv``: download of network interface ``<if>``.
`<if>` can be any local network interface as well as `'aggregate'`.
"""
if_name, key = element.split('.')
if if_name == 'aggregate':
if len(self.aggregate['counters'][key]) >= 2:
link_quality = float(self.aggregate['speed'] * 1024**2)
return (self.count_sec(if_name, key) * 8) / link_quality
elif len(self.interfaces[if_name]['counters'][key]) >= 2:
link_quality = float(self.interfaces[if_name]['stats']['speed'] * 1024**2)
return (self.count_sec(if_name, key) * 8) / link_quality
# program flow should only arrive here if we have less than 2
# datapoints in which case we can't establish used bandwidth.
return 0
def caption(self, fmt):
"""
Exposed keys:
* ``<if>.bytes_sent``: upload of network interface ``<if>``.
* ``<if>.bytes_recv``: download of network interface ``<if>``.
* ``<if>.if_up``: Boolean, whether the interface is up.
* ``<if>.speed``: interface speed in Mbit/s
* ``<if>.counters``: supplies access to interface counters. Possible sub-elements are:
* ``bytes_sent``
* ``bytes_recv``
* ``packets_sent``
* ``packets_recv``
* ``errin``
* ``errout``
* ``dropin``
* ``dropout``
`<if>` can be any local network interface as well as ``'aggregate'``.
Additionally, the ``'aggregate'`` interface exposes the total
count of network interfaces as ``if_count``.
"""
if not self.data:
return fmt
data = {}
data['aggregate'] = DotDict()
data['aggregate']['if_count'] = self.aggregate['if_count']
data['aggregate']['if_up'] = self.aggregate['if_up']
data['aggregate']['speed'] = self.aggregate['speed']
data['aggregate']['counters'] = DotDict()
for key in self.aggregate['counters'].keys():
data['aggregate']['counters'][key] = self.count_sec('aggregate', key)
if key.startswith('bytes'):
data['aggregate']['counters'][key] = pretty_bits(data['aggregate']['counters'][key]) + '/s'
for if_name in self.interfaces.keys():
data[if_name] = DotDict()
data[if_name]['addrs'] = DotDict()
all_addrs = []
for idx, addr in enumerate(self.interfaces[if_name]['addrs']):
data[if_name]['addrs'][str(idx)] = addr
all_addrs.append(addr.address)
data[if_name]['all_addrs'] = u"\n".join(all_addrs)
data[if_name]['stats'] = DotDict(self.interfaces[if_name]['stats'])
data[if_name]['counters'] = DotDict()
for key in self.interfaces[if_name]['counters'].keys():
data[if_name]['counters'][key] = self.count_sec(if_name, key)
if key.startswith('bytes'):
data[if_name]['counters'][key] = pretty_bits(data[if_name]['counters'][key]) + '/s'
return fmt.format(**data)
class BatteryMonitor(Monitor):
"""
Monitor laptop batteries.
"""
collector_type = BatteryCollector
def normalize(self, element):
"""
This function exposes no explicit elements, but always just returns
the current fill of the battery.
"""
# TODO: multi-battery support? needs support by psutil…
if not self.data:
return 0
return self.data.percent / 100.0
def caption(self, fmt):
"""
Exposed keys:
* ``power_plugged``: Boolean, whether the AC cable is connected.
* ``percent``: current fill of the battery in percent.
* ``secsleft``: seconds left till battery is completely drained.
* ``state``: Current state of the battery, one of ``'full'``, ``'charging'`` or ``'draining'``.
"""
if not self.data:
return fmt
data = self.data._asdict()
if not data['power_plugged']:
data['state'] = 'draining'
elif data['percent'] == 100:
data['state'] = 'full'
else:
data['state'] = 'charging'
return fmt.format(**data)
class DiskMonitor(Monitor):
"""
Monitors disk I/O and partitions.
"""
collector_type = DiskCollector
def __init__(self, *args, **kwargs):
super(DiskMonitor, self).__init__(*args, **kwargs)
self.normalization_values = {}
io = psutil.disk_io_counters(perdisk=True)
for disk, info in io.items():
#self.normalization_values[disk] = info._asdict()
for key, value in info._asdict().items():
element = '.'.join(['io', disk, key])
if key.endswith('_bytes'):
self.normalization_values[element] = 100 * 1024 ** 2 # assume baseline ability of 100 MByte/s
elif key.endswith('_count'):
self.normalization_values[element] = 0 # FIXME: I have 0 clue what a reasonable baseline here is
elif key.endswith('_time'):
self.normalization_values[element] = 1000 # one second of data, collector reports data in milliseconds/s
else:
self.normalization_values[element] = 0
def normalize(self, element):
"""
Elements exposed:
* ``io``
* Valid subelements are disk device file names as found in
``/dev``. Examples: ``ada0``, ``sda``.
Valid subsubelements are as follows:
* ``read_bytes``
* ``write_bytes``
* ``read_time``
* ``write_time``
* ``busy_time``
* ``partitions``
* Valid subelements are partition device file names as
found in ``/dev``, with dots (``.``) being replaced
with dashes (``-``). Examples: ``root-eli``, ``sda1``.
"""
parts = element.split('.')
if parts[0] == 'io':
disk, key = parts[1:]
value = self.data['io'][disk][key]
if self.normalization_values[element] < value:
self.normalization_values[element] = value
return self.data['io'][disk][key] / self.normalization_values[element]
elif parts[0] == 'partitions':
name = parts[1]
info = self.data['partitions'][name]['usage']
return info['used'] / info['total']
return 0
def caption(self, fmt):
"""
Exposed keys are the same as for :func:`DiskMonitor.normalize`.
"""
data = DotDict()
if 'partitions' in self.data and 'io' in self.data:
data['io'] = DotDict()
for name, diskinfo in self.data['io'].items():
data['io'][name] = DotDict()
for key, value in diskinfo.items():
if key.endswith('_bytes'):
value = pretty_bytes(value)
data['io'][name][key] = value
data['partitions'] = DotDict()
for name, partition in self.data['partitions'].items():
part_data = DotDict()
for key in ['name', 'device', 'mountpoint', 'fstype', 'opts']:
part_data[key] = partition[key]
part_data['usage'] = DotDict(partition['usage'])
data['partitions'][name] = part_data
return fmt.format(**data)
return fmt
class NetdataMonitor(Monitor):
"""
Monitor that interfaces with (remote) netdata instances.
"""
collector_type = NetdataCollector
def __init__(self, app, component, host, port):
self.collector_type = functools.partial(self.collector_type, host=host, port=port)
super(NetdataMonitor, self).__init__(app, component)
self.charts = set()
self.normalization_values = {} # keep a table of known maximums because netdata doesn't supply absolute normalization values
self.info_last_try = time.time()
try:
self.netdata_info = self.collector.client.charts()
except netdata.NetdataException as e:
print(f"Couldn't get chart overview from netdata host {host}!")
self.netdata_info = None
self.defective = True
self.almost_fixed = False
def __repr__(self):
return f"<{self.__class__.__name__} host={self.collector.client.host} port={self.collector.client.port}>"
def register_elements(self, elements):
for element in elements:
parts = element.split('.')
chart = '.'.join(parts[:2])
if not chart in self.charts:
self.normalization_values[chart] = 0
if self.netdata_info:
if not chart in self.netdata_info['charts']:
raise ValueError(f"Invalid chart: {chart} on netdata instance {self.host}:{self.port}!")
chart_info = self.netdata_info['charts'][chart]
if chart_info['units'] == 'percentage':
self.normalization_values[chart] = 100
else:
self.normalization_values[chart] = 0
self.charts.add(chart)
def run(self):
#while self.collector.is_alive():
while not self.seppuku:
try:
(chart, data) = self.queue_data.get(timeout=1/self.app.config['FPS'])
self.data[chart] = data
l = len(data['data'][0])
if(l < 2):
print(f"Missing data, marking {self} as defective.")
self.defective = True
else:
values = data['data'][0][1:]
if self.defective:
pass
elif values[0] is None: # ignore "dead" datapoints
print(f"Got dead datapoint, marking {self} as defective.")
self.defective = True
elif self.netdata_info['charts'][chart]['units'] == 'percentage':
self.normalization_values[chart] = 100 # in case self was defective when register_elements was called
else:
cumulative_value = sum(data['data'][0][1:])
if self.normalization_values[chart] < cumulative_value:
self.normalization_values[chart] = cumulative_value
except queue.Empty:
continue # try again
self.commit_seppuku()
def tick(self):
if self.defective:
t = time.time()
if t >= self.info_last_try + self.app.config['NETDATA_RETRY']:
if self.almost_fixed:
print(f"{self.__class__.__name__} instance almost fixed, ignoring first chart overview because of netdata weirdness.")
else:
print(f"{self.__class__.__name__} instance currently defective, trying to get netdata overview from {self.collector.client.host}.")
self.info_last_try = t
try:
self.netdata_info = self.collector.client.charts()
if self.almost_fixed:
self.defective = False
self.almost_fixed = False
self.tick() # do the actual tick (i.e. the else clause)
if not self.defective: # in case defective was re-set in because of dead datapoints
print("Success!")
else:
self.almost_fixed = True
except netdata.NetdataException as e:
print(f"Failed, will retry in {self.app.config['NETDATA_RETRY']} seconds.")
else:
if not self.queue_update.full():
#if not self.seppuku: # don't request more updates to collector when we're trying to die
for chart in self.charts:
self.queue_update.put(f"UPDATE {chart}", block=True)
def normalize(self, element):
"""
Exposed elements correspond to *chart names* and their datapoint
*dimension*s. For a list of valid chart and dimensions names, consult
``/api/v1/charts`` of the netdata instance in question.
Examples:
* ``system.cpu.nice``
* ``disk.ada0.writes``
"""
if self.defective:
return 0
else:
parts = element.split('.')
chart = '.'.join(parts[:2])
#if chart not in self.charts or not self.data[chart]:
if not chart in self.data:
#print(f"No data for {chart}")
return 0 #
#timestamp = self.data[chart]['data'][0][0] # first element of a netdata datapoint is always time
#if timestamp > self.last_updates[chart]:
subelem = parts[2]
subidx = self.data[chart]['labels'].index(subelem)
value = self.data[chart]['data'][0][subidx]
if value >= self.normalization_values[chart]:
self.normalization_values[chart] = value
if self.normalization_values[chart] == 0:
return 0
r = value / self.normalization_values[chart]
return r
def caption(self, fmt):
"""
Exposed keys are the same as for :func:`NetdataMonitor.normalize`.
"""
if not self.data or self.defective:
return fmt
data = DotDict()
for chart_name, chart_data in self.data.items():
chart_keys = chart_name.split('.')
unit = self.netdata_info['charts'][chart_name]['units'] # called "units" but actually only ever one. it's a string.
if not chart_keys[0] in data:
data[chart_keys[0]] = DotDict()
d = DotDict()
for idx, label in enumerate(chart_data['labels']):
value = chart_data['data'][0][idx]
if value == None:
value = 0
elif unit == 'bytes':
value = pretty_bytes(value)
elif unit.startswith('kilobytes'):
postfix = unit[9:]
value = pretty_bytes(value * 1024) + postfix
elif unit.startswith('kilobits'):
postfix = unit[8:]
value = pretty_bits(value * 1024) + postfix
else:
value = f"{value} {unit}"
d[label] = value
data[chart_keys[0]][chart_keys[1]] = d
return fmt.format(**data)
## Visualizers ##
class Visualizer(object):
"""
The base class for all visualizers. Not called widget to avoid naming
confusion with gtk widgets (which aren't even used in gulik).
Usually you won't instantiate this by yourself but use :func:`Box.place`.
Parameters:
app (:class:`Gulik`): The app managing visualizers and monitors
monitor (:class:`Monitor`): The monitor managing data collection for this visualizer
x (int): leftmost coordinate on the x-axis
y (int): topmost coordinate on the y-axis
width (int): overall width (including **margin** and **padding**)
height (int): overall height (including **margin** and **padding**)
margin (int, optional): margin applied around all sides
margin_left (int, optional): margin applied to the left side
margin_right (int, optional): margin applied to the right side
margin_top (int, optional): margin applied to the top side
margin_bottom (int, optional): margin applied to the bottom side
padding (int, optional): padding applied around all sides
padding_left (int, optional): padding applied to the left side
padding_right (int, optional): padding applied to the right side
padding_top (int, optional): padding applied to the top side
padding_bottom (int, optional): padding applied to the bottom side
elements (list of str): A list of :ref:`element`\s to visualize
captions (list of dict, optional): A list of :ref:`caption descriptions<caption-description>`
legend (bool): Whether to try to automatically create a legend of elements
legend_order('normal' or 'reverse', optional): Whether to reverse the legend order
legend_format(str): A format string, can contain ``{element}`` to refer to the element legend items refer to.
legend_size (int, optional): height of a single legend item
legend_placement ('padding' or 'inner'): Where to place the legend
legend_margin (int, optional): margin applied around all sides of the legend
legend_margin_left (int, optional): margin applied to the left side of the legend
legend_margin_right (int, optional): margin applied to the right side of the legend
legend_margin_top (int, optional): margin applied to the top side of the legend
legend_margin_bottom (int, optional): margin applied to the bottom side of the legend
legend_padding (int, optional): padding applied around all sides of the legend
legend_padding_left (int, optional): padding applied to the left side of the legend
legend_padding_right (int, optional): padding applied to the right side of the legend
legend_padding_top (int, optional): padding applied to the top side of the legend
legend_padding_bottom (int, optional): padding applied to the bottom side of the legend
foreground (:class:`Color`, optional): The foreground color, base-color for generated palettes
background (:class:`Color`, optional): The background color
pattern (function, optional): An executable :ref:`pattern`
palette (function, optional): An executable :ref:`palette`
combination (str, optional): The :ref:`combination` mode used when displaying multiple :ref:`element`\s
"""
def __init__(
self,
app,
monitor,
x=0,
y=0,
width=None,
height=None,
margin=None,
margin_left=None,
margin_right=None,
margin_top=None,
margin_bottom=None,
padding=None,
padding_left=None,
padding_right=None,
padding_top=None,
padding_bottom=None,
elements=None,
captions=None,
caption_placement=None,
legend=None,
legend_order=None,
legend_format=None,
legend_size=None,
legend_placement=None,
legend_margin=None,
legend_margin_left=None,
legend_margin_right=None,
legend_margin_top=None,
legend_margin_bottom=None,
legend_padding=None,
legend_padding_left=None,
legend_padding_right=None,
legend_padding_top=None,
legend_padding_bottom=None,
foreground=None,
background=None,
pattern=None,
palette=None,
combination=None,
operator=None
):
self.app = app
self.monitor = monitor
self.x = x
self.y = y
self.elements = ignore_none(elements, [])
self.captions = ignore_none(captions, list())
self.caption_placement = ignore_none(caption_placement, self.get_style('caption_placement'))
self.legend = ignore_none(legend, self.get_style('legend'))
self.legend_order = ignore_none(legend_order, self.get_style('legend_order'))
self.legend_format = legend_format
self.legend_placement = ignore_none(legend_placement, self.get_style('legend_placement'))
self.legend_size= ignore_none(legend_size, self.get_style('legend_size'))
self.legend_margin_left = ignore_none(legend_margin_left, legend_margin, self.get_style('legend_margin', 'left'))
self.legend_margin_right = ignore_none(legend_margin_right, legend_margin, self.get_style('legend_margin', 'right'))
self.legend_margin_top = ignore_none(legend_margin_top, legend_margin, self.get_style('legend_margin', 'top'))
self.legend_margin_bottom = ignore_none(legend_margin_bottom, legend_margin, self.get_style('legend_margin', 'bottom'))
self.legend_padding_left = ignore_none(legend_padding_left, legend_padding, self.get_style('legend_padding', 'left'))
self.legend_padding_right = ignore_none(legend_padding_right, legend_padding, self.get_style('legend_padding', 'right'))
self.legend_padding_top = ignore_none(legend_padding_top, legend_padding, self.get_style('legend_padding', 'top'))
self.legend_padding_bottom = ignore_none(legend_padding_bottom, legend_padding, self.get_style('legend_padding', 'bottom'))
self.operator = ignore_none(operator, self.get_style('operator'))
self.width = ignore_none(width, self.get_style('width'))
self.height = ignore_none(height, self.get_style('height'))
self.margin_left = ignore_none(margin_left, margin, self.get_style('margin', 'left'))
self.margin_right = ignore_none(margin_right, margin, self.get_style('margin', 'right'))
self.margin_top = ignore_none(margin_top, margin, self.get_style('margin', 'top'))
self.margin_bottom = ignore_none(margin_bottom, margin, self.get_style('margin', 'bottom'))
self.padding_left = ignore_none(padding_left, padding, self.get_style('padding', 'left'))
self.padding_right = ignore_none(padding_right, padding, self.get_style('padding', 'right'))
self.padding_top = ignore_none(padding_top, padding, self.get_style('padding', 'top'))
self.padding_bottom = ignore_none(padding_bottom, padding, self.get_style('padding', 'bottom'))
self.colors = {}
self.colors['foreground'] = ignore_none(foreground, self.get_style('color', 'foreground'))
self.colors['background'] = ignore_none(background, self.get_style('color', 'background'))
self.pattern = ignore_none(pattern, self.get_style('pattern'))
self.palette = ignore_none(palette, self.get_style('palette')) # function to generate color palettes with
self.combination = ignore_none(combination, 'separate') # combination mode when handling multiple elements. 'separate', 'cumulative' or 'cumulative_force'. cumulative assumes all values add up to max 1.0, while separate assumes every value can reach 1.0 and divides all values by the number of elements handled
assert self.inner_width > 0, f"margin and padding too big, implying negative inner width for {self.__class__.__name__}/{self.monitor.component}/{self.elements}"
assert self.inner_height > 0, f"margin and padding too big, implying negative inner height for {self.__class__.__name__}/{self.monitor.component}/{self.elements}"
self.monitor.register_elements(self.elements)
if self.legend:
legend_x = self.x + self.margin_left + self.padding_left
legend_y = self.y + self.margin_top + self.padding_top
if self.legend_placement == 'inner':
legend_height = self.inner_height
else: # 'padding'
legend_y += self.inner_height
legend_height = self.padding_bottom
rownum = legend_height // self.legend_size
if rownum < 1:
print(f"Can't add autolegend to {self.__class__.__name__}/{self.monitor.component} because of insufficient space: {legend_height}px")
else:
legend_info = self.legend_info()
colnum = math.ceil(len(legend_info) // rownum) or 1
cell_width = self.inner_width // colnum
#colors = self.palette(self.colors['foreground'], len(self.elements))
box = self.app.box(legend_x, legend_y, self.inner_width, legend_height)
if self.legend_order == 'reverse':
iterator = reversed(legend_info)
else:
iterator = legend_info
#for element in legend_elements:
for element in iterator:
color, text = legend_info[element]
if self.legend_format:
text = self.legend_format.format(**{'element': element})
box.place(
self.monitor.component,
Text,
text=text,
align='center_center',
foreground=color,
background=Color(0,0,0, 0),
width=cell_width,
height=self.legend_size,
margin_left=self.legend_margin_left,
margin_right=self.legend_margin_right,
margin_top=self.legend_margin_top,
margin_bottom=self.legend_margin_bottom,
padding_left=self.legend_padding_left,
padding_right=self.legend_padding_right,
padding_top=self.legend_padding_top,
padding_bottom=self.legend_padding_bottom,
legend=False
)
def get_style(self, name, subname=None):
"""
load the most specific style setting available given a name and optional subname.
usage examples: self.get_style('margin', 'left'),
"""
keys = []
if subname:
keys.append('_'.join([name, self.__class__.__name__, subname]).upper())
keys.append('_'.join([name, self.__class__.__name__]).upper())
if subname:
keys.append('_'.join([name, subname]).upper())
keys.append(name.upper())
for key in keys:
if key in self.app.config:
return self.app.config[key]
def legend_info(self):
""" defines colors for legend elements """
data = collections.OrderedDict()
colors = self.palette(self.colors['foreground'], len(self.elements))
for idx, color in enumerate(colors):
element = self.elements[idx]
data[element] = (color, element)
return data
@property
def padded_width(self):
return self.width - self.margin_left - self.margin_right
@property
def padded_height(self):
return self.height - self.margin_top - self.margin_bottom
@property
def inner_width(self):
return self.padded_width - self.padding_left - self.padding_right
@property
def inner_height(self):
return self.padded_height - self.padding_top - self.padding_bottom
def set_brush(self, context, color): # possible TODO: better function name
if self.pattern:
context.set_source_surface(self.pattern(color))
context.get_source().set_extend(cairo.Extend.REPEAT)
else:
context.set_source_rgba(*color.tuple_rgba())
def draw_background(self, context):
context.set_source_rgba(*self.colors['background'].tuple_rgba())
context.rectangle(self.x + self.margin_left+ self.padding_left, self.y + self.margin_top + self.padding_top, self.inner_width, self.inner_height)
context.fill()
def draw_captions(self, context):
for caption in self.captions:
context.save()
context.set_operator(caption.get('operator', self.get_style('operator', 'caption')))
if 'position' in caption:
if isinstance(caption['position'], str):
# handle alignment-style strings like "center_bottom"
if self.caption_placement == 'inner':
offset = [-x for x in alignment_offset(caption['position'], (self.inner_width, self.inner_height))]
else:
offset = [-x for x in alignment_offset(caption['position'], (self.padded_width, self.padded_height))]
else:
offset = caption['position']
else:
offset = [0, 0]
if self.caption_placement == 'inner':
position = [offset[0] + self.x + self.margin_left + self.padding_left, offset[1] + self.y + self.margin_top + self.padding_top]
else:
position = [offset[0] + self.x + self.margin_left, offset[1] + self.y + self.margin_top]
caption_text = self.monitor.caption(caption['text'])
self.app.draw_text(
context,
caption_text,
position[0],
position[1],
align=caption.get('align', None),
color=caption.get('color', self.get_style('color', 'caption')),
font_size=caption.get('font_size', self.get_style('font_size', 'caption')),
font_weight=caption.get('font_weight', self.get_style('font_weight', 'caption'))
)
context.restore()
def update(self, context):
"""
Parameters:
context (:class:`cairo.Context`): cairo context of the window
"""
context.save()
context.set_operator(self.operator)
self.draw(context)
context.restore()
def draw(self, context):
raise NotImplementedError("%s.draw not implemented!" % self.__class__.__name__)
class Text(Visualizer):
"""
Scrollable text using monitors' ``caption`` function to give textual
representations of values, prettified where necessary.
"""
def __init__(self, app, monitor, text, speed=25, align=None, **kwargs):
super(Text, self).__init__(app, monitor, **kwargs)
self.text = text # the text to be rendered, a format string passed to monitor.caption
self.previous_text = '' # to be able to detect change
self.speed = speed
self.align = ignore_none(align, 'left_top')
surface = cairo.ImageSurface(cairo.Format.ARGB32, 10, 10)
context = cairo.Context(surface)
font = Pango.FontDescription('%s %s 10' % (self.get_style('font'), self.get_style('font_weight')))
layout = PangoCairo.create_layout(context)
layout.set_font_description(font)
layout.set_text('0', -1) # naively assuming 0 is the highest glyph
size = layout.get_pixel_size()
if size[1] > 10:
self.font_size = self.inner_height * 10/size[1]
else:
self.font_size = self.inner_height # probably not gonna happen
self.direction = 'left'
self.offset = 0.0
self.step = speed / self.app.config['FPS'] # i.e. speed in pixel/s
def draw_background(self, context):
context.set_source_rgba(*self.colors['background'].tuple_rgba())
context.rectangle(self.x + self.margin_left, self.y + self.margin_top, self.padded_width, self.padded_height)
context.fill()
def draw(self, context):
text = self.monitor.caption(self.text)
self.draw_background(context)
context.save()
context.rectangle(self.x + self.margin_left + self.padding_left, self.y + self.margin_top + self.padding_top, self.inner_width, self.inner_height)
context.clip()
context.set_source_rgba(*self.colors['foreground'].tuple_rgba())
font = Pango.FontDescription('%s %s %d' % (self.get_style('font'), self.get_style('font_weight'), self.font_size))
layout = PangoCairo.create_layout(context)
layout.set_font_description(font)
layout.set_text(text, -1)
size = layout.get_pixel_size()
align_offset = alignment_offset(self.align, size) # needed for the specified text alignment
#align_offset = [sum(x) for x in zip(alignment_offset(self.align, size), alignment_offset(self.align, [self.inner_width, self.inner_height]))] # needed for the specified text alignment
max_offset = size[0] - self.inner_width # biggest needed offset for marquee, can be negative if all text fits
if max_offset <= 0 or text != self.previous_text:
self.direction = 'left'
self.offset = 0
x = self.x + self.margin_left + self.padding_left - self.offset
if max_offset < 0: # only account for horizontal offset when space allows
x += align_offset[0]
if self.align.startswith('center'):
x += self.inner_width / 2
elif self.align.startswith('right'):
x += self.inner_width
y = self.y + self.margin_top + self.padding_top + align_offset[1] # NOTE: does stuff even show up with vertical align != center?
if self.align.endswith('center'):
y += self.inner_height / 2
elif self.align.endswith('bottom'):
y += self.inner_height
context.translate(x, y)
PangoCairo.update_layout(context, layout)
PangoCairo.show_layout(context, layout)
context.restore()
if self.direction == 'left':
self.offset += self.step
if self.offset > max_offset:
self.direction = 'right'
self.offset = max_offset
else:
self.offset -= self.step
if self.offset < 0:
self.direction = 'left'
self.offset = 0
self.previous_text = text
class Rect(Visualizer):
"""
.. image:: _static/rect.png
"""
def draw_rect(self, context, value, color, offset=0.0):
self.set_brush(context, color)
context.rectangle(
self.x + self.margin_left + self.padded_width * offset,
self.y + self.margin_top,
self.padded_width * value,
self.padded_height)
context.fill()
def draw_background(self, context):
self.draw_rect(context, 1, self.colors['background'])
def draw(self, context):
colors = self.palette(self.colors['foreground'], len(self.elements))
offset = 0.0
self.draw_background(context)
for idx, element in enumerate(self.elements):
value = self.monitor.normalize(element)
if self.combination != 'cumulative':
value /= len(self.elements)
color = colors[idx]
self.draw_rect(context, value, color, offset)
if self.combination.startswith('cumulative'):
offset += value
else:
offset += 1.0 / len(self.elements)
self.draw_captions(context)
class MirrorRect(Visualizer):
"""
Mirrored variant of :class:`Rect`.
"""
def __init__(self, app, monitor, **kwargs):
self.left = kwargs['elements'][0]
self.right = kwargs['elements'][1]
kwargs['elements'] = self.left + self.right
super(MirrorRect, self).__init__(app, monitor, **kwargs)
self.x_center = self.x + self.margin_left + (self.inner_width / 2)
self.draw_left = self.draw_rect_negative
self.draw_right = self.draw_rect
def legend_info(self):
""" defines colors for legend elements """
data = collections.OrderedDict()
colors = self.palette(self.colors['foreground'], max(len(self.left), len(self.right)))
parts = [[] for _ in range(0, len(colors))]
for elements in (self.left, self.right):
for idx, element in enumerate(elements):
parts[idx].append(element.split('.'))
condensed = []
for items in parts:
condensed.append(condense_addr_parts(items))
assert len(colors) == len(condensed)
for idx, condensed_name in enumerate(condensed):
data[condensed_name] = (colors[idx], condensed_name)
return data
def draw_rect(self, context, value, color, offset=0.0):
self.set_brush(context, color)
context.rectangle(self.x_center + self.inner_width / 2 * offset, self.y + self.margin_top + self.padding_top, self.inner_width / 2 * value, self.inner_height)
context.fill()
def draw_rect_negative(self, context, value, color, offset=0.0):
self.set_brush(context, color)
context.rectangle(self.x_center - self.inner_width / 2 * offset - self.inner_width / 2 * value, self.y + self.margin_top + self.padding_top, self.inner_width / 2 * value, self.inner_height)
context.fill()
def draw(self, context):
colors = self.palette(self.colors['foreground'], max(len(self.left), len(self.right)))
self.draw_background(context)
for elements, drawer in ((self.left, self.draw_left), (self.right, self.draw_right)):
offset = 0.0
for idx, element in enumerate(elements):
value = self.monitor.normalize(element)
if self.combination != 'cumulative':
value /= len(elements)
color = colors[idx]
drawer(context, value, color, offset)
if self.combination.startswith('cumulative'):
offset += value
else:
offset += 1.0 / len(elements)
self.draw_captions(context)
class Arc(Visualizer):
"""
.. image:: _static/arc.png
Parameters:
stroke_width (int): width of the arc in pixels.
"""
def __init__(self, app, monitor, stroke_width=5, **kwargs):
super(Arc, self).__init__(app, monitor, **kwargs)
self.stroke_width = stroke_width
#self.radius = (min(self.width, self.height) / 2) - (2 * self.padding) - (self.stroke_width / 2)
self.radius = (min(self.inner_width, self.inner_height) / 2) - self.stroke_width / 2
self.x_center = self.x + self.margin_left + self.padding_left + (self.inner_width / 2)
self.y_center = self.y + self.margin_top + self.padding_top + (self.inner_height / 2)
def draw_arc(self, context, value, color, offset=0.0):
context.set_line_width(self.stroke_width)
context.set_line_cap(cairo.LINE_CAP_BUTT)
self.set_brush(context, color)
context.arc(
self.x_center,
self.y_center,
self.radius,
math.pi / 2 + math.pi * 2 * offset,
math.pi / 2 + math.pi * 2 * (offset + value)
)
context.stroke()
def draw_background(self, context):
self.draw_arc(context, 1, self.colors['background'])
def draw(self, context):
self.draw_background(context)
colors = self.palette(self.colors['foreground'], len(self.elements))
offset = 0.0
for idx, element in enumerate(self.elements):
value = self.monitor.normalize(element)
if self.combination != 'cumulative':
value /= len(self.elements)
color = colors[idx]
self.draw_arc(context, value, color, offset=offset)
if self.combination == 'separate':
offset += 1 / len(self.elements)
else:
offset += value
self.draw_captions(context)
class MirrorArc(MirrorRect, Arc):
"""
Mirrored variant of :class:`Arc`.
"""
def __init__(self, app, monitor, **kwargs):
super(MirrorArc, self).__init__(app, monitor, **kwargs)
self.draw_left = self.draw_arc_negative
self.draw_right = self.draw_arc
def draw_arc(self, context, value, color, offset=0.0):
value /= 2
offset /= 2
super(MirrorArc, self).draw_arc(context, value, color, offset=offset)
def draw_arc_negative(self, context, value, color, offset=0.0):
context.set_line_width(self.stroke_width)
context.set_line_cap(cairo.LINE_CAP_BUTT)
self.set_brush(context, color)
context.arc_negative(
self.x_center,
self.y_center,
self.radius,
math.pi / 2 - math.pi * offset,
math.pi / 2 - math.pi * (offset + value)
)
context.stroke()
def draw_background(self, context):
self.draw_arc(context, 2, self.colors['background'])
class Plot(Visualizer):
"""
.. image:: _static/plot.png
Parameters:
num_points (int, optional): The number of datapoints to show.
autoscale (bool, optional): Whether to automatically "zoom" into the data.
markers (bool, optional): Whether tho render markers at data point coordinates.
line (bool, optional): Whether to draw a line.
grid (bool, optional): Whether to draw a grid. The grid automatically adapts if ``autoscale`` is ``True``.
**kwargs: passed on to :class:`Visualizer`\.
"""
def __init__(self, app, monitor, num_points=None, autoscale=None, markers=None, line=None, grid=None, **kwargs):
super(Plot, self).__init__(app, monitor, **kwargs)
if num_points:
self.num_points = num_points
self.step = self.inner_width / (num_points - 1)
assert int(self.step) >= 1, "num_points %d exceeds pixel density!" % num_points
else:
# closest fit to 8px step that fills self.inner_width perfectly
self.num_points = int(self.inner_width // 8)
self.step = self.inner_width / (self.num_points - 1)
self.prepare_points() # creates self.points with a deque for every plot
self.autoscale = ignore_none(autoscale, self.get_style('autoscale'))
self.markers = ignore_none(markers, self.get_style('markers'))
self.line = ignore_none(line, self.get_style('line'))
self.grid = ignore_none(grid, self.get_style('grid'))
self.grid_height = self.inner_height
self.colors['plot_line'] = self.colors['foreground'].clone()
self.colors['plot_line'].alpha *= 0.8
self.colors['plot_fill'] = self.colors['foreground'].clone()
if self.line:
self.colors['plot_fill'].alpha *= 0.25
self.colors['grid_major'] = self.colors['plot_line'].clone()
self.colors['grid_major'].alpha *= 0.5
self.colors['grid_minor'] = self.colors['background'].clone()
self.colors['grid_minor'].alpha *= 0.8
self.colors['grid_milli'] = self.colors['background'].clone()
self.colors['grid_milli'].alpha *= 0.4
self.colors['caption_scale'] = self.colors['foreground'].clone()
self.colors['caption_scale'].alpha *= 0.6
self.colors_plot_marker = self.palette(self.colors['foreground'], len(self.elements))
self.colors_plot_line = self.palette(self.colors['plot_line'], len(self.elements))
self.colors_plot_fill = self.palette(self.colors['plot_fill'], len(self.elements))
def prepare_points(self):
self.points = collections.OrderedDict()
for element in self.elements:
self.points[element] = collections.deque([], self.num_points)
def get_scale_factor(self, elements=None):
if elements is None:
elements = self.elements
if self.combination.startswith('cumulative'):
cumulative_points = []
for idx in range(0, self.num_points):
value = 0.0
for element in elements:
try:
value += self.points[element][idx]
except IndexError as e:
continue # means self.points deques aren't filled completely yet
if self.combination == 'cumulative_force':
cumulative_points.append(value / len(elements))
else:
cumulative_points.append(value)
p = max(cumulative_points)
else:
maxes = []
for element in elements:
if len(self.points[element]):
maxes.append(max(self.points[element]))
p = max(maxes)
if p > 0:
return 1.0 / p
return 0.0
def get_points_scaled(self, element, elements=None):
if elements is None:
elements = self.elements
scale_factor = self.get_scale_factor(elements)
if scale_factor == 0.0:
return [0.0 for _ in range(0, len(self.points[element]))]
r = []
for amplitude in self.points[element]:
r.append(amplitude * scale_factor)
return r
def draw_grid(self, context, elements=None):
if elements is None:
elements = self.elements
scale_factor = self.get_scale_factor(elements)
context.set_line_width(1)
context.set_source_rgba(*self.colors['grid_minor'].tuple_rgba())
#context.set_dash([1,1])
x_coords = [x * self.step for x in range(0, self.num_points - 1)]
x_coords = [self.x + self.margin_left + self.padding_left + x for x in x_coords]
for x in x_coords:
context.move_to(x, self.y + self.margin_top + self.padding_top)
context.line_to(x, self.y + self.margin_top + self.padding_top + self.grid_height)
context.stroke()
if not self.autoscale:
for i in range(0, 110, 10): # 0,10,20..100
value = i / 100.0
y = self.y + self.margin_top + self.padding_top + self.grid_height - self.grid_height * value
context.move_to(self.x + self.margin_left + self.padding_left, y)
context.line_to(self.x + self.margin_left + self.padding_left + self.inner_width, y)
context.stroke()
elif scale_factor > 0:
if scale_factor > 1000:
return # current maximum value under 1 permill, thus no guides are placed
elif scale_factor > 100:
# current maximum under 1 percent, place permill guides
context.set_source_rgba(*self.colors['grid_milli'].tuple_rgba())
for i in range(0, 10):
# place lines for 0-9 percent
value = i / 1000.0 * scale_factor
y = self.y + self.margin_top + self.padding_top + self.grid_height - self.grid_height * value
if y < self.y + self.margin_top + self.padding_top:
break # stop the loop if guides would be placed outside the visualizer
context.move_to(self.x + self.margin_left + self.padding_left, y)
context.line_to(self.x + self.margin_left + self.padding_left + self.inner_width, y)
context.stroke()
elif scale_factor > 10:
context.set_source_rgba(*self.colors['grid_minor'].tuple_rgba())
for i in range(0, 10):
# place lines for 0-9 percent
value = i / 100.0 * scale_factor
y = self.y + self.margin_top + self.padding_top + self.grid_height - self.grid_height * value
if y < self.y + self.margin_top + self.padding_top:
break # stop the loop if guides would be placed outside the visualizer
context.move_to(self.x + self.margin_left + self.padding_left, y)
context.line_to(self.x + self.margin_left + self.padding_left + self.inner_width, y)
context.stroke()
else: # major (10% step) guides
context.set_source_rgba(*self.colors['grid_major'].tuple_rgba())
for i in range(0, 110, 10): # 0,10,20..100
value = i / 100.0 * scale_factor
y = self.y + self.margin_top + self.padding_top + self.grid_height - self.grid_height * value
if y < self.y + self.margin_top + self.padding_top:
break # stop the loop if guides would be placed outside the visualizer
context.move_to(self.x + self.margin_left + self.padding_left, y)
context.line_to(self.x + self.margin_left + self.padding_left + self.inner_width, y)
context.stroke()
#context.set_dash([1,0]) # reset dash
def draw_plot(self, context, points, colors, offset=None):
coords = []
for idx, amplitude in enumerate(points):
if offset:
amplitude += offset[idx]
coords.append((
self.x + idx * self.step + self.margin_left + self.padding_left,
self.y + self.margin_top + self.padding_top + self.inner_height - (self.inner_height * amplitude)
))
if self.line:
# draw lines
context.set_source_rgba(*colors['plot_line'].tuple_rgba())
context.set_line_width(2)
#context.set_line_cap(cairo.LINE_CAP_BUTT)
for idx, (x, y) in enumerate(coords):
if idx == 0:
context.move_to(x, y)
else:
context.line_to(x, y)
context.stroke()
if self.pattern:
context.set_source_surface(self.pattern(colors['plot_fill']))
context.get_source().set_extend(cairo.Extend.REPEAT)
context.move_to(self.x + self.margin_left + self.padding_left, self.y + self.margin_top + self.padding_top + self.inner_height)
for idx, (x, y) in enumerate(coords):
context.line_to(x, y)
if offset: # "cut out" the offset at the bottom
previous_amplitude = None
for i, amplitude in enumerate(reversed(offset)):
if len(offset) - i > len(points):
continue # ignore x coordinates not reached yet by the graph
if (amplitude != previous_amplitude or i == len(offset) - 1):
offset_x = self.x + self.margin_left + self.padding_left + self.inner_width - i * self.step
offset_y = self.y + self.margin_top + self.padding_top + self.inner_height - self.inner_height * amplitude
context.line_to(offset_x, offset_y)
else:
context.line_to(x, self.y + self.margin_top + self.padding_top + self.inner_height)
context.close_path()
context.fill()
if self.markers:
# place points
for (x, y) in coords:
context.set_source_rgba(*colors['plot_marker'].tuple_rgba())
context.arc(
x,
y,
2,
0,
2 * math.pi
)
context.fill()
def update(self, context):
for element in set(self.elements): # without set() the same element multiple times leads to multiple same points added every time.
self.points[element].append(self.monitor.normalize(element))
super(Plot, self).update(context)
def draw(self, context):
self.draw_background(context)
if self.grid:
self.draw_grid(context)
if self.autoscale:
scale_factor = self.get_scale_factor()
if scale_factor == 0.0:
text = u"∞X"
else:
text = "%sX" % pretty_si(self.get_scale_factor())
self.app.draw_text(context, text, self.x + self.margin_left +self.padding_left + self.inner_width, self.y, align='right_top', color=self.colors['caption_scale'], font_size=self.get_style('font_size', 'scale'))
colors_plot_marker = self.palette(self.colors['foreground'], len(self.elements))
colors_plot_line = self.palette(self.colors['plot_line'], len(self.elements))
colors_plot_fill = self.palette(self.colors['plot_fill'], len(self.elements))
offset = [0.0 for _ in range(0, self.num_points)]
for idx, element in enumerate(self.elements):
colors = {
'plot_marker': self.colors_plot_marker[idx],
'plot_line': self.colors_plot_line[idx],
'plot_fill': self.colors_plot_fill[idx]
}
if self.autoscale:
points = self.get_points_scaled(element)
else:
points = list(self.points[element])
if self.combination == 'separate':
self.draw_plot(context, points, colors)
elif self.combination.startswith('cumulative'):
if self.combination == 'cumulative_force':
for idx in range(0, len(points)):
points[idx] /= len(self.elements)
self.draw_plot(context, points, colors, offset)
for idx, value in enumerate(points):
offset[idx] += value
self.draw_captions(context)
class MirrorPlot(Plot):
"""
Mirrored variant of :class:`Plot`.
"""
def __init__(self, app, monitor, scale_lock=True, **kwargs):
self.up = kwargs['elements'][0]
self.down = kwargs['elements'][1]
kwargs['elements'] = self.up + self.down
super(MirrorPlot, self).__init__(app, monitor, **kwargs)
self.y_center = self.y + self.margin_top + self.padding_top + (self.inner_height / 2)
self.scale_lock = scale_lock # bool, whether to use the same scale for up and down
self.grid_height /= 2
palette_len = max((len(self.up), len(self.down)))
self.colors_plot_marker = self.palette(self.colors['foreground'], palette_len)
self.colors_plot_line = self.palette(self.colors['plot_line'], palette_len)
self.colors_plot_fill = self.palette(self.colors['plot_fill'], palette_len)
def legend_info(self):
data = collections.OrderedDict()
colors = self.palette(self.colors['foreground'], max(len(self.up), len(self.down)))
parts = [[] for _ in range(0, len(colors))]
for elements in (self.up, self.down):
for idx, element in enumerate(elements):
parts[idx].append(element.split('.'))
condensed = []
for items in parts:
condensed.append(condense_addr_parts(items))
assert len(colors) == len(condensed)
for idx, condensed_name in enumerate(condensed):
data[condensed_name] = (colors[idx], condensed_name)
return data
def prepare_points(self):
self.points = collections.OrderedDict()
for element in self.elements:
self.points[element] = collections.deque([], self.num_points)
def get_scale_factor(self, elements=None):
if self.scale_lock:
scale_up = super(MirrorPlot, self).get_scale_factor(self.up)
scale_down = super(MirrorPlot, self).get_scale_factor(self.down)
if (scale_up > 0) and (scale_down > 0): # both values > 0
return min((scale_up, scale_down))
elif (scale_up == 0) != (scale_down == 0): # one value is 0
return max(scale_up, scale_down)
else: # both values are 0
return 0
return super(MirrorPlot, self).get_scale_factor(elements)
def draw_grid(self, context, elements=None):
context.set_line_width(1)
context.set_source_rgba(*self.colors['grid_milli'].tuple_rgba())
context.move_to(self.x + self.margin_left + self.padding_left, self.y_center)
context.line_to(self.x + self.margin_left + self.padding_left + self.inner_width, self.y_center)
context.stroke()
if elements == self.up:
context.translate(0, self.grid_height)
super(MirrorPlot, self).draw_grid(context, elements=elements)
context.translate(0, -self.grid_height)
else:
super(MirrorPlot, self).draw_grid(context, elements=elements)
def draw_plot(self, context, points, colors, offset=None):
points = [x/2 for x in points]
if not offset is None:
offset =[x/2 for x in offset]
context.translate(0, -self.inner_height / 2)
super(MirrorPlot, self).draw_plot(context, points, colors, offset=offset)
context.translate(0, self.inner_height / 2)
def draw_plot_negative(self, context, points, colors, offset=None):
points = [-x for x in points]
if not offset is None:
offset = [-x for x in offset]
self.draw_plot(context, points, colors, offset=offset)
def update(self, context):
for element in set(self.up + self.down):
self.points[element].append(self.monitor.normalize(element))
super(Plot, self).update(context) # TRAP: calls parent of parent, not direct parent!
def draw(self, context):
# TODO: This is mostly a copy-paste of Plot.update+draw, needs moar DRY.
self.draw_background(context)
for elements, drawer in ((self.up, self.draw_plot), (self.down, self.draw_plot_negative)):
if self.grid:
self.draw_grid(context, elements)
if self.autoscale:
scale_factor = self.get_scale_factor(elements)
if scale_factor == 0.0:
text = u"∞X"
else:
text = "%sX" % pretty_si(self.get_scale_factor(elements))
if elements == self.up:
self.app.draw_text(context, text, self.x + self.margin_left + self.padding_left + self.inner_width, self.y + self.margin_top + self.padding_top, align='right_bottom', color=self.colors['caption_scale'], font_size=10)
elif not self.scale_lock: # don't show 'down' scalefactor if it's locked
self.app.draw_text(context, text, self.x + self.margin_left + self.padding_left + self.inner_width, self.y + self.margin_top + self.padding_top + self.inner_height, align='right_top', color=self.colors['caption_scale'], font_size=10)
offset = [0.0 for _ in range(0, self.num_points)]
for idx, element in enumerate(elements):
colors = {
'plot_marker': self.colors_plot_marker[idx],
'plot_line': self.colors_plot_line[idx],
'plot_fill': self.colors_plot_fill[idx]
}
if self.autoscale:
points = self.get_points_scaled(element, elements)
else:
points = list(self.points[element])
if self.combination == 'separate':
drawer(context, points, colors)
elif self.combination.startswith('cumulative'):
if self.combination == 'cumulative_force':
for idx in range(0, len(points)):
points[idx] /= len(elements)
drawer(context, points, colors, offset)
for idx, value in enumerate(points):
offset[idx] += value
self.draw_captions(context)
class Box(object):
"""
Can wrap multiple :class:`Visualizer`\s, used for layouting.
Orders added visualizers from left to right and top to bottom.
This is basically a smart helper for :func:`Gulik.add_visualizer`.
"""
def __init__(self, app, x, y, width, height):
self._last_right = 0
self._last_top = 0
self._last_bottom = 0
self._next_row_x = 0
self.app = app
self.x = x
self.y = y
self.width = width
self.height = height
def place(self, component, cls, **kwargs):
"""
place a new :class:`Visualizer`.
Parameters:
component (str): The :ref:`component` string identifying the data source.
cls (:class:`type`, child of :class:`Visualizer`): The visualizer class to instantiate.
**kwargs: passed on to ``cls``\' constructor. width and height defined in here are honored.
"""
width = kwargs.get('width', None)
height = kwargs.get('height', None)
if width is None:
width = self.width - self._last_right
if height is None:
height = self._last_bottom - self._last_top # same height as previous visualizer
if height == 0: # should only happen on first visualizer
height = self.height
elif height is None:
height = self.height - self._last_bottom
if self._last_right + width > self.width: # move to next "row"
#print("next row", component, cls, kwargs)
x = self._next_row_x
y = self._last_bottom
self._next_row_x = 0 # this will probably break adding a third multi-stacked column, but works for now
else:
x = self._last_right
y = self._last_top
kwargs['x'] = self.x + x
kwargs['y'] = self.y + y
kwargs['width'] = width
kwargs['height'] = height
self.app.add_visualizer(component, cls, **kwargs)
if y + height < self._last_bottom:
self._next_row_x = self._last_right
self._last_right = x + width
self._last_top = y
self._last_bottom = y + height
assert self._last_bottom <= self.y + self.height, f"Box already full! Can't place {cls.__name__}"
class Gulik(object):
"""
The main object thingamabob.
"""
monitor_table = {
'cpu': CPUMonitor,
'memory': MemoryMonitor,
'network': NetworkMonitor,
'battery': BatteryMonitor,
'disk': DiskMonitor
}
def __init__(self, configpath):
self.started = False
self.will_to_live = True # only needed because of gtk
self.screen = Gdk.Screen.get_default()
self.window = Window()
self.window.connect('draw', self.draw)
self.configpath = configpath
self.config = {}
self.setup = self.autosetup
self.monitors = {}
self.visualizers = []
#self.boxes = []
self.apply_config()
def reset(self):
# clear out any existing visualizers and monitors
for monitor in self.monitors.values(): # kill any existing monitors
monitor.seppuku = True
self.monitors = {}
self.visualizers = []
#self.boxes = []
def module_to_config(self, locals):
config = {}
for key in DEFAULTS:
config[key] = locals.get(key, DEFAULTS[key])
for key in set(locals) - set(DEFAULTS): # iterates through everything defined in config.py we haven't already covered with DEFAULTS
if key == key.upper(): # all-caps means it's config
config[key] = locals[key]
if not 'COLOR_FOREGROUND_TEXT' in config:
config['COLOR_FOREGROUND_TEXT'] = config['COLOR_CAPTION']
return config
def add_netdata_monitors(self):
# create monitors for all netdata hosts
for host in self.config['NETDATA_HOSTS']:
if isinstance(host, (list, tuple)): # handle (host, port) tuples and bare hostnames as values
host, port = host
else:
port = 19999
component = f"netdata-{host}"
self.monitors[component] = NetdataMonitor(self, component, host, port)
def resize_and_move(self):
self.window.resize(self.config['WIDTH'], self.config['HEIGHT'])
self.window.move(self.config['X'], self.config['Y']) # move apparently must be called after show_all
def apply_config(self):
print(f"Trying to load config from {self.configpath}")
custom = False # whether gulik is using a custom configuration
old_config = self.config
old_setup = self.setup
self.config = {}
try:
fd = open(self.configpath, mode='r')
config_string = fd.read()
except OSError as e:
print("No config at '%s' or insufficient permissions to read it. Falling back to defaults…" % self.configpath)
config_locals = DEFAULTS
else:
try:
config_locals = {}
exec(config_string, config_locals)
custom = True
except Exception as e:
print("Error in '%s': %s" % (self.configpath, e))
print("Falling back to defaults…")
config_locals = DEFAULTS
self.config = self.module_to_config(config_locals)
self.resize_and_move()
if 'setup' in config_locals:
self.setup = functools.partial(config_locals['setup'], app=self)
else:
self.setup = self.autosetup
self.reset()
self.add_netdata_monitors()
# NOTE: psutil-based monitors are autoloaded in add_visualizer and thus don't have to be handled here like netdata monitors
# finally, run the actual setup function to place all visualizers
try:
self.setup()
print("Done.")
except Exception as e:
# Hokay, so I *think* this catches all failure scenarios for config (re)loads…
if not custom:
print("Sorry, friend. The autosetup seems to be broken on your machine. You might want to file a bug report.")
print("Alternatively, you can create a custom-fit configuration at ~/.config/gulik/config.py.")
self.stop()
return
else:
if isinstance(self.setup, functools.partial):
name = self.setup.func.__name__
else:
name = self.setup.__name__
print(f"Your custom 'setup' function failed - {type(e).__name__}: {e}")
print("Falling back to previous configuration")
self.monitors = {}
self.visualizers = []
self.config = old_config or self.module_to_config(DEFAULTS) # DEFAULTS if config was empty before (happens on first time this function is called on a Gulik object
self.setup = old_setup
self.resize_and_move()
self.add_netdata_monitors()
try:
self.setup()
except Exception as e:
print("Previous setup failed, too. Reverting to autosetup.")
self.monitors = {}
self.visualizers = []
self.config = self.module_to_config(DEFAULTS)
self.setup = self.autosetup
self.resize_and_move()
self.add_netdata_monitors()
try:
self.setup()
except Exception as e:
raise
print("Well, this should never happen.")
print(e)
self.stop()
def signal_reload(self, *_):
self.apply_config() # re-creates monitors and visualizers
for monitor in self.monitors.values():
monitor.start()
def tick(self):
for monitor in self.monitors.values():
monitor.tick()
self.window.queue_draw()
return self.will_to_live # gtk stops executing timeout callbacks if they don't return True
def draw_text(self, context, text, x, y, align=None, color=None, font_size=None, font_weight=None):
if align is None:
align = 'left_top'
if color is None:
color = self.config['COLOR_CAPTION']
context.set_source_rgba(*color.tuple_rgba())
font_size = font_size or self.config['FONT_SIZE']
font_weight = font_weight or self.config['FONT_WEIGHT']
font = Pango.FontDescription('%s %s %d' % (self.config['FONT'], font_weight, font_size))
layout = PangoCairo.create_layout(context)
layout.set_font_description(font)
layout.set_text(text, -1)
size = layout.get_pixel_size()
x_offset, y_offset = alignment_offset(align, size)
context.translate(x + x_offset, y + y_offset)
PangoCairo.update_layout(context, layout)
PangoCairo.show_layout(context, layout)
context.translate(-x - x_offset, -y - y_offset)
return size
def draw_gulik(self, context):
# gulik is the messenger of our lady discordia.
# he is 200 by 133 poxels big
gulik = cairo.ImageSurface.create_from_png(os.path.join(__path__[0], 'gulik.png'))
context.save()
context.set_operator(cairo.OPERATOR_SOFT_LIGHT)
context.translate(self.config['WIDTH'] - 200, self.config['HEIGHT'] - 133)
context.set_source_surface(gulik)
context.rectangle(0, 0, 200, 133)
context.fill()
context.restore()
def draw(self, window, context):
context.set_operator(cairo.OPERATOR_CLEAR)
context.paint()
context.set_operator(cairo.OPERATOR_OVER)
context.set_source_rgba(*self.config['COLOR_WINDOW_BACKGROUND'].tuple_rgba())
context.rectangle(0, 0, self.config['WIDTH'], self.config['HEIGHT'])
context.fill()
self.draw_gulik(context)
for visualizer in self.visualizers:
visualizer.update(context)
def start(self):
assert self.config['FPS'] != 0
assert isinstance(self.config['FPS'], float) or self.config['FPS'] >= 1
for monitor in self.monitors.values():
monitor.start()
signal.signal(signal.SIGINT, self.stop) # so ctrl+c actually kills gulik
signal.signal(signal.SIGTERM, self.stop) # so kill actually kills gulik, and doesn't leave a braindead Gtk.main and Collector processes dangling around
signal.signal(signal.SIGUSR1, self.signal_reload) # reload config on user-defined signal. (no, not HUP)
GLib.timeout_add(1000/self.config['FPS'], self.tick)
self.tick() # first tick without delay so we get output asap
self.started = True
Gtk.main() # blocks until Gtk.main.quit is called
print("\nThank you for flying with phryk evil mad sciences, LLC. Please come again.")
def stop(self, num=None, frame=None):
spinner = '▏▎▍▌▋▊▉▉▊▋▌▍▎'
self.will_to_live = False # will stop calls by gtk main loop to self.tick
for monitor in self.monitors.values():
monitor.seppuku = True
i = 0
while monitor.is_alive(): # show spinner animation until the monitor is finished
i += 1
spinner_idx = i % len(spinner)
sys.stdout.write(f"\r{spinner[spinner_idx]} ")
sys.stdout.flush()
time.sleep(1/24)
#sys.stdout.write("\rn")
#sys.stdout.flush()
if self.started: # Gtk.main.quit throws an error if called when not started
Gtk.main_quit()
else:
exit() # to actually quit the program before Gtk.main is running
def box(self, x=0, y=0, width=None, height=None):
width = ignore_none(width, self.config['WIDTH'] - x)
height = ignore_none(height, self.config['HEIGHT'] - y)
box = Box(self, x, y, width, height)
#self.boxes.append(box)
return box
def add_visualizer(self, component, cls, **kwargs):
if not component in self.monitors:
if component in self.monitor_table:
print("Autoloading %s!" % self.monitor_table[component].__name__)
self.monitors[component] = self.monitor_table[component](self, component)
elif component.startswith('netdata-'):
raise LookupError(f"Unknown netdata host '{component[8:]}'")
else:
raise LookupError("No monitor class known for component '%s'. Custom monitor classes have to be added to Gulik.monitor_table to enable autoloading." % component)
monitor = self.monitors[component]
visualizer = cls(self, monitor, **kwargs)
self.visualizers.append(visualizer)
def autosetup(self, x=0, y=0, width=None, height=None):
box = self.box(x, y, width, height)
cpu_num = psutil.cpu_count()
all_cores = ['core_%d' % x for x in range(0, cpu_num)]
box.place(
'cpu',
Arc,
#elements=['aggregate'],
elements=all_cores,
width=box.width,
height=box.width,
stroke_width=10,
combination='cumulative_force',
legend=False,
padding_bottom=5,
captions=[
{
'text': 'CPU',
'position': 'left_top',
'align': 'left_top',
'font_weight': 'Bold',
'operator': Operator.HARD_LIGHT,
},
{
'text': '{aggregate:.1f}%',
'position': 'center_center',
'align': 'center_center',
},
{
'text': '{count} cores',
'position': 'right_bottom',
'align': 'right_bottom',
}
]
)
box.place(
'cpu',
Plot,
elements=all_cores,
width=box.width,
height=130,
autoscale=True,
combination='cumulative_force',
)
box.place(
'memory',
Arc,
elements=['other', 'top_3', 'top_2', 'top_1'],
width=box.width,
height=box.width + 100,
padding_bottom=80, # make space for 4 rows of legend
stroke_width=30,
combination='cumulative',
#legend_format="{{{element}}}",
legend_order='reverse',
legend_format="{{{element}.name}} ({{{element}.size}})",
captions=[
{
'text': 'memory',
'position': 'left_top',
'align': 'left_top',
'font_weight': 'Bold',
'operator': Operator.HARD_LIGHT,
},
{
'text': '{percent:.1f}%',
'position': 'center_center',
'align': 'center_center'
},
{
'text': '{total}',
'position': 'right_bottom',
'align': 'right_bottom',
}
]
)
last_visualizer = self.visualizers[-1]
palette = [color for color in reversed(last_visualizer.palette(last_visualizer.colors['foreground'], 4))]
#box.place('memory', Text, text='{top_1.name} ({top_1.size})', width=box.width, height=25, foreground=palette[0])
#box.place('memory', Text, text='{top_2.name} ({top_2.size})', width=box.width, height=25, foreground=palette[1])
#box.place('memory', Text, text='{top_3.name} ({top_3.size})', width=box.width, height=25, foreground=palette[2])
#box.place('memory', Text, text='other({other.size}/{other.count})', width=box.width, height=25, foreground=palette[3])
all_nics = [x for x in psutil.net_if_addrs().keys()]
all_nics_up = ['%s.bytes_sent' % x for x in all_nics]
all_nics_down = ['%s.bytes_recv' % x for x in all_nics]
all_nics_up_packets_ = ['%s.packets_sent' % x for x in all_nics]
all_nics_down_packets = ['%s.packets_recv' % x for x in all_nics]
all_nics_up_errors = ['%s.errout' % x for x in all_nics]
all_nics_down_errors = ['%s.errin' % x for x in all_nics]
all_nics_up_drop = ['%s.dropout' % x for x in all_nics]
all_nics_down_drop = ['%s.dropin' % x for x in all_nics]
partitions = psutil.disk_partitions()
all_partitions = []
for partition in partitions:
name = partition.device.split('/')[-1].replace('.', '-')
all_partitions.append(name)
all_disks = list(psutil.disk_io_counters(perdisk=True))
all_disks_read = [f"io.{disk}.read_bytes" for disk in all_disks]
all_disks_write = [f"io.{disk}.write_bytes" for disk in all_disks]
disk_busy_size = 130 / len(all_disks)
box.place(
'disk',
MirrorPlot,
width=box.width - disk_busy_size,
height=130,
elements=[all_disks_read, all_disks_write],
caption_placement='padding',
captions=[
{
'text': 'disk',
'position': 'left_top',
'align': 'left_top',
'font_weight': 'Bold',
'operator': Operator.HARD_LIGHT,
}
]
)
last_visualizer = self.visualizers[-1]
colors = last_visualizer.palette(last_visualizer.colors['foreground'], len(all_disks))
for idx, disk in enumerate(all_disks):
box.place(
'disk',
Arc,
width=disk_busy_size,
height=disk_busy_size,
foreground=colors[idx],
legend=False,
padding=0,
stroke_width=10,
elements=[f"io.{disk}.busy_time"]
)
# box.place(
# 'network',
# MirrorArc,
# width=box.width,
# height=box.width,
# padding_bottom=5,
# legend=False,
# elements=[all_nics_up, all_nics_down],
# combination='cumulative_force',
# captions=[
# {
# 'text': 'network',
# 'position': 'left_top',
# 'align': 'left_top',
# 'font_weight': 'Bold',
# 'operator': Operator.HARD_LIGHT,
# },
# {
# 'text': '{aggregate.counters.bytes_sent}\n{aggregate.counters.bytes_recv}',
# #'text': '{em0.all_addrs}',
# 'position': 'center_center',
# 'align': 'center_center',
# }
# ]
# )
box.place(
'network',
MirrorPlot,
width=box.width,
height=140,
margin_top=15,
#padding=15,
padding_bottom=50,
elements=[all_nics_up, all_nics_down],
combination='cumulative_force',
autoscale=True,
caption_placement='padding',
captions=[
{
'text': 'network',
'position': 'left_top',
'align': 'left_top',
'font_weight': 'Bold',
'operator': Operator.HARD_LIGHT,
}
]
)
#alignments = ['left_top', 'center_top','right_top']
#palette = self.visualizers[-1].colors_plot_marker
#for idx, if_name in enumerate(all_nics):
# # build a legend
# color = palette[idx]
# align = alignments[idx % 3] # boom.
# box.place('network', Text, text=if_name, foreground=color, width=box.width/3, height=25, align=align)
if psutil.sensors_battery() is not None:
color = self.config['COLOR_FOREGROUND'].clone()
color.alpha = 1
box.place(
'battery',
Rect,
width=box.width,
height=90,
margin_top=35,
elements=['battery'],
foreground=color,
captions=[
{
'text': '{percent}%',
'position': 'left_center',
'align': 'left_center',
'color': color,
'operator': Operator.DIFFERENCE, # such candy, many wow
},
{
'text': '{state}',
'position': 'right_center',
'align': 'right_center',
'color': color,
'operator': Operator.DIFFERENCE,
},
]
)