diff --git a/.vscode/launch.json b/.vscode/launch.json index 36d8f503..a215eac4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,6 +18,19 @@ // Enable break on exception when debugging tests (see: tests/conftest.py) "PYTEST_RAISE": "1", }, + }, + { + "name": "Debug Monitor", + "type": "debugpy", + "request": "launch", + "module": "edge_containers_cli", + "args": [ + "monitor" + ], + "console": "integratedTerminal", + "env": { + "EC_CLI_BACKEND": "DEMO", + }, } ] } diff --git a/src/edge_containers_cli/cmds/demo_commands.py b/src/edge_containers_cli/cmds/demo_commands.py index 3a5332d1..8f7c78e3 100644 --- a/src/edge_containers_cli/cmds/demo_commands.py +++ b/src/edge_containers_cli/cmds/demo_commands.py @@ -6,6 +6,7 @@ import time from datetime import datetime +from random import randrange, seed import polars @@ -13,7 +14,9 @@ from edge_containers_cli.definitions import ECContext from edge_containers_cli.globals import TIME_FORMAT -DELAY = 0.0 +DELAY = 2.0 +NUM_SERVICES = 8 +seed(237) def process_t(time_string) -> str: @@ -22,13 +25,12 @@ def process_t(time_string) -> str: sample_data = { - "name": ["demo-ea-01", "demo-ea-02", "demo-ea-03"], # type: ignore - "version": ["2024.10.1", "2024.10.1b", "2024.10.1"], - "ready": [True, True, False], + "name": [f"demo-ea-0{cnt}" for cnt in range(NUM_SERVICES)], + "version": ["1.0." + str(25 - cnt) for cnt in range(NUM_SERVICES)], + "ready": [True] * NUM_SERVICES, "deployed": [ - process_t("2024-10-22T11:23:10Z"), - process_t("2024-10-28T14:53:55Z"), - process_t("2024-10-22T12:51:50Z"), + process_t(f"2024-10-22T11:23:0{randrange(1,9, )}Z") + for cnt in range(NUM_SERVICES) ], } sample_ServicesDataFrame = ServicesDataFrame(polars.from_dict(sample_data)) @@ -69,6 +71,11 @@ def __init__( self._target_valid = False self._stateDF = sample_ServicesDataFrame + self.lorem_min = 10 + self.lorem_max = 50 + self.lorem_step = 5 + self.lorem_count = self.lorem_min + @demo_message def logs(self, service_name, prev): self._logs(service_name, prev) @@ -116,7 +123,11 @@ def _stop(self, service_name, commit=False): def _get_logs(self, service_name, prev) -> str: self._check_service(service_name) - logs_list = ["Lorem ipsum dolor sit amet"] * 25 + if self.lorem_count < self.lorem_max: + self.lorem_count += self.lorem_step + else: + self.lorem_count = self.lorem_min + logs_list = ["Lorem ipsum dolor sit amet"] * self.lorem_count return "\n".join(logs_list) def _get_services(self, running_only) -> ServicesDataFrame: diff --git a/src/edge_containers_cli/cmds/monitor.py b/src/edge_containers_cli/cmds/monitor.py index e0bb01a2..370a63c5 100755 --- a/src/edge_containers_cli/cmds/monitor.py +++ b/src/edge_containers_cli/cmds/monitor.py @@ -1,17 +1,16 @@ """TUI monitor for containerised IOCs.""" +import asyncio import logging from collections.abc import Callable from functools import total_ordering -from threading import Thread -from time import sleep from typing import Any, cast import polars from rich.style import Style from rich.syntax import Syntax from rich.text import Text -from textual import on, work +from textual import on from textual.app import App, ComposeResult from textual.binding import Binding from textual.color import Color @@ -32,7 +31,7 @@ from textual.widgets.data_table import RowKey from edge_containers_cli.cmds.commands import Commands -from edge_containers_cli.definitions import ECLogLevels +from edge_containers_cli.definitions import ECLogLevels, emoji from edge_containers_cli.logging import log @@ -78,10 +77,11 @@ class LogsScreen(ModalScreen, inherit_bindings=False): Binding("down,s,j", "scroll_down", "Scroll Down", show=False), Binding("left,h", "scroll_left", "Scroll Left", show=False), Binding("right,l", "scroll_right", "Scroll Right", show=False), - Binding("home,G", "scroll_home", "Scroll Home", show=False), - Binding("end,g", "scroll_end", "Scroll End", show=False), + Binding("home,G", "scroll_home", "Scroll Home", show=True, key_display="Home"), + Binding("end,g", "scroll_end", "Scroll End", show=True, key_display="End"), Binding("pageup,b", "page_up", "Page Up", show=False), Binding("pagedown,space", "page_down", "Page Down", show=False), + Binding("f", "follow_logs", "Follow Logs", show=True), ] def __init__(self, fetch_log: Callable, service_name) -> None: @@ -89,30 +89,61 @@ def __init__(self, fetch_log: Callable, service_name) -> None: self.fetch_log = fetch_log self.service_name = service_name - self.log_text = "" + self.auto_scroll = False + self.log_text = self.fetch_log(self.service_name, prev=False) + self._polling = True def compose(self) -> ComposeResult: + yield Header(show_clock=True) yield RichLog(highlight=True, id="log") yield Footer() def on_mount(self) -> None: + self.title = f"{self.service_name} logs" log = self.query_one(RichLog) - log.loading = True - self.load_logs(log) - - @work - async def load_logs(self, log: RichLog) -> None: - self.log_text: str = self.fetch_log(self.service_name, prev=False) - log.loading = False - width = max(len(line) for line in self.log_text.split("\n")) log.write( Syntax(self.log_text, "bash", line_numbers=True), - width=width + 10, + width=80, expand=True, shrink=False, scroll_end=True, ) - log.focus() + self._polling_task = asyncio.create_task(self._poll_logs()) + + def on_unmount(self) -> None: + """Executes when the app is closed.""" + self.stop() + + def stop(self): + self._polling = False + + async def _poll_logs(self): + while self._polling: + self.log_text = await asyncio.to_thread( + self.fetch_log, + self.service_name, + **{"prev": False}, + ) + await asyncio.sleep(1.0) + self.update_logs() + + def update_logs(self): + log = self.query_one(RichLog) + curr_x = log.scroll_x + curr_y = log.scroll_y + log.clear() + log.write( + Syntax(self.log_text, "bash", line_numbers=True), + width=80, + expand=True, + shrink=False, + scroll_end=False, + ) + if self.auto_scroll: + log.scroll_end(animate=False) + else: + log.scroll_x = curr_x + log.scroll_y = curr_y def action_close_screen(self) -> None: self.app.pop_screen() @@ -141,6 +172,12 @@ def action_page_up(self) -> None: log = self.query_one(RichLog) log.action_page_up() + def action_follow_logs(self) -> None: + log = self.query_one(RichLog) + self.auto_scroll = not self.auto_scroll + if self.auto_scroll: + log.scroll_end(animate=False) + @total_ordering class SortableText(Text): @@ -210,30 +247,57 @@ class IocTable(Widget): def __init__(self, commands, running_only: bool) -> None: super().__init__() + self.refresh_rate = 10 self.commands = commands self.running_only = running_only - self.iocs_df = self.commands._get_services(self.running_only) # noqa: SLF001 + self._indicator_lock = asyncio.Lock() + self._service_indicators = { + "name": [""], + emoji.exclaim: [""], + } + self.iocs_df = self._get_services_df(running_only) self._polling = True - self._poll_thread = Thread(target=self._poll_services) - self._poll_thread.start() self._get_iocs() + self._polling_task = asyncio.create_task( + self._poll_services() + ) # https://github.com/Textualize/textual/discussions/1828 + + def _get_services_df(self, running_only): + services_df = self.commands._get_services(running_only) # noqa: SLF001 + services_df = services_df.with_columns( + polars.when(polars.col("ready")) + .then(polars.lit(emoji.check_mark)) + .otherwise(polars.lit(emoji.cross_mark)) + .alias("ready") + ) + indicators_df = polars.DataFrame(self._service_indicators) + result = services_df.join(indicators_df, on="name", how="left").fill_null("") + return result - def _poll_services(self): + async def _poll_services(self): while self._polling: - # ioc list data table update loop - print() - self.iocs_df = self.commands._get_services(self.running_only) # noqa: SLF001 - sleep(1.0) + self.iocs_df = await asyncio.to_thread( + self._get_services_df, # noqa: SLF001 + self.running_only, + ) + await asyncio.sleep(1.0) + + async def update_indicators(self, name: str, indicator: str): + """Update indicators in a concurrecy-safe manner""" + async with self._indicator_lock: + if name in self._service_indicators["name"]: + index = self._service_indicators["name"].index(name) + self._service_indicators[emoji.exclaim][index] = indicator + else: + self._service_indicators["name"].append(name) + self._service_indicators[emoji.exclaim].append(indicator) def stop(self): self._polling = False - self._poll_thread.join() def _get_iocs(self) -> None: iocs = self._convert_df_to_list(self.iocs_df) - # give up the GIL to other threads - sleep(0) self.iocs = sorted(iocs, key=lambda d: d["name"]) exclude = [None] @@ -252,7 +316,7 @@ def _convert_df_to_list(self, iocs_df: polars.DataFrame | list) -> list[dict]: def on_mount(self) -> None: """Provides a loop after generating the app for updating the data.""" - self.set_interval(1.0, self.update_iocs) + self.set_interval(1 / self.refresh_rate, self.update_iocs) async def update_iocs(self) -> None: """Updates the IOC stats data.""" @@ -276,7 +340,10 @@ def _get_heading(self, column_id: str): def compose(self) -> ComposeResult: table: DataTable[Text] = DataTable( - id="body_table", header_height=1, show_cursor=False, zebra_stripes=True + id="body_table", + header_height=1, + show_cursor=False, + zebra_stripes=True, ) table.focus() @@ -331,7 +398,10 @@ async def populate_table(self) -> None: { "col_key": key, "contents": SortableText( - ioc[key], str(ioc[key]), self._get_color(str(ioc[key])) + ioc[key], + str(ioc[key]), + self._get_color(str(ioc[key])), + justify="center", ), } for key in self.columns @@ -406,6 +476,7 @@ def __init__( self.commands = commands self.running_only = running_only self.beamline = commands.target + self.busy_services = {} def compose(self) -> ComposeResult: """Create child widgets for the app.""" @@ -455,49 +526,56 @@ def _get_service_name(self) -> str: service_name = self._get_highlighted_cell("name") return service_name - def action_start_ioc(self) -> None: - """Start the IOC that is currently highlighted.""" + def _do_confirmed_action(self, action: str, command: Callable): service_name = self._get_service_name() + table = self.query_one(IocTable) + + async def for_task(command, service_name): + """Called to start asyncio to_thread task.""" + await table.update_indicators(service_name, emoji.road_works) + await asyncio.to_thread(command, service_name) + del self.busy_services[service_name] + await table.update_indicators(service_name, emoji.none) - def check_start(start: bool | None) -> None: - """Called when StartScreen is dismissed.""" + def after_dismiss_callback(start: bool | None) -> None: + """Called when ConfirmScreen is dismissed.""" if start: - self.commands.start(service_name, commit=False) + if service_name in self.busy_services: + log.info(f"Skipped {action}: {service_name} busy") + return None + else: + task = asyncio.create_task( + for_task(command, service_name), + name=service_name, + ) + self.busy_services[service_name] = task + + self.push_screen(ConfirmScreen(service_name, action), after_dismiss_callback) - self.push_screen(ConfirmScreen(service_name, "start"), check_start) + def action_start_ioc(self) -> None: + """Start the IOC that is currently highlighted.""" + self._do_confirmed_action("start", self.commands.start) def action_stop_ioc(self) -> None: """Stop the IOC that is currently highlighted.""" - service_name = self._get_service_name() - - def check_stop(stop: bool | None) -> None: - """Called when StopScreen is dismissed.""" - if stop: - self.commands.stop(service_name, commit=False) - - self.push_screen(ConfirmScreen(service_name, "stop"), check_stop) + self._do_confirmed_action("stop", self.commands.stop) def action_restart_ioc(self) -> None: """Restart the IOC that is currently highlighted.""" - service_name = self._get_service_name() - - def check_restart(restart: bool | None) -> None: - """Called when RestartScreen is dismissed.""" - if restart: - self.commands.restart(service_name) - - self.push_screen(ConfirmScreen(service_name, "restart"), check_restart) + self._do_confirmed_action("restart", self.commands.restart) def action_ioc_logs(self) -> None: """Display the logs of the IOC that is currently highlighted.""" service_name = self._get_service_name() # Convert to corresponding bool - ready = self._get_highlighted_cell("ready") == "True" + ready = self._get_highlighted_cell("ready") == emoji.check_mark if ready: command = self.commands._get_logs # noqa: SLF001 self.push_screen(LogsScreen(command, service_name)) + else: + log.info(f"Ignore request for logs - {service_name} not ready") def action_sort(self, col_name: str = "") -> None: """An action to sort the table rows by column heading.""" @@ -523,4 +601,5 @@ def action_monitor_logs(self) -> None: def update_sort_key(self, col_name: str) -> None: """Method called to update the table sort key attribute.""" table = self.query_one(IocTable) + log.info(f"New sort key '{col_name}'") table.sort_column_id = col_name diff --git a/src/edge_containers_cli/definitions.py b/src/edge_containers_cli/definitions.py index 32ae612e..7501b4eb 100644 --- a/src/edge_containers_cli/definitions.py +++ b/src/edge_containers_cli/definitions.py @@ -32,3 +32,11 @@ class ECContext: repo: str = "" target: str = "" log_url: str = "" + + +class emoji(str, Enum): + none = "" + road_works = "\U0001f6a7" + exclaim = "\U00002755" + check_mark = "\U00002705" + cross_mark = "\U0000274c" diff --git a/tests/test_demo.py b/tests/test_demo.py index e11dabb9..830cae2c 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -20,11 +20,16 @@ def test_stop(mock_run, DEMO): def test_ps(mock_run, DEMO): expect = ( - "| name | version | ready | deployed |\n" - "|------------|------------|-------|----------------------|\n" - "| demo-ea-01 | 2024.10.1 | true | 2024-10-22T11:23:10Z |\n" - "| demo-ea-02 | 2024.10.1b | true | 2024-10-28T14:53:55Z |\n" - "| demo-ea-03 | 2024.10.1 | false | 2024-10-22T12:51:50Z |\n" + "| name | version | ready | deployed |\n" + "|------------|---------|-------|----------------------|\n" + "| demo-ea-00 | 1.0.25 | true | 2024-10-22T11:23:08Z |\n" + "| demo-ea-01 | 1.0.24 | true | 2024-10-22T11:23:03Z |\n" + "| demo-ea-02 | 1.0.23 | true | 2024-10-22T11:23:04Z |\n" + "| demo-ea-03 | 1.0.22 | true | 2024-10-22T11:23:07Z |\n" + "| demo-ea-04 | 1.0.21 | true | 2024-10-22T11:23:01Z |\n" + "| demo-ea-05 | 1.0.20 | true | 2024-10-22T11:23:03Z |\n" + "| demo-ea-06 | 1.0.19 | true | 2024-10-22T11:23:07Z |\n" + "| demo-ea-07 | 1.0.18 | true | 2024-10-22T11:23:01Z |\n" ) res = mock_run.run_cli("ps")