From 3f72336463cb4dfc80621228d45fdfb5ea53a341 Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Thu, 10 Oct 2024 16:04:45 +0100 Subject: [PATCH] Add collapsable INFO logging widget --- src/edge_containers_cli/cli.py | 4 +- src/edge_containers_cli/cmds/argo_commands.py | 26 +++- src/edge_containers_cli/cmds/commands.py | 27 ++++- src/edge_containers_cli/cmds/demo_commands.py | 19 +-- src/edge_containers_cli/cmds/monitor.py | 113 ++++++++++-------- src/edge_containers_cli/cmds/monitor.tcss | 22 ++-- src/edge_containers_cli/shell.py | 1 + 7 files changed, 137 insertions(+), 75 deletions(-) diff --git a/src/edge_containers_cli/cli.py b/src/edge_containers_cli/cli.py index 6aaecc01..05997102 100644 --- a/src/edge_containers_cli/cli.py +++ b/src/edge_containers_cli/cli.py @@ -238,7 +238,7 @@ def start( ): """Start a service""" try: - backend.commands.start(service_name, commit) + backend.commands.start(service_name, commit=commit) except GitError as e: msg = f"{str(e)} - Commit failed. Try 'ec start --no-commit to set values without updating git" raise GitError(msg) from e @@ -255,7 +255,7 @@ def stop( ): """Stop a service""" try: - backend.commands.stop(service_name, commit) + backend.commands.stop(service_name, commit=commit) except GitError as e: msg = f"{str(e)} - Commit failed. Try ec stop --no-commit to set values without updating git" raise GitError(msg) from e diff --git a/src/edge_containers_cli/cmds/argo_commands.py b/src/edge_containers_cli/cmds/argo_commands.py index 122ad79a..daf75397 100644 --- a/src/edge_containers_cli/cmds/argo_commands.py +++ b/src/edge_containers_cli/cmds/argo_commands.py @@ -7,7 +7,7 @@ import webbrowser from datetime import datetime from pathlib import Path - +from time import sleep import polars from ruamel.yaml import YAML @@ -16,6 +16,7 @@ from edge_containers_cli.git import set_values from edge_containers_cli.globals import TIME_FORMAT from edge_containers_cli.shell import ShellError, shell +from edge_containers_cli.logging import log def extract_ns_app(target: str) -> tuple[str, str]: @@ -26,8 +27,23 @@ def extract_ns_app(target: str) -> tuple[str, str]: def patch_value(target: str, key: str, value: str | bool | int): cmd_temp_ = f"argocd app set {target} -p {key}={value}" shell.run_command(cmd_temp_, skip_on_dryrun=True) - cmd_sync = f"argocd app sync {target} --apply-out-of-sync-only" - shell.run_command(cmd_sync, skip_on_dryrun=True) + max_attempts = 3 + attempt = 1 + sleep_time = 2 + while attempt <= max_attempts: + try: + # Sync may conflict with autosync "another operation is already in progress" + cmd_sync = f"argocd app sync {target} --apply-out-of-sync-only" + shell.run_command(cmd_sync, skip_on_dryrun=True) + return None + except ShellError as e: + if attempt == max_attempts: + log.debug(f"Argo patch failed after {max_attempts} attempts") + raise + else: + log.debug(f"Argo patch attempt {attempt} failed. Retrying...") + sleep(sleep_time) + attempt += 1 def push_value(target: str, key: str, value: str | bool | int): @@ -84,14 +100,14 @@ def restart(self, service_name): ) shell.run_command(cmd, skip_on_dryrun=True) - def start(self, service_name, commit): + def start(self, service_name, commit=False): self._check_service(service_name) if commit: push_value(self.target, f"ec_services.{service_name}.enabled", True) else: patch_value(self.target, f"ec_services.{service_name}.enabled", True) - def stop(self, service_name, commit): + def stop(self, service_name, commit=False): self._check_service(service_name) if commit: push_value(self.target, f"ec_services.{service_name}.enabled", False) diff --git a/src/edge_containers_cli/cmds/commands.py b/src/edge_containers_cli/cmds/commands.py index 2d80f28d..56648cca 100644 --- a/src/edge_containers_cli/cmds/commands.py +++ b/src/edge_containers_cli/cmds/commands.py @@ -47,6 +47,29 @@ def __init__(self, ctx: ECContext): self._repo = ctx.repo self._log_url = ctx.log_url + def __getattribute__(self, attr_name): + """Override to wrap each abstract method""" + attr = super().__getattribute__(attr_name) + if callable(attr): + if attr_name[0]=="_": + log_f = log.debug + else: + log_f = log.info + def wrapper(*args, **kwargs): + call_msg = f"Calling: {attr_name} {', '.join(str(item) for item in args)}" + return_msg = f"Returned: {attr_name} {', '.join(str(item) for item in args)}" + if kwargs: + call_msg = f"{call_msg} [{', '.join(f'{k}={v}' for k, v in kwargs.items())}]" + return_msg = f"{return_msg} [{', '.join(f'{k}={v}' for k, v in kwargs.items())}]" + log_f(call_msg) + result = attr(*args, **kwargs) + log_f(return_msg) + return result + return wrapper + else: + return attr + + @property def target(self): if not self._target_valid: # Only validate once @@ -103,11 +126,11 @@ def restart(self, service_name: str) -> None: raise NotImplementedError @abstractmethod - def start(self, service_name: str, commit: bool) -> None: + def start(self, service_name: str, commit: bool = False) -> None: raise NotImplementedError @abstractmethod - def stop(self, service_name: str, commit: bool) -> None: + def stop(self, service_name: str, commit: bool = False) -> None: raise NotImplementedError def template(self, svc_instance: Path, args: str) -> None: diff --git a/src/edge_containers_cli/cmds/demo_commands.py b/src/edge_containers_cli/cmds/demo_commands.py index d3be4563..26e57a66 100644 --- a/src/edge_containers_cli/cmds/demo_commands.py +++ b/src/edge_containers_cli/cmds/demo_commands.py @@ -12,6 +12,7 @@ from edge_containers_cli.cmds.commands import CommandError, Commands, ServicesDataFrame from edge_containers_cli.definitions import ECContext from edge_containers_cli.globals import TIME_FORMAT +from edge_containers_cli.logging import log DELAY = 0.0 @@ -37,19 +38,15 @@ def process_t(time_string) -> str: def demo_wrapper(): """Using closure to display once""" called = False - def decorator(function): """Called selectively to avoid breaking autocompletion""" - def wrapper(*args, **kwargs): nonlocal called if not called: called = True print("***RUNNING IN DEMO MODE***") return function(*args, **kwargs) - return wrapper - return decorator @@ -83,11 +80,14 @@ def ps(self, running_only): @demo_message def restart(self, service_name): - self.stop(service_name, False) - self.start(service_name, False) + self._stop(service_name, commit=False) + self._start(service_name, commit=False) @demo_message - def start(self, service_name, commit): + def start(self, service_name, commit=False): + self._start(service_name, commit=commit) + + def _start(self, service_name, commit=False): self._check_service(service_name) time.sleep(DELAY) self._stateDF = self._stateDF.with_columns( @@ -98,7 +98,10 @@ def start(self, service_name, commit): ) @demo_message - def stop(self, service_name, commit): + def stop(self, service_name, commit=False): + self._stop(service_name, commit=commit) + + def _stop(self, service_name, commit=False): self._check_service(service_name) time.sleep(DELAY) self._stateDF = self._stateDF.with_columns( diff --git a/src/edge_containers_cli/cmds/monitor.py b/src/edge_containers_cli/cmds/monitor.py index 81e57773..aa392976 100755 --- a/src/edge_containers_cli/cmds/monitor.py +++ b/src/edge_containers_cli/cmds/monitor.py @@ -10,33 +10,35 @@ from rich.style import Style from rich.syntax import Syntax from rich.text import Text +import logging -# from textual import on -from textual import work -from textual.app import App, ComposeResult +from textual import work, on +from textual.app import App, ComposeResult, RenderResult from textual.binding import Binding from textual.color import Color -from textual.containers import Grid +from textual.containers import Grid, Vertical, ScrollableContainer from textual.reactive import reactive from textual.screen import ModalScreen from textual.widget import Widget -from textual.widgets import Button, DataTable, Footer, Header, Label, RichLog +from textual.widgets import Button, DataTable, Footer, Header, Label, RichLog, Collapsible, Static, Label from textual.widgets.data_table import RowKey from edge_containers_cli.cmds.commands import Commands +from edge_containers_cli.logging import log +from edge_containers_cli.definitions import ECLogLevels -class OptionScreen(ModalScreen[bool], inherit_bindings=False): +class ConfirmScreen(ModalScreen[bool], inherit_bindings=False): BINDINGS = [ Binding("y,enter", "option_yes", "Yes"), Binding("n,c,escape", "option_cancel", "Cancel"), ] - def __init__(self, service_name: str) -> None: + def __init__(self, service_name: str, type_action: str) -> None: super().__init__() self.service_name = service_name - self.type_action = "stop" + self.type_action = type_action def compose(self) -> ComposeResult: yield Grid( @@ -50,46 +52,15 @@ def compose(self) -> ComposeResult: ) yield Footer() - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "yes": - self.action_option_yes() - else: - self.action_option_cancel() - + @on(Button.Pressed, "#yes") def action_option_yes(self) -> None: self.dismiss(True) + @on(Button.Pressed, "#cancel") def action_option_cancel(self) -> None: self.dismiss(False) -class StartScreen(OptionScreen): - """Screen with dialog to start service.""" - - def __init__(self, service_name: str) -> None: - super().__init__(service_name) - - self.type_action = "start" - - -class StopScreen(OptionScreen): - """Screen with dialog to stop service.""" - - def __init__(self, service_name: str) -> None: - super().__init__(service_name) - - self.type_action = "stop" - - -class RestartScreen(OptionScreen): - """Screen with dialog to restart service.""" - - def __init__(self, service_name: str) -> None: - super().__init__(service_name) - - self.type_action = "restart" - - class LogsScreen(ModalScreen, inherit_bindings=False): """Screen to display IOC logs.""" @@ -375,6 +346,34 @@ async def populate_table(self) -> None: table.sort(self.sort_column_id, reverse=False) +class MonitorLogHandler(logging.Handler): + def __init__(self, rich_log: RichLog): + super().__init__() + self.rich_log = rich_log + + def emit(self, record: logging.LogRecord): + log_entry = self.format(record) + self.rich_log.write(Text(log_entry)) + + +class MonitorLogs(Static): + """Widget to display the monitor logs.""" + + def __init__(self) -> None: + super().__init__() + + def compose(self) -> ComposeResult: + yield RichLog(max_lines=25) + + def on_mount(self) -> None: + rich_log = self.query_one(RichLog) + handler = MonitorLogHandler(rich_log) + handler.setFormatter(logging.Formatter('%(asctime)s - %(message)s')) + log.addHandler(handler) + log.removeHandler(log.handlers[0]) # Cut noise from main handler + log.setLevel(ECLogLevels.INFO.value) + + class MonitorApp(App): CSS_PATH = "monitor.tcss" @@ -385,7 +384,8 @@ class MonitorApp(App): Binding("r", "restart_ioc", "Restart IOC"), Binding("l", "ioc_logs", "IOC Logs"), Binding("o", "sort", "Sort"), - Binding("d", "toggle_dark", "Toggle dark mode"), + Binding("m", "monitor_logs", "Monitor logs", show=False), + #Binding("d", "toggle_dark", "Toggle dark mode"), ] def __init__( @@ -402,8 +402,16 @@ def __init__( def compose(self) -> ComposeResult: """Create child widgets for the app.""" yield Header(show_clock=True) - self.table = IocTable(self.commands, self.running_only) - yield self.table + with Vertical(): + with Static(id="ioc_table_container"): + self.table = IocTable(self.commands, self.running_only) + yield ScrollableContainer(self.table) + with Static(id="collapsible_container"): + yield Collapsible( + MonitorLogs(), + title="Monitor Logs (m)", + collapsed=True + ) yield Footer() def on_mount(self) -> None: @@ -446,9 +454,9 @@ def action_start_ioc(self) -> None: def check_start(start: bool | None) -> None: """Called when StartScreen is dismissed.""" if start: - self.commands.start(service_name, False) + self.commands.start(service_name, commit=False) - self.push_screen(StartScreen(service_name), check_start) + self.push_screen(ConfirmScreen(service_name, "start"), check_start) def action_stop_ioc(self) -> None: """Stop the IOC that is currently highlighted.""" @@ -457,9 +465,9 @@ def action_stop_ioc(self) -> None: def check_stop(stop: bool | None) -> None: """Called when StopScreen is dismissed.""" if stop: - self.commands.stop(service_name, False) + self.commands.stop(service_name, commit=False) - self.push_screen(StopScreen(service_name), check_stop) + self.push_screen(ConfirmScreen(service_name, "stop"), check_stop) def action_restart_ioc(self) -> None: """Restart the IOC that is currently highlighted.""" @@ -470,7 +478,7 @@ def check_restart(restart: bool | None) -> None: if restart: self.commands.restart(service_name) - self.push_screen(RestartScreen(service_name), check_restart) + self.push_screen(ConfirmScreen(service_name, "restart"), check_restart) def action_ioc_logs(self) -> None: """Display the logs of the IOC that is currently highlighted.""" @@ -499,6 +507,13 @@ def action_sort(self, col_name: str = "") -> None: new_col = cols[0 if col_index + 1 > 3 else col_index + 1] self.update_sort_key(new_col) + + def action_monitor_logs(self) -> None: + """Get a new hello and update the content area.""" + collapsed_state = self.query_one(Collapsible).collapsed + self.query_one(Collapsible).collapsed = not collapsed_state + + def update_sort_key(self, col_name: str) -> None: """Method called to update the table sort key attribute.""" table = self.query_one(IocTable) diff --git a/src/edge_containers_cli/cmds/monitor.tcss b/src/edge_containers_cli/cmds/monitor.tcss index 330e50f8..53935b0f 100644 --- a/src/edge_containers_cli/cmds/monitor.tcss +++ b/src/edge_containers_cli/cmds/monitor.tcss @@ -6,19 +6,23 @@ Footer > .footer--key { background: rgb(30, 144, 255); } -IocTable { - height: 1fr; +Vertical { + height: 100%; } - -.narrow IocTable { - height: 100%; +#collapsible_container { + height: 1fr; + max-height: 50%; + align: center bottom } - -IocTable > ScrollView { - height: 100%; +#ioc_table_container { + height: 1fr; +} +Collapsible > .collapsible--content { + max-height: 50%; + overflow: auto; } -OptionScreen { +ConfirmScreen { align: center middle; } diff --git a/src/edge_containers_cli/shell.py b/src/edge_containers_cli/shell.py index 5917e70d..96fa9303 100644 --- a/src/edge_containers_cli/shell.py +++ b/src/edge_containers_cli/shell.py @@ -113,6 +113,7 @@ def run_interactive( result = p_result.returncode == 0 log.debug(f"returning: {result}") else: + log.info(f"Dry run - skipping: {command}") result = True return result