From 2f016ef3727f7149c9ea42bfc83e553b74e4e89c Mon Sep 17 00:00:00 2001 From: Alexander U Date: Thu, 28 Jul 2022 19:03:30 -0400 Subject: [PATCH 1/8] Initial commit for v1-alpha-2 release --- app/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version.py b/app/version.py index 50f8cdf..35f0101 100644 --- a/app/version.py +++ b/app/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -VERSION = "v1-alpha" +VERSION = "v1-alpha-2" From 71c46bcfbd91ea7f850cde1719e07b2bf0f99a6b Mon Sep 17 00:00:00 2001 From: Alexander U Date: Thu, 18 Aug 2022 11:49:33 -0400 Subject: [PATCH 2/8] Add python version requirements to README --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 52135e7..4b8b667 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,11 @@ Tensu is a TUI (curses) based program for interacting with SensuGo Enterprises' ![screenshot](/misc/screenshot-2.jpg "Screenshot") # Installation + +Requirements: + +* Python 3.7+ + ``` pip3 install -r requirements.txt ``` From 17c62c99326ef43c22fb8f4fdbc85d1065291970 Mon Sep 17 00:00:00 2001 From: Alexander U Date: Thu, 18 Aug 2022 12:06:17 -0400 Subject: [PATCH 3/8] Update to use versions that are tested in CI --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4b8b667..bba13c3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Tensu is a TUI (curses) based program for interacting with SensuGo Enterprises' Requirements: -* Python 3.7+ +* Python 3.8, 3.9, 3.10 ``` pip3 install -r requirements.txt From 013479c06f36775c1c0cca00796f79bf27e02560 Mon Sep 17 00:00:00 2001 From: Alexander U Date: Fri, 29 Jul 2022 12:00:42 -0400 Subject: [PATCH 4/8] First pass on configurable hotkeys --- app/columnheader.py | 2 +- app/defaults.py | 16 +++++ tensu.py | 139 ++++++++++++++++++++++++++++---------------- 3 files changed, 105 insertions(+), 52 deletions(-) diff --git a/app/columnheader.py b/app/columnheader.py index 5e78a2c..73fda6d 100644 --- a/app/columnheader.py +++ b/app/columnheader.py @@ -84,7 +84,7 @@ def draw(self) -> None: curr_x=curr_x, add_back_pct=add_back_pct, self_w=self.w, - column_grow_pct=column_grow_pct + column_grow_pct=column_grow_pct, ) self.win.addstr(0, curr_x, column_name, self.theme) diff --git a/app/defaults.py b/app/defaults.py index 30aa9c8..098fd2f 100644 --- a/app/defaults.py +++ b/app/defaults.py @@ -18,6 +18,14 @@ class ViewOptions: ALL = "ALL" SILENCED = "SILENCED" + def __eq__(self, other): + return any( + [ + other == getattr(self, item) + for item in filter(lambda x: (not x.startswith("_")), dir(self)) + ] + ) + class Filters: """Defines string values for various filters.""" @@ -30,6 +38,14 @@ class Filters: SILENCED_CREATOR_REGEX = "CREATOR_REGEX" SILENCED_REASON_REGEX = "REASON_REGEX" + def __eq__(self, other): + return any( + [ + other == getattr(self, item) + for item in filter(lambda x: (not x.startswith("_")), dir(self)) + ] + ) + DEFAULT_KEYMAP = { ViewOptions.NOT_PASSING: {"label": "Alt+1", "modifier": 27, "key": 49}, diff --git a/tensu.py b/tensu.py index f26de36..27b2bd9 100755 --- a/tensu.py +++ b/tensu.py @@ -237,62 +237,30 @@ def resize_term(self): handle_terminal_resize(self.s) self.make_windows() - def handle_user_input(self): - """Handle input from users keyboard.""" - - ch = self.s.getch() - if ch == -1 or ch == 410: - return - - self.logger.debug("handle_user_input", key=ch) - - if ch == ord("q") or ch == ord("Q"): - raise KeyboardInterrupt + def handle_filter_event(self, identifier): + """Handle prompting for filter inputs - if ch == 338: # PageDown - self.page_index(1) - if ch == 339: # PageUp - self.page_index(0) - - if ch == 27: # Alt - nextch = self.s.getch() - if nextch == 49: # 1 - self.change_view(ViewOptions.NOT_PASSING) - if nextch == 50: # 2 - self.change_view(ViewOptions.ALL) - if nextch == 51: # 3 - self.change_view(ViewOptions.SILENCED) - - if ch == 16: # Ctrl+P - self.set_namespace() + and setting action button activation states + """ + self.logger.debug("Handling filter event", identifier=identifier) - if ch == 6: # Ctrl+F - if self.view_state_is_events(): + # TODO: Make this cleaner + if self.view_state_is_events(): + if identifier == Filters.EVENT_HOST_REGEX: self.prompt_and_set_filter( "Host Regex Filter", Filters.EVENT_HOST_REGEX ) self.action_button_host_regex.draw( bool(self.get_filter_value(Filters.EVENT_HOST_REGEX)) ) - else: - self.prompt_and_set_filter( - "Silencing Entry Filter", Filters.SILENCED_NAME_REGEX - ) - self.action_button_silenced_name_regex.draw( - bool(self.get_filter_value(Filters.SILENCED_NAME_REGEX)) - ) - - if ch == 14: # Ctrl+N - if self.view_state_is_events(): + elif identifier == Filters.EVENT_CHECK_REGEX: self.prompt_and_set_filter( "Check Name Regex Filter", Filters.EVENT_CHECK_REGEX ) self.action_button_check_regex.draw( bool(self.get_filter_value(Filters.EVENT_CHECK_REGEX)) ) - - if ch == 15: # Ctrl+O - if self.view_state_is_events(): + elif identifier == Filters.EVENT_OUTPUT_REGEX: self.prompt_and_set_filter( "Check Oupout Regex Filter", Filters.EVENT_OUTPUT_REGEX ) @@ -300,21 +268,74 @@ def handle_user_input(self): bool(self.get_filter_value(Filters.EVENT_OUTPUT_REGEX)) ) else: + self.logger.debug(f"Invalid identifier {identifier}") + + elif self.view_state_is_silenced(): + if identifier == Filters.SILENCED_NAME_REGEX: + self.prompt_and_set_filter( + "Silencing Entry Filter", Filters.SILENCED_NAME_REGEX + ) + self.action_button_silenced_name_regex.draw( + bool(self.get_filter_value(Filters.SILENCED_NAME_REGEX)) + ) + elif identifier == Filters.SILENCED_CREATOR_REGEX: self.prompt_and_set_filter( "Creator Regex Filter", Filters.SILENCED_CREATOR_REGEX ) self.action_button_creator_regex.draw( bool(self.get_filter_value(Filters.SILENCED_CREATOR_REGEX)) ) - - if ch == 18: # Ctrl+R - if self.view_state_is_silenced(): + elif identifier == Filters.SILENCED_REASON_REGEX: self.prompt_and_set_filter( "Reason Regex Filter", Filters.SILENCED_REASON_REGEX ) self.action_button_reason_regex.draw( bool(self.get_filter_value(Filters.SILENCED_REASON_REGEX)) ) + else: + self.logger.debug(f"Invalid identifier {identifier}") + + def dispatch_key_event(self, identifier): + """Handle key events that are mapped via the state file""" + self.logger.debug( + "Dispatching key event", + identifier=identifier, + is_view_option=(identifier == ViewOptions()), + is_filter_option=(identifier == Filters()), + ) + + if identifier == ViewOptions(): + self.change_view(identifier) + + if identifier == Filters(): + self.handle_filter_event(identifier) + + def handle_user_input(self): + """Handle input from users keyboard.""" + + ch = self.s.getch() + if ch == -1 or ch == 410: + return + + self.logger.debug("handle_user_input", key=ch) + nextch = None + for identifier in self.state["keymap"]: + key_options = self.state["keymap"][identifier] + modifier = key_options.get("modifier", None) + key = key_options.get("key") + + if ch == modifier: + nextch = nextch or self.s.getch() + if nextch == key: + self.dispatch_key_event(identifier) + return + elif ch == key: + self.dispatch_key_event(identifier) + return + + # Static key assignments + if ch == 16: # Ctrl+P + self.set_namespace() if ch == 10: # Enter if self.view_state_is_events(): @@ -328,6 +349,15 @@ def handle_user_input(self): if ch == curses.KEY_UP or ch == ord("k"): self.move_index(-1) + if ch == ord("q") or ch == ord("Q"): + raise KeyboardInterrupt + + if ch == 338: # PageDown + self.page_index(1) + + if ch == 339: # PageUp + self.page_index(0) + def show_silenced_info(self): """Show a modal window with additional information. @@ -370,18 +400,22 @@ def make_action_bar_bottom(self): if self.view_state_is_events(): self.action_button_host_regex = ContextButton( - self.action_bar_bottom, " Ctrl+F ", " Host Regex ", 1, 0 + self.action_bar_bottom, + f" {self.keymapping(Filters.EVENT_HOST_REGEX)['label']} ", + " Host Regex ", + 1, + 0, ) self.action_button_check_regex = ContextButton( self.action_bar_bottom, - " Ctrl+N ", + f" {self.keymapping(Filters.EVENT_CHECK_REGEX)['label']} ", " CheckName Regex ", self.action_button_host_regex.x + self.action_button_host_regex.w, 0, ) self.action_button_output_regex = ContextButton( self.action_bar_bottom, - " Ctrl+O ", + f" {self.keymapping(Filters.EVENT_OUTPUT_REGEX)['label']} ", " CheckOutput Regex ", self.action_button_check_regex.x + self.action_button_check_regex.w, 0, @@ -425,6 +459,9 @@ def make_action_bar_bottom(self): bool(self.get_filter_value(Filters.SILENCED_REASON_REGEX)) ) + def keymapping(self, identifier): + return self.state["keymap"][identifier] + def make_control_bar(self): """Draws the 'ControlBar' with buttons for switching views.""" @@ -434,7 +471,7 @@ def make_control_bar(self): self.state, ViewOptions.NOT_PASSING, self.control_bar_top, - " Alt+1 ", + f" {self.keymapping(ViewOptions.NOT_PASSING)['label']} ", " Not Passing ", 1, ) @@ -443,7 +480,7 @@ def make_control_bar(self): self.state, ViewOptions.ALL, self.control_bar_top, - " Alt+2 ", + f" {self.keymapping(ViewOptions.ALL)['label']} ", " All ", self.button_not_passing.x + self.button_not_passing.w, ) @@ -452,7 +489,7 @@ def make_control_bar(self): self.state, ViewOptions.SILENCED, self.control_bar_top, - " Alt+3 ", + f" {self.keymapping(ViewOptions.SILENCED)['label']} ", " Silences ", self.button_all.x + self.button_all.w, ) From 2cba74710dbc6b0bc6f97421240b932066dc10f5 Mon Sep 17 00:00:00 2001 From: Alexander U Date: Fri, 29 Jul 2022 15:28:10 -0400 Subject: [PATCH 5/8] Add further tests --- test.py | 1 + tests/test_tensu.py | 69 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 tests/test_tensu.py diff --git a/test.py b/test.py index 9787775..d44e7d5 100755 --- a/test.py +++ b/test.py @@ -7,6 +7,7 @@ from tests.test_display import DisplayTests # noqa from tests.test_utils import UtilTests # noqa from tests.test_sensu_go import SensuGoHelperTests # noqa +from tests.test_tensu import TensuTests # noqa def load_tests(loader, tests, ignore): diff --git a/tests/test_tensu.py b/tests/test_tensu.py new file mode 100644 index 0000000..fc18ed0 --- /dev/null +++ b/tests/test_tensu.py @@ -0,0 +1,69 @@ +# Copyright 2022 Two Sigma Open Source, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest import mock +from tensu import Tensu +from app import defaults +import unittest +import logging +import sys + +class TensuTests(unittest.TestCase): + #curses_mock = FakeCurses() + + class FakeFile: + def __init__(self, data): + self.data = data + + def read(self): + return data + + @classmethod + def setUpClass(self): + logging.basicConfig(stream=sys.stderr) + self.logger = logging.getLogger(self.__name__) + self.logger.setLevel(logging.DEBUG) + + @mock.patch("os.path.exists") + @mock.patch("tensu.Tensu.__init__") + def test_get_state_file_not_found(self, mock_tensu, mock_exists): + # It should return the state from internal defaults + mock_tensu.return_value = None + mock_exists.return_value = False + t = Tensu() + t.state_file = "/foo" + assert t.get_state() == defaults.InternalDefaults.STATE + + @mock.patch("os.path.exists") + @mock.patch("tensu.Tensu.__init__") + def test_get_state_exception(self, mock_tensu, mock_exists): + # It should return the state from internal defaults + mock_tensu.return_value = None + mock_exists.side_effect = Exception("Boom!") + t = Tensu() + t.state_file = "/foo" + assert t.get_state() == defaults.InternalDefaults.STATE + + @mock.patch('builtins.open') + @mock.patch("os.path.exists") + @mock.patch("tensu.Tensu.__init__") + def test_get_state_all_keys(self, mock_tensu, mock_exists, mock_open): + # It should return the state from internal defaults + mock_tensu.return_value = None + mock_exists.return_value = True + mock_open.return_value = self.FakeFile('{"status_message": "foo"}') + t = Tensu() + t.state_file = "/foo" + s = t.get_state() + assert all([s[key] == defaults.InternalDefaults.STATE[key] for key in defaults.InternalDefaults.STATE]) From e804750cc82f9d466a165481e11de656ce05d8fc Mon Sep 17 00:00:00 2001 From: Alexander U Date: Wed, 17 Aug 2022 22:25:24 -0400 Subject: [PATCH 6/8] Fix some bugs --- app/defaults.py | 20 ++++++++++++-------- tensu.py | 27 +++++++++++++++++++-------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/app/defaults.py b/app/defaults.py index 098fd2f..5713c71 100644 --- a/app/defaults.py +++ b/app/defaults.py @@ -47,14 +47,6 @@ def __eq__(self, other): ) -DEFAULT_KEYMAP = { - ViewOptions.NOT_PASSING: {"label": "Alt+1", "modifier": 27, "key": 49}, - ViewOptions.ALL: {"label": "Alt+2", "modifier": 27, "key": 50}, - ViewOptions.SILENCED: {"label": "Alt+3", "modifier": 27, "key": 51}, - Filters.EVENT_HOST_REGEX: {"label": "Ctrl+F", "key": 6}, - Filters.EVENT_CHECK_REGEX: {"label": "Ctrl+N", "key": 14}, - Filters.EVENT_OUTPUT_REGEX: {"label": "Ctrl+O", "key": 15}, -} class AuthenticationOptions: @@ -70,6 +62,18 @@ class InternalDefaults: APPNAME = "Tensu" + DEFAULT_KEYMAP = { + ViewOptions.NOT_PASSING: {"label": "Alt+1", "modifier": 27, "key": 49}, + ViewOptions.ALL: {"label": "Alt+2", "modifier": 27, "key": 50}, + ViewOptions.SILENCED: {"label": "Alt+3", "modifier": 27, "key": 51}, + Filters.SILENCED_NAME_REGEX: {"label":"Ctrl+F", "key": 6}, + Filters.SILENCED_CREATOR_REGEX: {"label":"Ctrl+F", "key": 15}, + Filters.SILENCED_REASON_REGEX: {"label":"Ctrl+F", "key": 18}, + Filters.EVENT_HOST_REGEX: {"label": "Ctrl+F", "key": 6}, + Filters.EVENT_CHECK_REGEX: {"label": "Ctrl+N", "key": 14}, + Filters.EVENT_OUTPUT_REGEX: {"label": "Ctrl+O", "key": 15}, + } + STATE = { "status_message": "Welcome to Tensu!", "status_is_error": False, diff --git a/tensu.py b/tensu.py index 27b2bd9..43a15ac 100755 --- a/tensu.py +++ b/tensu.py @@ -91,6 +91,7 @@ def __init__(self, args): self.resource_handler = ResourceHandler(self.state, self.sensu_go_helper) self.resource_handler.set_callable(self.update_view) self.resource_handler.set_fetch_status_callable(self.update_fetch_status) + self.logger.debug("", keymap=self.keymap()) def configure_logger(self): """Configures the application logger @@ -319,19 +320,23 @@ def handle_user_input(self): self.logger.debug("handle_user_input", key=ch) nextch = None - for identifier in self.state["keymap"]: - key_options = self.state["keymap"][identifier] + matches = [] + for identifier in self.keymap(): + key_options = self.keymap()[identifier] modifier = key_options.get("modifier", None) key = key_options.get("key") if ch == modifier: nextch = nextch or self.s.getch() if nextch == key: - self.dispatch_key_event(identifier) - return - elif ch == key: + matches.append(identifier) + elif ch == key and modifier is None: + matches.append(identifier) + + if matches: + for identifier in matches: self.dispatch_key_event(identifier) - return + return # Static key assignments if ch == 16: # Ctrl+P @@ -459,8 +464,14 @@ def make_action_bar_bottom(self): bool(self.get_filter_value(Filters.SILENCED_REASON_REGEX)) ) - def keymapping(self, identifier): - return self.state["keymap"][identifier] + def keymap(self): + """ Return a merged copy of keymappings + with userdefined mappings falling back on + default mappings. """ + return { **InternalDefaults.DEFAULT_KEYMAP, **self.state["keymap"] } + + def keymapping(self, identifier): + return self.keymap()[identifier] def make_control_bar(self): """Draws the 'ControlBar' with buttons for switching views.""" From 169a826f526852254e2f880726501e864715e6cc Mon Sep 17 00:00:00 2001 From: Alexander U Date: Thu, 18 Aug 2022 00:12:00 -0400 Subject: [PATCH 7/8] Fix tests and add more tests --- app/defaults.py | 8 ++-- tensu.py | 9 ++--- test.py | 2 +- tests/test_tensu.py | 97 ++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 95 insertions(+), 21 deletions(-) diff --git a/app/defaults.py b/app/defaults.py index 5713c71..7c9243e 100644 --- a/app/defaults.py +++ b/app/defaults.py @@ -47,8 +47,6 @@ def __eq__(self, other): ) - - class AuthenticationOptions: """Defines string values for authentication options.""" @@ -66,9 +64,9 @@ class InternalDefaults: ViewOptions.NOT_PASSING: {"label": "Alt+1", "modifier": 27, "key": 49}, ViewOptions.ALL: {"label": "Alt+2", "modifier": 27, "key": 50}, ViewOptions.SILENCED: {"label": "Alt+3", "modifier": 27, "key": 51}, - Filters.SILENCED_NAME_REGEX: {"label":"Ctrl+F", "key": 6}, - Filters.SILENCED_CREATOR_REGEX: {"label":"Ctrl+F", "key": 15}, - Filters.SILENCED_REASON_REGEX: {"label":"Ctrl+F", "key": 18}, + Filters.SILENCED_NAME_REGEX: {"label": "Ctrl+F", "key": 6}, + Filters.SILENCED_CREATOR_REGEX: {"label": "Ctrl+F", "key": 15}, + Filters.SILENCED_REASON_REGEX: {"label": "Ctrl+F", "key": 18}, Filters.EVENT_HOST_REGEX: {"label": "Ctrl+F", "key": 6}, Filters.EVENT_CHECK_REGEX: {"label": "Ctrl+N", "key": 14}, Filters.EVENT_OUTPUT_REGEX: {"label": "Ctrl+O", "key": 15}, diff --git a/tensu.py b/tensu.py index 43a15ac..c0847b2 100755 --- a/tensu.py +++ b/tensu.py @@ -91,7 +91,6 @@ def __init__(self, args): self.resource_handler = ResourceHandler(self.state, self.sensu_go_helper) self.resource_handler.set_callable(self.update_view) self.resource_handler.set_fetch_status_callable(self.update_fetch_status) - self.logger.debug("", keymap=self.keymap()) def configure_logger(self): """Configures the application logger @@ -465,12 +464,12 @@ def make_action_bar_bottom(self): ) def keymap(self): - """ Return a merged copy of keymappings + """Return a merged copy of keymappings with userdefined mappings falling back on - default mappings. """ - return { **InternalDefaults.DEFAULT_KEYMAP, **self.state["keymap"] } + default mappings.""" + return {**InternalDefaults.DEFAULT_KEYMAP, **self.state["keymap"]} - def keymapping(self, identifier): + def keymapping(self, identifier): return self.keymap()[identifier] def make_control_bar(self): diff --git a/test.py b/test.py index d44e7d5..f2d50d7 100755 --- a/test.py +++ b/test.py @@ -7,7 +7,7 @@ from tests.test_display import DisplayTests # noqa from tests.test_utils import UtilTests # noqa from tests.test_sensu_go import SensuGoHelperTests # noqa -from tests.test_tensu import TensuTests # noqa +from tests.test_tensu import TensuTests # noqa def load_tests(loader, tests, ignore): diff --git a/tests/test_tensu.py b/tests/test_tensu.py index fc18ed0..eb2b82b 100644 --- a/tests/test_tensu.py +++ b/tests/test_tensu.py @@ -18,16 +18,26 @@ import unittest import logging import sys +import json +import io -class TensuTests(unittest.TestCase): - #curses_mock = FakeCurses() - class FakeFile: - def __init__(self, data): - self.data = data +class TensuTests(unittest.TestCase): + # curses_mock = FakeCurses() - def read(self): - return data + km = { + "NOT_PASSING": {"label": "Alt+1", "modifier": 27, "key": 49}, + "ALL": {"label": "Alt+2", "modifier": 27, "key": 50}, + # Purposefully remove this entry for testing + # "SILENCED": { + # "label": "Alt+3", + # "modifier": 27, + # "key": 51 + # }, + "HOST_REGEX": {"label": "Ctrl+F", "key": 7}, + "CHECK_REGEX": {"label": "Ctrl+N", "key": 14}, + "OUTPUT_REGEX": {"label": "Ctrl+X", "key": 12}, + } @classmethod def setUpClass(self): @@ -55,15 +65,82 @@ def test_get_state_exception(self, mock_tensu, mock_exists): t.state_file = "/foo" assert t.get_state() == defaults.InternalDefaults.STATE - @mock.patch('builtins.open') + @mock.patch("builtins.open") + @mock.patch("os.path.exists") + @mock.patch("tensu.Tensu.__init__") + def test_get_state_default_state(self, mock_tensu, mock_exists, mock_open): + # It should return the state from internal defaults + mock_tensu.return_value = None + mock_exists.return_value = True + mock_open.return_value = io.StringIO("{}") + t = Tensu() + t.state_file = "/foo" + s = t.get_state() + assert all( + [ + s[key] == defaults.InternalDefaults.STATE[key] + for key in defaults.InternalDefaults.STATE + ] + ) + + @mock.patch("builtins.open") + @mock.patch("os.path.exists") + @mock.patch("tensu.Tensu.__init__") + def test_get_state_default_state_mixed(self, mock_tensu, mock_exists, mock_open): + # It should return the state from internal defaults + mock_tensu.return_value = None + mock_exists.return_value = True + mock_open.return_value = io.StringIO('{"status_message": "foobar"}') + t = Tensu() + t.state_file = "/foo" + s = t.get_state() + assert s["status_message"] == "foobar" + assert all( + [ + s[key] == defaults.InternalDefaults.STATE[key] + for key in filter( + lambda x: x != "status_message", defaults.InternalDefaults.STATE + ) + ] + ) + + @mock.patch("builtins.open") @mock.patch("os.path.exists") @mock.patch("tensu.Tensu.__init__") def test_get_state_all_keys(self, mock_tensu, mock_exists, mock_open): # It should return the state from internal defaults mock_tensu.return_value = None mock_exists.return_value = True - mock_open.return_value = self.FakeFile('{"status_message": "foo"}') + mock_open.return_value = io.StringIO("{}") + t = Tensu() + t.state_file = "/foo" + s = t.get_state() + assert all( + [ + s[key] == defaults.InternalDefaults.STATE[key] + for key in defaults.InternalDefaults.STATE + ] + ) + + @mock.patch("builtins.open") + @mock.patch("os.path.exists") + @mock.patch("tensu.Tensu.__init__") + def test_get_state_custom_keys(self, mock_tensu, mock_exists, mock_open): + # It should merge user defined keys with any missing from defaults. + mock_tensu.return_value = None + mock_exists.return_value = True + ff = json.dumps({"status_message": "foo", "keymap": self.km}) + mock_open.return_value = io.StringIO(ff) t = Tensu() t.state_file = "/foo" s = t.get_state() - assert all([s[key] == defaults.InternalDefaults.STATE[key] for key in defaults.InternalDefaults.STATE]) + t.state = s + self.logger.debug(json.dumps(s, indent=2)) + self.logger.debug(json.dumps(self.km, indent=2)) + self.logger.debug(json.dumps(t.keymap(), indent=2)) + assert s["keymap"] == self.km + assert ( + t.keymap()["SILENCED"] + == defaults.InternalDefaults.DEFAULT_KEYMAP["SILENCED"] + ) + assert all(self.km[key] == t.keymap()[key] for key in self.km) From 59ad3c284ccb3ae95e66dad0d4f2b0e7443ef091 Mon Sep 17 00:00:00 2001 From: Alexander U Date: Fri, 14 Apr 2023 15:20:00 -0400 Subject: [PATCH 8/8] Initial commit for event sorting. Silences to follow --- app/columnheader.py | 2 +- app/defaults.py | 16 +++++++++ app/display.py | 1 + app/eventinfowindow.py | 7 ++++ app/eventitem.py | 5 +++ app/listselect.py | 2 +- app/listselectitem.py | 4 +++ app/utils.py | 7 ++++ tensu.py | 73 ++++++++++++++++++++++++++++++++++++++++-- 9 files changed, 112 insertions(+), 5 deletions(-) diff --git a/app/columnheader.py b/app/columnheader.py index 5e78a2c..73fda6d 100644 --- a/app/columnheader.py +++ b/app/columnheader.py @@ -84,7 +84,7 @@ def draw(self) -> None: curr_x=curr_x, add_back_pct=add_back_pct, self_w=self.w, - column_grow_pct=column_grow_pct + column_grow_pct=column_grow_pct, ) self.win.addstr(0, curr_x, column_name, self.theme) diff --git a/app/defaults.py b/app/defaults.py index 30aa9c8..4b5d493 100644 --- a/app/defaults.py +++ b/app/defaults.py @@ -19,6 +19,20 @@ class ViewOptions: SILENCED = "SILENCED" +class SortOptions: + SORT_BY_TIMESTAMP = "sort_by_timestamp" + SORT_BY_LAST_OK = "sort_by_last_ok" + SORT_BY_ENTITY = "sort_by_entity" + SORT_BY_ISSUED = "sort_by_issued" + SORT_BY_SEVERITY = "sort_by_severity" + + @classmethod + def all(cls): + return list( + i.lower() for i in filter(lambda x: x.startswith("SORT_BY"), dir(cls)) + ) + + class Filters: """Defines string values for various filters.""" @@ -62,4 +76,6 @@ class InternalDefaults: "fetch_interval_ms": 700, "view": ViewOptions.NOT_PASSING, "keymap": DEFAULT_KEYMAP, + "sort": SortOptions.SORT_BY_TIMESTAMP, + "sort_orders": {x: False for x in SortOptions.all()}, } diff --git a/app/display.py b/app/display.py index c226a39..bc3bf1e 100644 --- a/app/display.py +++ b/app/display.py @@ -28,6 +28,7 @@ ("Hostname", 30, 0.10), ("Check Name", 32, 0.10), ("Output", 3, 0.80), + ("Timestamp", 20, 0), ("Issued", 19, 0), ) # Column Name, Minimum Width, Grow Percent diff --git a/app/eventinfowindow.py b/app/eventinfowindow.py index 57c1cb9..c0ed40c 100644 --- a/app/eventinfowindow.py +++ b/app/eventinfowindow.py @@ -90,6 +90,13 @@ def retrieve_and_draw(self) -> None: datas = ( ("id:", self.item["id"]), + ( + "Timestamp:", + "{} ({})".format( + self.item["timestamp"], + datetime.fromtimestamp(self.item["timestamp"]), + ), + ), ("Entity:", self.item["entity"]["metadata"]["name"]), ("Proxy Entity:", self.item["check"]["proxy_entity_name"]), ("Check:", self.item["check"]["metadata"]["name"]), diff --git a/app/eventitem.py b/app/eventitem.py index 05f9c28..adc04d2 100644 --- a/app/eventitem.py +++ b/app/eventitem.py @@ -54,6 +54,7 @@ def draw(self) -> None: name = self.event["check"]["metadata"]["name"] hostname = self.event["entity"]["metadata"]["name"] issued = self.event["check"]["issued"] + timestamp = self.event["timestamp"] output = self.event["check"]["output"] is_silenced = self.event["check"]["is_silenced"] @@ -81,6 +82,9 @@ def draw(self) -> None: check_state = "unknown" issued_str = f"{datetime.fromtimestamp(issued).strftime('%Y-%m-%d %H:%M:%S')}" + timestamp_str = ( + f"{datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')}" + ) if is_silenced: if self.selected: @@ -97,6 +101,7 @@ def draw(self) -> None: (hostname, hostname_theme), (name, theme), (output, output_theme), + (timestamp_str, theme), (issued_str, theme), ) curr_x = 0 diff --git a/app/listselect.py b/app/listselect.py index e9c22db..d8f28dc 100644 --- a/app/listselect.py +++ b/app/listselect.py @@ -30,7 +30,7 @@ def __init__(self, state: dict, stdscr, items: dict, title: str) -> None: # the title. Whichever is longest. w = ( max(len(sorted(items, key=lambda k: len(k), reverse=True)[0]), len(title)) - + 2 + + 4 ) super().__init__( h, diff --git a/app/listselectitem.py b/app/listselectitem.py index 1393296..ef68b7b 100644 --- a/app/listselectitem.py +++ b/app/listselectitem.py @@ -42,6 +42,10 @@ def draw(self) -> None: theme = curses.color_pair(ColorPairs.BUTTON_TEXT_SELECTED) else: theme = curses.color_pair(ColorPairs.BUTTON_TEXT) + self.logger.debug( + "text_w={}, list_select_item_w={}, parent_w={}, attempting to draw {}" + .format(len(self.text), self.w, self.parent.w, self.text) + ) self.color(theme) self.win.addstr(0, 0, self.text) self.win.refresh() diff --git a/app/utils.py b/app/utils.py index df252fd..ef292e0 100644 --- a/app/utils.py +++ b/app/utils.py @@ -20,6 +20,13 @@ class Utils: """A helper class.""" + @staticmethod + def direction_icon(b: bool) -> str: + if b is False: + return "▲" + else: + return "▼" + @staticmethod def current_milli_time() -> int: """Get the current time as epoch in milliseconds.""" diff --git a/tensu.py b/tensu.py index f26de36..c1279cd 100755 --- a/tensu.py +++ b/tensu.py @@ -17,7 +17,12 @@ from app.display import ( handle_terminal_resize, ) -from app.defaults import ViewOptions, InternalDefaults, AuthenticationOptions, Filters +from app.defaults import ( + ViewOptions, + InternalDefaults, + AuthenticationOptions, + Filters, +) from app.silencedinfowindow import SilencedInfoWindow from app.dataviewcontainer import DataViewContainer from datetime import datetime, timezone @@ -265,6 +270,11 @@ def handle_user_input(self): if ch == 16: # Ctrl+P self.set_namespace() + self.make_windows() + + if ch == ord("/"): + self.change_sort() + self.make_windows() if ch == 6: # Ctrl+F if self.view_state_is_events(): @@ -365,10 +375,24 @@ def make_action_bar_bottom(self): curses.COLS - 28, 0, ) - self.action_button_change_namespace.draw(False) if self.view_state_is_events(): + sort_name = self.state["sort"] + sort_friendly = sort_name.replace("sort_by_", "") + sort_direction = Utils.direction_icon(self.state["sort_orders"][sort_name]) + sort_text = f" Sort: {sort_friendly} {sort_direction} " + self.action_button_sort = ContextButton( + self.action_bar_bottom, + " / ", + sort_text, + (curses.COLS - self.action_button_change_namespace.w) + - len(sort_text) + - 4, + 0, + ) + self.action_button_sort.draw(False) + self.action_button_host_regex = ContextButton( self.action_bar_bottom, " Ctrl+F ", " Host Regex ", 1, 0 ) @@ -478,10 +502,38 @@ def view_state_is_silenced(self): return self.state["view"] == ViewOptions.SILENCED + def sort_by_timestamp(self, event): + return event["timestamp"] + + def sort_by_last_ok(self, event): + return event["check"]["last_ok"] + + def sort_by_entity(self, event): + return event["entity"]["metadata"]["name"] + + def sort_by_issued(self, event): + return event["check"]["issued"] + + def sort_by_severity(self, event): + return event["check"]["status"] + + def sort_events(self, items): + sort_name = self.state["sort"] + sort_function = getattr(self, sort_name) + sort_direction = self.state["sort_orders"][sort_name] + + return sorted(items, key=sort_function, reverse=sort_direction) + def apply_filters(self, items): """Filters events and silenced items from the user supplied regex filters.""" - filtered = items + if self.view_state_is_events(): + filtered = self.sort_events(items) + + if self.view_state_is_silenced(): + filtered = items + # filtered = self.sort_silenced(items) + for f in self.filters: r = re.compile(f["value"]) if self.view_state_is_events(): @@ -670,6 +722,21 @@ def check_authentication(self): finally: self.next_auth_check_time = Utils.current_milli_time() + (1000 * 10) + def change_sort(self): + list_items = [] + for k in self.state["sort_orders"]: + d = Utils.direction_icon(not self.state["sort_orders"][k]) + o = f"{k} {d}" + list_items.append(o) + + ls = ListSelect(self.state, self.s, list_items, "Change Sort") + ls.draw() + sort_select = ls.select().split()[0] + self.state["sort"] = sort_select + self.state["sort_orders"][sort_select] = not self.state["sort_orders"][ + sort_select + ] + def set_namespace(self): """Display a prompt to choose from a list of Sensu namespaces."""