sen-0.6.0/0000755000372000037200000000000013254656257013161 5ustar travistravis00000000000000sen-0.6.0/docs/0000755000372000037200000000000013254656257014111 5ustar travistravis00000000000000sen-0.6.0/docs/devel.md0000644000372000037200000000227313254656210015523 0ustar travistravis00000000000000# Development Documentation ## gif screencast refresh ### ffcast, ffmpeg, convert Open terminal and prepare environment: ``` $ xfce4-terminal --hide-menubar --hide-toolbar $ sleep 20 ; docker run -d fedora bash -c "while true : ; do sleep 1 ; date ; done" ``` Record screen: ``` $ ffcast -w % ffmpeg -f x11grab -show_region 1 -framerate 20 -video_size %s -i %D+%c -codec:v huffyuv -vf crop="iw-mod(iw\\,2):ih-mod(ih\\,2)" out.avi ``` Convert to gif and optimize: ``` $ ffmpeg -ss 3 -i out.avi -t 00:00:50.00 -vf scale=720:-1 -pix_fmt rgb24 out.gif $ convert -limit memory 1 -limit map 1 -layers Optimize out.gif out_optimised.gif ``` * `-ss 3` — cut until second 3 * `-t 00:00:50.00` — process until second 50 * `-vf scale=720:-1` — resize gif to 720px (looks like a prefered GitHub size) ### byzanz Open terminal and get terminal coords: ``` $ xwininfo ``` Record: ``` $ byzanz-record -v --delay=5 -x 164 -y 183 -w 892 -h 418 -d 25 -- out.gif ``` * `-d` — duration Current gifs are created with `st`, `100x30`, `Inconsolata dz` size 16. http://unix.stackexchange.com/questions/113695/gif-screencasting-the-unix-way http://askubuntu.com/questions/4428/how-to-create-a-screencast sen-0.6.0/docs/features.md0000644000372000037200000000051113254656210016233 0ustar travistravis00000000000000# Features ## Image Tree You can invoke the buffer with `f5`. ![Image Tree](/data/image-tree.gif) ## Container Info Press `` to see more info about a container. ![Image Tree](/data/container-info.gif) ## Image Info Press `` to see the detailed info about your image. ![Image Tree](/data/image-info.gif) sen-0.6.0/docs/releasing.md0000644000372000037200000000065313254656210016375 0ustar travistravis00000000000000# How to release `sen`? 1. set correct version in `setup.py` and `sen/__init__.py` 2. verify that docker hub builds are passing 3. do **NOT** create feature branch with same name as tag 4. prepare changelog (`CHANGELOG.md` and release notes) 5. when tagged with release, verify that release was successful on travis 6. correct Docker Hub tags - let latest release point to `latest` 7. bump version in setup.py to `x.y.z-dev` sen-0.6.0/sen/0000755000372000037200000000000013254656257013746 5ustar travistravis00000000000000sen-0.6.0/sen/tui/0000755000372000037200000000000013254656257014547 5ustar travistravis00000000000000sen-0.6.0/sen/tui/chunks/0000755000372000037200000000000013254656257016042 5ustar travistravis00000000000000sen-0.6.0/sen/tui/chunks/__init__.py0000644000372000037200000000012013254656210020131 0ustar travistravis00000000000000""" This package contains parts of buffers: generic widgets + docker context """sen-0.6.0/sen/tui/chunks/container.py0000644000372000037200000000414313254656210020365 0ustar travistravis00000000000000""" Container-specific chunks. """ import logging from sen.tui.widgets.util import SelectableText, get_map logger = logging.getLogger(__name__) class ContainerStatusWidget(SelectableText): def __init__(self, docker_container, nice_status=True): markup, attr = get_container_status_markup(docker_container, nice_status=nice_status) super().__init__(markup, attr) class ContainerOneLinerWidget(SelectableText): def __init__(self, ui, docker_container): self.ui = ui self.docker_container = docker_container super().__init__(get_basic_container_markup(docker_container)) def get_detailed_container_row(docker_container): row = [] container_id = SelectableText(docker_container.short_id) row.append(container_id) commands = docker_container.command.split("\n") command_str = commands[0] if len(commands) > 0: command_str += "..." command = SelectableText(command_str, get_map(defult="main_list_ddg")) row.append(command) image = SelectableText(docker_container.image_name()) row.append(image) row.append(ContainerStatusWidget(docker_container)) name = SelectableText(docker_container.short_name) row.append(name) return row def get_container_status_markup(docker_container, nice_status=True): if docker_container.running: attr_map = get_map("main_list_green") elif docker_container.status_created: attr_map = get_map("main_list_yellow") elif docker_container.exited_well: attr_map = get_map("main_list_orange") else: attr_map = get_map("main_list_red") if nice_status: return docker_container.nice_status, attr_map else: return docker_container.simple_status_cap, attr_map def get_basic_container_markup(docker_container): text_markup = [docker_container.short_id, " "] markup, attr = get_container_status_markup(docker_container) text_markup.append((attr["normal"], markup)) if docker_container.names: text_markup.append(" ") text_markup.append(("main_list_lg", docker_container.names[0])) return text_markupsen-0.6.0/sen/tui/chunks/image.py0000644000372000037200000000604413254656210017467 0ustar travistravis00000000000000""" Image specific chunks. """ import logging from sen.util import humanize_bytes from sen.docker_backend import RootImage from sen.tui.widgets.list.util import get_time_attr_map from sen.tui.widgets.util import SelectableText, get_map logger = logging.getLogger(__name__) class LayerWidget(SelectableText): def __init__(self, ui, docker_image, index=None): self.ui = ui self.docker_image = docker_image label = [] logger.debug("creating layer widget for %s", docker_image) if index is not None: separator = "└─" if index == 0: label = [separator] else: label = [2 * index * " " + separator] super().__init__(label + get_basic_image_markup(docker_image, with_size=True)) def get_detailed_image_row(docker_image): row = [] image_id = SelectableText(docker_image.short_id, maps=get_map()) row.append(image_id) command = SelectableText(docker_image.command, maps=get_map(defult="main_list_ddg")) row.append(command) base_image = docker_image.base_image() base_image_text = "" if base_image: base_image_text = base_image.short_name base_image_w = SelectableText(base_image_text, maps=get_map()) row.append(base_image_w) time = SelectableText(docker_image.display_time_created(), maps=get_time_attr_map(docker_image.created)) row.append(time) image_names_markup = get_image_names_markup(docker_image) # urwid.Text([]) tracebacks if image_names_markup: image_names = SelectableText(image_names_markup) else: image_names = SelectableText("") row.append(image_names) return row def get_image_names_markup(docker_image): text_markup = [] for n in docker_image.names: if n.registry: text_markup.append(("main_list_dg", n.registry + "/")) if n.namespace and n.repo: text_markup.append(("main_list_lg", n.namespace + "/" + n.repo)) else: if n.repo == "": text_markup.append(("main_list_dg", n.repo)) else: text_markup.append(("main_list_lg", n.repo)) if n.tag: if n.tag not in ["", "latest"]: text_markup.append(("main_list_dg", ":" + n.tag)) text_markup.append(("main_list_dg", ", ")) text_markup = text_markup[:-1] return text_markup def get_basic_image_markup(docker_image, with_size=False): if isinstance(docker_image, RootImage): return [str(docker_image)] text_markup = [docker_image.short_id] if docker_image.names: text_markup.append(" ") text_markup.append(("main_list_lg", docker_image.names[0].to_str())) c = docker_image.container_command or docker_image.comment if c: text_markup.append(" ") text_markup.append(("main_list_ddg", c)) if with_size: text_markup.append(" ") text_markup.append(("main_list_ddg", "(%s)" % humanize_bytes(docker_image.total_size))) return text_markup sen-0.6.0/sen/tui/chunks/misc.py0000644000372000037200000000104013254656210017327 0ustar travistravis00000000000000""" Unsorted chunks. """ import logging from sen.docker_backend import DockerImage, DockerContainer from sen.tui.chunks.container import get_detailed_container_row from sen.tui.chunks.image import get_detailed_image_row logger = logging.getLogger(__name__) def get_row(docker_object): if isinstance(docker_object, DockerImage): return get_detailed_image_row(docker_object) elif isinstance(docker_object, DockerContainer): return get_detailed_container_row(docker_object) else: raise Exception("what ") sen-0.6.0/sen/tui/commands/0000755000372000037200000000000013254656257016350 5ustar travistravis00000000000000sen-0.6.0/sen/tui/commands/__init__.py0000644000372000037200000000024013254656210020442 0ustar travistravis00000000000000 # FIXME: DEBUG: make sure all commands are loaded from .backend import RemoveCommand from .ui import KillBufferCommand from .widget import NavigateTopCommand sen-0.6.0/sen/tui/commands/backend.py0000644000372000037200000001527513254656210020310 0ustar travistravis00000000000000""" these commands may take long to finish, they are usually being run against external service (e.g. docker engine) """ import logging import webbrowser from sen.tui.buffer import LogsBuffer, InspectBuffer, DfBuffer from sen.tui.commands.base import BackendCommand, register_command, Option from sen.tui.widgets.list.util import get_operation_notify_widget from sen.docker_backend import DockerContainer logger = logging.getLogger(__name__) class OperationCommand(BackendCommand): def do(self, fn_name, pre_message=None, notif_level="info", **kwargs): pre_message = pre_message or self.pre_info_message.format( container_name=self.docker_object.short_name) self.ui.notify_message(pre_message) try: operation = getattr(self.docker_object, fn_name)(**kwargs) except AttributeError: log_txt = "you can't {} {}".format(fn_name, self.docker_object) logger.error(log_txt) notif_txt = "You can't {} {} {!r}.".format( fn_name, self.docker_object.pretty_object_type.lower(), self.docker_object.short_name) self.ui.notify_message(notif_txt, level="error") except Exception as ex: self.ui.notify_message(str(ex), level="error") raise else: self.ui.remove_notification_message(pre_message) self.ui.notify_widget( get_operation_notify_widget(operation, notif_level=notif_level) ) class MatchingOperationCommand(OperationCommand): """ this is just a shortcut """ def run(self): self.do(self.name) @register_command class RemoveCommand(OperationCommand): name = "rm" description = "remove provided object, image or container" options_definitions = [Option("force", "Force removal of the selected object.", default=False, aliases=["-f", "f"]), Option("yes", "Don't ask before removing.", default=False, aliases=["-y"])] def run(self): logger.debug("remove %s force=%s yes=%s", self.docker_object, self.arguments.force, self.arguments.yes) if not self.arguments.yes and not self.ui.yolo: logger.debug("we need confirmation from user") cmd = "rm -y" if not self.arguments.force else "rm -y -f" self.ui.run_command( "prompt prompt-text=\"You are about to remove %s %s, type enter to continue: \" " "initial-text=\"%s\"" % ( self.docker_object.pretty_object_type.lower(), self.docker_object.short_name, cmd ), docker_object=self.docker_object ) return if self.arguments.force: self.ui.notify_message("Removing forcibly!", level="important") self.do("remove", pre_message="Removing {} {}...".format( self.docker_object.pretty_object_type.lower(), self.docker_object.short_name), notif_level="important", force=self.arguments.force) @register_command class StartContainerCommand(MatchingOperationCommand): name = "start" description = "start a container" pre_info_message = "Starting container {container_name}..." @register_command class StopContainerCommand(MatchingOperationCommand): name = "stop" description = "stop a container" pre_info_message = "Stopping container {container_name}..." @register_command class RestartContainerCommand(MatchingOperationCommand): name = "restart" description = "restart a container" pre_info_message = "Restarting container {container_name}..." @register_command class KillContainerCommand(MatchingOperationCommand): name = "kill" description = "kill a container" pre_info_message = "Killing container {container_name}..." @register_command class PauseContainerCommand(MatchingOperationCommand): name = "pause" description = "pause a container" pre_info_message = "Pausing container {container_name}..." @register_command class UnpauseContainerCommand(MatchingOperationCommand): name = "unpause" description = "unpause a container" pre_info_message = "Unpausing container {container_name}..." @register_command class LogsCommand(BackendCommand): name = "logs" description = "display logs of a container" options_definitions = [Option("follow", "Follow logs.", default=False, aliases=["-f", "f"])] def run(self): self.ui.add_and_display_buffer( LogsBuffer(self.ui, self.docker_object, follow=self.arguments.follow)) @register_command class InspectCommand(BackendCommand): name = "inspect" description = "inspect provided object, a container or an image" def run(self): if not self.docker_object: self.ui.notify_message("No docker object specified.", level="error") return self.ui.add_and_display_buffer(InspectBuffer(self.ui, self.docker_object)) @register_command class DfCommand(BackendCommand): name = "df" description = "show disk usage" def run(self): b = DfBuffer(self.ui) self.ui.add_and_display_buffer(b) df = self.docker_backend.df() b.refresh(df=df.response, containers=self.docker_backend.get_containers(cached=True, stopped=True).response, images=self.docker_backend.get_images(cached=True).response) @register_command class OpenPortsInBrowser(BackendCommand): name = "open-browser" # TODO: user should be able to specify port and ip: by hitting keybind in container info view description = "open exposed port in a browser" def run(self): if not isinstance(self.docker_object, DockerContainer): self.ui.notify_message("No docker container specified.", level="error") return if not self.docker_object.running: self.ui.notify_message("Container is not running - no ports are available.", level="error") return ports = list(self.docker_object.net.ports.keys()) ips = self.docker_object.net.ips logger.debug("ports = %s, ips = %s", ports, ips) if not ports: self.ui.notify_message( "Container %r doesn't expose any ports." % self.docker_object.short_name, level="error" ) return url = "http://{}:{}".format(ips[list(ips.keys())[0]]["ip_address4"], ports[0]) logger.info("opening %s in browser", url) webbrowser.open(url) sen-0.6.0/sen/tui/commands/base.py0000644000372000037200000002201513254656210017621 0ustar travistravis00000000000000""" Definition of commands This could be also split into two parts: generic framework part, application specific part """ import logging import shlex logger = logging.getLogger(__name__) # command -> Class commands_mapping = {} class KeyNotMapped(Exception): pass class NoSuchCommand(Exception): pass class NoSuchOptionOrArgument(Exception): pass # class decorator to register commands def register_command(kls): commands_mapping[kls.name] = kls for a in kls.aliases: commands_mapping[a] = kls return kls class CommandPriority: pass class BackendPriority(CommandPriority): """ command takes long to execute """ class FrontendPriority(CommandPriority): """ command needs to be executed ASAP """ class SameThreadPriority(CommandPriority): """ run the task in same thread as UI """ def true_action(val=None): if val is not None: return val return True class ArgumentBase: """ Base class for arguments and options """ def __init__(self, name, description, action=true_action, default=None): self.name = name self.description = description self.default = default self.action = action class Option(ArgumentBase): """ options alter behavior, are not positional """ def __init__(self, name, description, action=true_action, aliases=None, default=None): super().__init__(name, description, action=action, default=default) self.aliases = aliases or [] def __str__(self): return "{} default={} action=\"{}\" aliases={}".format( self.name, self.default, self.action, self.aliases ) def __unicode__(self): return self.__str__() class Argument(ArgumentBase): """ arguments are positional """ pass def normalize_arg_name(name): return name.replace("-", "_") # so we can access names-with-dashes class ArgumentProcessor: """ responsible for parsing given list of arguments """ def __init__(self, options, arguments): """ :param options: list of options :param arguments: list of arguments """ self.given_arguments = {} self.options = {} for a in options: self.options[a.name] = a self.given_arguments[normalize_arg_name(a.name)] = a.default for alias in a.aliases: self.options[alias] = a for o in arguments: self.given_arguments[normalize_arg_name(o.name)] = o.default self.arguments = arguments logger.info("arguments = %s", arguments) logger.info("options = %s", options) def process(self, argument_list): """ :param argument_list: list of str, input from user :return: dict: {"cleaned_arg_name": "value"} """ arg_index = 0 for a in argument_list: opt_and_val = a.split("=", 1) opt_name = opt_and_val[0] try: # option argument = self.options[opt_name] except KeyError: # argument try: argument = self.arguments[arg_index] except IndexError: logger.error("option/argument %r not specified", a) raise NoSuchOptionOrArgument("No such option or argument: %r" % opt_name) logger.info("argument found: %s", argument) safe_arg_name = normalize_arg_name(argument.name) # so we can access names-with-dashes logger.info("argument is available under name %r", safe_arg_name) if isinstance(argument, Argument): arg_index += 1 value = (a, ) else: try: value = (opt_and_val[1], ) except IndexError: value = tuple() arg_val = argument.action(*value) logger.info("argument %r has value %r", safe_arg_name, arg_val) self.given_arguments[safe_arg_name] = arg_val return self.given_arguments class CommandArgumentsGetter: def __init__(self, given_arguments): self.given_arguments = given_arguments def set_argument(self, arg_name, value): self.given_arguments[arg_name] = value def __getattr__(self, item): try: return self.given_arguments[item] except KeyError: # this is an error in code, not user error logger.error("no argument/option defined: %r", item) raise AttributeError("No such option or argument: %r" % item) class Command: # command name, unique identifier, used also in prompt name = "" # message explaining what's about to happen pre_info_message = "" # message explaining what has happened post_info_message = "" # how long it takes to run the command - in which queue it should be executed priority = None # used in help message description = "" # define options options_definitions = [] # define arguments arguments_definitions = [] # command is available under these aliases aliases = [] def __init__(self, ui=None, docker_backend=None, docker_object=None, buffer=None, size=None): """ :param ui: :param docker_backend: :param docker_object: :param buffer: """ logger.debug( "command %r initialized: ui=%r, docker_backend=%r, docker_object=%r, buffer=%r", self.name, ui, docker_backend, docker_object, buffer) self.ui = ui self.docker_backend = docker_backend self.docker_object = docker_object self.buffer = buffer self.size = size self.argument_processor = ArgumentProcessor(self.options_definitions, self.arguments_definitions) self.arguments = None def process_args(self, arguments): """ :param arguments: dict :return: """ given_arguments = self.argument_processor.process(arguments) logger.info("given arguments = %s", given_arguments) self.arguments = CommandArgumentsGetter(given_arguments) def run(self): raise NotImplementedError() class FrontendCommand(Command): priority = FrontendPriority() class BackendCommand(Command): priority = BackendPriority() class SameThreadCommand(Command): priority = SameThreadPriority() class Commander: """ Responsible for managing commands: it's up to workers to do the commands actually. """ def __init__(self, ui, docker_backend): self.ui = ui self.docker_backend = docker_backend self.modifier_keys_pressed = [] logger.debug("available commands: %s", commands_mapping) def get_command(self, command_input, docker_object=None, buffer=None, size=None): """ return command instance which is the actual command to be executed :param command_input: str, command name and its args: "command arg arg2=val opt" :param docker_object: :param buffer: :param size: tuple, so we can call urwid.keypress(size, ...) :return: instance of Command """ logger.debug("get command for command input %r", command_input) if not command_input: # noop, don't do anything return if command_input[0] in ["/"]: # we could add here !, @, ... command_name = command_input[0] unparsed_command_args = shlex.split(command_input[1:]) else: command_input_list = shlex.split(command_input) command_name = command_input_list[0] unparsed_command_args = command_input_list[1:] try: CommandClass = commands_mapping[command_name] except KeyError: logger.info("no such command: %r", command_name) raise NoSuchCommand("There is no such command: %s" % command_name) else: cmd = CommandClass(ui=self.ui, docker_backend=self.docker_backend, docker_object=docker_object, buffer=buffer, size=size) cmd.process_args(unparsed_command_args) return cmd def get_command_input_by_key(self, key): logger.debug("get command input for key %r", key) modifier_keys = ["g"] # FIXME: we should be able to figure this out from existing keybinds inp = "".join(self.modifier_keys_pressed) + key try: command_input = self.ui.current_buffer.get_keybinds()[inp] except KeyError: if key in modifier_keys: # TODO: inform user maybe self.modifier_keys_pressed.append(key) logger.info("modifier keys pressed: %s", self.modifier_keys_pressed) return else: logger.info("no such keybind: %r", inp) self.modifier_keys_pressed.clear() raise KeyNotMapped("No such keybind: %r." % inp) else: self.modifier_keys_pressed.clear() return command_input sen-0.6.0/sen/tui/commands/display.py0000644000372000037200000000400513254656210020353 0ustar travistravis00000000000000""" commands related to displaying stuff FIXME: some of these commands duplicate stuff in ui """ import logging from sen.docker_backend import DockerImage, DockerContainer from sen.exceptions import NotifyError from sen.tui.buffer import ( TreeBuffer, HelpBuffer, ImageInfoBuffer, ContainerInfoBuffer, MainListBuffer ) from sen.tui.commands.base import FrontendCommand, register_command logger = logging.getLogger(__name__) @register_command class DisplayLayersTreeCommand(FrontendCommand): name = "display-layers-tree" def run(self): self.ui.add_and_display_buffer(TreeBuffer(self.docker_object, self.ui)) @register_command class DisplayHelpCommand(FrontendCommand): name = "display-help" def run(self): self.ui.add_and_display_buffer(HelpBuffer(self.ui, self.buffer)) @register_command class DisplayInfoBufferCommand(FrontendCommand): name = "display-info" def run(self): # TODO: needs better abstraction, backend object should be tied together with frontend # object via a new Class if isinstance(self.docker_object, DockerImage): buffer_class = ImageInfoBuffer elif isinstance(self.docker_object, DockerContainer): buffer_class = ContainerInfoBuffer else: self.ui.notify_message("Can't display info for '%s'" % self.docker_object, level="error") logger.error("unable to display info buffer for %r", self.docker_object) return try: # FIXME: this try/except block should be in upper frame self.ui.add_and_display_buffer(buffer_class(self.docker_object, self.ui)) except NotifyError as ex: self.ui.notify_message(str(ex), level="error") logger.error(repr(ex)) @register_command class DisplayListingCommand(FrontendCommand): name = "display-listing" def run(self): b = MainListBuffer(self.ui, self.docker_backend) self.ui.add_and_display_buffer(b, redraw=True) sen-0.6.0/sen/tui/commands/ui.py0000644000372000037200000002105213254656210017324 0ustar travistravis00000000000000""" application independent commands """ import logging import urwid from sen.exceptions import NotifyError from sen.tui.buffer import HelpBuffer, TreeBuffer from sen.tui.commands.base import ( register_command, SameThreadCommand, Option, Argument, NoSuchCommand ) from sen.util import log_traceback, log_last_traceback logger = logging.getLogger(__name__) class LogTracebackMixin: @log_traceback def do(self, fn, *args, **kwargs): try: fn(*args, **kwargs) except NotifyError as ex: logger.error(repr(ex)) self.ui.notify_message(str(ex), level="error") except Exception as ex: logger.error(repr(ex)) self.ui.notify_message("Command failed: %s" % str(ex), level="error") raise @register_command class QuitCommand(SameThreadCommand): name = "quit" # TODO: make this configurable by asking whether to quit or not description = "Quit sen. No questions asked." def run(self): self.ui.quit() @register_command class KillBufferCommand(SameThreadCommand): name = "kill-buffer" description = "Remove currently displayed buffer." options_definitions = [ Option("quit-if-no-buffer", "Quit when there's no buffer left", default=False) ] def run(self): buffers_left = self.ui.remove_current_buffer(close_if_no_buffer=self.arguments.quit_if_no_buffer) if buffers_left is None: self.ui.notify_message("Last buffer will not be removed.") elif buffers_left == 0: self.ui.run_command(QuitCommand.name) @register_command class SelectBufferCommand(SameThreadCommand): name = "select-buffer" description = "Display buffer with selected index." arguments_definitions = [ Argument("index", "Index of buffer to display", default=1, action=int) ] def run(self): self.ui.pick_and_display_buffer(self.arguments.index) @register_command class SelectNextBufferCommand(SelectBufferCommand): name = "select-next-buffer" description = "Display next buffer." def run(self): self.arguments.set_argument("index", self.ui.current_buffer_index + 1) super().run() @register_command class SelectPreviousBufferCommand(SelectBufferCommand): name = "select-previous-buffer" description = "Display previous buffer." def run(self): self.arguments.set_argument("index", self.ui.current_buffer_index - 1) super().run() @register_command class DisplayBufferCommand(SameThreadCommand): name = "display-buffer" # TODO: make this a universal display function arguments_definitions = [Argument("buffer", "Buffer instance to show.")] description = "This is an internal command and doesn't work from command line." def run(self): # TODO: doesn't work!, the method expects buffer class, not string self.ui.add_and_display_buffer(self.arguments.buffer) @register_command class DisplayHelpCommand(SameThreadCommand): name = "help" description = "Display help about buffer or command. When 'query' is not specified " + \ "help for current buffer is being displayed." arguments_definitions = [Argument("query", "input string: command, buffer name")] def run(self): if self.arguments.query is None: self.ui.add_and_display_buffer(HelpBuffer(self.ui, self.buffer)) return try: command = self.ui.commander.get_command(self.arguments.query) except NoSuchCommand: self.ui.notify_message("There is no such command: %r" % self.arguments.query) else: self.ui.add_and_display_buffer(HelpBuffer(self.ui, command)) return # TODO: help view for commands could be displayed in footer @register_command class DisplayLayersCommand(DisplayBufferCommand): name = "layers" description = "open a tree view of all image layers (`docker images --tree` equivalent)" def run(self): self.arguments.set_argument("buffer", TreeBuffer(self.ui, self.docker_backend)) super().run() @log_traceback def run_command_callback(ui, docker_object, edit_widget, text_input): logger.debug("%r %r", edit_widget, text_input) if "\n" in text_input: inp = text_input.strip() inp = inp.replace("\n", "") # first restore statusbar and then run the command ui.prompt_bar = None ui.set_focus("body") try: ui.run_command(inp, docker_object=docker_object) except NoSuchCommand as ex: logger.info("non-existent command initiated: %r", inp) ui.notify_message(str(ex), level="error") except Exception as ex: logger.info("command %r failed: %r", inp, ex) ui.notify_message("Error while running command '{}': {}".format( inp, ex ), level="error") log_last_traceback() ui.reload_footer() @register_command class PromptCommand(SameThreadCommand): name = "prompt" description = "Customize and pre-populate prompt with initial data." options_definitions = [ Option("prompt-text", "Text forming the actual prompt", default=":"), Option("initial-text", "Prepopulated text", default="") ] def run(self): """ prompt for text input. """ # set up widgets leftpart = urwid.Text(self.arguments.prompt_text, align='left') editpart = urwid.Edit(multiline=True, edit_text=self.arguments.initial_text) # build promptwidget edit = urwid.Columns([ ('fixed', len(self.arguments.prompt_text), leftpart), ('weight', 1, editpart), ]) self.ui.prompt_bar = urwid.AttrMap(edit, "main_list_dg") self.ui.reload_footer() self.ui.set_focus("footer") urwid.connect_signal(editpart, "change", run_command_callback, user_args=[self.ui, self.docker_object]) @register_command class SearchCommand(SameThreadCommand, LogTracebackMixin): name = "search" description = "search and highlight (provide empty string to disable searching)" arguments_definitions = [ Argument("query", "Input string to search for") ] aliases = ["/"] def run(self): # TODO: implement incsearch # - match needs to be highlighted somehow, not with focus though # - a line could split into a Text with multiple markups query = self.arguments.query if self.arguments.query is not None else "" self.do(self.ui.current_buffer.find_next, query) @register_command class SearchNextCommand(SameThreadCommand, LogTracebackMixin): name = "search-next" description = "next search occurrence" def run(self): self.do(self.ui.current_buffer.find_next) @register_command class SearchPreviousCommand(SameThreadCommand, LogTracebackMixin): name = "search-previous" description = "previous search occurrence" def run(self): self.do(self.ui.current_buffer.find_previous) @register_command class SearchCommand(SameThreadCommand, LogTracebackMixin): name = "filter" description = """\ Display only lines matching provided query (provide empty query to clear filtering) In main listing, you may specify more precise query with these space-separated filters: * t[ype]=c[ontainer[s]] * t[ype]=i[mage[s]] * s[tate]=r[unning]) Examples * "type=container" - show only containers (short equivalent is "t=c") * "type=image fedora" - show images with string "fedora" in name (equivalent "t=i fedora")\ """ arguments_definitions = [ Argument("query", "Input query string", default="") ] def run(self): # TODO: realtime list change would be mindblowing self.do(self.ui.current_buffer.filter, self.arguments.query) @register_command class RefreshCurrentBufferCommand(SameThreadCommand): name = "refresh" description = "Refresh current buffer." def run(self): self.ui.current_buffer.refresh() @register_command class SearchPreviousCommand(SameThreadCommand): name = "toggle-live-updates" description = "Toggle realtime updates of the interface (this is useful when you are " + \ "removing multiple objects and don't want the listing change during " + \ "the process so you accidentally remove something)." def run(self): self.ui.current_buffer.widget.toggle_realtime_events() @register_command class RedrawUI(SameThreadCommand): name = "redraw" description = "Redraw user interface." def run(self): self.ui.loop.screen.clear() self.ui.refresh() sen-0.6.0/sen/tui/commands/widget.py0000644000372000037200000000505313254656210020175 0ustar travistravis00000000000000""" widget specific commands """ import logging from sen.tui.commands.base import register_command, SameThreadCommand import urwidtrees logger = logging.getLogger(__name__) @register_command class NavigateTopCommand(SameThreadCommand): name = "navigate-top" description = "go to first line" def run(self): # FIXME: refactor if isinstance(self.buffer.widget, urwidtrees.TreeBox): self.buffer.widget.focus_first() else: self.buffer.widget.set_focus(0) self.buffer.widget.reload_widget() @register_command class NavigateBottomCommand(SameThreadCommand): name = "navigate-bottom" description = "go to last line" def run(self): # FIXME: refactor if isinstance(self.buffer.widget, urwidtrees.TreeBox): self.buffer.widget.focus_last() else: self.buffer.widget.set_focus(len(self.buffer.widget.body) - 1) self.buffer.widget.reload_widget() @register_command class NavigateUpCommand(SameThreadCommand): name = "navigate-up" description = "go one line up" def run(self): return super(self.buffer.widget.__class__, self.buffer.widget).keypress(self.size, "up") @register_command class NavigateDownCommand(SameThreadCommand): name = "navigate-down" description = "go one line down" def run(self): return super(self.buffer.widget.__class__, self.buffer.widget).keypress(self.size, "down") @register_command class NavigateUpwardsCommand(SameThreadCommand): name = "navigate-upwards" description = "go 10 lines up" def run(self): if isinstance(self.buffer.widget, urwidtrees.TreeBox): self.ui.notify_message("This movement is not available.", level="error") return try: self.buffer.widget.set_focus(self.buffer.widget.get_focus()[1] - 10) except IndexError: self.buffer.widget.set_focus(0) self.buffer.widget.reload_widget() return @register_command class NavigateDownwardsCommand(SameThreadCommand): name = "navigate-downwards" description = "go 10 lines down" def run(self): if isinstance(self.buffer.widget, urwidtrees.TreeBox): self.ui.notify_message("This movement is not available.", level="error") return try: self.buffer.widget.set_focus(self.buffer.widget.get_focus()[1] + 10) except IndexError: self.buffer.widget.set_focus(len(self.buffer.widget.body) - 1) self.buffer.widget.reload_widget() return sen-0.6.0/sen/tui/views/0000755000372000037200000000000013254656257015704 5ustar travistravis00000000000000sen-0.6.0/sen/tui/views/__init__.py0000644000372000037200000000014513254656210020002 0ustar travistravis00000000000000""" this package contains widgets which provide a complete view which usually fills whole buffer """ sen-0.6.0/sen/tui/views/base.py0000644000372000037200000000037513254656210017162 0ustar travistravis00000000000000""" Base class for views, the API. """ class View: # TODO: views should have API for searching and filtering # there should be a map between widgets and their searchable content def refresh(self): raise NotImplementedError() sen-0.6.0/sen/tui/views/container_info.py0000644000372000037200000003641313254656210021247 0ustar travistravis00000000000000import pprint import logging import threading import urwid import urwidtrees from urwid import BoxAdapter from sen.exceptions import NotAvailableAnymore, NotifyError from sen.tui.chunks.container import ContainerStatusWidget from sen.tui.chunks.image import LayerWidget from sen.tui.views.base import View from sen.tui.widgets.graph import ContainerInfoGraph from sen.tui.widgets.list.base import WidgetBase from sen.tui.widgets.list.util import RowWidget, UnselectableRowWidget from sen.tui.widgets.table import assemble_rows from sen.tui.widgets.util import ( SelectableText, get_map, ColorText, UnselectableListBox ) from sen.util import log_traceback, humanize_bytes logger = logging.getLogger(__name__) class Process: """ single process returned for container.stats() query so we can hash the object """ def __init__(self, data): self.data = data @property def pid(self): return self.data["PID"] @property def ppid(self): return self.data["PPID"] @property def command(self): return self.data["COMMAND"] def __str__(self): return "[{}] {}".format(self.pid, self.command) def __repr__(self): return self.__str__() class ProcessList: """ util functions for process returned by container.stats() """ def __init__(self, data): self.data = [Process(x) for x in data] self._nesting = {x.pid: [] for x in self.data} for x in self.data: try: self._nesting[x.ppid].append(x) except KeyError: pass logger.debug(pprint.pformat(self._nesting, indent=2)) self._pids = [x.pid for x in self.data] self._pid_index = {x.pid: x for x in self.data} def get_parent_process(self, process): return self._pid_index.get(process.ppid, None) def get_root_process(self): # FIXME: error handling root_process = [x for x in self.data if x.ppid not in self._pids] return root_process[0] def get_first_child_process(self, process): try: return self._nesting[process.pid][0] except (KeyError, IndexError): return def get_last_child_process(self, process): try: return self._nesting[process.pid][-1] except (KeyError, IndexError): return def get_next_sibling(self, process): children = self._nesting.get(process.ppid, []) if len(children) <= 0: return None try: p = children[children.index(process) + 1] except IndexError: return return p def get_prev_sibling(self, process): children = self._nesting.get(process.ppid, []) if len(children) <= 0: return None logger.debug("prev of %s has children %s", process, children) prev_idx = children.index(process) - 1 if prev_idx < 0: # when this code path is not present, tree navigation is seriously messed up return None else: return children[prev_idx] class ProcessTreeBackend(urwidtrees.Tree): def __init__(self, data): """ :param data: dict, response from container.top() """ super().__init__() self.data = data self.process_list = ProcessList(data) self.root = self.process_list.get_root_process() def __getitem__(self, pos): logger.debug("do widget for %s", pos) return RowWidget([SelectableText(str(pos))]) # Tree API def parent_position(self, pos): v = self.process_list.get_parent_process(pos) logger.debug("parent of %s is %s", pos, v) return v def first_child_position(self, pos): logger.debug("first child process for %s", pos) v = self.process_list.get_first_child_process(pos) logger.debug("first child of %s is %s", pos, v) return v def last_child_position(self, pos): v = self.process_list.get_last_child_process(pos) logger.debug("last child of %s is %s", pos, v) return v def next_sibling_position(self, pos): v = self.process_list.get_next_sibling(pos) logger.debug("next of %s is %s", pos, v) return v def prev_sibling_position(self, pos): v = self.process_list.get_prev_sibling(pos) logger.debug("prev of %s is %s", pos, v) return v class ProcessTree(urwidtrees.TreeBox): def __init__(self, data): tree = ProcessTreeBackend(data) # We hide the usual arrow tip and use a customized collapse-icon. t = urwidtrees.ArrowTree( tree, arrow_att="tree", # lines, tip icon_collapsed_att="tree", # + icon_expanded_att="tree", # - icon_frame_att="tree", # [ ] ) super().__init__(t) class ContainerInfoView(WidgetBase, View): """ display info about container """ def __init__(self, ui, docker_container): self.walker = urwid.SimpleFocusListWalker([]) super().__init__(ui, self.walker) self.docker_container = docker_container self.stop = threading.Event() self.view_widgets = [] def refresh(self): self.view_widgets.clear() self.docker_container.refresh() self._basic_data() self._net() self._image() self._process_tree() self._resources() self._labels() self._logs() # we'll update listwalker in one step: changing it periodically can be race-y self.set_body(self.view_widgets) self.set_focus(0) @property def focused_docker_object(self): try: return self.focus.columns.widget_list[0].docker_image except AttributeError: return None def _basic_data(self): data = [ [SelectableText("Id", maps=get_map("main_list_green")), SelectableText(self.docker_container.container_id)], [SelectableText("Status", maps=get_map("main_list_green")), ContainerStatusWidget(self.docker_container, nice_status=False)], [SelectableText("Created", maps=get_map("main_list_green")), SelectableText("{0}, {1}".format(self.docker_container.display_formal_time_created(), self.docker_container.display_time_created()))], [SelectableText("Command", maps=get_map("main_list_green")), SelectableText(self.docker_container.command)], ] # TODO: add exit code, created, started, finished, pid if self.docker_container.names: data.append( [SelectableText("Name", maps=get_map("main_list_green")), SelectableText("".join(self.docker_container.names))], ) if self.docker_container.size_root_fs: data.append( [SelectableText("Image Size", maps=get_map("main_list_green")), SelectableText(humanize_bytes(self.docker_container.size_root_fs))]) if self.docker_container.size_rw_fs: data.append( [SelectableText("Writable Layer Size", maps=get_map("main_list_green")), SelectableText(humanize_bytes(self.docker_container.size_rw_fs))]) self.view_widgets.extend(assemble_rows(data, ignore_columns=[1])) def _net(self): try: net = self.docker_container.net except NotAvailableAnymore: raise NotifyError("Container %s is not available anymore" % self.docker_container) ports = net.ports data = [] if ports: data.extend([[SelectableText("")], [ SelectableText("Host Port", maps=get_map("main_list_white")), SelectableText("Container Port", maps=get_map("main_list_white")) ]]) for container_port, host_port in ports.items(): if host_port and container_port: data.append([ SelectableText(host_port), SelectableText(container_port) ]) ips = net.ips logger.debug(ips) if ips: data.extend([[SelectableText("")], [ SelectableText("Network Name", maps=get_map("main_list_white")), SelectableText("IP Address", maps=get_map("main_list_white")) ]]) for net_name, net_data in ips.items(): a4 = net_data.get("ip_address4", "none") a6 = net_data.get("ip_address6", "") data.append([ SelectableText(net_name), SelectableText(a4) ]) if a6: data.append([ SelectableText(net_name), SelectableText(a6) ]) if data: self.view_widgets.extend(assemble_rows(data, dividechars=3, ignore_columns=[1])) def _image(self): if self.docker_container.image: self.view_widgets.append(RowWidget([SelectableText("")])) self.view_widgets.append(RowWidget([SelectableText("Image", maps=get_map("main_list_white"))])) self.view_widgets.append(RowWidget([LayerWidget(self.ui, self.docker_container.image)])) def _resources(self): self.view_widgets.append(RowWidget([SelectableText("")])) self.view_widgets.append(RowWidget([SelectableText("Resource Usage", maps=get_map("main_list_white"))])) cpu_g = ContainerInfoGraph("graph_lines_cpu_tips", "graph_lines_cpu") mem_g = ContainerInfoGraph("graph_lines_mem_tips", "graph_lines_mem") blk_r_g = ContainerInfoGraph("graph_lines_blkio_r_tips", "graph_lines_blkio_r") blk_w_g = ContainerInfoGraph("graph_lines_blkio_w_tips", "graph_lines_blkio_w") net_r_g = ContainerInfoGraph("graph_lines_net_r_tips", "graph_lines_net_r") net_w_g = ContainerInfoGraph("graph_lines_net_w_tips", "graph_lines_net_w") cpu_label = ColorText("CPU ", "graph_lines_cpu_legend") cpu_value = ColorText("0.0 %", "graph_lines_cpu_legend") mem_label = ColorText("Memory ", "graph_lines_mem_legend") mem_value = ColorText("0.0 %", "graph_lines_mem_legend") blk_r_label = ColorText("I/O Read ", "graph_lines_blkio_r_legend") blk_r_value = ColorText("0 B", "graph_lines_blkio_r_legend") blk_w_label = ColorText("I/O Write ", "graph_lines_blkio_w_legend") blk_w_value = ColorText("0 B", "graph_lines_blkio_w_legend") net_r_label = ColorText("Net Rx ", "graph_lines_net_r_legend") net_r_value = ColorText("0 B", "graph_lines_net_r_legend") net_w_label = ColorText("Net Tx ", "graph_lines_net_w_legend") net_w_value = ColorText("0 B", "graph_lines_net_w_legend") self.view_widgets.append(urwid.Columns([ BoxAdapter(cpu_g, 12), BoxAdapter(mem_g, 12), ("weight", 0.5, BoxAdapter(blk_r_g, 12)), ("weight", 0.5, BoxAdapter(blk_w_g, 12)), ("weight", 0.5, BoxAdapter(net_r_g, 12)), ("weight", 0.5, BoxAdapter(net_w_g, 12)), BoxAdapter(UnselectableListBox(urwid.SimpleFocusListWalker([ UnselectableRowWidget([(12, cpu_label), cpu_value]), UnselectableRowWidget([(12, mem_label), mem_value]), UnselectableRowWidget([(12, blk_r_label), blk_r_value]), UnselectableRowWidget([(12, blk_w_label), blk_w_value]), UnselectableRowWidget([(12, net_r_label), net_r_value]), UnselectableRowWidget([(12, net_w_label), net_w_value]), ])), 12), ])) self.view_widgets.append(RowWidget([SelectableText("")])) @log_traceback def realtime_updates(): g = self.docker_container.stats().response while True: try: update = next(g) except Exception as ex: if "Timeout" in ex.__class__.__name__: logger.info("timeout when reading stats: %r", ex) g = self.docker_container.stats().response continue logger.error("error while getting stats: %r", ex) self.ui.notify_message("Error while getting stats: %s" % ex, level="error") # TODO: if debug raise break if self.stop.is_set(): break logger.debug(update) cpu_percent = update["cpu_percent"] cpu_value.text = "%.2f %%" % cpu_percent cpu_g.rotate_value(int(cpu_percent), max_val=100) mem_percent = update["mem_percent"] mem_current = humanize_bytes(update["mem_current"]) mem_value.text = "%.2f %% (%s)" % (mem_percent, mem_current) mem_g.rotate_value(int(mem_percent), max_val=100) blk_read = update["blk_read"] blk_write = update["blk_write"] blk_r_value.text = humanize_bytes(blk_read) blk_w_value.text = humanize_bytes(blk_write) r_max_val = blk_r_g.rotate_value(blk_read, adaptive_max=True) w_max_val = blk_w_g.rotate_value(blk_write, adaptive_max=True) blk_r_g.set_max(max((r_max_val, w_max_val))) blk_w_g.set_max(max((r_max_val, w_max_val))) net_read = update["net_rx"] net_write = update["net_tx"] net_r_value.text = humanize_bytes(net_read) net_w_value.text = humanize_bytes(net_write) r_max_val = net_r_g.rotate_value(net_read, adaptive_max=True) w_max_val = net_w_g.rotate_value(net_write, adaptive_max=True) net_r_g.set_max(max((r_max_val, w_max_val))) net_w_g.set_max(max((r_max_val, w_max_val))) self.thread = threading.Thread(target=realtime_updates, daemon=True) self.thread.start() def _labels(self): if not self.docker_container.labels: return [] data = [] self.view_widgets.append(RowWidget([SelectableText("Labels", maps=get_map("main_list_white"))])) for label_key, label_value in self.docker_container.labels.items(): data.append([SelectableText(label_key, maps=get_map("main_list_green")), SelectableText(label_value)]) self.view_widgets.extend(assemble_rows(data, ignore_columns=[1])) def _process_tree(self): top = self.docker_container.top().response logger.debug(top) if top: self.view_widgets.append(RowWidget([SelectableText("")])) self.view_widgets.append(RowWidget([SelectableText("Process Tree", maps=get_map("main_list_white"))])) self.view_widgets.append(BoxAdapter(ProcessTree(top), len(top))) def _logs(self): operation = self.docker_container.logs(follow=False, lines=10) if operation.response: l = [] l.append(RowWidget([SelectableText("")])) l.append(RowWidget([SelectableText("Logs", maps=get_map("main_list_white"))])) l.extend([RowWidget([SelectableText(x)]) for x in operation.response.splitlines()]) self.view_widgets.extend(l) def destroy(self): self.stop.set() sen-0.6.0/sen/tui/views/disk_usage.py0000644000372000037200000001157613254656210020373 0ustar travistravis00000000000000""" TODO: * nicer list * summary * clickable items * enable deleting volumes """ import urwid from sen.util import humanize_bytes, graceful_chain_get from sen.tui.views.base import View from sen.tui.widgets.list.util import SingleTextRow from sen.tui.widgets.table import assemble_rows from sen.tui.constants import MAIN_LIST_FOCUS from sen.tui.widgets.util import SelectableText, get_map from sen.tui.widgets.list.base import WidgetBase class DfBufferView(WidgetBase, View): def __init__(self, ui, buffer): """ :param ui: :param buffer: Buffer instance, display help about this buffer """ self.ui = ui self.buffer = buffer self.walker = urwid.SimpleFocusListWalker([]) super().__init__(ui, self.walker) def refresh(self, df=None, containers=None, images=None): content = [] if df is None: content += [ SingleTextRow("Data is being loaded, it may even take a couple minutes.", maps={"normal": "main_list_white", "focus": MAIN_LIST_FOCUS}), ] else: if containers: content += [ SingleTextRow("Containers", maps={"normal": "main_list_white", "focus": MAIN_LIST_FOCUS}), SingleTextRow("") ] containers_content = [[ SelectableText("Name", maps=get_map("main_list_lg")), SelectableText("Image Size", maps=get_map("main_list_lg")), SelectableText("Writable Layer Size", maps=get_map("main_list_lg")), ]] for c in containers: containers_content.append( [SelectableText(c.short_name), SelectableText(humanize_bytes(c.size_root_fs or 0)), SelectableText(humanize_bytes(c.size_rw_fs or 0)), ]) content.extend(assemble_rows(containers_content, dividechars=3)) content += [ SingleTextRow("") ] if images: content += [ SingleTextRow("Images", maps={"normal": "main_list_white", "focus": MAIN_LIST_FOCUS}), SingleTextRow("") ] images_content = [[ SelectableText("Name", maps=get_map("main_list_lg")), SelectableText("Size", maps=get_map("main_list_lg")), SelectableText("Shared Size", maps=get_map("main_list_lg")), SelectableText("Unique Size", maps=get_map("main_list_lg")) ]] for i in images: images_content.append([ SelectableText(i.short_name, maps=get_map("main_list_dg")), SelectableText( humanize_bytes(i.total_size or 0), maps=get_map("main_list_dg")), SelectableText( humanize_bytes(i.shared_size or 0), maps=get_map("main_list_dg")), SelectableText( humanize_bytes(i.unique_size or 0), maps=get_map("main_list_dg")) ]) content.extend(assemble_rows(images_content, dividechars=3)) content += [ SingleTextRow("") ] volumes = graceful_chain_get(df, "Volumes") if volumes: content += [ SingleTextRow("Volumes", maps={"normal": "main_list_white", "focus": MAIN_LIST_FOCUS}), SingleTextRow("") ] volumes_content = [[ SelectableText("Name", maps=get_map("main_list_lg")), SelectableText("Links", maps=get_map("main_list_lg")), SelectableText("Size", maps=get_map("main_list_lg")), ]] for v in volumes: v_name = graceful_chain_get(v, "Name", default="") v_size = graceful_chain_get(v, "UsageData", "Size", default=0) v_links = graceful_chain_get(v, "UsageData", "RefCount", default=0) volumes_content.append([ SelectableText(v_name, maps=get_map("main_list_dg")), SelectableText("%s" % v_links, maps=get_map("main_list_dg")), SelectableText( humanize_bytes(v_size), maps=get_map("main_list_dg")), ]) content.extend(assemble_rows(volumes_content, dividechars=3)) self.set_body(content) self.set_focus(0) sen-0.6.0/sen/tui/views/help.py0000644000372000037200000001073713254656210017203 0ustar travistravis00000000000000import urwid from sen.tui.views.base import View from sen.tui.widgets.list.util import SingleTextRow from sen.tui.widgets.table import assemble_rows from sen.tui.constants import MAIN_LIST_FOCUS from sen.tui.widgets.util import SelectableText, get_map from sen.tui.widgets.list.base import WidgetBase class HelpBufferView(WidgetBase, View): def __init__(self, ui, buffer, global_keybinds): """ :param ui: :param buffer: Buffer instance, display help about this buffer :param global_keybinds: dict """ self.ui = ui self.buffer = buffer self.walker = urwid.SimpleFocusListWalker([]) self.global_keybinds = global_keybinds super().__init__(ui, self.walker) def refresh(self): template = [ SingleTextRow("Buffer: " + self.buffer.display_name, maps={"normal": "main_list_white", "focus": MAIN_LIST_FOCUS}), SingleTextRow(""), SingleTextRow(self.buffer.description, maps={"normal": "main_list_dg", "focus": MAIN_LIST_FOCUS}), SingleTextRow(""), ] if self.buffer.keybinds: template += [ SingleTextRow("Buffer-specific Keybindings", maps={"normal": "main_list_white", "focus": MAIN_LIST_FOCUS}), SingleTextRow(""), ] template.extend(assemble_rows( [[SelectableText(key, maps=get_map("main_list_yellow")), SelectableText(command, maps=get_map("main_list_lg"))] for key, command in self.buffer.keybinds.items()], ignore_columns=[1], dividechars=3)) template += [ SingleTextRow(""), SingleTextRow("Global Keybindings", maps={"normal": "main_list_white", "focus": MAIN_LIST_FOCUS}), SingleTextRow("") ] template.extend(assemble_rows( [[SelectableText(key, maps=get_map("main_list_yellow")), SelectableText(command, maps=get_map("main_list_lg"))] for key, command in self.global_keybinds.items()], ignore_columns=[1], dividechars=3)) self.set_body(template) self.set_focus(0) class HelpCommandView(WidgetBase, View): def __init__(self, ui, command): """ :param ui: :param command: Command instance """ self.ui = ui self.command = command self.walker = urwid.SimpleFocusListWalker([]) super().__init__(ui, self.walker) def refresh(self): invocation = ["[{}=value]".format(a.name) for a in self.command.options_definitions] + \ ["<{}>".format(o.name) for o in self.command.arguments_definitions] template = [ SingleTextRow("Command: {} {}".format(self.command.name, " ".join(invocation)), maps={"normal": "main_list_white", "focus": MAIN_LIST_FOCUS}), SingleTextRow(""), ] template += [SingleTextRow(s, maps={"normal": "main_list_dg", "focus": MAIN_LIST_FOCUS}) for s in self.command.description.split("\n")] template += [SingleTextRow("")] if self.command.arguments_definitions: template += [ SingleTextRow(""), SingleTextRow("Arguments", maps={"normal": "main_list_white", "focus": MAIN_LIST_FOCUS}), SingleTextRow(""), ] template.extend(assemble_rows( [[SelectableText(argument.name, maps=get_map("main_list_yellow")), SelectableText(argument.description, maps=get_map("main_list_lg"))] for argument in self.command.arguments_definitions], ignore_columns=[1], dividechars=3)) if self.command.options_definitions: template += [ SingleTextRow(""), SingleTextRow("Options", maps={"normal": "main_list_white", "focus": MAIN_LIST_FOCUS}), SingleTextRow(""), ] template.extend(assemble_rows( [[SelectableText(option.name, maps=get_map("main_list_yellow")), SelectableText(option.description, maps=get_map("main_list_lg"))] for option in self.command.options_definitions], ignore_columns=[1], dividechars=3)) self.set_body(template) self.set_focus(0) sen-0.6.0/sen/tui/views/image_info.py0000644000372000037200000001204013254656210020335 0ustar travistravis00000000000000import logging import urwid from sen.docker_backend import RootImage from sen.tui.chunks.container import ContainerOneLinerWidget from sen.tui.chunks.image import LayerWidget from sen.tui.views.base import View from sen.tui.widgets.list.base import WidgetBase from sen.tui.widgets.list.util import RowWidget from sen.tui.widgets.table import assemble_rows from sen.tui.widgets.util import SelectableText, get_map from sen.util import humanize_bytes logger = logging.getLogger(__name__) class TagWidget(SelectableText): """ so we can easily access image and tag """ def __init__(self, docker_image, tag): self.docker_image = docker_image self.tag = tag super().__init__(str(self.tag)) class ImageInfoWidget(WidgetBase, View): """ display info about image """ def __init__(self, ui, docker_image): self.walker = urwid.SimpleFocusListWalker([]) super().__init__(ui, self.walker) self.docker_image = docker_image def refresh(self): self.docker_image.refresh() self.walker.clear() self._basic_data() self._containers() self._image_names() self._layers() self._labels() self.set_focus(0) @property def focused_docker_object(self): # TODO: enable removing image names try: return self.focus.columns.widget_list[0].docker_container except AttributeError: try: return self.focus.columns.widget_list[0].docker_image except AttributeError: return None def _basic_data(self): data = [ [SelectableText("Id", maps=get_map("main_list_green")), SelectableText(self.docker_image.image_id)], [SelectableText("Created", maps=get_map("main_list_green")), SelectableText("{0}, {1}".format(self.docker_image.display_formal_time_created(), self.docker_image.display_time_created()))], [SelectableText("Size", maps=get_map("main_list_green")), SelectableText(humanize_bytes(self.docker_image.total_size))], ] if self.docker_image.unique_size: data.append( [SelectableText("Unique Size", maps=get_map("main_list_green")), SelectableText(humanize_bytes(self.docker_image.unique_size))]) if self.docker_image.shared_size: data.append( [SelectableText("Shared Size", maps=get_map("main_list_green")), SelectableText(humanize_bytes(self.docker_image.shared_size))]) data.append([SelectableText("Command", maps=get_map("main_list_green")), SelectableText(self.docker_image.container_command)]) self.walker.extend(assemble_rows(data, ignore_columns=[1])) def _image_names(self): if not self.docker_image.names: return self.walker.append(RowWidget([SelectableText("")])) self.walker.append(RowWidget([SelectableText("Image Names", maps=get_map("main_list_white"))])) for n in self.docker_image.names: self.walker.append(RowWidget([TagWidget(self.docker_image, n)])) def _layers(self): self.walker.append(RowWidget([SelectableText("")])) self.walker.append(RowWidget([SelectableText("Layers", maps=get_map("main_list_white"))])) i = self.docker_image parent = i.parent_image layers = self.docker_image.layers index = 0 if isinstance(parent, RootImage) and len(layers) > 0: # pulled image, docker 1.10+ for image in layers: self.walker.append( RowWidget([LayerWidget(self.ui, image, index=index)]) ) index += 1 else: self.walker.append(RowWidget([LayerWidget(self.ui, self.docker_image, index=index)])) while True: index += 1 parent = i.parent_image if parent: self.walker.append(RowWidget([LayerWidget(self.ui, parent, index=index)])) i = parent else: break def _labels(self): if not self.docker_image.labels: return [] data = [] self.walker.append(RowWidget([SelectableText("")])) self.walker.append(RowWidget([SelectableText("Labels", maps=get_map("main_list_white"))])) for label_key, label_value in self.docker_image.labels.items(): data.append([SelectableText(label_key, maps=get_map("main_list_green")), SelectableText(label_value)]) self.walker.extend(assemble_rows(data, ignore_columns=[1])) def _containers(self): if not self.docker_image.containers(): return self.walker.append(RowWidget([SelectableText("")])) self.walker.append(RowWidget([SelectableText("Containers", maps=get_map("main_list_white"))])) for container in self.docker_image.containers(): self.walker.append(RowWidget([ContainerOneLinerWidget(self.ui, container)])) sen-0.6.0/sen/tui/views/main.py0000644000372000037200000001640313254656210017173 0ustar travistravis00000000000000import logging import re import threading import urwid from sen.exceptions import NotifyError, NotAvailableAnymore from sen.tui.chunks.misc import get_row from sen.tui.widgets.list.util import ( get_operation_notify_widget, ResponsiveRowWidget ) from sen.tui.widgets.table import ResponsiveTable logger = logging.getLogger(__name__) class MainLineWidget(ResponsiveRowWidget): def __init__(self, docker_object): self.docker_object = docker_object super().__init__(get_row(docker_object)) def matches_search(self, s): return self.docker_object.matches_search(s) def __repr__(self): return "{}({})".format(self.__class__.__name__, self.docker_object) def __str__(self): return "{}".format(self.docker_object) class MainListBox(ResponsiveTable): def __init__(self, ui, docker_backend): self.d = docker_backend super(MainListBox, self).__init__(ui, urwid.SimpleFocusListWalker([])) # urwid is not thread safe self.refresh_lock = threading.Lock() # realtime lock self.realtime_lock = threading.Lock() self.stop_realtime_events = threading.Event() def refresh(self, query=None): """ refresh listing, also apply filters :return: """ logger.info("refresh listing") focus_on_top = len(self.body) == 0 # focus if empty with self.refresh_lock: self.query(query_string=query) if focus_on_top: try: self.set_focus(0) except IndexError: pass def process_realtime_event(self, event): with self.realtime_lock: if self.stop_realtime_events.is_set(): logger.info("received docker event when this functionality is disabled") return self.refresh(query=self.filter_query) def filter(self, s, widgets_to_filter=None): self.refresh(query=s) def toggle_realtime_events(self): with self.realtime_lock: if self.stop_realtime_events.is_set(): self.stop_realtime_events.clear() self.ui.notify_message("Enabling live updates from docker.") else: self.stop_realtime_events.set() self.ui.notify_message("Disabling live updates from docker.") self.ui.reload_footer() def query(self, query_string=""): """ query and display, also apply filters :param query_string: str :return: None """ def query_notify(operation): w = get_operation_notify_widget(operation, display_always=False) if w: self.ui.notify_widget(w) if query_string is not None: self.filter_query = query_string.strip() # FIXME: this could be part of filter command since it's command line backend_query = { "cached": False, "containers": True, "images": True, } def containers(): backend_query["containers"] = True backend_query["images"] = not backend_query["images"] backend_query["cached"] = True def images(): backend_query["containers"] = not backend_query["containers"] backend_query["images"] = True backend_query["cached"] = True def running(): backend_query["stopped"] = False backend_query["cached"] = True backend_query["images"] = False query_conf = [ { "query_keys": ["t", "type"], "query_values": ["c", "container", "containers"], "callback": containers }, { "query_keys": ["t", "type"], "query_values": ["i", "images", "images"], "callback": images }, { "query_keys": ["s", "state"], "query_values": ["r", "running"], "callback": running }, ] query_list = re.split(r"[\s,]", self.filter_query) unprocessed = [] for query_str in query_list: if not query_str: continue # process here x=y queries and pass rest to parent filter() try: query_key, query_value = query_str.split("=", 1) except ValueError: unprocessed.append(query_str) else: logger.debug("looking up query key %r and query value %r", query_key, query_value) for c in query_conf: if query_key in c["query_keys"] and query_value in c["query_values"]: c["callback"]() break else: raise NotifyError("Invalid query string: %r", query_str) widgets = [] logger.debug("doing query %s", backend_query) query, c_op, i_op = self.d.filter(**backend_query) for o in query: try: line = MainLineWidget(o) except NotAvailableAnymore: continue widgets.append(line) if unprocessed: new_query = " ".join(unprocessed) logger.debug("doing parent query for unprocessed string: %r", new_query) super().filter(new_query, widgets_to_filter=widgets) else: self.set_body(widgets) self.ro_content = widgets query_notify(i_op) query_notify(c_op) def status_bar(self): columns_list = [] def add_subwidget(markup, color_attr=None): if color_attr is None: w = urwid.AttrMap(urwid.Text(markup), "status_text") else: w = urwid.AttrMap(urwid.Text(markup), color_attr) columns_list.append((len(markup), w)) add_subwidget("Images: ") images_count = len(self.d.get_images(cached=True).response) if images_count < 10: add_subwidget(str(images_count), "status_text_green") elif images_count < 50: add_subwidget(str(images_count), "status_text_yellow") else: add_subwidget(str(images_count), "status_text_orange") add_subwidget(", Containers: ") containers_count = len(self.d.get_containers(cached=True, stopped=True).response) if containers_count < 5: add_subwidget(str(containers_count), "status_text_green") elif containers_count < 30: add_subwidget(str(containers_count), "status_text_yellow") elif containers_count < 100: add_subwidget(str(containers_count), "status_text_orange") else: add_subwidget(str(containers_count), "status_text_red") add_subwidget(", Running: ") running_containers = self.d.get_containers(cached=True, stopped=False).response running_containers_n = len(running_containers) add_subwidget(str(running_containers_n), "status_text_green" if running_containers_n > 0 else "status_text") with self.realtime_lock: if self.stop_realtime_events.is_set(): add_subwidget(", Live updates are disabled") parent_cols = super().status_bar() if parent_cols: add_subwidget(", ") return columns_list + parent_cols sen-0.6.0/sen/tui/widgets/0000755000372000037200000000000013254656257016215 5ustar travistravis00000000000000sen-0.6.0/sen/tui/widgets/list/0000755000372000037200000000000013254656257017170 5ustar travistravis00000000000000sen-0.6.0/sen/tui/widgets/list/__init__.py0000644000372000037200000000007313254656210021266 0ustar travistravis00000000000000""" module with widgets for displaying lists of objects """sen-0.6.0/sen/tui/widgets/list/base.py0000644000372000037200000001020513254656210020437 0ustar travistravis00000000000000import logging import threading import urwid from sen.exceptions import NotifyError logger = logging.getLogger(__name__) class WidgetBase(urwid.ListBox): """ common class for widgets """ def __init__(self, ui, *args, **kwargs): self.ui = ui self.search_string = None self.filter_query = "" super().__init__(*args, **kwargs) self.ro_content = self.body[:] # unfiltered content of a widget self.body_change_lock = threading.Lock() def set_body(self, widgets): with self.body_change_lock: self.body[:] = widgets def reload_widget(self): # this is the easiest way to refresh body with self.body_change_lock: self.body[:] = self.body def _search(self, reverse_search=False): if self.search_string is None: raise NotifyError("No search pattern specified.") if not self.search_string: self.search_string = None return pos = self.focus_position original_position = pos wrapped = False while True: if reverse_search: obj, pos = self.body.get_prev(pos) else: obj, pos = self.body.get_next(pos) if obj is None: # wrap wrapped = True if reverse_search: obj, pos = self.body[-1], len(self.body) else: obj, pos = self.body[0], 0 if wrapped and ( (pos > original_position and not reverse_search) or (pos < original_position and reverse_search) ): raise NotifyError("Pattern not found: %r." % self.search_string) # FIXME: figure out nicer search api if hasattr(obj, "matches_search"): condition = obj.matches_search(self.search_string) else: if hasattr(obj, "original_widget"): text = obj.original_widget.text else: text = obj.text condition = self.search_string in text if condition: self.set_focus(pos) self.reload_widget() break def filter(self, s, widgets_to_filter=None): s = s.strip() if not s: self.filter_query = None self.set_body(self.ro_content) return widgets = [] for obj in widgets_to_filter or self.ro_content: # FIXME: figure out nicer search api if hasattr(obj, "matches_search"): condition = obj.matches_search(s) else: if hasattr(obj, "original_widget"): text = obj.original_widget.text else: text = obj.text condition = s in text if condition: widgets.append(obj) if not widgets_to_filter: self.filter_query = s self.set_body(widgets) def find_previous(self, search_pattern=None): if search_pattern is not None: self.search_string = search_pattern self._search(reverse_search=True) def find_next(self, search_pattern=None): if search_pattern is not None: self.search_string = search_pattern self._search() def status_bar(self): columns_list = [] def add_subwidget(markup, color_attr=None): if color_attr is None: w = urwid.AttrMap(urwid.Text(markup), "status_text") else: w = urwid.AttrMap(urwid.Text(markup), color_attr) columns_list.append((len(markup), w)) if self.search_string: add_subwidget("Search: ") add_subwidget(repr(self.search_string)) if self.search_string and self.filter_query: add_subwidget(", ") if self.filter_query: add_subwidget("Filter: ") add_subwidget(repr(self.filter_query)) return columns_list @property def focused_docker_object(self): return self.get_focus()[0].docker_object sen-0.6.0/sen/tui/widgets/list/common.py0000644000372000037200000001166013254656210021023 0ustar travistravis00000000000000import logging import re import threading import traceback import urwid from sen.tui.widgets.list.base import WidgetBase from sen.util import _ensure_unicode logger = logging.getLogger(__name__) # def translate_asci_sequence(s): # # FIXME: not finished # translation_map = { # "34": "dark blue" # } # return translation_map.get(s, "") def strip_from_ansi_esc_sequences(text): """ find ANSI escape sequences in text and remove them :param text: str :return: list, should be passed to ListBox """ # esc[ + values + control character # h, l, p commands are complicated, let's ignore them seq_regex = r"\x1b\[[0-9;]*[mKJusDCBAfH]" regex = re.compile(seq_regex) start = 0 response = "" for match in regex.finditer(text): end = match.start() response += text[start:end] start = match.end() response += text[start:len(text)] return response # def colorize_text(text): # """ # finds ANSI color escapes in text and transforms them to urwid # # :param text: str # :return: list, should be passed to ListBox # """ # # FIXME: not finished # response = [] # # http://ascii-table.com/ansi-escape-sequences.php # regex_pattern = r"(?:\x1b\[(\d+)?(?:;(\d+))*m)([^\x1b]+)" # [%d;%d;...m # regex = re.compile(regex_pattern, re.UNICODE) # for match in regex.finditer(text): # groups = match.groups() # t = groups[-1] # color_specs = groups[:-1] # urwid_spec = translate_asci_sequence(color_specs) # if urwid_spec: # item = (urwid.AttrSpec(urwid_spec, "main_list_dg"), t) # else: # item = t # item = urwid.AttrMap(urwid.Text(t, align="left", wrap="any"), "main_list_dg", "main_list_white") # response.append(item) # return response class ScrollableListBox(WidgetBase): def __init__(self, ui, text, focus_bottom=True): self.walker = urwid.SimpleFocusListWalker([]) super().__init__(ui, self.walker) self.set_text(text) if focus_bottom: try: self.set_focus(len(self.walker) - 2) except IndexError: pass def set_text(self, text): self.walker.clear() text = _ensure_unicode(text) # logger.debug(repr(text)) text = strip_from_ansi_esc_sequences(text) list_of_texts = text.split("\n") self.walker[:] = [ urwid.AttrMap(urwid.Text(t.rstrip(), align="left", wrap="any"), "main_list_dg", "main_list_white") for t in list_of_texts ] class AsyncScrollableListBox(WidgetBase): def __init__(self, generator, ui, static_data=None): self.log_texts = [] if static_data: static_data = _ensure_unicode(static_data).split("\n") for d in static_data: log_entry = d.rstrip() if log_entry: self.log_texts.append(urwid.Text(("main_list_dg", log_entry), align="left", wrap="any")) walker = urwid.SimpleFocusListWalker(self.log_texts) super(AsyncScrollableListBox, self).__init__(ui, walker) walker.set_focus(len(walker) - 1) def fetch_logs(): line_w = urwid.AttrMap( urwid.Text("", align="left", wrap="any"), "main_list_dg", "main_list_white" ) walker.append(line_w) while True: try: line = next(generator) except StopIteration: logger.info("no more logs") line_w = urwid.AttrMap( urwid.Text("No more logs.", align="left", wrap="any"), "main_list_dg", "main_list_white" ) walker.append(line_w) walker.set_focus(len(walker) - 1) break except Exception as ex: logger.error(traceback.format_exc()) ui.notify_message("Error while fetching logs: %s", ex) break line = _ensure_unicode(line) if self.stop.is_set(): break if self.filter_query: if self.filter_query not in line: continue line_w.original_widget.set_text(line_w.original_widget.text + line.rstrip("\r\n")) if line.endswith("\n"): walker.set_focus(len(walker) - 1) line_w = urwid.AttrMap( urwid.Text("", align="left", wrap="any"), "main_list_dg", "main_list_white" ) walker.append(line_w) ui.refresh() self.stop = threading.Event() self.thread = threading.Thread(target=fetch_logs, daemon=True) self.thread.start() def destroy(self): self.stop.set() sen-0.6.0/sen/tui/widgets/list/util.py0000644000372000037200000000575413254656210020517 0ustar travistravis00000000000000import datetime import logging import urwid from sen.tui.constants import MAIN_LIST_FOCUS from sen.tui.widgets.responsive_column import ResponsiveColumns from sen.tui.widgets.util import SelectableText, get_map logger = logging.getLogger(__name__) def get_color_text(markup, color_attr="status_text"): w = urwid.AttrMap(urwid.Text(markup), color_attr) return len(markup), w def get_operation_notify_widget(operation, notif_level="info", display_always=True): if not operation: return attr = "notif_{}".format(notif_level) took = operation.took text_list = [] if took > 300: fmt_str = "{} Query took " text_list.append((attr, fmt_str.format(operation.pretty_message))) command_took_str = "{:.2f}".format(took) if took < 500: text_list.append(("notif_text_yellow", command_took_str)) elif took < 1000: text_list.append(("notif_text_orange", command_took_str)) else: command_took_str = "{:.2f}".format(took / 1000.0) text_list.append(("notif_text_red", command_took_str)) text_list.append((attr, " s")) if took < 1000: text_list.append((attr, " ms")) elif display_always: text_list.append((attr, operation.pretty_message)) else: return return urwid.AttrMap(urwid.Text(text_list), attr) def get_time_attr_map(t): """ now -> | hour ago -> | day ago -> | |--------------|--------------------|---------------------| """ now = datetime.datetime.now() if t + datetime.timedelta(hours=3) > now: return get_map("main_list_white") if t + datetime.timedelta(days=3) > now: return get_map("main_list_lg") else: return get_map("main_list_dg") class UnselectableRowWidget(urwid.AttrMap): def __init__(self, columns, attr="main_list_dg", focus_map=MAIN_LIST_FOCUS, dividechars=1): self.widgets = columns self.columns = urwid.Columns(columns, dividechars=dividechars) super().__init__(self.columns, attr, focus_map=focus_map) @property def contents(self): return self.columns.contents def render(self, size, focus=False): for w in self.columns.widget_list: if hasattr(w, "set_map"): w.set_map('focus' if focus else 'normal') return urwid.AttrMap.render(self, size, focus) class RowWidget(UnselectableRowWidget): def selectable(self): return True class SingleTextRow(RowWidget): def __init__(self, text_markup, maps=None): super().__init__([SelectableText(text_markup, maps=maps)]) class ResponsiveRowWidget(RowWidget): def __init__(self, columns, attr="main_list_dg", focus_map=MAIN_LIST_FOCUS, dividechars=1): self.widgets = columns self.columns = ResponsiveColumns(columns, dividechars=dividechars) urwid.AttrMap.__init__(self, self.columns, attr, focus_map=focus_map) sen-0.6.0/sen/tui/widgets/__init__.py0000644000372000037200000000016613254656210020316 0ustar travistravis00000000000000""" This package is meant for generic widgets which are not tied to anything. These could easily be part of urwid. """sen-0.6.0/sen/tui/widgets/graph.py0000644000372000037200000000322613254656210017660 0ustar travistravis00000000000000import math import logging import urwid logger = logging.getLogger(__name__) def find_max(list_of_lists): list_of_ints = [x[0] for x in list_of_lists] m = max(list_of_ints) try: return 2 ** int(math.log2(m) + 1) except ValueError: return 1 class ContainerInfoGraph(urwid.BarGraph): def __init__(self, fg, bg, graph_bg="graph_bg", bar_width=None): """ create a very simple graph :param fg: attr for smoothing (fg needs to be set) :param bg: attr for bars (bg needs to be set) :param graph_bg: attr for graph background :param bar_width: int, width of bars """ # satt smoothes graph lines satt = {(1, 0): fg} super().__init__( [graph_bg, bg], hatt=[fg], satt=satt, ) if bar_width is not None: # breaks badly when set too high self.set_bar_width(bar_width) def render(self, size, focus=False): data, top, hlines = self._get_data(size) maxcol, maxrow = size if len(data) < maxcol: data += [[0] for x in range(maxcol - len(data))] self.set_data(data, top, hlines) logger.debug(data) return super().render(size, focus) def rotate_value(self, val, max_val=None, adaptive_max=False): """ """ data, _, _ = self.data data = data[1:] + [[int(val)]] if adaptive_max: max_val = find_max(data) self.set_data(data, max_val) return max_val def set_max(self, value): data, top, hlines = self.data self.set_data(data, value, hlines) sen-0.6.0/sen/tui/widgets/responsive_column.py0000644000372000037200000000110513254656210022323 0ustar travistravis00000000000000import logging import urwid logger = logging.getLogger(__name__) class ResponsiveColumns(urwid.Columns): """ Widgets arranged horizontally in columns from left to right """ def column_widths(self, size, focus=False): """ Return a list of column widths. 0 values in the list mean hide corresponding column completely """ maxcol = size[0] self._cache_maxcol = maxcol widths = [width for i, (w, (t, width, b)) in enumerate(self.contents)] self._cache_column_widths = widths return widths sen-0.6.0/sen/tui/widgets/table.py0000644000372000037200000001077713254656210017657 0ustar travistravis00000000000000import logging import urwid from sen.tui.widgets.list.base import WidgetBase from sen.tui.widgets.list.util import RowWidget logger = logging.getLogger(__name__) def calculate_max_cols_length(table, size): """ :param table: list of lists: [["row 1 column 1", "row 1 column 2"], ["row 2 column 1", "row 2 column 2"]] each item consists of instance of urwid.Text :returns dict, {index: width} """ max_cols_lengths = {} for row in table: col_index = 0 for idx, widget in enumerate(row.widgets): l = widget.pack((size[0], ))[0] max_cols_lengths[idx] = max(max_cols_lengths.get(idx, 0), l) col_index += 1 max_cols_lengths.setdefault(0, 1) # in case table is empty return max_cols_lengths def assemble_rows(data, max_allowed_lengths=None, dividechars=1, ignore_columns=None): """ :param data: list of lists: [["row 1 column 1", "row 1 column 2"], ["row 2 column 1", "row 2 column 2"]] each item consists of instance of urwid.Text :param max_allowed_lengths: dict: {col_index: maximum_allowed_length} :param ignore_columns: list of ints, indexes which should not be calculated """ rows = [] max_lengths = {} ignore_columns = ignore_columns or [] # shitty performance, here we go # it would be way better to do a single double loop and provide mutable variable # FIXME: merge this code with calculate() from above for row in data: col_index = 0 for widget in row: if col_index in ignore_columns: continue l = len(widget.text) if max_allowed_lengths: if col_index in max_allowed_lengths and max_allowed_lengths[col_index] < l: # l is bigger then what is allowed l = max_allowed_lengths[col_index] max_lengths.setdefault(col_index, l) max_lengths[col_index] = max(l, max_lengths[col_index]) col_index += 1 for row in data: row_widgets = [] for idx, item in enumerate(row): if idx in ignore_columns: row_widgets.append(item) else: row_widgets.append((max_lengths[idx], item)) rows.append( RowWidget(row_widgets, dividechars=dividechars) ) return rows class ResponsiveTable(WidgetBase): def __init__(self, ui, walker, headers=None, dividechars=1, responsive=True): """ :param walker: list of ResponsiveRow instances :param headers: list of widgets which should be displayed as headers """ self.dividechars = dividechars self.responsive = responsive super().__init__(ui, walker) def render(self, size, focus=False): screen_width = size[0] # max text length for each column -- table min_col_lengths = calculate_max_cols_length(self.body, size) # compute maximal column width -- looks nicer max_col_width = int(screen_width / len(min_col_lengths)) - self.dividechars for k, v in min_col_lengths.items(): min_col_lengths[k] = min(min_col_lengths[k], max_col_width) columns_occupy = sum(min_col_lengths.values()) if self.responsive: # responsibility! spread the columns to_spread = screen_width - len(min_col_lengths) * self.dividechars - columns_occupy if to_spread: spread_remaining = to_spread weights = {} # longer cols will get more space sum_weights = 0 sorted_by_length = sorted(min_col_lengths.items(), key=lambda x: x[1]) longest_col = sorted_by_length[-1][0] for weight, (idx, length) in enumerate(sorted_by_length): weights[idx] = weight + 1 # first weight is 1; 0 doesn't add anything sum_weights += weight + 1 for k, v in min_col_lengths.items(): expansion = int(to_spread * weights[k] / sum_weights) min_col_lengths[k] += expansion spread_remaining -= expansion # add remaining to the longest col min_col_lengths[longest_col] += spread_remaining for row in self.body: row.contents[:] = [ (w, (urwid.GIVEN, min_col_lengths[idx], is_box)) for idx, (w, (_, _, is_box)) in enumerate(row.contents) ] return super().render(size, focus=focus) sen-0.6.0/sen/tui/widgets/tree.py0000644000372000037200000000444113254656210017516 0ustar travistravis00000000000000import logging import urwidtrees from sen.tui.widgets.list.util import RowWidget from sen.tui.widgets.util import SelectableText from sen.tui.chunks.image import get_basic_image_markup logger = logging.getLogger(__name__) class TreeNodeWidget(SelectableText): def __init__(self, ui, docker_image): self.ui = ui self.docker_image = docker_image super().__init__(get_basic_image_markup(docker_image, with_size=True)) class TreeBackend(urwidtrees.Tree): # FIXME: rewrite to use SimpleTree instead (for sake of docker-1.10 changes: how does one # index an image with id def __init__(self, docker_backend, ui): super().__init__() self.ui = ui self.root = docker_backend.scratch_image def __getitem__(self, pos): return RowWidget([TreeNodeWidget(self.ui, pos)]) # Tree API def parent_position(self, pos): return pos.parent_image def first_child_position(self, pos): ch = pos.children if ch: return pos.children[0] else: return None def last_child_position(self, pos): ch = pos.children if ch: return pos.children[-1] else: return None def next_sibling_position(self, pos): return pos.get_next_sibling() def prev_sibling_position(self, pos): return pos.get_prev_sibling() class ImageTree(urwidtrees.TreeBox): def __init__(self, ui, docker_backend): self.ui = ui tree = TreeBackend(docker_backend, ui) # We hide the usual arrow tip and use a customized collapse-icon. t = urwidtrees.ArrowTree( tree, arrow_att="tree", # lines, tip icon_collapsed_att="tree", # + icon_expanded_att="tree", # - icon_frame_att="tree", # [ ] indent=2, ) super().__init__(t) @property def focused_docker_object(self): image = self.get_focus()[1] logger.debug("focused image is %s", image) return image def focus_first(self): self.set_focus(self._tree.root) def focus_last(self): # taken from alot, thanks pazz! logger.debug(next(self._tree.positions())) self.set_focus(next(self._tree.positions(reverse=True))) sen-0.6.0/sen/tui/widgets/util.py0000644000372000037200000000473613254656210017543 0ustar travistravis00000000000000import logging import threading import urwid from sen.tui.constants import MAIN_LIST_FOCUS logger = logging.getLogger(__name__) def get_map(defult="main_list_dg"): return {"normal": defult, "focus": MAIN_LIST_FOCUS} class AdHocAttrMap(urwid.AttrMap): """ Ad-hoc attr map change taken from https://github.com/pazz/alot/ """ def __init__(self, w, maps, init_map='normal'): self.maps = maps urwid.AttrMap.__init__(self, w, maps[init_map]) if isinstance(w, urwid.Text): self.attrs = [x[0] for x in self.original_widget.get_text()[1]] def set_map(self, attrstring): attr_map = {None: self.maps[attrstring]} # for urwid.Text only: do hovering for all markups in the widget if isinstance(self.original_widget, urwid.Text): if attrstring == "normal": for a in self.attrs: attr_map[self.maps["focus"]] = a elif attrstring == "focus": for a in self.attrs: attr_map[a] = self.maps["focus"] self.set_attr_map(attr_map) class ColorTextMixin: @property def text(self): return self.original_widget.text @text.setter def text(self, value): self.original_widget.set_text(value) def keypress(self, size, key): """ get rid of tback: `AttributeError: 'Text' object has no attribute 'keypress'` """ return key class ColorText(ColorTextMixin, urwid.AttrMap): def __init__(self, text, color): super().__init__(urwid.Text(text, align="left", wrap="clip"), color) class SelectableText(ColorTextMixin, AdHocAttrMap): def __init__(self, text, maps=None): maps = maps or get_map() super().__init__(urwid.Text(text, align="left", wrap="clip"), maps) class ThreadSafeFrame(urwid.Frame): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.update_lock = threading.Lock() def set_body(self, body): with self.update_lock: return super().set_body(body=body) def set_footer(self, footer): with self.update_lock: return super().set_footer(footer=footer) def set_header(self, header): with self.update_lock: return super().set_header(header=header) def render(self, size, focus=False): with self.update_lock: return super().render(size=size, focus=focus) class UnselectableListBox(urwid.ListBox): _selectable = False sen-0.6.0/sen/tui/__init__.py0000644000372000037200000000002213254656210016637 0ustar travistravis00000000000000__author__ = 'tt' sen-0.6.0/sen/tui/buffer.py0000644000372000037200000002473513254656210016372 0ustar travistravis00000000000000import logging from sen.docker_backend import DockerContainer, RootImage from sen.exceptions import NotifyError from sen.tui.commands.base import Command from sen.tui.views.disk_usage import DfBufferView from sen.tui.views.help import HelpBufferView, HelpCommandView from sen.tui.views.main import MainListBox from sen.tui.views.image_info import ImageInfoWidget from sen.tui.views.container_info import ContainerInfoView from sen.tui.widgets.list.common import AsyncScrollableListBox, ScrollableListBox from sen.tui.widgets.list.util import get_operation_notify_widget from sen.tui.widgets.tree import ImageTree logger = logging.getLogger(__name__) class Buffer: """ base buffer class """ name = None # unique identifier description = None # for help display_name = None # display in status bar widget = None # display this in main frame # global keybinds which will be available in every buffer global_keybinds = { # navigation "home": "navigate-top", "gg": "navigate-top", "end": "navigate-bottom", "G": "navigate-bottom", "down": "navigate-down", "j": "navigate-down", "up": "navigate-up", "k": "navigate-up", "ctrl d": "navigate-downwards", "ctrl u": "navigate-upwards", # UI ":": "prompt", "/": "prompt prompt-text=\"\" initial-text=\"/\"", "n": "search-next", "N": "search-previous", "f4": "prompt initial-text=\"filter \"", "x": "kill-buffer", "q": "kill-buffer quit-if-no-buffer", "ctrl i": "select-next-buffer", "ctrl o": "select-previous-buffer", "h": "help", "?": "help", "f5": "layers", } # buffer specific keybinds keybinds = {} def __init__(self): logger.debug("creating buffer %r", self) self._keybinds = None # cache self.refresh() def __repr__(self): return "{}(name={!r}, widget={!r})".format( self.__class__.__name__, self.display_name, self.widget) def destroy(self): destroy_method = getattr(self.widget, "destroy", None) if destroy_method: destroy_method() def find_previous(self, s=None): logger.debug("searching next %r in %r", s, self.__class__.__name__) try: self.widget.find_previous(s) except AttributeError as ex: logger.debug(repr(ex)) raise NotifyError("Can't search in this buffer.") def find_next(self, s=None): logger.debug("searching next %r in %r", s, self.__class__.__name__) try: self.widget.find_next(s) except AttributeError as ex: logger.debug(repr(ex)) raise NotifyError("Can't search in this buffer.") def build_status_bar(self): status_bar = getattr(self.widget, "status_bar", None) if status_bar: return status_bar() def filter(self, s): logger.debug("filter widget %r with query %r", self.widget, s) self.widget.filter(s) def get_keybinds(self): if self._keybinds is None: self._keybinds = {} self._keybinds.update(self.global_keybinds) self._keybinds.update(self.keybinds) return self._keybinds def refresh(self): refresh_func = getattr(self.widget, "refresh", None) if refresh_func: logger.info("refreshing widget %s", self.widget) refresh_func() def process_realtime_event(self, event): logger.info("buffer %s doesn't process realtime events", self) return class ImageInfoBuffer(Buffer): description = "Dashboard for information about selected image.\n" + \ "You can run command `df` to get more detailed info about disk usage." keybinds = { "enter": "display-info", "d": "rm", "i": "inspect", "@": "refresh", } def __init__(self, docker_image, ui): """ :param docker_image: :param ui: ui object so we refresh """ if isinstance(docker_image, RootImage): raise NotifyError("Image \"scratch\" doesn't provide any more information.") if docker_image.image_id == "": raise NotifyError("This image (layer) is not available due to changes in docker-1.10 " "image representation.") self.docker_image = docker_image self.display_name = docker_image.short_name self.widget = ImageInfoWidget(ui, docker_image) super().__init__() def process_realtime_event(self, event): if event.get("id", None) == self.docker_image.object_id: self.widget.refresh() class ContainerInfoBuffer(Buffer): description = "Detailed info about selected container presented in a slick dashboard." keybinds = { "enter": "display-info", "@": "refresh", "i": "inspect", } def __init__(self, docker_container, ui): """ :param docker_container: :param ui: ui object so we refresh """ self.docker_container = docker_container self.display_name = docker_container.short_name self.widget = ContainerInfoView(ui, docker_container) super().__init__() def process_realtime_event(self, event): action = event.get("Action", None) if action == "top": return if event.get("id", None) == self.docker_container.object_id: self.widget.refresh() class TreeBuffer(Buffer): display_name = "Layers" description = "Tree view of all layers available on your docker engine." keybinds = { "enter": "display-info", } def __init__(self, ui, docker_backend): self.widget = ImageTree(ui, docker_backend) super().__init__() class MainListBuffer(Buffer): display_name = "Listing" description = "List of all known docker images and containers display in a single list" keybinds = { "d": "rm", "D": "rm -f", "s": "start", "t": "stop", "r": "restart", "X": "kill", "p": "pause", "u": "unpause", "enter": "display-info", "b": "open-browser", "l": "logs", "f": "logs -f", "i": "inspect", "!": "toggle-live-updates", # TODO: rfe: move to global so this affects every buffer "@": "refresh", # FIXME: move to global and refactor & rewrite } def __init__(self, ui, docker_backend): self.ui = ui self.widget = MainListBox(ui, docker_backend) super().__init__() def process_realtime_event(self, event): self.widget.process_realtime_event(event) class LogsBuffer(Buffer): description = "Display logs of selected container." display_name = "Logs " def __init__(self, ui, docker_object, follow=False): """ :param docker_object: container to display logs :param ui: ui object so we can refresh """ self.display_name += "({})".format(docker_object.short_name) if isinstance(docker_object, DockerContainer): try: pre_message = "Getting logs for container {}...".format(docker_object.short_name) ui.notify_message(pre_message) if follow: # FIXME: this is a bit race-y -- we might lose some logs with this approach operation = docker_object.logs(follow=follow, lines=0) static_data = docker_object.logs(follow=False).response self.widget = AsyncScrollableListBox(operation.response, ui, static_data=static_data) else: operation = docker_object.logs(follow=follow) self.widget = ScrollableListBox(ui, operation.response) ui.remove_notification_message(pre_message) ui.notify_widget(get_operation_notify_widget(operation, display_always=False)) except Exception as ex: # FIXME: let's catch 404 and print that container doesn't exist # instead of printing ugly HTTP error raise NotifyError("Error getting logs for container %s: %r" % (docker_object, ex)) else: raise NotifyError("Only containers have logs.") super().__init__() class InspectBuffer(Buffer): display_name = "Inspect " description = "Display all the information docker knows about selected object: " + \ "same output as `docker inspect`." def __init__(self, ui, docker_object): """ :param docker_object: object to inspect """ self.docker_object = docker_object self.ui = ui self.widget = None self.display_name += docker_object.short_name super().__init__() def refresh(self): inspect_data = self.docker_object.display_inspect() self.widget = ScrollableListBox(self.ui, inspect_data) def process_realtime_event(self, event): if event.get("id", None) == self.docker_object.object_id: self.ui.notify_message("Docker object changed, refreshing.") focus = self.widget.get_focus()[1] self.widget.set_text(self.docker_object.display_inspect()) self.widget.set_focus(focus) class HelpBuffer(Buffer): # TODO: apply this interface to other buffers: create views description = "Show information about currently displayed buffer and " + \ "what keybindings are available there" display_name = "Help" def __init__(self, ui, inp): """ display buffer with more info about object 'inp' :param ui: UI instance :param inp: Buffer, Command instance """ self.ui = ui if isinstance(inp, Buffer): self.display_name += "({})".format(inp.display_name) self.widget = HelpBufferView(ui, inp, self.global_keybinds) elif isinstance(inp, Command): self.display_name += "({})".format(inp.name) self.widget = HelpCommandView(ui, inp) super().__init__() class DfBuffer(Buffer): description = "Show information about how much disk space container, images and volumes take." display_name = "Disk Usage" def __init__(self, ui): """ :param ui: UI instance """ self.ui = ui self.widget = DfBufferView(ui, self) super().__init__() def refresh(self, df=None, containers=None, images=None): self.widget.refresh(df=df, containers=containers, images=images) sen-0.6.0/sen/tui/constants.py0000644000372000037200000000723413254656210017130 0ustar travistravis00000000000000 MAIN_LIST_FOCUS = "main_list_focus" STATUS_BG = "#06a" STATUS_BG_FOCUS = "#08d" # name, fg, bg, mono, fg_h, bg_h PALLETE = [ (MAIN_LIST_FOCUS, 'default', 'brown', "default", "white", "#060"), # a60 ('main_list_lg', 'light gray', 'default', "default", "g100", "default"), ('main_list_dg', 'dark gray', 'default', "default", "g78", "default"), ('main_list_ddg', 'dark gray', 'default', "default", "g56", "default"), ('main_list_white', 'white', 'default', "default", "white", "default"), ('main_list_green', 'dark green', 'default', "default", "#0f0", "default"), ('main_list_yellow', 'brown', 'default', "default", "#ff0", "default"), ('main_list_orange', 'light red', 'default', "default", "#fa0", "default"), ('main_list_red', 'dark red', 'default', "default", "#f00", "default"), ('image_names', 'light magenta', 'default', "default", "#F0F", "default"), ('status_box', 'default', 'black', "default", "g100", STATUS_BG), ('status_box_focus', 'default', 'black', "default", "white", STATUS_BG_FOCUS), ('status', 'default', 'default', "default", "default", STATUS_BG), ('status_text', 'default', 'default', "default", "g100", STATUS_BG), ('status_text_green', 'default', 'default', "default", "#0f0", STATUS_BG), ('status_text_yellow', 'default', 'default', "default", "#ff0", STATUS_BG), ('status_text_orange', 'default', 'default', "default", "#f80", STATUS_BG), ('status_text_red', 'default', 'default', "default", "#f66", STATUS_BG), ('notif_error', "white", 'dark red', "default", "white", "#f00",), ('notif_info', 'white', 'default', "default", "g100", "default"), ('notif_important', 'white', 'default', "default", "white", "default"), ('notif_text_green', 'white', 'default', "white", "#0f0", "default"), ('notif_text_yellow', 'white', 'default', "white", "#ff0", "default"), ('notif_text_orange', 'white', 'default', "white", "#f80", "default"), ('notif_text_red', 'white', 'default', "white", "#f66", "default"), ('tree', 'dark green', 'default', "default", "dark green", "default"), ('graph_bg', "default", 'default', "default", "default", "default"), ('graph_lines_cpu', "default", 'default', "default", "default", "#d63"), ('graph_lines_cpu_tips', "default", 'default', "default", "#d63", "default"), ('graph_lines_cpu_legend', "default", 'default', "default", "#f96", "default"), ('graph_lines_mem', "default", 'default', "default", "default", "#39f"), ('graph_lines_mem_tips', "default", 'default', "default", "#39f", "default"), ('graph_lines_mem_legend', "default", 'default', "default", "#6af", "default"), ('graph_lines_blkio_r', "default", 'default', "default", "default", "#9b0"), ('graph_lines_blkio_r_tips', "default", 'default', "default", "#9b0", "default"), ('graph_lines_blkio_r_legend', "default", 'default', "default", "#cf0", "default"), ('graph_lines_blkio_w', "default", 'default', "default", "default", "#b90"), ('graph_lines_blkio_w_tips', "default", 'default', "default", "#b90", "default"), ('graph_lines_blkio_w_legend', "default", 'default', "default", "#fc0", "default"), ('graph_lines_net_r', "default", 'default', "default", "default", "#3ca"), ('graph_lines_net_r_tips', "default", 'default', "default", "#3ca", "default"), ('graph_lines_net_r_legend', "default", 'default', "default", "#6fc", "default"), ('graph_lines_net_w', "default", 'default', "default", "default", "#3ac"), ('graph_lines_net_w_tips', "default", 'default', "default", "#3ac", "default"), ('graph_lines_net_w_legend', "default", 'default', "default", "#6cf", "default"), ] STATUS_BAR_REFRESH_SECONDS = 5 CLEAR_NOTIF_BAR_MESSAGE_IN = 5 sen-0.6.0/sen/tui/init.py0000644000372000037200000000371313254656210016055 0ustar travistravis00000000000000""" Application specific code. """ import logging import threading from sen.exceptions import NotifyError from sen.tui.commands.base import Commander, SameThreadPriority from sen.tui.commands.display import DisplayListingCommand from sen.tui.ui import get_app_in_loop from sen.tui.constants import PALLETE from sen.docker_backend import DockerBackend logger = logging.getLogger(__name__) class Application: def __init__(self, yolo=False): self.d = DockerBackend() self.loop, self.ui = get_app_in_loop(PALLETE) self.ui.yolo = yolo self.ui.commander = Commander(self.ui, self.d) self.rt_thread = threading.Thread(target=self.realtime_updates, daemon=True) self.rt_thread.start() def run(self): self.ui.run_command(DisplayListingCommand.name, queue=SameThreadPriority()) self.loop.run() def realtime_updates(self): """ fetch realtime events from docker and pass them to buffers :return: None """ # TODO: make this available for every buffer logger.info("starting receiving events from docker") it = self.d.realtime_updates() while True: try: event = next(it) except NotifyError as ex: self.ui.notify_message("error when receiving realtime events from docker: %s" % ex, level="error") return # FIXME: we should pass events to all buffers # ATM the buffers can't be rendered since they are not displayed # and hence traceback like this: ListBoxError("Listbox contents too short! ... logger.debug("pass event to current buffer %s", self.ui.current_buffer) try: self.ui.current_buffer.process_realtime_event(event) except Exception as ex: # swallow any exc logger.error("error while processing runtime event: %r", ex) sen-0.6.0/sen/tui/ui.py0000644000372000037200000003161713254656210015533 0ustar travistravis00000000000000""" This is a framework for terminal interfaces built on top of urwid.Frame. It must NOT contain any application specific code. """ import logging import threading from concurrent.futures.thread import ThreadPoolExecutor import urwid from sen.exceptions import NotifyError from sen.tui.commands.base import ( FrontendPriority, BackendPriority, SameThreadPriority, KeyNotMapped ) from sen.tui.constants import CLEAR_NOTIF_BAR_MESSAGE_IN from sen.tui.widgets.util import ThreadSafeFrame from sen.util import log_traceback, OrderedSet logger = logging.getLogger(__name__) class ConcurrencyMixin: def __init__(self): # worker for long-running tasks - requests self.worker = ThreadPoolExecutor(max_workers=4) # worker for quick ui operations self.ui_worker = ThreadPoolExecutor(max_workers=2) @staticmethod def _run(worker, f, *args, **kwargs): # TODO: do another wrapper to wrap notify exceptions and show them f = log_traceback(f) worker.submit(f, *args, **kwargs) def run_in_background(self, task, *args, **kwargs): logger.info("running task %r(%s, %s) in background", task, args, kwargs) self._run(self.worker, task, *args, **kwargs) def run_quickly_in_background(self, task, *args, **kwargs): logger.info("running a quick task %r(%s, %s) in background", task, args, kwargs) self._run(self.ui_worker, task, *args, **kwargs) class UI(ThreadSafeFrame, ConcurrencyMixin): """ handles all UI-specific code """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # widget -> message or None self.widget_message_dict = {} # message -> widget self.message_widget_dict = {} self.status_bar = None self.prompt_bar = None # lock when managing notifications: # * when accessing self.notification_* # * when accessing widgets # and most importantly, remember, locking is voodoo self.notifications_lock = threading.RLock() # populated when loop and UI are instantiated self.loop = None self.commander = None self.buffers = [] self.buffer_movement_history = OrderedSet() self.main_list_buffer = None # singleton self.current_buffer = None def refresh(self): self.loop.refresh() def quit(self): """ This could be called from another thread, so let's do this via alarm """ def q(*args): raise urwid.ExitMainLoop() self.worker.shutdown(wait=False) self.ui_worker.shutdown(wait=False) self.loop.set_alarm_in(0, q) # FIXME: move these to separate mixin def _set_main_widget(self, widget, redraw): """ add provided widget to widget list and display it :param widget: :return: """ self.set_body(widget) self.reload_footer() if redraw: logger.debug("redraw main widget") self.refresh() def display_buffer(self, buffer, redraw=True): """ display provided buffer :param buffer: Buffer :return: """ logger.debug("display buffer %r", buffer) self.buffer_movement_history.append(buffer) self.current_buffer = buffer self._set_main_widget(buffer.widget, redraw=redraw) def add_and_display_buffer(self, buffer, redraw=True): """ add provided buffer to buffer list and display it :param buffer: :return: """ # FIXME: some buffers have arguments, do a proper comparison -- override __eq__ if buffer not in self.buffers: logger.debug("adding new buffer {!r}".format(buffer)) self.buffers.append(buffer) self.display_buffer(buffer, redraw=redraw) def pick_and_display_buffer(self, i): """ pick i-th buffer from list and display it :param i: int :return: None """ if len(self.buffers) == 1: # we don't need to display anything # listing is already displayed return else: try: self.display_buffer(self.buffers[i]) except IndexError: # i > len self.display_buffer(self.buffers[0]) @property def current_buffer_index(self): return self.buffers.index(self.current_buffer) def remove_current_buffer(self, close_if_no_buffer=False): if len(self.buffers) == 1 and not close_if_no_buffer: return self.buffers.remove(self.current_buffer) self.buffer_movement_history.remove(self.current_buffer) self.current_buffer.destroy() if len(self.buffers) > 0: self.display_buffer(self.buffer_movement_history[-1], True) return len(self.buffers) def reload_footer(self, refresh=True, rebuild_statusbar=True): logger.debug("reload footer") footer = list(self.widget_message_dict.keys()) if self.prompt_bar: footer.append(self.prompt_bar) else: if rebuild_statusbar or self.status_bar is None: self.status_bar = self.build_statusbar() footer.append(self.status_bar) # logger.debug(footer) self.set_footer(urwid.Pile(footer)) if refresh: self.loop.refresh() def build_statusbar(self): """construct and return statusbar widget""" if self.prompt_bar: logger.info("prompt is active, won't build status bar") return try: left_widgets = self.current_buffer.build_status_bar() or [] except AttributeError: left_widgets = [] text_list = [] # FIXME: this code should be placed in buffer # TODO: display current active worker threads for idx, buffer in enumerate(self.buffers): # #1 [I] fedora #2 [L] fmt = "#{idx} [{name}]" markup = fmt.format(idx=idx, name=buffer.display_name) text_list.append(( "status_box_focus" if buffer == self.current_buffer else "status_box", markup, )) text_list.append(" ") text_list = text_list[:-1] if text_list: buffer_text = urwid.Text(text_list, align="right") else: buffer_text = urwid.Text("", align="right") columns = urwid.Columns(left_widgets + [buffer_text]) return urwid.AttrMap(columns, "status") def remove_notification_message(self, message): logger.debug("requested remove of message %r from notif bar", message) with self.notifications_lock: try: w = self.message_widget_dict[message] except KeyError: logger.warning("there is no notification %r displayed: %s", message, self.message_widget_dict) return else: logger.debug("remove widget %r from new pile", w) del self.widget_message_dict[w] del self.message_widget_dict[message] self.reload_footer(rebuild_statusbar=False) def remove_widget(self, widget, message=None): logger.debug("remove widget %r from notif bar", widget) with self.notifications_lock: try: del self.widget_message_dict[widget] except KeyError: logger.info("widget %s was already removed", widget) return if message: del self.message_widget_dict[message] self.reload_footer(rebuild_statusbar=False) def notify_message(self, message, level="info", clear_if_dupl=True, clear_in=CLEAR_NOTIF_BAR_MESSAGE_IN): """ :param message, str :param level: str, {info, error} :param clear_if_dupl: bool, if True, don't display the notification again :param clear_in: seconds, remove the notificantion after some time opens notification popup. """ with self.notifications_lock: if clear_if_dupl and message in self.message_widget_dict.keys(): logger.debug("notification %r is already displayed", message) return logger.debug("display notification %r", message) widget = urwid.AttrMap(urwid.Text(message), "notif_{}".format(level)) return self.notify_widget(widget, message=message, clear_in=clear_in) def notify_widget(self, widget, message=None, clear_in=CLEAR_NOTIF_BAR_MESSAGE_IN): """ opens notification popup. :param widget: instance of Widget, widget to display :param message: str, message to remove from list of notifications :param clear_in: int, time seconds when notification should be removed """ @log_traceback def clear_notification(*args, **kwargs): # the point here is the log_traceback self.remove_widget(widget, message=message) if not widget: return logger.debug("display notification widget %s", widget) with self.notifications_lock: self.widget_message_dict[widget] = message if message: self.message_widget_dict[message] = widget self.reload_footer(rebuild_statusbar=False) self.loop.set_alarm_in(clear_in, clear_notification) return widget def run_command(self, command_input, queue=None, **kwargs): kwargs["buffer"] = self.current_buffer command = self.commander.get_command(command_input, **kwargs) if command is None: return if queue is None: queue = command.priority if isinstance(queue, FrontendPriority): self.run_quickly_in_background(command.run) elif isinstance(queue, BackendPriority): self.run_in_background(command.run) elif isinstance(queue, SameThreadPriority): logger.info("running command %s", command) try: command.run() except NotifyError as ex: self.notify_message(str(ex), level="error") logger.error(repr(ex)) else: raise RuntimeError("command %s doesn't have any priority: %s %s" % (command_input, command.priority, FrontendPriority)) def run_command_by_key(self, key, size, **kwargs): command_input = self.commander.get_command_input_by_key(key) self.run_command(command_input, size=size, **kwargs) def keypress(self, size, key): logger.debug("%s keypress %r", self.__class__.__name__, key) # we should pass the key to header, body, footer first so it's consumed in e.g. statusbar key = super().keypress(size, key) if key is None: logger.info("key was consumed by frame components") return logger.info("key was not consumed by frame components") focused_docker_object = None selected_widget = getattr(self.current_buffer, "widget", None) if selected_widget: focused_docker_object = getattr(self.current_buffer.widget, "focused_docker_object", None) logger.debug("focused docker object is %s", focused_docker_object) try: self.run_command_by_key( key, docker_object=focused_docker_object, size=size ) except KeyNotMapped as ex: super_class = ThreadSafeFrame logger.debug("calling: %s.keypress(%s, %s)", super_class, size, key) # TODO: up/down doesn't do anything if len(lines) < screen height, that's confusing key = super_class.keypress(self, size, key) if key: self.notify_message(str(ex), level="error") logger.debug("was key handled? %s", "yes" if key is None else "no") return key return class ThreadSafeLoop(urwid.MainLoop): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.refresh_lock = threading.RLock() def entering_idle(self): with self.refresh_lock: return super().entering_idle() def refresh(self): """ explicitely refresh user interface; useful when changing widgets dynamically """ logger.debug("refresh user interface") try: with self.refresh_lock: self.draw_screen() except AssertionError: logger.warning("application is not running") pass def get_app_in_loop(pallete): screen = urwid.raw_display.Screen() screen.set_terminal_properties(256) screen.register_palette(pallete) ui = UI(urwid.SolidFill()) decorated_ui = urwid.AttrMap(ui, "root") loop = ThreadSafeLoop(decorated_ui, screen=screen, event_loop=urwid.AsyncioEventLoop(), handle_mouse=False) ui.loop = loop return loop, ui sen-0.6.0/sen/__init__.py0000644000372000037200000000126713254656210016052 0ustar travistravis00000000000000import logging from sen.constants import FALLBACK_LOG_PATH __version__ = "0.6.0" def set_logging(name="sen", level=logging.DEBUG, path=FALLBACK_LOG_PATH): logger = logging.getLogger(name) # do not propagate logs from logger 'sen' to root logger (as they could be accidentally # displayed in terminal) logger.propagate = False logger.setLevel(level) handler = logging.FileHandler(path) handler.setLevel(logging.DEBUG) # handler = logging.StreamHandler(sys.stderr) formatter = logging.Formatter( '%(asctime)s.%(msecs).03d %(filename)-17s %(levelname)-6s %(message)s', '%H:%M:%S') handler.setFormatter(formatter) logger.addHandler(handler) sen-0.6.0/sen/cli.py0000755000372000037200000000473413254656210015067 0ustar travistravis00000000000000#!/usr/bin/env python3 """ yes, this is python 3 ONLY project """ from __future__ import print_function import sys # let's be so gentle and print the error message even on python 2 if sys.version_info.major <= 2: error_message = """\ It looks like you are running sen with python 2. I'm sorry but sen is a python 3 only project. Please see installation steps at official project page: https://github.com/TomasTomecek/sen""" print(error_message, file=sys.stderr) sys.exit(2) import argparse import logging import sen from sen import set_logging from sen.exceptions import TerminateApplication from sen.tui.init import Application from sen.util import get_log_file_path, log_last_traceback logger = logging.getLogger("sen") def main(): parser = argparse.ArgumentParser( description="Terminal User Interface for Docker Engine" ) parser.add_argument( "--yolo", "--skip-prompt-for-irreversible-action", action="store_true", default=False, help="Don't prompt when performing irreversible actions, a.k.a. YOLO!" ) exclusive_group = parser.add_mutually_exclusive_group() exclusive_group.add_argument( "--debug", action="store_true", default=None, help="Set logging level to debug" ) args = parser.parse_args() # !IMPORTANT! make sure that sen does NOT log via `logging.info` b/c it sets up root logger # and adds StreamHandler which causes to display logs on stdout which is definitely what we # don't want in a terminal app (thanks to Slavek Kabrda for explanation) if args.debug: set_logging(level=logging.DEBUG, path=get_log_file_path()) logger.debug("sen loaded from %s", sen.__file__) else: set_logging(level=logging.INFO, path=get_log_file_path()) logger.info("application started") try: app = Application(yolo=args.yolo) except TerminateApplication as ex: print("Error: {0}".format(str(ex)), file=sys.stderr) return 1 try: app.run() except KeyboardInterrupt: print("Quitting on user request.") return 1 except Exception as ex: # pylint: disable=broad-except log_last_traceback() if args.debug: raise else: # TODO: improve this message to be more thorough print("There was an error during program execution, see logs for more info.") return 1 return 0 if __name__ == "__main__": sys.exit(main()) sen-0.6.0/sen/constants.py0000644000372000037200000000022213254656210016315 0ustar travistravis00000000000000PROJECT_NAME = "sen" LOG_FILE_NAME = "sen.debug.log" FALLBACK_LOG_PATH = "/tmp/sen.debug.log" ISO_DATETIME_PARSE_STRING = "%Y-%m-%dT%H:%M:%S.%f" sen-0.6.0/sen/docker_backend.py0000644000372000037200000007111313254656210017226 0ustar travistravis00000000000000import functools import json import logging import datetime import traceback from operator import attrgetter from sen.constants import ISO_DATETIME_PARSE_STRING from sen.exceptions import ( TerminateApplication, NotifyError, NotAvailableAnymore ) import docker import docker.errors from sen.net import NetData from sen.util import ( calculate_cpu_percent, calculate_cpu_percent2, calculate_blkio_bytes, calculate_network_bytes, repeater, humanize_time, graceful_chain_get ) logger = logging.getLogger(__name__) DINOSAUR_TIME = datetime.datetime.fromordinal(1) class ImageNameStruct(object): """ stolen from atomic-reactor; thanks @mmilata! """ def __init__(self, registry=None, namespace=None, repo=None, tag=None): self.registry = registry self.namespace = namespace self.repo = repo self.tag = tag @classmethod def parse(cls, image_name): result = cls() # registry.org/namespace/repo:tag s = image_name.split('/', 2) if len(s) == 2: if '.' in s[0] or ':' in s[0]: result.registry = s[0] else: result.namespace = s[0] elif len(s) == 3: result.registry = s[0] result.namespace = s[1] if result.namespace == 'library': # https://github.com/projectatomic/atomic-reactor/issues/45 logger.debug("namespace 'library' -> ''") result.namespace = None result.repo = s[-1] try: result.repo, result.tag = result.repo.rsplit(':', 1) except ValueError: pass return result def to_str(self, registry=True, tag=True, explicit_tag=False, explicit_namespace=False): if self.repo is None: raise RuntimeError('No image repository specified') result = self.repo if self.repo != "" else "" # don't display junk if tag and self.tag and self.tag != "": result = '{0}:{1}'.format(result, self.tag) elif tag and explicit_tag and self.tag != "": result = '{0}:{1}'.format(result, 'latest') # don't display junk if self.namespace: result = '{0}/{1}'.format(self.namespace, result) elif explicit_namespace: result = '{0}/{1}'.format('library', result) if registry and self.registry: result = '{0}/{1}'.format(self.registry, result) return result def __str__(self): return self.to_str(registry=True, tag=True) def __repr__(self): return "ImageName(image=%s)" % repr(self.to_str()) def __eq__(self, other): return type(self) == type(other) and self.__dict__ == other.__dict__ def __ne__(self, other): return not self == other def __hash__(self): return hash(self.to_str()) def copy(self): return ImageNameStruct( registry=self.registry, namespace=self.namespace, repo=self.repo, tag=self.tag) def operation(fmt_str): def wrap(func): @functools.wraps(func) def wrapper(self, *args, **kwargs): pretty_message = "" before = datetime.datetime.now() response = func(self, *args, **kwargs) after = datetime.datetime.now() # we want milliseconds, not seconds command_took = (after - before).total_seconds() * 1000 # # this line literally break zsh; wat? # logger.debug("%s(%s, %s) %s -> [%f ms]", func.__name__, args, kwargs, self, # command_took) if fmt_str: pretty_message = fmt_str.format(object_type=getattr(self, "pretty_object_type", ""), object_short_name=getattr(self, "short_name", "")) return Operation(response, pretty_message=pretty_message, took=command_took) return wrapper return wrap class Operation: """ class for describing performed operation """ def __init__(self, response, pretty_message="", took=None): self.response = response self.pretty_message = pretty_message self.took = took class DockerObject: """ Common base for images and containers """ def __init__(self, data, docker_backend, object_id=None): self._id = object_id self._short_id = None self.data = data # `client.containers` or `client.images` self.docker_backend = docker_backend self._created = None self._inspect = None self._names = None @property def d(self): """ shortcut for instance of Docker client """ return self.docker_backend.client @property def created_int(self): return self.data["Created"] @property def created(self): if self._created is None: self._created = datetime.datetime.fromtimestamp(self.data["Created"]) return self._created def set_id(self): if self._id is None: try: self._id = self.data["Id"] except KeyError: raise RuntimeError("initial data not specified") @property def object_id(self): if self._id is None: self.set_id() return self._id @property def short_id(self): if self._short_id is None: self.set_id() if ":" in self._id: colon_index = self._id.index(":") + 1 self._short_id = self._id[colon_index:][:12] else: self._short_id = self._id[:12] return self._short_id def display_time_created(self): return humanize_time(self.created) def display_formal_time_created(self): # http://tools.ietf.org/html/rfc2822.html#section-3.3 return self.created.strftime("%d %b %Y, %H:%M:%S") def inspect(self, cached=True): raise NotImplementedError() def display_inspect(self): try: return json.dumps(self.inspect().response, indent=2) except docker.errors.NotFound: raise NotAvailableAnymore() @property def labels(self): labels = self.data["Labels"] return labels @property def natural_sort_value(self): return self.created def metadata_get(self, path, cached=True): """ get metadata from inspect, specified by path :param path: list of str :param cached: bool, use cached version of inspect if available """ try: value = graceful_chain_get(self.inspect(cached=cached).response, *path) except docker.errors.NotFound: logger.warning("object %s is not available anymore", self) raise NotAvailableAnymore() return value def refresh(self): """ refresh metadata """ self.inspect(cached=False) def __eq__(self, other): return type(self) == type(other) and self._id == other._id def __ne__(self, other): return not self == other def __hash__(self): return hash(self._id) class DockerImage(DockerObject): def __init__(self, data, docker_backend, object_id=None): super().__init__(data, docker_backend, object_id=object_id) self._unique_size = None self._total_size = None self._virtual_size = None self._shared_size = None @property def image_id(self): return self.object_id @property def parent_id(self): if self.data: return self.data.get("ParentId", None) else: return self.metadata_get(["Parent"]) @property def pretty_object_type(self): return "Image" @property def parent_image(self): try: parent_id = self.parent_id except Exception as ex: logger.error("error while getting parent ID of image %s: %r", self, ex) logger.info(traceback.format_exc()) raise if parent_id: return self.docker_backend.get_image_by_id(parent_id) else: return self.docker_backend.scratch_image @property def layers(self): """ similar as parent images, except that it uses /history API endpoint :return: """ # sample output: # { # "Created": 1457116802, # "Id": "sha256:507cb13a216097710f0d234668bf64a4c92949c573ba15eba13d05aad392fe04", # "Size": 204692029, # "Tags": [ # "docker.io/fedora:latest" # ], # "Comment": "", # "CreatedBy": "/bin/sh -c #(nop) ADD file:bcb5e5c... in /" # } try: response = self.d.history(self.image_id) except docker.errors.NotFound: raise NotAvailableAnymore() layers = [] for l in response: layer_id = l["Id"] if layer_id == "": layers.append(DockerImage(l, self.docker_backend)) else: layers.append(self.docker_backend.get_image_by_id(layer_id)) return layers @property def children(self): return self.docker_backend.get_images_for_parent(self) def get_next_sibling(self): imgs = self.parent_image.children if len(imgs) == 1: return None try: return imgs[imgs.index(self) + 1] except IndexError: return None def get_prev_sibling(self): imgs = self.parent_image.children if len(imgs) == 1: return None # 0 - 1 turns into -1 which turns into last element which creates cycle # which totally messes up whole tree prev_index = imgs.index(self) - 1 if prev_index < 0: return None else: return imgs[prev_index] @property def command(self): cmd = self.metadata_get(["Config", "Cmd"]) if cmd: return " ".join(cmd) return "" @property def container_command(self): # history item created_by = graceful_chain_get(self.data, "CreatedBy") if created_by: return created_by try: cmd = self.metadata_get(["ContainerConfig", "Cmd"]) except NotAvailableAnymore: pass else: if cmd: return " ".join(cmd) return "" @property def comment(self): return self.metadata_get(["Comment"]) @property def total_size(self): """ Size of ALL layers in bytes :return: int or None """ return self._total_size or self.data.get("Size", 0) @property def unique_size(self): """ Size of ONLY this particular layer :return: int or None """ self._virtual_size = self._virtual_size or \ graceful_chain_get(self.data, "VirtualSize", default=0) try: return self._virtual_size - self._shared_size except TypeError: return 0 @property def shared_size(self): """ I guess this is size of layers which are shared with some other image :return: int or None """ return self._shared_size @property def names(self): if self._names is None: self._names = [] if self.data is None: return self._names # RepoTags = image, Tags = output from `history` command, Tags can be None for t in self.data.get("RepoTags", self.data.get("Tags")) or []: image_name = ImageNameStruct.parse(t) if image_name.to_str(): self._names.append(image_name) # sort by name length self._names.sort(key=lambda x: len(x.to_str())) return self._names @property def short_name(self): try: ins = self.names[0] except IndexError: return self.short_id if ins.repo == "": return self.short_id return ins.to_str() def base_image(self): child_image = self while True: try: parent_image = self.docker_backend.get_image_by_id(child_image.parent_id) except Exception as ex: logger.warning("error while getting image by ID: %r", ex) parent_image = None if parent_image is None: try: child_image = child_image.parent_image except Exception as ex: logger.error("error while getting parent image for image %s: %r", self, ex) return None if child_image is None: return None else: return parent_image @operation("Inspect image {object_short_name}.") def inspect(self, cached=False): if self._inspect is None or cached is False: try: self._inspect = self.d.inspect_image(self.image_id) except docker.errors.NotFound: self._inspect = self._inspect or {} return self._inspect @operation("{object_type} {object_short_name} removed!") def remove(self, force=False): return self.d.remove_image(self.image_id, force=force) @operation("Tag of {object_type} {object_short_name} removed!") def remove_tag(self, tag): assert tag in self.names return self.d.remove_image(str(tag)) def matches_search(self, s): return s in self.image_id or any([s in str(x) for x in self.names]) def __str__(self): # it's dangerous to put many stuff here b/c most of the values are loaded dynamically # and it's trivial to go into nested exception madness return "image {}".format(self.short_id) def __repr__(self): return self.__str__() def containers(self): return self.docker_backend.get_containers_for_image(self.image_id) class RootImage(DockerImage): """ this is essentially "scratch" but you cannot inspect it anymore """ def __init__(self, docker_backend): self.image_name = "scratch" super().__init__(None, docker_backend, object_id="") @property def parent_id(self): return None @property def parent_image(self): return None def get_next_sibling(self): return None def get_prev_sibling(self): return None @property def names(self): return [ImageNameStruct.parse(self.image_name)] def __str__(self): return self.image_name class DockerContainer(DockerObject): """ Container related logic """ def __init__(self, data, docker_backend, object_id=None): super(DockerContainer, self).__init__(data, docker_backend, object_id) self.size_root_fs = None self.size_rw_fs = None def __str__(self): return "{} ({})".format(self.container_id, self.short_name) # properties @property def container_id(self): return self.object_id @property def names(self): if self._names is None: self._names = [] for t in self.data.get("Names", []): self._names.append(t) # sort by name length self._names.sort(key=lambda x: len(x)) return self._names @property def command(self): return self.data["Command"] @property def nice_status(self): return self.data["Status"] @property def simple_status(self): return self.metadata_get(["State", "Status"]) @property def simple_status_cap(self): return self.simple_status.capitalize() @property def running(self): return self.metadata_get(["State", "Running"]) @property def status_created(self): return self.simple_status == "created" @property def exit_code(self): return self.metadata_get(["State", "ExitCode"]) @property def exited_well(self): return self.exit_code == 0 @property def short_name(self): try: return self.names[0] except IndexError: return self.short_id @property def pretty_object_type(self): return "Container" @property def image_id(self): """ this container is created from image with id...""" try: # docker >= 1.9 image_id = self.data["ImageID"] except KeyError: # docker <= 1.8 image_id = self.metadata_get(["Image"]) return image_id @property def image(self): return self.docker_backend.get_image_by_id(self.image_id) @property def ip_address(self): # docker == 1.10 ip_address = self.metadata_get(["NetworkSettings", "IPAddress"]) return ip_address @property def net(self): """ get ACTIVE port mappings of a container :return: dict: { "host_port": "container_port" } """ try: return NetData(self.inspect(cached=True).response) except docker.errors.NotFound: raise NotAvailableAnymore() @property def started_at(self): s = self.metadata_get(["State", "StartedAt"]) if s: # python expects 6 digits in milliseconds, docker returns 9 s = s[:26] if s == "0001-01-01T00:00:00Z": return DINOSAUR_TIME s = s.replace("Z", "0") try: started_at = datetime.datetime.strptime(s, ISO_DATETIME_PARSE_STRING) except ValueError as ex: logger.error("unable to parse datetime %s: %s", s, ex) return DINOSAUR_TIME return started_at @property def finished_at(self): f = self.metadata_get(["State", "FinishedAt"]) if f: f = f[:26] if f == "0001-01-01T00:00:00Z": return DINOSAUR_TIME finished_at = datetime.datetime.strptime(f, ISO_DATETIME_PARSE_STRING) return finished_at @property def natural_sort_value(self): # docker acts weird: 'created' is provided as ordinal and is local time # 'started' and 'finished' are UTC as timestamp; it would be awesome to unite those b/c # atm these inconsistencies mess ordering try: # Nones are unsortable return max([x for x in [self.started_at, self.finished_at, super().natural_sort_value] if x]) except NotAvailableAnymore: return super().natural_sort_value # methods def image_name(self): if self.image is not None: return self.image.short_name else: return self.image_id[:12] def matches_search(self, s): return s in self.container_id or \ s in self.short_name # api calls @operation("Get resources statistics.") def stats(self): cpu_total = 0.0 cpu_system = 0.0 cpu_percent = 0.0 for x in self.d.stats(self.container_id, decode=True, stream=True): blk_read, blk_write = calculate_blkio_bytes(x) net_r, net_w = calculate_network_bytes(x) mem_current = x["memory_stats"]["usage"] mem_total = x["memory_stats"]["limit"] try: cpu_percent, cpu_system, cpu_total = calculate_cpu_percent2(x, cpu_total, cpu_system) except KeyError as e: logger.error("error while getting new CPU stats: %r, falling back") cpu_percent = calculate_cpu_percent(x) r = { "cpu_percent": cpu_percent, "mem_current": mem_current, "mem_total": x["memory_stats"]["limit"], "mem_percent": (mem_current / mem_total) * 100.0, "blk_read": blk_read, "blk_write": blk_write, "net_rx": net_r, "net_tx": net_w, } yield r @operation("List processes in running container.") def top(self): """ list of processes in a running container :return: None or list of dicts """ # let's get resources from .stats() ps_args = "-eo pid,ppid,wchan,args" # returns {"Processes": [values], "Titles": [values]} # it's easier to play with list of dicts: [{"pid": 1, "ppid": 0}] try: response = self.d.top(self.container_id, ps_args=ps_args) except docker.errors.APIError as ex: logger.warning("error getting processes: %r", ex) return [] # TODO: sort? logger.debug(json.dumps(response, indent=2)) return [dict(zip(response["Titles"], process)) for process in response["Processes"] or []] @operation("Inspect container {object_short_name}.") def inspect(self, cached=False): if cached is False or self._inspect is None: try: self._inspect = self.d.inspect_container(self.container_id) except docker.errors.NotFound: self._inspect = self._inspect or {} return self._inspect @operation("Logs of container {object_short_name} received.") def logs(self, follow=False, lines="all"): # when tail is set to all, it takes ages to populate widget # docker-py does `inspect` in the background try: logs_data = self.d.logs(self.container_id, stream=follow, tail=lines) except docker.errors.NotFound: return None else: return logs_data @operation("{object_type} {object_short_name} removed!") def remove(self, force=False): self.d.remove_container(self.container_id, force=force) @operation("{object_type} {object_short_name} started.") def start(self): self.d.start(self.container_id) @operation("{object_type} {object_short_name} stopped.") def stop(self): self.d.stop(self.container_id) @operation("{object_type} {object_short_name} restarted.") def restart(self): self.d.restart(self.container_id) @operation("{object_type} {object_short_name} killed.") def kill(self): self.d.kill(self.container_id) @operation("{object_type} {object_short_name} paused.") def pause(self): self.d.pause(self.container_id) @operation("{object_type} {object_short_name} unpaused.") def unpause(self): self.d.unpause(self.container_id) class DockerBackend: """ backend for docker """ def __init__(self): self._containers = None self._images = None # displayed images self._all_images = None # docker images -a self._df = None kwargs = {"version": "auto"} kwargs.update(docker.utils.kwargs_from_env(assert_hostname=False)) try: APIClientClass = docker.Client # 1.x except AttributeError: APIClientClass = docker.APIClient # 2.x try: self.client = APIClientClass(**kwargs) except docker.errors.DockerException as ex: raise TerminateApplication("can't establish connection to docker daemon: {0}".format(str(ex))) self.scratch_image = RootImage(self) # backend queries @operation("Get list of images.") def get_images(self, cached=True): if cached is False or self._images is None: logger.debug("doing images() query") self._images = {} images_response = repeater(self.client.images) or [] for i in images_response: img = DockerImage(i, self) self._images[img.image_id] = img self._all_images = {} # FIXME: performance: do just all=True all_images_response = repeater(self.client.images, kwargs={"all": True}) or [] for i in all_images_response: img = DockerImage(i, self) self._all_images[img.image_id] = img return list(self._images.values()) @operation("Get list of containers.") def get_containers(self, cached=True, stopped=True): if cached is False or self._containers is None: logger.debug("doing containers() query") self._containers = {} containers_reponse = repeater(self.client.containers, kwargs={"all": stopped}) or [] for c in containers_reponse: container = DockerContainer(c, self) self._containers[container.container_id] = container if not stopped: return [x for x in list(self._containers.values()) if x.running] return list(self._containers.values()) @operation("Get disk usage.") def df(self, cached=True): if cached is False or self._df is None: logger.debug("getting disk-usage") # TODO: wrap in try/execpt self._df = self.client.df() # TODO: attach these to real containers and images # # since DOCKER API-1.25 (v.1.13.0) # df = self.client.df() # if 'Containers' in df: # df_containers = df['Containers'] containers_data = graceful_chain_get(self._df, "Containers") for c_data in containers_data: c = graceful_chain_get(self._containers, graceful_chain_get(c_data, "Id")) c.size_root_fs = graceful_chain_get(c_data, "SizeRootFs") c.size_rw_fs = graceful_chain_get(c_data, "SizeRw") images_data = graceful_chain_get(self._df, "Images") for i_data in images_data: i = graceful_chain_get(self._images, graceful_chain_get(i_data, "Id")) i._total_size = graceful_chain_get(i_data, "Size") i._shared_size = graceful_chain_get(i_data, "SharedSize") i._virtual_size = graceful_chain_get(i_data, "VirtualSize") return self._df def realtime_updates(self): event = it = None while True: if not it or not event: it = repeater(self.client.events, kwargs={"decode": True}, retries=5) if not it: raise NotifyError("Unable to fetch realtime updates from docker engine.") event = repeater(next, args=(it, ), retries=2) # likely an engine restart if not event: continue logger.debug("RT event: %s", event) yield event # try: # # 1.10+ # is_container = event["Type"] == "container" # except KeyError: # # event["from'] means it's a container # is_container = "from" in event # if is_container: # # inspect doesn't contain info about status and you can't query just one # # container with containers() # # let's do full-blown containers() query; it's not that expensive # self.get_containers(cached=False) # else: # # similar as ^ # # images() doesn't support query by ID # # inspect doesn't contain info about repositories # self.get_images(cached=False) # content, _, _ = self.filter(containers=True, images=True, stopped=True, # cached=True, sort_by_created=True) # yield content # service methods def get_image_by_id(self, image_id): return self._all_images.get(image_id) def get_images_for_parent(self, image): if not image: return [] l = sorted([x for x in self._all_images.values() if x.parent_image == image], key=lambda x: x.created_int) return l def get_container_by_id(self, container_id): return self._containers.get(container_id) def get_containers_for_image(self, image_id): return [container for container in self._containers.values() if container.image_id == image_id] def filter(self, containers=True, images=True, stopped=True, cached=False, sort_by_created=True): """ since django is so awesome, let's use their ORM API :return: """ content = [] containers_o = None images_o = None # return containers when containers=False and running=True if containers or not stopped: containers_o = self.get_containers(cached=cached, stopped=stopped) content += containers_o.response if images: images_o = self.get_images(cached=cached) content += images_o.response if sort_by_created: content.sort(key=attrgetter("natural_sort_value"), reverse=True) return content, containers_o, images_o sen-0.6.0/sen/exceptions.py0000644000372000037200000000044113254656210016465 0ustar travistravis00000000000000class NotAvailableAnymore(Exception): """ This object was available but isn't anymore. """ class NotifyError(Exception): """ There was an error, we should notify user. """ class TerminateApplication(Exception): """ Close application gracefully. """ sen-0.6.0/sen/net.py0000644000372000037200000000513213254656210015074 0ustar travistravis00000000000000""" networking in docker backend """ from sen.util import graceful_chain_get def extract_data_from_inspect(network_name, network_data): """ :param network_name: str :param network_data: dict :return: dict: { "ip_address4": "12.34.56.78" "ip_address6": "ff:fa:..." } """ a4 = None if network_name == "host": a4 = "127.0.0.1" n = {} a4 = graceful_chain_get(network_data, "IPAddress") or a4 if a4: n["ip_address4"] = a4 a6 = graceful_chain_get(network_data, "GlobalIPv6Address") if a6: n["ip_address4"] = a6 return n class NetData: def __init__(self, inspect_data): self.inspect_data = inspect_data self.net_settings = graceful_chain_get(self.inspect_data, "NetworkSettings") self._ports = None self._ips = None @property def ports(self): """ :return: dict { # container -> host "1234": "2345" } """ if self._ports is None: self._ports = {} if self.net_settings["Ports"]: for key, value in self.net_settings["Ports"].items(): cleaned_port = key.split("/")[0] self._ports[cleaned_port] = graceful_chain_get(value, 0, "HostPort") # in case of --net=host, there's nothing in network settings, let's get it from "Config" exposed_ports_section = graceful_chain_get(self.inspect_data, "Config", "ExposedPorts") if exposed_ports_section: for key, value in exposed_ports_section.items(): cleaned_port = key.split("/")[0] self._ports[cleaned_port] = None # extremely docker specific return self._ports @property def ips(self): """ :return: dict: { "default": { "ip_address4": "12.34.56.78" "ip_address6": "ff:fa:..." } "other": { ... } } """ if self._ips is None: self._ips = {} default_net = extract_data_from_inspect("default", self.net_settings) if default_net: self._ips["default"] = default_net # this can be None networks = self.inspect_data["NetworkSettings"]["Networks"] if networks: for network_name, network_data in networks.items(): self._ips[network_name] = extract_data_from_inspect(network_name, network_data) return self._ips sen-0.6.0/sen/util.py0000644000372000037200000001773013254656210015272 0ustar travistravis00000000000000import os import logging import functools import traceback import time from datetime import datetime from docker.errors import APIError from sen.constants import PROJECT_NAME, LOG_FILE_NAME logger = logging.getLogger(__name__) def _ensure_unicode(s): try: return s.decode("utf-8") except AttributeError: return s def log_last_traceback(): logger.error(traceback.format_exc()) def log_traceback(func): @functools.wraps(func) def wrapper(*args, **kwargs): logger.info("function %s is about to be started", func) try: response = func(*args, **kwargs) except Exception: log_last_traceback() else: logger.info("function %s finished", func) # TODO: how long it took? return response return wrapper def setup_dirs(): """Make required directories to hold logfile. :returns: str """ try: top_dir = os.path.abspath(os.path.expanduser(os.environ["XDG_CACHE_HOME"])) except KeyError: top_dir = os.path.abspath(os.path.expanduser("~/.cache")) our_cache_dir = os.path.join(top_dir, PROJECT_NAME) os.makedirs(our_cache_dir, mode=0o775, exist_ok=True) return our_cache_dir def get_log_file_path(): return os.path.join(setup_dirs(), LOG_FILE_NAME) def humanize_bytes(bytesize, precision=2): """ Humanize byte size figures https://gist.github.com/moird/3684595 """ abbrevs = ( (1 << 50, 'PB'), (1 << 40, 'TB'), (1 << 30, 'GB'), (1 << 20, 'MB'), (1 << 10, 'kB'), (1, 'bytes') ) if bytesize == 1: return '1 byte' for factor, suffix in abbrevs: if bytesize >= factor: break if factor == 1: precision = 0 return '%.*f %s' % (precision, bytesize / float(factor), suffix) def humanize_time(value): abbrevs = ( (1, "now"), (2, "{seconds} seconds ago"), (59, "{seconds} seconds ago"), (60, "{minutes} minute ago"), (119, "{minutes} minute ago"), (120, "{minutes} minutes ago"), (3599, "{minutes} minutes ago"), (3600, "{hours} hour ago"), (7199, "{hours} hour ago"), (86399, "{hours} hours ago"), (86400, "{days} day ago"), (172799, "{days} day ago"), (172800, "{days} days ago"), (172800, "{days} days ago"), (2591999, "{days} days ago"), (2592000, "{months} month ago"), (5183999, "{months} month ago"), (5184000, "{months} months ago"), ) n = datetime.now() delta = n - value for guard, message in abbrevs: s = int(delta.total_seconds()) if guard >= s: break return message.format(seconds=delta.seconds, minutes=int(delta.seconds // 60), hours=int(delta.seconds // 3600), days=delta.days, months=int(delta.days // 30)) # # This function is able to crash python b/c it may write monster-amount of data. # # Use it only for debugging, do not ship it! # def log_vars_from_tback(process_frames=5): # for th in threading.enumerate(): # try: # thread_frames = sys._current_frames()[th.ident] # except KeyError: # continue # logger.debug(''.join(traceback.format_stack(thread_frames))) # # logger.error(traceback.format_exc()) # if process_frames <= 0: # return # tb = sys.exc_info()[2] # while 1: # if not tb.tb_next: # break # tb = tb.tb_next # stack = [] # f = tb.tb_frame # while f: # stack.append(f) # f = f.f_back # for frame in stack[:process_frames]: # logger.debug("frame %s:%s", frame.f_code.co_filename, frame.f_lineno) # for key, value in frame.f_locals.items(): # try: # logger.debug("%20s = %s", key, value) # except Exception: # logger.debug("%20s = CANNOT PRINT VALUE", key) # # # self_instance = frame.f_locals.get("self", None) # # if not self_instance: # # continue # # for key in dir(self_instance): # # if key.startswith("__"): # # continue # # try: # # value = getattr(self_instance, key, None) # # logger.debug("%20s = %s", "self." + key, value) # # except Exception: # # logger.debug("%20s = CANNOT PRINT VALUE", "self." + key) # this is taken directly from docker client: # https://github.com/docker/docker/blob/28a7577a029780e4533faf3d057ec9f6c7a10948/api/client/stats.go#L309 def calculate_cpu_percent(d): cpu_count = len(d["cpu_stats"]["cpu_usage"]["percpu_usage"]) cpu_percent = 0.0 cpu_delta = float(d["cpu_stats"]["cpu_usage"]["total_usage"]) - \ float(d["precpu_stats"]["cpu_usage"]["total_usage"]) system_delta = float(d["cpu_stats"]["system_cpu_usage"]) - \ float(d["precpu_stats"]["system_cpu_usage"]) if system_delta > 0.0: cpu_percent = cpu_delta / system_delta * 100.0 * cpu_count return cpu_percent # again taken directly from docker: # https://github.com/docker/cli/blob/2bfac7fcdafeafbd2f450abb6d1bb3106e4f3ccb/cli/command/container/stats_helpers.go#L168 # precpu_stats in 1.13+ is completely broken, doesn't contain any values def calculate_cpu_percent2(d, previous_cpu, previous_system): # import json # du = json.dumps(d, indent=2) # logger.debug("XXX: %s", du) cpu_percent = 0.0 cpu_total = float(d["cpu_stats"]["cpu_usage"]["total_usage"]) cpu_delta = cpu_total - previous_cpu cpu_system = float(d["cpu_stats"]["system_cpu_usage"]) system_delta = cpu_system - previous_system online_cpus = d["cpu_stats"].get("online_cpus", len(d["cpu_stats"]["cpu_usage"]["percpu_usage"])) if system_delta > 0.0: cpu_percent = (cpu_delta / system_delta) * online_cpus * 100.0 return cpu_percent, cpu_system, cpu_total def calculate_blkio_bytes(d): """ :param d: :return: (read_bytes, wrote_bytes), ints """ bytes_stats = graceful_chain_get(d, "blkio_stats", "io_service_bytes_recursive") if not bytes_stats: return 0, 0 r = 0 w = 0 for s in bytes_stats: if s["op"] == "Read": r += s["value"] elif s["op"] == "Write": w += s["value"] return r, w def calculate_network_bytes(d): """ :param d: :return: (received_bytes, transceived_bytes), ints """ networks = graceful_chain_get(d, "networks") if not networks: return 0, 0 r = 0 t = 0 for if_name, data in networks.items(): logger.debug("getting stats for interface %r", if_name) r += data["rx_bytes"] t += data["tx_bytes"] return r, t def graceful_chain_get(d, *args, default=None): t = d for a in args: try: t = t[a] except (KeyError, ValueError, TypeError, AttributeError): logger.debug("can't get %r from %s", a, t) return default return t def repeater(call, args=None, kwargs=None, retries=4): """ repeat call x-times: docker API is just awesome :param call: function :param args: tuple, args for function :param kwargs: dict, kwargs for function :param retries: int, how many times we try? :return: response of the call """ args = args or () kwargs = kwargs or {} t = 1.0 for x in range(retries): try: return call(*args, **kwargs) except APIError as ex: logger.error("query #%d: docker returned an error: %r", x, ex) except Exception as ex: # this may be pretty bad log_last_traceback() logger.error("query #%d: generic error: %r", x, ex) t *= 2 time.sleep(t) class OrderedSet(list): def append(self, p_object): if p_object in self: self.remove(p_object) return super().append(p_object) sen-0.6.0/sen.egg-info/0000755000372000037200000000000013254656257015440 5ustar travistravis00000000000000sen-0.6.0/sen.egg-info/PKG-INFO0000644000372000037200000000140513254656257016535 0ustar travistravis00000000000000Metadata-Version: 1.1 Name: sen Version: 0.6.0 Summary: Terminal User Interface for Docker Engine Home-page: https://github.com/TomasTomecek/sen/ Author: Tomas Tomecek Author-email: tomas@tomecek.net License: MIT Description: UNKNOWN Platform: UNKNOWN Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: Intended Audience :: System Administrators Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: POSIX :: Linux Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: 3.4 Classifier: Topic :: System :: Monitoring sen-0.6.0/sen.egg-info/SOURCES.txt0000644000372000037200000000265713254656257017336 0ustar travistravis00000000000000LICENSE MANIFEST.in README.md requirements-test.txt requirements.txt setup.cfg setup.py docs/devel.md docs/features.md docs/releasing.md sen/__init__.py sen/cli.py sen/constants.py sen/docker_backend.py sen/exceptions.py sen/net.py sen/util.py sen.egg-info/PKG-INFO sen.egg-info/SOURCES.txt sen.egg-info/dependency_links.txt sen.egg-info/entry_points.txt sen.egg-info/requires.txt sen.egg-info/top_level.txt sen/tui/__init__.py sen/tui/buffer.py sen/tui/constants.py sen/tui/init.py sen/tui/ui.py sen/tui/chunks/__init__.py sen/tui/chunks/container.py sen/tui/chunks/image.py sen/tui/chunks/misc.py sen/tui/commands/__init__.py sen/tui/commands/backend.py sen/tui/commands/base.py sen/tui/commands/display.py sen/tui/commands/ui.py sen/tui/commands/widget.py sen/tui/views/__init__.py sen/tui/views/base.py sen/tui/views/container_info.py sen/tui/views/disk_usage.py sen/tui/views/help.py sen/tui/views/image_info.py sen/tui/views/main.py sen/tui/widgets/__init__.py sen/tui/widgets/graph.py sen/tui/widgets/responsive_column.py sen/tui/widgets/table.py sen/tui/widgets/tree.py sen/tui/widgets/util.py sen/tui/widgets/list/__init__.py sen/tui/widgets/list/base.py sen/tui/widgets/list/common.py sen/tui/widgets/list/util.py tests/__init__.py tests/constants.py tests/real.py tests/test_commands.py tests/test_concurrency.py tests/test_container_info.py tests/test_docker_backend.py tests/test_net.py tests/test_util.py tests/test_widgets.py tests/utils.pysen-0.6.0/sen.egg-info/dependency_links.txt0000644000372000037200000000015513254656257021520 0ustar travistravis00000000000000git+https://github.com/pazz/urwidtrees.git@9142c59d3e41421ff6230708d08b6a134e0a8eed#egg=urwidtrees-1.0.3.dev sen-0.6.0/sen.egg-info/entry_points.txt0000644000372000037200000000004613254656257020736 0ustar travistravis00000000000000[console_scripts] sen = sen.cli:main sen-0.6.0/sen.egg-info/requires.txt0000644000372000037200000000003013254656257020031 0ustar travistravis00000000000000urwid docker urwidtrees sen-0.6.0/sen.egg-info/top_level.txt0000644000372000037200000000000413254656257020164 0ustar travistravis00000000000000sen sen-0.6.0/tests/0000755000372000037200000000000013254656257014323 5ustar travistravis00000000000000sen-0.6.0/tests/__init__.py0000644000372000037200000000000113254656210016410 0ustar travistravis00000000000000 sen-0.6.0/tests/constants.py0000644000372000037200000000004413254656210016674 0ustar travistravis00000000000000SCREEN_WIDTH = 80 SCREEN_HEIGHT = 5 sen-0.6.0/tests/real.py0000644000372000037200000007175013254656210015617 0ustar travistravis00000000000000import logging from flexmock import flexmock import docker # docker 1.10 image_data = [{ 'Created': 1500000000, 'Id': 'sha256:3ab9a7ed8a169ab89b09fb3e12a14a390d3c662703b65b4541c0c7bde0ee97eb', 'ParentId': '3ab9a7ed8a169ab89b09fb3e12a14a390d3c662703b65b4541c0c7bde0ee97eb', 'RepoDigests': [], 'RepoTags': ['image:latest'], 'Size': 0, 'VirtualSize': 850000000 }] container_data = { 'Command': 'ls', 'Created': 1451419101, 'HostConfig': {'NetworkMode': 'default'}, 'Id': 'ce3779014be349654fb757d6eed61954fb33b75510a3845e0dd8107ed8bef765', 'Image': 'banana', 'ImageID': '0a31ac8bd1d47354c824984916e298726fe2525ba19f07da7865004b256588a3', 'Labels': {'aqwe': 'zxcasd', 'aqwe2': 'zxcasd2', 'x': 'y'}, 'Names': ['/elated_bose'], 'Ports': [], 'Status': 'Exited (0) 47 hours ago' } version_data = { 'ApiVersion': '1.21', 'Arch': 'amd64', 'BuildTime': 'Thu Sep 10 17:53:19 UTC 2015', 'GitCommit': 'af9b534-dirty', 'GoVersion': 'go1.5.1', 'KernelVersion': '4.2.5-300.fc23.x86_64', 'Os': 'linux', 'Version': '1.9.0-dev-fc24' } top_data = { "Titles": [ "PID", "PPID", "WCHAN", "COMMAND" ], "Processes": [ [ "18725", "23743", "hrtime", "sleep 100000" ], [ "18733", "23743", "hrtime", "sleep 100000" ], [ "18743", "23743", "hrtime", "sleep 100000" ], [ "23743", "24542", "poll_s", "sh" ], [ "23819", "23743", "hrtime", "sleep 100000" ], [ "24502", "21459", "wait", "sh" ], [ "24542", "24502", "wait", "sh" ] ] } stats_data = { "memory_stats": { "max_usage": 162844672, "stats": { "hierarchical_memsw_limit": 9223372036854771712, "total_active_anon": 89210880, "rss": 89210880, "total_inactive_file": 61886464, "unevictable": 0, "total_writeback": 0, "total_pgmajfault": 443, "total_pgpgout": 6463, "pgpgout": 6463, "inactive_anon": 0, "total_swap": 0, "recent_rotated_anon": 28087, "rss_huge": 0, "recent_scanned_anon": 28087, "swap": 0, "total_dirty": 0, "pgmajfault": 443, "hierarchical_memory_limit": 9223372036854771712, "total_active_file": 7221248, "dirty": 0, "mapped_file": 44122112, "total_inactive_anon": 0, "total_cache": 69107712, "total_unevictable": 0, "pgfault": 72694, "inactive_file": 61886464, "total_pgpgin": 45115, "total_pgfault": 72694, "total_rss": 89210880, "pgpgin": 45115, "active_file": 7221248, "cache": 69107712, "recent_scanned_file": 18992, "total_mapped_file": 44122112, "recent_rotated_file": 1766, "total_rss_huge": 0, "writeback": 0, "active_anon": 89210880 }, "usage": 158318592, "limit": 12285616128, "failcnt": 0 }, "precpu_stats": { "cpu_usage": { "total_usage": 0, "usage_in_usermode": 0, "usage_in_kernelmode": 0, "percpu_usage": None }, "throttling_data": { "periods": 0, "throttled_periods": 0, "throttled_time": 0 }, "system_cpu_usage": 0 }, "cpu_stats": { "cpu_usage": { "total_usage": 12270431082, "usage_in_usermode": 11950000000, "usage_in_kernelmode": 270000000, "percpu_usage": [ 907668070, 2527522511, 4443050630, 4392189871 ] }, "throttling_data": { "periods": 0, "throttled_periods": 0, "throttled_time": 0 }, "system_cpu_usage": 129418060000000 }, "pids_stats": {}, "read": "2016-03-04T17:54:13.542707177+01:00", "networks": { "eth0": { "rx_packets": 1018, "rx_errors": 0, "tx_errors": 0, "rx_bytes": 141847, "tx_packets": 20, "tx_bytes": 1636, "tx_dropped": 0, "rx_dropped": 0 } }, "blkio_stats": { "io_service_bytes_recursive": [ { "value": 18249728, "major": 7, "op": "Read", "minor": 0 }, { "value": 253952, "major": 7, "op": "Write", "minor": 0 }, { "value": 135168, "major": 7, "op": "Sync", "minor": 0 }, { "value": 18368512, "major": 7, "op": "Async", "minor": 0 }, { "value": 18503680, "major": 7, "op": "Total", "minor": 0 }, { "value": 18249728, "major": 253, "op": "Read", "minor": 1 }, { "value": 253952, "major": 253, "op": "Write", "minor": 1 }, { "value": 135168, "major": 253, "op": "Sync", "minor": 1 }, { "value": 18368512, "major": 253, "op": "Async", "minor": 1 }, { "value": 18503680, "major": 253, "op": "Total", "minor": 1 }, { "value": 72112128, "major": 253, "op": "Read", "minor": 2 }, { "value": 1978368, "major": 253, "op": "Write", "minor": 2 }, { "value": 1855488, "major": 253, "op": "Sync", "minor": 2 }, { "value": 72235008, "major": 253, "op": "Async", "minor": 2 }, { "value": 74090496, "major": 253, "op": "Total", "minor": 2 } ], "io_wait_time_recursive": [], "io_serviced_recursive": [ { "value": 595, "major": 7, "op": "Read", "minor": 0 }, { "value": 41, "major": 7, "op": "Write", "minor": 0 }, { "value": 26, "major": 7, "op": "Sync", "minor": 0 }, { "value": 610, "major": 7, "op": "Async", "minor": 0 }, { "value": 636, "major": 7, "op": "Total", "minor": 0 }, { "value": 595, "major": 253, "op": "Read", "minor": 1 }, { "value": 41, "major": 253, "op": "Write", "minor": 1 }, { "value": 26, "major": 253, "op": "Sync", "minor": 1 }, { "value": 610, "major": 253, "op": "Async", "minor": 1 }, { "value": 636, "major": 253, "op": "Total", "minor": 1 }, { "value": 1176, "major": 253, "op": "Read", "minor": 2 }, { "value": 68, "major": 253, "op": "Write", "minor": 2 }, { "value": 52, "major": 253, "op": "Sync", "minor": 2 }, { "value": 1192, "major": 253, "op": "Async", "minor": 2 }, { "value": 1244, "major": 253, "op": "Total", "minor": 2 } ], "io_time_recursive": [], "io_queue_recursive": [], "io_merged_recursive": [], "sectors_recursive": [], "io_service_time_recursive": [] } } # stats 1.13 stats_1_13 = [ { "read": "2017-08-14T09:51:41.318110362Z", "preread": "2017-08-14T09:51:40.318034402Z", "pids_stats": { "current": 1 }, "blkio_stats": { "io_service_bytes_recursive": [ { "major": 7, "minor": 0, "op": "Read", "value": 36864 }, { "major": 7, "minor": 0, "op": "Write", "value": 12288 }, { "major": 7, "minor": 0, "op": "Sync", "value": 49152 }, { "major": 7, "minor": 0, "op": "Async", "value": 0 }, { "major": 7, "minor": 0, "op": "Total", "value": 49152 }, { "major": 253, "minor": 1, "op": "Read", "value": 36864 }, { "major": 253, "minor": 1, "op": "Write", "value": 12288 }, { "major": 253, "minor": 1, "op": "Sync", "value": 49152 }, { "major": 253, "minor": 1, "op": "Async", "value": 0 }, { "major": 253, "minor": 1, "op": "Total", "value": 49152 }, { "major": 253, "minor": 3, "op": "Read", "value": 46022656 }, { "major": 253, "minor": 3, "op": "Write", "value": 155648 }, { "major": 253, "minor": 3, "op": "Sync", "value": 46178304 }, { "major": 253, "minor": 3, "op": "Async", "value": 0 }, { "major": 253, "minor": 3, "op": "Total", "value": 46178304 } ], "io_serviced_recursive": [ { "major": 7, "minor": 0, "op": "Read", "value": 6 }, { "major": 7, "minor": 0, "op": "Write", "value": 3 }, { "major": 7, "minor": 0, "op": "Sync", "value": 9 }, { "major": 7, "minor": 0, "op": "Async", "value": 0 }, { "major": 7, "minor": 0, "op": "Total", "value": 9 }, { "major": 253, "minor": 1, "op": "Read", "value": 6 }, { "major": 253, "minor": 1, "op": "Write", "value": 3 }, { "major": 253, "minor": 1, "op": "Sync", "value": 9 }, { "major": 253, "minor": 1, "op": "Async", "value": 0 }, { "major": 253, "minor": 1, "op": "Total", "value": 9 }, { "major": 253, "minor": 3, "op": "Read", "value": 1074 }, { "major": 253, "minor": 3, "op": "Write", "value": 17 }, { "major": 253, "minor": 3, "op": "Sync", "value": 1091 }, { "major": 253, "minor": 3, "op": "Async", "value": 0 }, { "major": 253, "minor": 3, "op": "Total", "value": 1091 } ], "io_queue_recursive": [], "io_service_time_recursive": [], "io_wait_time_recursive": [], "io_merged_recursive": [], "io_time_recursive": [], "sectors_recursive": [] }, "num_procs": 0, "storage_stats": {}, "cpu_stats": { "cpu_usage": { "total_usage": 4325370082, "percpu_usage": [ 1583545267, 1441574399, 932669369, 367581047, 0, 0, 0, 0 ], "usage_in_kernelmode": 490000000, "usage_in_usermode": 3800000000 }, "system_cpu_usage": 111726360000000, "throttling_data": { "periods": 0, "throttled_periods": 0, "throttled_time": 0 } }, "precpu_stats": { "cpu_usage": { "total_usage": 4172062391, "percpu_usage": [ 1545098206, 1408480094, 870204053, 348280038, 0, 0, 0, 0 ], "usage_in_kernelmode": 430000000, "usage_in_usermode": 3710000000 }, "system_cpu_usage": 111722350000000, "throttling_data": { "periods": 0, "throttled_periods": 0, "throttled_time": 0 } }, "memory_stats": { "usage": 179355648, "max_usage": 179404800, "stats": { "active_anon": 53280768, "active_file": 11571200, "cache": 119050240, "dirty": 51224576, "hierarchical_memory_limit": 9223372036854771712, "hierarchical_memsw_limit": 9223372036854771712, "inactive_anon": 0, "inactive_file": 107479040, "mapped_file": 21954560, "pgfault": 37991, "pgmajfault": 259, "pgpgin": 64385, "pgpgout": 22312, "recent_rotated_anon": 35307, "recent_rotated_file": 2829, "recent_scanned_anon": 35307, "recent_scanned_file": 31903, "rss": 53280768, "rss_huge": 0, "swap": 0, "total_active_anon": 53280768, "total_active_file": 11571200, "total_cache": 119050240, "total_dirty": 51224576, "total_inactive_anon": 0, "total_inactive_file": 107479040, "total_mapped_file": 21954560, "total_pgfault": 37991, "total_pgmajfault": 259, "total_pgpgin": 64385, "total_pgpgout": 22312, "total_rss": 53280768, "total_rss_huge": 0, "total_swap": 0, "total_unevictable": 0, "total_writeback": 0, "unevictable": 0, "writeback": 0 }, "limit": 12283822080 }, "name": "/hungry_brown", "id": "98a9cd0009984e61a24499aec44bd244078a6a09486782015b3966916eee5c08", "networks": { "eth0": { "rx_bytes": 63518221, "rx_packets": 46327, "rx_errors": 0, "rx_dropped": 0, "tx_bytes": 1601338, "tx_packets": 23966, "tx_errors": 0, "tx_dropped": 0 } } }, { "read": "2017-08-14T09:51:42.318182309Z", "preread": "2017-08-14T09:51:41.318110362Z", "pids_stats": { "current": 1 }, "blkio_stats": { "io_service_bytes_recursive": [ { "major": 7, "minor": 0, "op": "Read", "value": 36864 }, { "major": 7, "minor": 0, "op": "Write", "value": 12288 }, { "major": 7, "minor": 0, "op": "Sync", "value": 49152 }, { "major": 7, "minor": 0, "op": "Async", "value": 0 }, { "major": 7, "minor": 0, "op": "Total", "value": 49152 }, { "major": 253, "minor": 1, "op": "Read", "value": 36864 }, { "major": 253, "minor": 1, "op": "Write", "value": 12288 }, { "major": 253, "minor": 1, "op": "Sync", "value": 49152 }, { "major": 253, "minor": 1, "op": "Async", "value": 0 }, { "major": 253, "minor": 1, "op": "Total", "value": 49152 }, { "major": 253, "minor": 3, "op": "Read", "value": 46039040 }, { "major": 253, "minor": 3, "op": "Write", "value": 155648 }, { "major": 253, "minor": 3, "op": "Sync", "value": 46194688 }, { "major": 253, "minor": 3, "op": "Async", "value": 0 }, { "major": 253, "minor": 3, "op": "Total", "value": 46194688 } ], "io_serviced_recursive": [ { "major": 7, "minor": 0, "op": "Read", "value": 6 }, { "major": 7, "minor": 0, "op": "Write", "value": 3 }, { "major": 7, "minor": 0, "op": "Sync", "value": 9 }, { "major": 7, "minor": 0, "op": "Async", "value": 0 }, { "major": 7, "minor": 0, "op": "Total", "value": 9 }, { "major": 253, "minor": 1, "op": "Read", "value": 6 }, { "major": 253, "minor": 1, "op": "Write", "value": 3 }, { "major": 253, "minor": 1, "op": "Sync", "value": 9 }, { "major": 253, "minor": 1, "op": "Async", "value": 0 }, { "major": 253, "minor": 1, "op": "Total", "value": 9 }, { "major": 253, "minor": 3, "op": "Read", "value": 1075 }, { "major": 253, "minor": 3, "op": "Write", "value": 17 }, { "major": 253, "minor": 3, "op": "Sync", "value": 1092 }, { "major": 253, "minor": 3, "op": "Async", "value": 0 }, { "major": 253, "minor": 3, "op": "Total", "value": 1092 } ], "io_queue_recursive": [], "io_service_time_recursive": [], "io_wait_time_recursive": [], "io_merged_recursive": [], "io_time_recursive": [], "sectors_recursive": [] }, "num_procs": 0, "storage_stats": {}, "cpu_stats": { "cpu_usage": { "total_usage": 4733027699, "percpu_usage": [ 1598666891, 1746779810, 999006836, 388574162, 0, 0, 0, 0 ], "usage_in_kernelmode": 550000000, "usage_in_usermode": 4140000000 }, "system_cpu_usage": 111730350000000, "throttling_data": { "periods": 0, "throttled_periods": 0, "throttled_time": 0 } }, "precpu_stats": { "cpu_usage": { "total_usage": 4325370082, "percpu_usage": [ 1583545267, 1441574399, 932669369, 367581047, 0, 0, 0, 0 ], "usage_in_kernelmode": 490000000, "usage_in_usermode": 3800000000 }, "system_cpu_usage": 111726360000000, "throttling_data": { "periods": 0, "throttled_periods": 0, "throttled_time": 0 } }, "memory_stats": { "usage": 186646528, "max_usage": 186646528, "stats": { "active_anon": 53792768, "active_file": 12742656, "cache": 125739008, "dirty": 57917440, "hierarchical_memory_limit": 9223372036854771712, "hierarchical_memsw_limit": 9223372036854771712, "inactive_anon": 0, "inactive_file": 112996352, "mapped_file": 21954560, "pgfault": 38262, "pgmajfault": 259, "pgpgin": 66151, "pgpgout": 22318, "recent_rotated_anon": 35432, "recent_rotated_file": 3121, "recent_scanned_anon": 35432, "recent_scanned_file": 33834, "rss": 53800960, "rss_huge": 0, "swap": 0, "total_active_anon": 53792768, "total_active_file": 12742656, "total_cache": 125739008, "total_dirty": 57917440, "total_inactive_anon": 0, "total_inactive_file": 112996352, "total_mapped_file": 21954560, "total_pgfault": 38262, "total_pgmajfault": 259, "total_pgpgin": 66151, "total_pgpgout": 22318, "total_rss": 53800960, "total_rss_huge": 0, "total_swap": 0, "total_unevictable": 0, "total_writeback": 0, "unevictable": 0, "writeback": 0 }, "limit": 12283822080 }, "name": "/hungry_brown", "id": "98a9cd0009984e61a24499aec44bd244078a6a09486782015b3966916eee5c08", "networks": { "eth0": { "rx_bytes": 70507552, "rx_packets": 51415, "rx_errors": 0, "rx_dropped": 0, "tx_bytes": 1766536, "tx_packets": 26469, "tx_errors": 0, "tx_dropped": 0 } } }, { "read": "2017-08-14T09:51:43.318289249Z", "preread": "2017-08-14T09:51:42.318182309Z", "pids_stats": { "current": 1 }, "blkio_stats": { "io_service_bytes_recursive": [ { "major": 7, "minor": 0, "op": "Read", "value": 36864 }, { "major": 7, "minor": 0, "op": "Write", "value": 12288 }, { "major": 7, "minor": 0, "op": "Sync", "value": 49152 }, { "major": 7, "minor": 0, "op": "Async", "value": 0 }, { "major": 7, "minor": 0, "op": "Total", "value": 49152 }, { "major": 253, "minor": 1, "op": "Read", "value": 36864 }, { "major": 253, "minor": 1, "op": "Write", "value": 12288 }, { "major": 253, "minor": 1, "op": "Sync", "value": 49152 }, { "major": 253, "minor": 1, "op": "Async", "value": 0 }, { "major": 253, "minor": 1, "op": "Total", "value": 49152 }, { "major": 253, "minor": 3, "op": "Read", "value": 46039040 }, { "major": 253, "minor": 3, "op": "Write", "value": 155648 }, { "major": 253, "minor": 3, "op": "Sync", "value": 46194688 }, { "major": 253, "minor": 3, "op": "Async", "value": 0 }, { "major": 253, "minor": 3, "op": "Total", "value": 46194688 } ], "io_serviced_recursive": [ { "major": 7, "minor": 0, "op": "Read", "value": 6 }, { "major": 7, "minor": 0, "op": "Write", "value": 3 }, { "major": 7, "minor": 0, "op": "Sync", "value": 9 }, { "major": 7, "minor": 0, "op": "Async", "value": 0 }, { "major": 7, "minor": 0, "op": "Total", "value": 9 }, { "major": 253, "minor": 1, "op": "Read", "value": 6 }, { "major": 253, "minor": 1, "op": "Write", "value": 3 }, { "major": 253, "minor": 1, "op": "Sync", "value": 9 }, { "major": 253, "minor": 1, "op": "Async", "value": 0 }, { "major": 253, "minor": 1, "op": "Total", "value": 9 }, { "major": 253, "minor": 3, "op": "Read", "value": 1075 }, { "major": 253, "minor": 3, "op": "Write", "value": 17 }, { "major": 253, "minor": 3, "op": "Sync", "value": 1092 }, { "major": 253, "minor": 3, "op": "Async", "value": 0 }, { "major": 253, "minor": 3, "op": "Total", "value": 1092 } ], "io_queue_recursive": [], "io_service_time_recursive": [], "io_wait_time_recursive": [], "io_merged_recursive": [], "io_time_recursive": [], "sectors_recursive": [] }, "num_procs": 0, "storage_stats": {}, "cpu_stats": { "cpu_usage": { "total_usage": 5723669129, "percpu_usage": [ 1598666891, 2737421240, 999006836, 388574162, 0, 0, 0, 0 ], "usage_in_kernelmode": 570000000, "usage_in_usermode": 5120000000 }, "system_cpu_usage": 111734320000000, "throttling_data": { "periods": 0, "throttled_periods": 0, "throttled_time": 0 } }, "precpu_stats": { "cpu_usage": { "total_usage": 4733027699, "percpu_usage": [ 1598666891, 1746779810, 999006836, 388574162, 0, 0, 0, 0 ], "usage_in_kernelmode": 550000000, "usage_in_usermode": 4140000000 }, "system_cpu_usage": 111730350000000, "throttling_data": { "periods": 0, "throttled_periods": 0, "throttled_time": 0 } }, "memory_stats": { "usage": 218624000, "max_usage": 218624000, "stats": { "active_anon": 85700608, "active_file": 18706432, "cache": 125739008, "dirty": 57917440, "hierarchical_memory_limit": 9223372036854771712, "hierarchical_memsw_limit": 9223372036854771712, "inactive_anon": 0, "inactive_file": 107032576, "mapped_file": 21954560, "pgfault": 46060, "pgmajfault": 259, "pgpgin": 73949, "pgpgout": 22318, "recent_rotated_anon": 43222, "recent_rotated_file": 4577, "recent_scanned_anon": 43222, "recent_scanned_file": 35290, "rss": 85741568, "rss_huge": 0, "swap": 0, "total_active_anon": 85700608, "total_active_file": 18706432, "total_cache": 125739008, "total_dirty": 57917440, "total_inactive_anon": 0, "total_inactive_file": 107032576, "total_mapped_file": 21954560, "total_pgfault": 46060, "total_pgmajfault": 259, "total_pgpgin": 73949, "total_pgpgout": 22318, "total_rss": 85741568, "total_rss_huge": 0, "total_swap": 0, "total_unevictable": 0, "total_writeback": 0, "unevictable": 0, "writeback": 0 }, "limit": 12283822080 }, "name": "/hungry_brown", "id": "98a9cd0009984e61a24499aec44bd244078a6a09486782015b3966916eee5c08", "networks": { "eth0": { "rx_bytes": 70507552, "rx_packets": 51415, "rx_errors": 0, "rx_dropped": 0, "tx_bytes": 1766606, "tx_packets": 26470, "tx_errors": 0, "tx_dropped": 0 } } } ] # docker 1.11; docker inspect fedora inspect_image_data = [ { "Id": "sha256:6547ce9b34076d54d455d99a77d6e4e4e03203610b1a82d83c60cc4a0cee1434", "RepoTags": [ "fedora:latest" ], "RepoDigests": [], "Parent": "a79ad4dac406fcf85b9c7315fe08de5b620c1f7a12f45c8185c843f4b4a49c4e", # faked "Comment": "", "Created": "2016-01-04T21:26:31.943198534Z", "Container": "328e8788d8464dca333fd928a128778871e14668f38f5ae8e4121d44eaddd177", "ContainerConfig": { "Hostname": "328e8788d846", "Domainname": "", "User": "", "AttachStdin": False, "AttachStdout": False, "AttachStderr": False, "Tty": False, "OpenStdin": False, "StdinOnce": False, "Env": None, "Cmd": [ "/bin/sh", "-c", "#(nop) ADD file:b028ccee96c12c106da1e17b9cd93f3dbce86b888b2114116a481b289a46def8 in /" ], "Image": "b0082ba983ef3569aad347f923a9cec8ea764c239179081a1e2c47709788dc44", "Volumes": None, "WorkingDir": "", "Entrypoint": None, "OnBuild": None, "Labels": None }, "DockerVersion": "1.8.3", "Author": "Adam Miller \u003cmaxamillion@fedoraproject.org\u003e", "Config": { "Hostname": "328e8788d846", "Domainname": "", "User": "", "AttachStdin": False, "AttachStdout": False, "AttachStderr": False, "Tty": False, "OpenStdin": False, "StdinOnce": False, "Env": None, "Cmd": None, "Image": "b0082ba983ef3569aad347f923a9cec8ea764c239179081a1e2c47709788dc44", "Volumes": None, "WorkingDir": "", "Entrypoint": None, "OnBuild": None, "Labels": None }, "Architecture": "amd64", "Os": "linux", "Size": 206283556, "VirtualSize": 206283556, "GraphDriver": { "Name": "devicemapper", "Data": { "DeviceId": "139", "DeviceName": "docker-253:0-559511492-aa5ca2480c3fb5665db23937f543319feb0a5c36b09b31c396230280a80d6a69", "DeviceSize": "107374182400" } }, "RootFS": { "Type": "layers", "Layers": [ "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef", "sha256:15b864f11a279764b047bdd56de7fcb813118196f16a4f471296bb21e18358a2" ] } } ] def images_response(*args, **kwargs): logging.debug("fake image response") global image_data return image_data def containers_response(*args, **kwargs): global container_data return [container_data] def stats_response(*args, **kwargs): global stats_data return iter([stats_data]) def stats_response_1_13(*args, **kwargs): global stats_1_13 return iter(stats_1_13) def mock(): try: client_class = docker.Client # 1.x except AttributeError: client_class = docker.APIClient # 2.x flexmock(client_class, images=images_response) flexmock(client_class, containers=containers_response) flexmock(client_class, version=lambda *args, **kwargs: version_data) flexmock(client_class, top=lambda *args, **kwargs: top_data) flexmock(client_class, stats=stats_response_1_13) flexmock(client_class, inspect_image=lambda *args, **kwargs: inspect_image_data) sen-0.6.0/tests/test_commands.py0000644000372000037200000000370013254656210017522 0ustar travistravis00000000000000# -*- coding: utf-8 -*- import pytest from sen.tui.commands.base import Command, Option, Commander, register_command, Argument @register_command class MyCommand(Command): name = "test" description = "test desc" options_definitions = [ Option("test-opt", "test-opt desc", aliases=["x"]) ] aliases = ["mango"] def run(self): return 42 @register_command class MyCommand2(Command): name = "test2" description = "test desc 2" options_definitions = [ Option("test-opt", "test-opt desc", aliases=["x"]), Option("test-opt2", "test-opt2 desc", default="banana"), ] arguments_definitions = [ Argument("test-arg", "test-arg desc") ] def run(self): return 43 def test_command_class(): c = Command() assert c.arguments is None c.process_args([]) assert c.arguments is not None with pytest.raises(AttributeError): print(c.arguments.not_there) with pytest.raises(NotImplementedError): c.run() def test_custom_command(): c = MyCommand() c.process_args(["x"]) assert c.arguments.test_opt is True assert c.run() == 42 def test_arg_and_opt(): c = MyCommand2() c.process_args(["x", "y"]) assert c.arguments.test_opt is True assert c.arguments.test_arg == "y" assert c.run() == 43 def test_default_arg(): c = MyCommand2() c.process_args([]) assert c.arguments.test_opt2 == "banana" assert c.run() == 43 def test_opt(): c = MyCommand2() c.process_args(["y"]) assert c.arguments.test_opt is None assert c.arguments.test_arg == "y" assert c.run() == 43 def test_commander(): com = Commander(None, None) c = com.get_command("test test-opt") assert c.arguments.test_opt is True assert c.run() == 42 def test_command_aliases(): com = Commander(None, None) c = com.get_command("mango x") assert c.arguments.test_opt is True assert c.run() == 42 sen-0.6.0/tests/test_concurrency.py0000644000372000037200000000410113254656210020247 0ustar travistravis00000000000000""" This suite should test whether sen is capable of running in concurrent high load environment """ import random import threading from sen.tui.ui import UI from sen.tui.widgets.list.base import WidgetBase import urwid from .utils import get_random_text_widget, get_random_text class MockUI: buffers = [] def refresh(self): pass def set_alarm_in(self, *args, **kwargs): pass def test_main_frame(): lower_bound = 5 upper_bound = 10 list_count = 50 def loop_skeleton(greater_f, less_f, l, l_b, u_b, l_c): # we could do while True, but then we need to clean the threads for _ in range(100): if len(l) > l_c: for _ in range(random.randint(l_b, u_b)): greater_f() else: for _ in range(random.randint(l_b, u_b)): less_f() body_widgets = [get_random_text_widget() for _ in range(100)] ui = MockUI() frame = WidgetBase(ui, urwid.SimpleFocusListWalker(body_widgets)) def add_and_remove_random(): widgets = [] def less_f(): if random.randint(1, 2) % 2: w = get_random_text_widget() frame.notify_widget(w) widgets.append(w) else: widgets.append(frame.notify_message(get_random_text())) def greater_f(): w = random.choice(widgets) frame.remove_widget(w) widgets.remove(w) loop_skeleton(greater_f, less_f, widgets, lower_bound, upper_bound, list_count) def change_body(): def less_f(): body_widgets.insert(0, get_random_text_widget()) def greater_f(): body_widgets.remove(random.choice(body_widgets)) loop_skeleton(greater_f, less_f, body_widgets, lower_bound, upper_bound, list_count) nt = threading.Thread(target=add_and_remove_random, daemon=True) bt = threading.Thread(target=change_body, daemon=True) nt.start() bt.start() for x in range(50): frame.render((70, 70)) nt.join() bt.join() sen-0.6.0/tests/test_container_info.py0000644000372000037200000000317213254656210020721 0ustar travistravis00000000000000from sen.docker_backend import DockerBackend, DockerContainer from sen.tui.views.container_info import ProcessList from tests.real import mock def test_short_id(): mock() b = DockerBackend() operation = DockerContainer({"Status": "Up", "Id": "voodoo"}, b).top() top_response = operation.response pt = ProcessList(top_response) # 24502 # \ 24542 # \ 23743 # \ 18725 # \ 18733 # \ 18743 # \ 23819 root_process = pt.get_root_process() assert root_process.pid == "24502" assert pt.get_parent_process(root_process) is None p_24542 = pt.get_first_child_process(root_process) assert p_24542.pid == "24542" assert pt.get_last_child_process(root_process).pid == "24542" p_23743 = pt.get_first_child_process(p_24542) assert p_23743.pid == "23743" assert pt.get_last_child_process(p_24542).pid == "23743" p_18725 = pt.get_first_child_process(p_23743) assert p_18725.pid == "18725" assert pt.get_prev_sibling(p_18725) is None assert pt.get_parent_process(p_18725).pid == "23743" p_18733 = pt.get_next_sibling(p_18725) assert p_18733.pid == "18733" p_23819 = pt.get_last_child_process(p_23743) assert p_23819.pid == "23819" assert pt.get_next_sibling(p_23819) is None assert pt.get_parent_process(p_23819).pid == "23743" p_18743 = pt.get_prev_sibling(p_23819) assert p_18743.pid == "18743" assert pt.get_prev_sibling(p_18733) is p_18725 assert pt.get_next_sibling(p_18733) is p_18743 assert pt.get_prev_sibling(p_18743) is p_18733 assert pt.get_next_sibling(p_18743) is p_23819 sen-0.6.0/tests/test_docker_backend.py0000644000372000037200000000417213254656210020643 0ustar travistravis00000000000000from sen.docker_backend import DockerBackend from sen.util import calculate_cpu_percent2, calculate_cpu_percent from .real import image_data, mock, container_data def test_images_call(): mock() b = DockerBackend() operation = b.get_images(cached=False) images_response = operation.response assert len(images_response) == 1 assert images_response[0].image_id == image_data[0]["Id"] assert images_response[0].short_name == image_data[0]["RepoTags"][0] assert images_response[0].parent_id == image_data[0]["ParentId"] assert images_response[0].created_int == image_data[0]["Created"] assert [str(x) for x in images_response[0].names] == image_data[0]["RepoTags"] def test_containers_call(): mock() b = DockerBackend() operation = b.get_containers(cached=False) containers_response = operation.response assert len(containers_response) == 1 assert containers_response[0].container_id == container_data["Id"] assert containers_response[0].names == container_data["Names"] assert containers_response[0].short_name == container_data["Names"][0] assert containers_response[0].created_int == container_data["Created"] assert containers_response[0].command == container_data["Command"] assert containers_response[0].nice_status == container_data["Status"] assert containers_response[0].image_id == container_data["ImageID"] # assert containers_response[0].image_name() def test_short_id(): mock() b = DockerBackend() operation = b.get_images() images_response = operation.response assert images_response[0].short_id == image_data[0]["Id"][image_data[0]["Id"].index(":")+1:][:12] def test_top(): pass def test_stats(): mock() b = DockerBackend() c = b.get_containers() c0 = c.response.pop() operation = c0.stats() stats_stream = operation.response assert next(stats_stream) for x in c0.d.stats('x', decode=True, stream=True): calculate_cpu_percent(x) t = 0.0 s = 0.0 for x in c0.d.stats('x', decode=True, stream=True): calculate_cpu_percent(x) _, s, t = calculate_cpu_percent2(x, t, s) sen-0.6.0/tests/test_net.py0000644000372000037200000000507213254656210016513 0ustar travistravis00000000000000from sen.net import NetData empty_inspect = { "NetworkSettings": { "SecondaryIPAddresses": None, "EndpointID": "", "IPv6Gateway": "", "IPAddress": "", "LinkLocalIPv6PrefixLen": 0, "Networks": None, "GlobalIPv6PrefixLen": 0, "MacAddress": "", "GlobalIPv6Address": "", "SandboxID": "", "SecondaryIPv6Addresses": None, "SandboxKey": "", "HairpinMode": False, "IPPrefixLen": 0, "Bridge": "", "Gateway": "", "Ports": None, "LinkLocalIPv6Address": "" }, } populated_inspect = { "NetworkSettings": { "HairpinMode": False, "SandboxKey": "/var/run/docker/netns/7c1ad2c7d916", "MacAddress": "02:42:ac:11:00:07", "SandboxID": "7c1ad2c7d916cdbb6f92ef22feb420b67b186eb719d7b968fa508820e55617ab", "SecondaryIPAddresses": None, "Ports": { "8080/tcp": [ { "HostPort": "31003", "HostIp": "0.0.0.0" } ] }, "SecondaryIPv6Addresses": None, "IPAddress": "172.17.0.7", "GlobalIPv6Address": "", "EndpointID": "4ec3605fe3c70ef85cacee52d29db70c67f87cf85fd8021bf9e35ae09d4a5be2", "LinkLocalIPv6Address": "", "Networks": { "bridge": { "MacAddress": "02:42:ac:11:00:07", "Links": None, "IPAMConfig": None, "IPAddress": "172.17.0.7", "NetworkID": "2e90eba6e82abb1fe2ba82bfafbb1cf675e92cb9b33e667c78d5ab12cc3ab5b5", "GlobalIPv6Address": "", "GlobalIPv6PrefixLen": 0, "Aliases": None, "IPv6Gateway": "", "Gateway": "172.17.0.1", "EndpointID": "4ec3605fe3c70ef85cacee52d29db70c67f87cf85fd8021bf9e35ae09d4a5be2", "IPPrefixLen": 16 } }, "Bridge": "", "LinkLocalIPv6PrefixLen": 0, "IPv6Gateway": "", "Gateway": "172.17.0.1", "GlobalIPv6PrefixLen": 0, "IPPrefixLen": 16 }, "Config": { "ExposedPorts": { "8787/tcp": {} }, } } def test_empty_network(): nd = NetData(empty_inspect) assert nd.ips == {} assert nd.ports == {} def test_populated_network(): nd = NetData(populated_inspect) assert nd.ips == {"bridge": {"ip_address4": "172.17.0.7"}, "default": {"ip_address4": "172.17.0.7"}} assert nd.ports == {"8080": "31003", "8787": None} sen-0.6.0/tests/test_util.py0000644000372000037200000001073313254656210016702 0ustar travistravis00000000000000# -*- coding: utf-8 -*- import logging import time from concurrent.futures.thread import ThreadPoolExecutor from datetime import datetime, timedelta from sen.tui.widgets.list.common import strip_from_ansi_esc_sequences from sen.util import _ensure_unicode, log_traceback, repeater, humanize_time, \ OrderedSet import pytest @pytest.mark.parametrize("inp,expected", [ ("a", "a"), (b"a", "a"), ("\u2606", "☆"), (b'\xe2\x98\x86', "☆"), ]) def test_ensure_unicode(inp, expected): assert _ensure_unicode(inp) == expected def test_log_traceback(caplog): @log_traceback def f(): raise Exception() caplog.set_level(logging.DEBUG) f() assert caplog.records[0].message.endswith(" is about to be started") assert caplog.records[1].message.startswith("Traceback") assert caplog.records[1].message.endswith("Exception\n") def test_log_traceback_without_tb(caplog): @log_traceback def f(): pass caplog.set_level(logging.DEBUG) f() assert caplog.records[0].message.endswith(" is about to be started") assert caplog.records[1].message.endswith(" finished") def test_log_traceback_threaded(caplog): @log_traceback def f(): raise Exception() caplog.set_level(logging.DEBUG) e = ThreadPoolExecutor(max_workers=1) f = e.submit(f) while f.running(): time.sleep(0.1) assert caplog.records[0].message.endswith(" is about to be started") assert caplog.records[1].message.startswith("Traceback") assert caplog.records[1].message.endswith("Exception\n") # def test_log_vars_from_tback(caplog): # a = 1 # b = None # c = [] # try: # raise Exception() # except Exception: # log_vars_from_tback(4) # # def has_similar_message(msg): # for log_entry in caplog.records(): # if msg in log_entry.message: # return True # return False # # assert has_similar_message("c = []") # assert has_similar_message("b = None") # assert has_similar_message("a = 1") def test_repeater(): def g(l, item=1): l.append(1) return l[item] assert repeater(g, args=([], )) == 1 with pytest.raises(Exception): repeater(f, args=([], ), kwargs={"item": 10}) == 1 @pytest.mark.parametrize("inp,expected", [ (timedelta(seconds=1), "now"), (timedelta(seconds=2), "2 seconds ago"), (timedelta(seconds=59), "59 seconds ago"), (timedelta(minutes=1), "1 minute ago"), (timedelta(minutes=1, seconds=1), "1 minute ago"), (timedelta(minutes=1, seconds=59), "1 minute ago"), (timedelta(minutes=2), "2 minutes ago"), (timedelta(minutes=59, seconds=59), "59 minutes ago"), (timedelta(minutes=60), "1 hour ago"), (timedelta(hours=1), "1 hour ago"), (timedelta(hours=1, minutes=59, seconds=59), "1 hour ago"), (timedelta(hours=2), "2 hours ago"), (timedelta(hours=23, minutes=59, seconds=59), "23 hours ago"), (timedelta(hours=24), "1 day ago"), (timedelta(days=1, hours=23, minutes=59, seconds=59), "1 day ago"), (timedelta(hours=48), "2 days ago"), (timedelta(days=29, hours=23, minutes=59, seconds=59), "29 days ago"), (timedelta(days=30), "1 month ago"), (timedelta(days=59, hours=23, minutes=59, seconds=59), "1 month ago"), (timedelta(days=60), "2 months ago"), ]) def test_humanize_time(inp, expected): # flexmock(datetime, now=datetime(year=2000, month=1, day=1)) n = datetime.now() assert humanize_time(n - inp) == expected def test_ordered_set(): s = OrderedSet() s.append(1) assert s == [1] s.append(2) assert s == [1, 2] s.append(1) assert s == [2, 1] # @pytest.mark.parametrize("inp,expected", [ # ("aaa", ["aaa"]), # ( # "qwe [01;34m asd [01;33mbnm", # ["qwe ", " asd ", "bnm"] # ) # ]) # def test_colorize_text(inp, expected): # got = colorize_text(inp) # for idx, c in enumerate(got): # if isinstance(c, str): # assert expected[idx] == c # else: # assert expected[idx] == c[1] @pytest.mark.parametrize("inp,expected", [ ("aaa", "aaa"), ( "root:x:0:0:root:/root:/bin/b\x1b[01;31m\x1b[Ka\x1b[m\x1b[Ksh", "root:x:0:0:root:/root:/bin/bash" ), ( "\x1b[0m\x1b[01;36mbin\x1b[0m\r\n\x1b[01;34mboot\x1b[0m\r\n\x1b[01;34mdev\x1b[0m\r\n", "bin\r\nboot\r\ndev\r\n" ) ]) def test_strip_from_ansi_seqs(inp, expected): got = strip_from_ansi_esc_sequences(inp) assert got == expected sen-0.6.0/tests/test_widgets.py0000644000372000037200000001055713254656210017377 0ustar travistravis00000000000000import logging import random from itertools import chain import pytest from flexmock import flexmock from urwid.listbox import SimpleListWalker from sen.tui.widgets.list.base import WidgetBase from sen.tui.widgets.list.common import ScrollableListBox, AsyncScrollableListBox from sen.tui.widgets.list.util import ResponsiveRowWidget from sen.tui.widgets.table import ResponsiveTable, assemble_rows from .utils import get_random_text_widget from .constants import SCREEN_WIDTH, SCREEN_HEIGHT class MockUI: buffers = [] def refresh(self): pass def set_alarm_in(self, *args, **kwargs): pass class DataGenerator: @classmethod def text(cls, prefix="line", lines_no=3, return_bytes=False): s = "\n".join(["{}{}".format(prefix, x+1) for x in range(lines_no)]) if return_bytes: return s.encode("utf-8") return s @classmethod def stream(cls, prefix="line", lines_no=3, return_bytes=False): text = [] for x in range(lines_no): l = "{}{}\n".format(prefix, x+1) if return_bytes: l = l.encode("utf-8") text.append(l) s = chain(text) return iter(s) @classmethod def render(cls, prefix="line", lines_no=3, return_bytes=True): w = "{:%d}" % SCREEN_WIDTH response = [] for x in range(SCREEN_HEIGHT): if x >= lines_no: l = w.format("") else: l = w.format("{}{}".format(prefix, x+1)) if return_bytes: response.append(l.encode("utf-8")) else: response.append(l) return response @pytest.mark.parametrize("inp,expected", [ (DataGenerator.text(), DataGenerator.render()), (DataGenerator.text(return_bytes=True), DataGenerator.render()), (DataGenerator.text(prefix="liné"), DataGenerator.render(prefix="liné")), (DataGenerator.text(prefix="liné", return_bytes=True), DataGenerator.render(prefix="liné")), ]) def test_scrollable_listbox(inp, expected): lb = ScrollableListBox(MockUI(), inp) canvas = lb.render((SCREEN_WIDTH, SCREEN_HEIGHT)) text = [bytes().join([t for at, cs, t in ln]) for ln in canvas.content()] assert text == expected @pytest.mark.parametrize("inp,expected", [ (DataGenerator.stream(), DataGenerator.render()), (DataGenerator.stream(return_bytes=True), DataGenerator.render()), (DataGenerator.stream(prefix="liné"), DataGenerator.render(prefix="liné")), (DataGenerator.stream(prefix="liné", return_bytes=True), DataGenerator.render(prefix="liné")), ]) def test_async_scrollable_listbox(inp, expected): ui = flexmock(refresh=lambda: None) lb = AsyncScrollableListBox(inp, ui) lb.thread.join() canvas = lb.render((SCREEN_WIDTH, SCREEN_HEIGHT)) text = [bytes().join([t for at, cs, t in ln]) for ln in canvas.content()] w = "{:%d}" % SCREEN_WIDTH s = w.format("{}".format("No more logs.")) expected[4] = s.encode("utf-8") assert text == expected def test_table_random_data(): rows = [ResponsiveRowWidget([get_random_text_widget(random.randint(2, 9)) for _ in range(5)]) for _ in range(5)] table = ResponsiveTable(MockUI(), SimpleListWalker(rows)) canvas = table.render((80, 20), focus=False) text = [bytes().join([t for at, cs, t in ln]) for ln in canvas.content()] logging.info("%r", text) assert len(text) == 20 assert text[0].startswith(rows[0].original_widget.widget_list[0].text.encode("utf-8")) def test_table_empty(): rows = [] table = ResponsiveTable(MockUI(), SimpleListWalker(rows)) canvas = table.render((80, 20), focus=False) text = [bytes().join([t for at, cs, t in ln]) for ln in canvas.content()] assert len(text) == 20 assert text[0] == b" " * 80 def test_assemble_rows_long_text(): rows = [[get_random_text_widget(10), get_random_text_widget(300)] for _ in range(5)] assembled_rows = assemble_rows(rows, ignore_columns=[1]) lb = WidgetBase(MockUI(), SimpleListWalker(assembled_rows)) canvas = lb.render((80, 20), focus=False) text = [bytes().join([t for at, cs, t in ln]) for ln in canvas.content()] logging.info("%r", text) assert len(text) == 20 first_col, second_col = text[0].split(b" ", 1) assert first_col == rows[0][0].text.encode("utf-8") assert rows[0][1].text.encode("utf-8").startswith(second_col) sen-0.6.0/tests/utils.py0000644000372000037200000000047313254656210016026 0ustar travistravis00000000000000import string import random import urwid characters = string.digits + string.ascii_letters + string.punctuation def get_random_text(length=32): return "".join([random.choice(characters) for _ in range(length)]) def get_random_text_widget(length=32): return urwid.Text(get_random_text(length=length)) sen-0.6.0/LICENSE0000644000372000037200000000207113254656210014153 0ustar travistravis00000000000000The MIT License (MIT) Copyright (c) 2015 Tomas Tomecek Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. sen-0.6.0/MANIFEST.in0000644000372000037200000000021413254656210014701 0ustar travistravis00000000000000include LICENSE include README.md include requirements.txt include requirements-test.txt recursive-include tests * recursive-include docs * sen-0.6.0/README.md0000644000372000037200000001463113254656210014432 0ustar travistravis00000000000000# sen [![Build Status](https://travis-ci.org/TomasTomecek/sen.svg?branch=master)](https://travis-ci.org/TomasTomecek/sen) `sen` is a terminal user interface for docker engine: * it can interactively manage your containers and images: * manage? start, stop, restart, kill, delete,... * there is a "dashboard" view for containers and images * you are able to inspect containers and images * sen can fetch logs of containers and even stream logs real-time * some buffers support searching and filtering * sen receives real-time updates from docker when anything changes * e.g. if you pull a container in another terminal, sen will pick it up * sen notifies you whenever something happens (and reports slow queries) * supports a lot of vim-like keybindings (`j`, `k`, `gg`, `/`, ...) * you can get interactive tree view of all images (equivalent of `docker images --tree`) * see how much space containers, images and volumes occupy (just type `:df`) You can [see the features yourself](/docs/features.md). ![Preview of sen](/data/sen-preview.gif) # Installation and running `sen` I strongly advise to run `sen` from a docker container provided on docker hub: ``` $ docker pull tomastomecek/sen ``` This repository has set up automated builds on docker hub. In case you run into some issue, try pulling latest version first before opening an issue. This is the recommended way of running `sen` in a container: ``` $ docker run -v /var/run/docker.sock:/run/docker.sock -ti -e TERM tomastomecek/sen ``` Some distros have `/var/run` simlinked to `/run`, so you can do `/run/docker.sock:/run/docker.sock` instead. In case you would like to try development version of sen, you can pull `tomastomecek/sen:dev`. ## docker You can easily build a docker image with sen inside: ``` $ docker build --tag=$USER/sen https://github.com/tomastomecek/sen $ docker run -v /var/run/docker.sock:/run/docker.sock -ti -e TERM $USER/sen ``` ## PyPI `sen` is using [`urwidtrees`](https://github.com/pazz/urwidtrees) as a dependency. Unfortunately, the upstream maintainer doesn't maintain it on PyPI so we need to install it directly from git, before installing sen (the forked PyPI version has a [bug](https://github.com/TomasTomecek/sen/issues/128) in installation process): ``` $ pip3 install git+https://github.com/pazz/urwidtrees.git@9142c59d3e41421ff6230708d08b6a134e0a8eed#egg=urwidtrees-1.0.3.dev ``` `sen` releases are available on PyPI: ``` $ pip3 install sen ``` If `pip3` executable is not available on your system, you can run pip like this: ``` $ python3 -m pip install sen ``` And then start sen like this: ``` $ sen ``` ## git `sen` is a python 3 only project. I recommend using at least python 3.4. This is how you can install `sen` from git: ``` $ git clone https://github.com/TomasTomecek/sen $ cd sen $ pip3 install --user -r ./requirements.txt $ ./setup.py install $ sen ``` Or even run `sen` straight from git: ``` $ git clone https://github.com/TomasTomecek/sen $ cd sen $ pip3 install --user -r ./requirements.txt $ PYTHONPATH="$PWD:$PYTHONPATH" ./sen/cli.py ``` If `pip3` executable is not available on your system, you can run pip like this: ``` $ python3 -m pip install sen ``` # Prerequisite Either: * The unix socket for docker engine needs to be accessible. By default it's located at `/run/docker.sock`. Or: * Have the `DOCKER_HOST`, `DOCKER_TLS_VERIFY`, and `DOCKER_CERT_PATH` set properly. If you're using `docker-machine` or `boot2docker` you're all set! # Keybindings Since I am heavy `vim` user, these keybindings are trying to stay close to vim. ## Global ``` / search (provide empty query to disable searching) n next search occurrence N previous search occurrence f4 display only lines matching provided query (provide empty query to clear filtering) f5 open a tree view of all images (`docker images --tree` equivalent) ctrl o navigate to next buffer ctrl i navigate to previous buffer x remove buffer q remove buffer, quit if no buffer is left ctrl l redraw user interface h, ? show help : open command prompt ``` ## Movement ``` gg, home go to first item G, end go to last item j go one line down k go one line up pg up ctrl u go 10 lines up pg down ctrl d go 10 lines down ``` ## Listing ``` @ refresh listing f4 filtering, for more info run `help filter` in sen ``` ## Image commands in listing ``` i inspect image d remove image (irreversible!) D remove image forcibly (irreversible!) enter display detailed info about image (when layer is focused) ``` ## Container commands in listing ``` i inspect container l display logs of container f follow logs of container d remove container (irreversible!) D remove container forcibly (irreversible!) t stop container s start container r restart container p pause container u unpause container b open container's mapped ports in a web-browser X kill container ! toggle realtime updates of the interface (this is useful when you are removing multiple objects and don't want the listing change during that so you accidentally remove something) ``` ## Tree buffer ``` enter display detailed info about image (opens image info buffer) ``` ## Image info buffer ``` enter display detailed info about image (when an image is focused) i inspect image or container, whatever is focused ``` ## Container info buffer ``` enter display detailed info about image (when image of the container is focued) i inspect image (when image of the container is focued) ``` ## Disk usage buffer You can enter it by typing command `df`. # Why I started sen? Since I started using docker, I always dreamed of having a docker TUI. Something like [tig](https://github.com/jonas/tig), [htop](http://hisham.hm/htop/) or [alot](https://github.com/pazz/alot). Some appeared over time. Such as [docker-mon](https://github.com/icecrime/docker-mon) or [ctop](https://github.com/yadutaf/ctop). Unfortunately, those are not proper docker TUIs. They are meant for monitoring and diagnostics. So I realized that if I want make my dream come true, I need to do it myself. That's where I started working on *sen* (*dream* in Slovak). But! As the time went, [someone](https://github.com/moncho) else had the same idea as I did: [dry](https://github.com/moncho/dry). sen-0.6.0/requirements-test.txt0000644000372000037200000000002713254656210017406 0ustar travistravis00000000000000pytest>=3.4.0 flexmock sen-0.6.0/requirements.txt0000644000372000037200000000034613254656210016435 0ustar travistravis00000000000000urwid docker # this is latest upstream commit (April 2017) # upstream maintainer of urwidtrees doesn't maintain PyPI urwidtrees -e git+https://github.com/pazz/urwidtrees.git@9142c59d3e41421ff6230708d08b6a134e0a8eed#egg=urwidtrees sen-0.6.0/setup.cfg0000644000372000037200000000011713254656257015001 0ustar travistravis00000000000000[metadata] description-file = README.md [egg_info] tag_build = tag_date = 0 sen-0.6.0/setup.py0000755000372000037200000000300313254656210014657 0ustar travistravis00000000000000#!/usr/bin/env python3 import os from setuptools import setup, find_packages # vcs+proto://host/path@revision#egg=project-version # this is latest upstream commit (April 2017) # upstream maintainer of urwidtrees doesn't maintain PyPI urwidtrees urwidtrees_source = "git+https://github.com/pazz/urwidtrees.git@9142c59d3e41421ff6230708d08b6a134e0a8eed#egg=urwidtrees-1.0.3.dev" requirements = [ "urwid", "docker", "urwidtrees" ] setup( name='sen', version='0.6.0', description="Terminal User Interface for Docker Engine", author='Tomas Tomecek', author_email='tomas@tomecek.net', url="https://github.com/TomasTomecek/sen/", license="MIT", entry_points={ 'console_scripts': ['sen=sen.cli:main'], }, packages=find_packages(exclude=["*.tests", "*.tests.*", "tests.*", "tests"]), install_requires=requirements, dependency_links=[urwidtrees_source], classifiers=['Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'Intended Audience :: System Administrators', 'License :: OSI Approved :: MIT License', 'Operating System :: POSIX :: Linux', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 3.4', 'Topic :: System :: Monitoring', ], ) sen-0.6.0/PKG-INFO0000644000372000037200000000140513254656257014256 0ustar travistravis00000000000000Metadata-Version: 1.1 Name: sen Version: 0.6.0 Summary: Terminal User Interface for Docker Engine Home-page: https://github.com/TomasTomecek/sen/ Author: Tomas Tomecek Author-email: tomas@tomecek.net License: MIT Description: UNKNOWN Platform: UNKNOWN Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: Intended Audience :: System Administrators Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: POSIX :: Linux Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: 3.4 Classifier: Topic :: System :: Monitoring