From d482f0a2b7fcbcdf98c6b4d2083810cb821c41b0 Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Wed, 1 Oct 2025 10:19:05 +0300 Subject: [PATCH 01/11] implement filtering --- src/lazy_ecs/core/utils.py | 70 +++++++- src/lazy_ecs/features/container/container.py | 15 ++ src/lazy_ecs/features/container/ui.py | 175 ++++++++++++++++--- tests/test_container_ui.py | 144 +++++++++------ 4 files changed, 325 insertions(+), 79 deletions(-) diff --git a/src/lazy_ecs/core/utils.py b/src/lazy_ecs/core/utils.py index 9194600..e50ab92 100644 --- a/src/lazy_ecs/core/utils.py +++ b/src/lazy_ecs/core/utils.py @@ -2,13 +2,38 @@ from __future__ import annotations +import atexit +import select +import sys +import threading from collections.abc import Iterator -from contextlib import contextmanager +from contextlib import contextmanager, suppress from typing import TYPE_CHECKING, Literal from rich.console import Console from rich.spinner import Spinner +# Try to import Unix-specific terminal control modules +try: + import termios + import tty + + HAS_TERMIOS = True + # Store original terminal settings globally + _original_terminal_settings = None + if sys.stdin.isatty(): + _original_terminal_settings = termios.tcgetattr(sys.stdin.fileno()) + + # Register cleanup on exit + def restore_terminal() -> None: + if _original_terminal_settings and sys.stdin.isatty(): + with suppress(Exception): + termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _original_terminal_settings) + + atexit.register(restore_terminal) +except ImportError: + HAS_TERMIOS = False + if TYPE_CHECKING: from mypy_boto3_ecs.client import ECSClient @@ -79,3 +104,46 @@ def paginate_aws_list( results.extend(page.get(result_key, [])) return results + + +def wait_for_keypress(stop_event: threading.Event) -> str | None: + """Wait for a single keypress in a non-blocking manner. + + Returns the key pressed, or None if stop_event is set. + This runs in a separate thread to allow checking for keypresses without blocking. + """ + if HAS_TERMIOS and sys.stdin.isatty(): + # Unix/Linux/macOS with terminal support + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + # Set terminal to cbreak mode for single-character input + tty.setcbreak(fd) + + # Check for input with timeout + while not stop_event.is_set(): + # Use select to check if input is available (0.01 second timeout for responsiveness) + if select.select([sys.stdin], [], [], 0.01)[0]: + char = sys.stdin.read(1) + # Handle Ctrl-C properly + if char == "\x03": # Ctrl-C + raise KeyboardInterrupt() + return char + + return None + except (Exception, KeyboardInterrupt): + # Restore settings before re-raising + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + if isinstance(sys.exc_info()[1], KeyboardInterrupt): + raise + return None + finally: + # Always restore terminal settings + with suppress(Exception): + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + else: + # Fallback for Windows or when termios is not available + try: + return sys.stdin.read(1) + except Exception: + return None diff --git a/src/lazy_ecs/features/container/container.py b/src/lazy_ecs/features/container/container.py index 7dfb82d..e06509d 100644 --- a/src/lazy_ecs/features/container/container.py +++ b/src/lazy_ecs/features/container/container.py @@ -15,6 +15,7 @@ from mypy_boto3_ecs.type_defs import ContainerDefinitionOutputTypeDef, TaskDefinitionTypeDef from mypy_boto3_logs.client import CloudWatchLogsClient from mypy_boto3_logs.type_defs import ( + FilteredLogEventTypeDef, LiveTailSessionLogEventTypeDef, OutputLogEventTypeDef, StartLiveTailResponseStreamTypeDef, @@ -106,6 +107,20 @@ def get_container_logs(self, log_group: str, log_stream: str, lines: int = 50) - ) return response.get("events", []) + def get_container_logs_filtered( + self, log_group: str, log_stream: str, filter_pattern: str, lines: int = 50 + ) -> list[FilteredLogEventTypeDef]: + """Get container logs with CloudWatch filter pattern applied.""" + if not self.logs_client: + return [] + response = self.logs_client.filter_log_events( + logGroupName=log_group, + logStreamNames=[log_stream], + filterPattern=filter_pattern, + limit=lines, + ) + return response.get("events", []) + def get_live_container_logs_tail( self, log_group: str, log_stream: str, event_filter_pattern: str = "" ) -> Generator[StartLiveTailResponseStreamTypeDef | LiveTailSessionLogEventTypeDef]: diff --git a/src/lazy_ecs/features/container/ui.py b/src/lazy_ecs/features/container/ui.py index 16d52ef..32bb795 100644 --- a/src/lazy_ecs/features/container/ui.py +++ b/src/lazy_ecs/features/container/ui.py @@ -2,13 +2,16 @@ from __future__ import annotations +import queue +import threading +import time from datetime import datetime -from typing import cast +from typing import Any, cast from rich.console import Console from ...core.base import BaseUIComponent -from ...core.utils import print_error, show_spinner +from ...core.utils import print_error, show_spinner, wait_for_keypress from .container import ContainerService console = Console() @@ -22,7 +25,7 @@ def __init__(self, container_service: ContainerService) -> None: self.container_service = container_service def show_logs_live_tail(self, cluster_name: str, task_arn: str, container_name: str, lines: int = 50) -> None: - """Display recent logs then continue streaming in real time for a container.""" + """Display recent logs then continue streaming in real time for a container with interactive filtering.""" log_config = self.container_service.get_log_config(cluster_name, task_arn, container_name) if not log_config: print_error(f"Could not find log configuration for container '{container_name}'") @@ -35,8 +38,61 @@ def show_logs_live_tail(self, cluster_name: str, task_arn: str, container_name: log_group_name = log_config.get("log_group") log_stream_name = log_config.get("log_stream") - # First, fetch and display recent logs - events = self.container_service.get_container_logs(log_group_name, log_stream_name, lines) + filter_pattern = "" + while True: + action = self._display_logs_with_tail( + container_name, log_group_name, log_stream_name, filter_pattern, lines + ) + + if action == "s": + console.print("\nStopped tailing logs.", style="yellow") + break + if action == "f": + console.print("\n" + "=" * 80, style="dim") + console.print("šŸ” FILTER MODE - Enter CloudWatch filter pattern", style="bold cyan") + console.print("Examples:", style="dim") + console.print(" ERROR - Include only ERROR", style="dim") + console.print(" -healthcheck - Exclude healthcheck", style="dim") + console.print(" ERROR -healthcheck - Include ERROR, exclude healthcheck", style="dim") + new_filter = console.input("Filter pattern → ").strip() + if new_filter: + filter_pattern = new_filter + console.print(f"āœ“ Filter applied: {filter_pattern}", style="green") + elif action == "x": + console.print("\n" + "=" * 80, style="dim") + console.print("šŸ“ EXCLUDE MODE - Type the text you want to filter out", style="bold yellow") + console.print("Examples: 'healthcheck', 'session', 'INFO'", style="dim") + exclude_text = console.input("Exclude pattern → ").strip() + if exclude_text: + filter_pattern = f"-{exclude_text}" + console.print(f"āœ“ Excluding: {exclude_text}", style="green") + elif action == "c": + filter_pattern = "" + console.print("\nāœ“ Filter cleared", style="green") + + def _display_logs_with_tail( + self, + container_name: str, + log_group_name: str, + log_stream_name: str, + filter_pattern: str, + lines: int, + ) -> str: + """Display historical logs then tail new logs with optional filtering. + + Returns the action key pressed by the user (s=stop, f=filter, x=exclude, c=clear). + """ + # Show filter status + if filter_pattern: + console.print(f"\nšŸ” Active filter: {filter_pattern}", style="yellow") + + # Fetch and display recent logs + if filter_pattern: + events = self.container_service.get_container_logs_filtered( + log_group_name, log_stream_name, filter_pattern, lines + ) + else: + events = self.container_service.get_container_logs(log_group_name, log_stream_name, lines) console.print(f"\nLast {len(events)} log entries for container '{container_name}':", style="bold cyan") console.print(f"Log group: {log_group_name}", style="dim") @@ -49,33 +105,104 @@ def show_logs_live_tail(self, cluster_name: str, task_arn: str, container_name: message = event["message"].rstrip() dt = datetime.fromtimestamp(timestamp / 1000) console.print(f"[{dt.strftime('%H:%M:%S')}] {message}") - # Track these to avoid duplicates when tailing event_id = event.get("eventId") key = event_id or (timestamp, message) seen_logs.add(key) - # Then continue with live tail - console.print("\nNow tailing new logs (Press Ctrl+C to stop)...", style="bold cyan") + # Tail new logs with keyboard commands + console.print("\nTailing logs... Press: (s)top (f)ilter e(x)clude (c)lear filter", style="bold cyan") console.print("=" * 80, style="dim") + stop_event = threading.Event() + key_queue: queue.Queue[str | None] = queue.Queue() + log_queue: queue.Queue[dict[str, Any] | None] = queue.Queue() + + def keyboard_listener() -> None: + while not stop_event.is_set(): + try: + key = wait_for_keypress(stop_event) + if key: + key_queue.put(key) + except KeyboardInterrupt: + key_queue.put(None) # Signal interrupt + raise + + def log_reader() -> None: + """Read logs in separate thread to avoid blocking.""" + try: + for event in self.container_service.get_live_container_logs_tail( + log_group_name, log_stream_name, filter_pattern + ): + if stop_event.is_set(): + break + log_queue.put(cast(dict[str, Any], event)) + except Exception: + pass # Iterator exhausted or error + finally: + log_queue.put(None) # Signal end of logs + + keyboard_thread = threading.Thread(target=keyboard_listener, daemon=True) + keyboard_thread.start() + + log_thread = threading.Thread(target=log_reader, daemon=True) + log_thread.start() + + action_key = "" + try: - for event in self.container_service.get_live_container_logs_tail(log_group_name, log_stream_name): - event_map = cast(dict, event) - event_id = event_map.get("eventId") - key = event_id or (event_map.get("timestamp"), event_map.get("message")) - if key in seen_logs: - continue - seen_logs.add(key) - timestamp = event_map.get("timestamp") - message = str(event_map.get("message")).rstrip() - if timestamp: - dt = datetime.fromtimestamp(int(timestamp) / 1000) - console.print(f"[{dt.strftime('%H:%M:%S')}] {message}") - else: - console.print(message) + while True: + # Check for keyboard input first (more responsive) + try: + key = key_queue.get_nowait() + if key in ("s", "f", "x", "c"): + action_key = key + stop_event.set() + # Clear any extra keys that were pressed + while not key_queue.empty(): + try: + key_queue.get_nowait() + except queue.Empty: + break + # Give immediate feedback + if action_key == "f": + console.print("\n[Entering filter mode...]", style="cyan") + elif action_key == "x": + console.print("\n[Entering exclude mode...]", style="yellow") + elif action_key == "c": + console.print("\n[Clearing filter...]", style="green") + break + except queue.Empty: + pass + + # Check for new log events (non-blocking) + try: + event = log_queue.get_nowait() + if event is None: + # End of logs signal + pass + else: + event_map = event + event_id = event_map.get("eventId") + key_tuple = event_id or (event_map.get("timestamp"), event_map.get("message")) + if key_tuple not in seen_logs: + seen_logs.add(key_tuple) + timestamp = event_map.get("timestamp") + message = str(event_map.get("message")).rstrip() + if timestamp: + dt = datetime.fromtimestamp(int(timestamp) / 1000) + console.print(f"[{dt.strftime('%H:%M:%S')}] {message}") + else: + console.print(message) + except queue.Empty: + # No new logs, just wait a bit + time.sleep(0.01) # Very small delay to avoid busy-waiting except KeyboardInterrupt: - console.print("\nšŸ›‘ Stopped tailing logs.", style="yellow") - console.print("=" * 80, style="dim") + console.print("\nšŸ›‘ Interrupted.", style="yellow") + action_key = "s" + finally: + stop_event.set() + + return action_key def show_container_environment_variables(self, cluster_name: str, task_arn: str, container_name: str) -> None: with show_spinner(): diff --git a/tests/test_container_ui.py b/tests/test_container_ui.py index 7354cca..56a892c 100644 --- a/tests/test_container_ui.py +++ b/tests/test_container_ui.py @@ -1,9 +1,7 @@ """Tests for ContainerUI class.""" -from collections.abc import Generator -from datetime import datetime -from typing import Any, cast -from unittest.mock import Mock, call, patch +import queue +from unittest.mock import Mock, patch import pytest @@ -27,78 +25,98 @@ def container_ui(mock_ecs_client, mock_task_service): return ContainerUI(container_service) -def test_show_logs_live_tail_success(container_ui): - """Test displaying recent logs then streaming live tail container logs successfully.""" +def test_show_logs_live_tail_with_stop(container_ui): + """Test displaying logs and stopping immediately.""" log_config = {"log_group": "test-log-group", "log_stream": "test-stream"} recent_events = [ {"timestamp": 1234567888000, "message": "Recent log message 1"}, - {"timestamp": 1234567889000, "message": "Recent log message 2"}, ] + live_events = [ - {"eventId": "event1", "timestamp": 1234567890000, "message": "Live tail log message 1"}, - {"eventId": "event2", "timestamp": 1234567891000, "message": "Live tail log message 2"}, + {"eventId": "event1", "timestamp": 1234567890000, "message": "Live message"}, ] container_ui.container_service.get_log_config = Mock(return_value=log_config) container_ui.container_service.get_container_logs = Mock(return_value=recent_events) container_ui.container_service.get_live_container_logs_tail = Mock(return_value=iter(live_events)) - with patch("rich.console.Console.print") as mock_console_print: - container_ui.show_logs_live_tail("test-cluster", "task-arn", "web-container") + # Mock the queues to immediately provide 's' key + with ( + patch("lazy_ecs.features.container.ui.queue.Queue") as mock_queue_class, + patch("lazy_ecs.features.container.ui.threading.Thread"), + patch("rich.console.Console.print"), + ): + key_queue = Mock() + log_queue = Mock() - container_ui.container_service.get_log_config.assert_called_once_with("test-cluster", "task-arn", "web-container") - container_ui.container_service.get_container_logs.assert_called_once_with("test-log-group", "test-stream", 50) - container_ui.container_service.get_live_container_logs_tail.assert_called_once_with("test-log-group", "test-stream") - - expected_calls = [ - call("\nLast 2 log entries for container 'web-container':", style="bold cyan"), - call("Log group: test-log-group", style="dim"), - call("Log stream: test-stream", style="dim"), - call("=" * 80, style="dim"), - ] + # Key queue: First call returns False (has key), subsequent calls return True (empty) + key_queue.empty.side_effect = [False, True, True, True, True] + key_queue.get_nowait.return_value = "s" - for event in recent_events: - timestamp = cast(int, event["timestamp"]) - dt = datetime.fromtimestamp(timestamp / 1000) - message = cast(str, event["message"]).rstrip() - expected_calls.append(call(f"[{dt.strftime('%H:%M:%S')}] {message}")) + # Log queue: Always empty for this test (we just want to stop immediately) + log_queue.get_nowait.side_effect = queue.Empty() - expected_calls.extend( - [ - call("\nNow tailing new logs (Press Ctrl+C to stop)...", style="bold cyan"), - call("=" * 80, style="dim"), - ] - ) + # Return different queue instances for key_queue and log_queue + mock_queue_class.side_effect = [key_queue, log_queue] - for event in live_events: - timestamp = cast(int, event["timestamp"]) - dt = datetime.fromtimestamp(timestamp / 1000) - message = cast(str, event["message"]).rstrip() - expected_calls.append(call(f"[{dt.strftime('%H:%M:%S')}] {message}")) + container_ui.show_logs_live_tail("test-cluster", "task-arn", "web-container") - expected_calls.append(call("=" * 80, style="dim")) - mock_console_print.assert_has_calls(expected_calls, any_order=False) + container_ui.container_service.get_log_config.assert_called_once_with("test-cluster", "task-arn", "web-container") -def test_show_logs_live_tail_keyboard_interrupt(container_ui): - """Test handling keyboard interruptions with Ctrl+C during live logs tail.""" +def test_show_logs_live_tail_with_exclude(container_ui): + """Test excluding patterns during log tailing.""" log_config = {"log_group": "test-log-group", "log_stream": "test-stream"} recent_events = [ - {"timestamp": 1234567888000, "message": "Recent log message 1"}, + {"timestamp": 1234567888000, "message": "Normal message"}, + ] + filtered_events = [ + {"timestamp": 1234567889000, "message": "Another message"}, + ] + live_events_first = [ + {"eventId": "event1", "timestamp": 1234567890000, "message": "Live message"}, + ] + live_events_second = [ + {"eventId": "event2", "timestamp": 1234567891000, "message": "Second live message"}, ] - - def mock_generator() -> Generator[dict[str, Any], None, None]: - yield {"eventId": "event1", "timestamp": 1234567890000, "message": "Live tail log message 1"} - raise KeyboardInterrupt() container_ui.container_service.get_log_config = Mock(return_value=log_config) container_ui.container_service.get_container_logs = Mock(return_value=recent_events) - container_ui.container_service.get_live_container_logs_tail = Mock(return_value=mock_generator()) + container_ui.container_service.get_container_logs_filtered = Mock(return_value=filtered_events) + # Return different iterators for each call + container_ui.container_service.get_live_container_logs_tail = Mock( + side_effect=[iter(live_events_first), iter(live_events_second)] + ) + + # Mock the queues to simulate pressing 'x' then 's' keys + with ( + patch("rich.console.Console.input") as mock_input, + patch("lazy_ecs.features.container.ui.queue.Queue") as mock_queue_class, + patch("lazy_ecs.features.container.ui.threading.Thread"), + patch("rich.console.Console.print"), + ): + mock_input.return_value = "healthcheck" + + key_queue = Mock() + log_queue = Mock() + + # Key queue: First iteration return 'x', clear queue, then later return 's' + key_queue.empty.side_effect = [False, True, True, True, False, True, True, True] + key_queue.get_nowait.side_effect = ["x", queue.Empty(), "s"] + + # Log queue: Always empty for simplicity + log_queue.get_nowait.side_effect = queue.Empty() + + # Return queue instances: First call for key_queue, second for log_queue + # Then again for the second iteration after 'x' is pressed + mock_queue_class.side_effect = [key_queue, log_queue, key_queue, log_queue] - with patch("rich.console.Console.print") as mock_console_print: container_ui.show_logs_live_tail("test-cluster", "task-arn", "web-container") - mock_console_print.assert_any_call("\nšŸ›‘ Stopped tailing logs.", style="yellow") + # Should be called with -healthcheck filter pattern + container_ui.container_service.get_container_logs_filtered.assert_called_once_with( + "test-log-group", "test-stream", "-healthcheck", 50 + ) def test_show_logs_live_tail_no_config(container_ui): @@ -106,14 +124,32 @@ def test_show_logs_live_tail_no_config(container_ui): container_ui.container_service.get_log_config = Mock(return_value=None) container_ui.container_service.list_log_groups = Mock(return_value=["group1", "group2"]) - with ( - patch("lazy_ecs.features.container.ui.print_error") as mock_print_error, - patch("rich.console.Console.print") as mock_console_print, - ): + with patch("lazy_ecs.features.container.ui.print_error") as mock_print_error: container_ui.show_logs_live_tail("test-cluster", "task-arn", "web-container") mock_print_error.assert_called_once_with("Could not find log configuration for container 'web-container'") - mock_console_print.assert_any_call("Available log groups:", style="dim") + + +def test_get_container_logs_filtered(mock_ecs_client, mock_task_service): + """Test filtering container logs with CloudWatch pattern.""" + mock_logs_client = Mock() + mock_logs_client.filter_log_events.return_value = { + "events": [ + {"timestamp": 1234567890000, "message": "ERROR: Something failed"}, + ] + } + + container_service = ContainerService(mock_ecs_client, mock_task_service, None, mock_logs_client) + events = container_service.get_container_logs_filtered("test-log-group", "test-stream", "ERROR", 50) + + assert len(events) == 1 + assert "ERROR" in events[0]["message"] + mock_logs_client.filter_log_events.assert_called_once_with( + logGroupName="test-log-group", + logStreamNames=["test-stream"], + filterPattern="ERROR", + limit=50, + ) def test_show_container_environment_variables_success(container_ui): From ff9b499ce07f4c42926bbda82a1432786b13e0fa Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Wed, 1 Oct 2025 10:28:28 +0300 Subject: [PATCH 02/11] just use filter as it already supports exclusions --- src/lazy_ecs/features/container/ui.py | 19 +++++-------------- tests/test_container_ui.py | 14 +++++++------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/lazy_ecs/features/container/ui.py b/src/lazy_ecs/features/container/ui.py index 32bb795..8443a7a 100644 --- a/src/lazy_ecs/features/container/ui.py +++ b/src/lazy_ecs/features/container/ui.py @@ -51,21 +51,14 @@ def show_logs_live_tail(self, cluster_name: str, task_arn: str, container_name: console.print("\n" + "=" * 80, style="dim") console.print("šŸ” FILTER MODE - Enter CloudWatch filter pattern", style="bold cyan") console.print("Examples:", style="dim") - console.print(" ERROR - Include only ERROR", style="dim") - console.print(" -healthcheck - Exclude healthcheck", style="dim") + console.print(" ERROR - Include only ERROR messages", style="dim") + console.print(" -healthcheck - Exclude healthcheck messages", style="dim") + console.print(" -session -determine - Exclude both session and determine", style="dim") console.print(" ERROR -healthcheck - Include ERROR, exclude healthcheck", style="dim") new_filter = console.input("Filter pattern → ").strip() if new_filter: filter_pattern = new_filter console.print(f"āœ“ Filter applied: {filter_pattern}", style="green") - elif action == "x": - console.print("\n" + "=" * 80, style="dim") - console.print("šŸ“ EXCLUDE MODE - Type the text you want to filter out", style="bold yellow") - console.print("Examples: 'healthcheck', 'session', 'INFO'", style="dim") - exclude_text = console.input("Exclude pattern → ").strip() - if exclude_text: - filter_pattern = f"-{exclude_text}" - console.print(f"āœ“ Excluding: {exclude_text}", style="green") elif action == "c": filter_pattern = "" console.print("\nāœ“ Filter cleared", style="green") @@ -110,7 +103,7 @@ def _display_logs_with_tail( seen_logs.add(key) # Tail new logs with keyboard commands - console.print("\nTailing logs... Press: (s)top (f)ilter e(x)clude (c)lear filter", style="bold cyan") + console.print("\nTailing logs... Press: (s)top (f)ilter (c)lear filter", style="bold cyan") console.print("=" * 80, style="dim") stop_event = threading.Event() @@ -154,7 +147,7 @@ def log_reader() -> None: # Check for keyboard input first (more responsive) try: key = key_queue.get_nowait() - if key in ("s", "f", "x", "c"): + if key in ("s", "f", "c"): action_key = key stop_event.set() # Clear any extra keys that were pressed @@ -166,8 +159,6 @@ def log_reader() -> None: # Give immediate feedback if action_key == "f": console.print("\n[Entering filter mode...]", style="cyan") - elif action_key == "x": - console.print("\n[Entering exclude mode...]", style="yellow") elif action_key == "c": console.print("\n[Clearing filter...]", style="green") break diff --git a/tests/test_container_ui.py b/tests/test_container_ui.py index 56a892c..0480dc7 100644 --- a/tests/test_container_ui.py +++ b/tests/test_container_ui.py @@ -64,8 +64,8 @@ def test_show_logs_live_tail_with_stop(container_ui): container_ui.container_service.get_log_config.assert_called_once_with("test-cluster", "task-arn", "web-container") -def test_show_logs_live_tail_with_exclude(container_ui): - """Test excluding patterns during log tailing.""" +def test_show_logs_live_tail_with_filter_exclude(container_ui): + """Test filter with exclude patterns during log tailing.""" log_config = {"log_group": "test-log-group", "log_stream": "test-stream"} recent_events = [ {"timestamp": 1234567888000, "message": "Normal message"}, @@ -88,27 +88,27 @@ def test_show_logs_live_tail_with_exclude(container_ui): side_effect=[iter(live_events_first), iter(live_events_second)] ) - # Mock the queues to simulate pressing 'x' then 's' keys + # Mock the queues to simulate pressing 'f' for filter then 's' to stop with ( patch("rich.console.Console.input") as mock_input, patch("lazy_ecs.features.container.ui.queue.Queue") as mock_queue_class, patch("lazy_ecs.features.container.ui.threading.Thread"), patch("rich.console.Console.print"), ): - mock_input.return_value = "healthcheck" + mock_input.return_value = "-healthcheck" # Exclude pattern key_queue = Mock() log_queue = Mock() - # Key queue: First iteration return 'x', clear queue, then later return 's' + # Key queue: First iteration return 'f', clear queue, then later return 's' key_queue.empty.side_effect = [False, True, True, True, False, True, True, True] - key_queue.get_nowait.side_effect = ["x", queue.Empty(), "s"] + key_queue.get_nowait.side_effect = ["f", queue.Empty(), "s"] # Log queue: Always empty for simplicity log_queue.get_nowait.side_effect = queue.Empty() # Return queue instances: First call for key_queue, second for log_queue - # Then again for the second iteration after 'x' is pressed + # Then again for the second iteration after 'f' is pressed mock_queue_class.side_effect = [key_queue, log_queue, key_queue, log_queue] container_ui.show_logs_live_tail("test-cluster", "task-arn", "web-container") From 8231701a280fa182fff605e436e32f5a6ceb07a3 Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Wed, 1 Oct 2025 10:29:47 +0300 Subject: [PATCH 03/11] update roadmap --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d9f7bf2..5b5812b 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ lazy-ecs will automatically use the standard AWS credentials chain: ### Container-Level Features šŸš€ - āœ… **Container log viewing** - Display recent logs with timestamps from CloudWatch -- āœ… **Container log live tail viewing** - Display logs live tail with timestamps from CloudWatch +- āœ… **Container log live tail viewing** - Real-time log streaming with instant keyboard shortcuts +- āœ… **Log filtering** - CloudWatch filter patterns (include/exclude) during live tail - āœ… **Basic container details** - Show container name, image, CPU/memory configuration - āœ… **Show environment variables & secrets** - Display environment variables and secrets configuration (without exposing secret values) - āœ… **Show port mappings** - Display container port configurations and networking @@ -124,8 +125,8 @@ lazy-ecs will automatically use the standard AWS credentials chain: ### Advanced Features šŸŽÆ - ⬜ **Enhanced log features**: - - ⬜ Search/filter logs by keywords or time range - - āœ… Follow logs in real-time (tail -f style) - complex UI implementation + - āœ… Search/filter logs by keywords (CloudWatch patterns with include/exclude) + - āœ… Follow logs in real-time (tail -f style) with responsive keyboard shortcuts - ⬜ Download logs to file - ⬜ **Monitoring integration**: - ⬜ Show CloudWatch metrics for containers/tasks From 6d75af7594b24cd6f996d1d47d98086e74aa2fe0 Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Wed, 1 Oct 2025 10:42:01 +0300 Subject: [PATCH 04/11] refactor: extract magic numbers and keys as constants in container UI --- src/lazy_ecs/features/container/ui.py | 34 +++++++++++++++++---------- tests/test_container_ui.py | 4 ++-- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/lazy_ecs/features/container/ui.py b/src/lazy_ecs/features/container/ui.py index 8443a7a..152f718 100644 --- a/src/lazy_ecs/features/container/ui.py +++ b/src/lazy_ecs/features/container/ui.py @@ -16,6 +16,14 @@ console = Console() +# Constants +SEPARATOR_WIDTH = 80 +LOG_POLL_INTERVAL = 0.01 # seconds +KEY_STOP = "s" +KEY_FILTER = "f" +KEY_CLEAR = "c" +VALID_ACTION_KEYS = (KEY_STOP, KEY_FILTER, KEY_CLEAR) + class ContainerUI(BaseUIComponent): """UI component for container display.""" @@ -44,11 +52,11 @@ def show_logs_live_tail(self, cluster_name: str, task_arn: str, container_name: container_name, log_group_name, log_stream_name, filter_pattern, lines ) - if action == "s": + if action == KEY_STOP: console.print("\nStopped tailing logs.", style="yellow") break - if action == "f": - console.print("\n" + "=" * 80, style="dim") + if action == KEY_FILTER: + console.print("\n" + "=" * SEPARATOR_WIDTH, style="dim") console.print("šŸ” FILTER MODE - Enter CloudWatch filter pattern", style="bold cyan") console.print("Examples:", style="dim") console.print(" ERROR - Include only ERROR messages", style="dim") @@ -59,7 +67,7 @@ def show_logs_live_tail(self, cluster_name: str, task_arn: str, container_name: if new_filter: filter_pattern = new_filter console.print(f"āœ“ Filter applied: {filter_pattern}", style="green") - elif action == "c": + elif action == KEY_CLEAR: filter_pattern = "" console.print("\nāœ“ Filter cleared", style="green") @@ -73,7 +81,7 @@ def _display_logs_with_tail( ) -> str: """Display historical logs then tail new logs with optional filtering. - Returns the action key pressed by the user (s=stop, f=filter, x=exclude, c=clear). + Returns the action key pressed by the user (s=stop, f=filter, c=clear). """ # Show filter status if filter_pattern: @@ -90,7 +98,7 @@ def _display_logs_with_tail( console.print(f"\nLast {len(events)} log entries for container '{container_name}':", style="bold cyan") console.print(f"Log group: {log_group_name}", style="dim") console.print(f"Log stream: {log_stream_name}", style="dim") - console.print("=" * 80, style="dim") + console.print("=" * SEPARATOR_WIDTH, style="dim") seen_logs = set() for event in events: @@ -104,7 +112,7 @@ def _display_logs_with_tail( # Tail new logs with keyboard commands console.print("\nTailing logs... Press: (s)top (f)ilter (c)lear filter", style="bold cyan") - console.print("=" * 80, style="dim") + console.print("=" * SEPARATOR_WIDTH, style="dim") stop_event = threading.Event() key_queue: queue.Queue[str | None] = queue.Queue() @@ -147,7 +155,7 @@ def log_reader() -> None: # Check for keyboard input first (more responsive) try: key = key_queue.get_nowait() - if key in ("s", "f", "c"): + if key in VALID_ACTION_KEYS: action_key = key stop_event.set() # Clear any extra keys that were pressed @@ -157,9 +165,9 @@ def log_reader() -> None: except queue.Empty: break # Give immediate feedback - if action_key == "f": + if action_key == KEY_FILTER: console.print("\n[Entering filter mode...]", style="cyan") - elif action_key == "c": + elif action_key == KEY_CLEAR: console.print("\n[Clearing filter...]", style="green") break except queue.Empty: @@ -186,14 +194,14 @@ def log_reader() -> None: console.print(message) except queue.Empty: # No new logs, just wait a bit - time.sleep(0.01) # Very small delay to avoid busy-waiting + time.sleep(LOG_POLL_INTERVAL) # Small delay to avoid busy-waiting except KeyboardInterrupt: console.print("\nšŸ›‘ Interrupted.", style="yellow") - action_key = "s" + action_key = KEY_STOP finally: stop_event.set() - return action_key + return action_key # pyrefly: ignore[bad-return] - always assigned def show_container_environment_variables(self, cluster_name: str, task_arn: str, container_name: str) -> None: with show_spinner(): diff --git a/tests/test_container_ui.py b/tests/test_container_ui.py index 0480dc7..c8af413 100644 --- a/tests/test_container_ui.py +++ b/tests/test_container_ui.py @@ -51,7 +51,7 @@ def test_show_logs_live_tail_with_stop(container_ui): # Key queue: First call returns False (has key), subsequent calls return True (empty) key_queue.empty.side_effect = [False, True, True, True, True] - key_queue.get_nowait.return_value = "s" + key_queue.get_nowait.return_value = "s" # KEY_STOP # Log queue: Always empty for this test (we just want to stop immediately) log_queue.get_nowait.side_effect = queue.Empty() @@ -102,7 +102,7 @@ def test_show_logs_live_tail_with_filter_exclude(container_ui): # Key queue: First iteration return 'f', clear queue, then later return 's' key_queue.empty.side_effect = [False, True, True, True, False, True, True, True] - key_queue.get_nowait.side_effect = ["f", queue.Empty(), "s"] + key_queue.get_nowait.side_effect = ["f", queue.Empty(), "s"] # KEY_FILTER then KEY_STOP # Log queue: Always empty for simplicity log_queue.get_nowait.side_effect = queue.Empty() From 0b77ee0f4201ef48083135e4fb3cac46a904c84d Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Wed, 1 Oct 2025 10:48:17 +0300 Subject: [PATCH 05/11] refactor: extract log formatting logic into dedicated method --- src/lazy_ecs/features/container/ui.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/lazy_ecs/features/container/ui.py b/src/lazy_ecs/features/container/ui.py index 152f718..02ec925 100644 --- a/src/lazy_ecs/features/container/ui.py +++ b/src/lazy_ecs/features/container/ui.py @@ -32,6 +32,14 @@ def __init__(self, container_service: ContainerService) -> None: super().__init__() self.container_service = container_service + @staticmethod + def _format_log_entry(timestamp: int | None, message: str) -> str: + """Format a log entry with timestamp.""" + if timestamp: + dt = datetime.fromtimestamp(timestamp / 1000) + return f"[{dt.strftime('%H:%M:%S')}] {message}" + return message + def show_logs_live_tail(self, cluster_name: str, task_arn: str, container_name: str, lines: int = 50) -> None: """Display recent logs then continue streaming in real time for a container with interactive filtering.""" log_config = self.container_service.get_log_config(cluster_name, task_arn, container_name) @@ -104,8 +112,7 @@ def _display_logs_with_tail( for event in events: timestamp = event["timestamp"] message = event["message"].rstrip() - dt = datetime.fromtimestamp(timestamp / 1000) - console.print(f"[{dt.strftime('%H:%M:%S')}] {message}") + console.print(self._format_log_entry(timestamp, message)) event_id = event.get("eventId") key = event_id or (timestamp, message) seen_logs.add(key) @@ -187,11 +194,7 @@ def log_reader() -> None: seen_logs.add(key_tuple) timestamp = event_map.get("timestamp") message = str(event_map.get("message")).rstrip() - if timestamp: - dt = datetime.fromtimestamp(int(timestamp) / 1000) - console.print(f"[{dt.strftime('%H:%M:%S')}] {message}") - else: - console.print(message) + console.print(self._format_log_entry(timestamp, message)) except queue.Empty: # No new logs, just wait a bit time.sleep(LOG_POLL_INTERVAL) # Small delay to avoid busy-waiting From 05eafdd30c8a7bbe4a2fda3733cd852c3b35d6f2 Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Wed, 1 Oct 2025 10:50:28 +0300 Subject: [PATCH 06/11] refactor: add helper method for queue draining --- src/lazy_ecs/features/container/ui.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/lazy_ecs/features/container/ui.py b/src/lazy_ecs/features/container/ui.py index 02ec925..1ad39ec 100644 --- a/src/lazy_ecs/features/container/ui.py +++ b/src/lazy_ecs/features/container/ui.py @@ -40,6 +40,15 @@ def _format_log_entry(timestamp: int | None, message: str) -> str: return f"[{dt.strftime('%H:%M:%S')}] {message}" return message + @staticmethod + def _drain_queue(q: queue.Queue[Any]) -> None: + """Drain all items from a queue without blocking.""" + while not q.empty(): + try: + q.get_nowait() + except queue.Empty: + break + def show_logs_live_tail(self, cluster_name: str, task_arn: str, container_name: str, lines: int = 50) -> None: """Display recent logs then continue streaming in real time for a container with interactive filtering.""" log_config = self.container_service.get_log_config(cluster_name, task_arn, container_name) @@ -165,12 +174,7 @@ def log_reader() -> None: if key in VALID_ACTION_KEYS: action_key = key stop_event.set() - # Clear any extra keys that were pressed - while not key_queue.empty(): - try: - key_queue.get_nowait() - except queue.Empty: - break + self._drain_queue(key_queue) # Give immediate feedback if action_key == KEY_FILTER: console.print("\n[Entering filter mode...]", style="cyan") From 782ddc7075db9b7b7010fdacfb43b1ede74e20de Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Wed, 1 Oct 2025 10:55:32 +0300 Subject: [PATCH 07/11] refactor: use LogEvent dataclass for type safety and cleaner code --- src/lazy_ecs/features/container/ui.py | 59 +++++++++++++++++---------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/src/lazy_ecs/features/container/ui.py b/src/lazy_ecs/features/container/ui.py index 1ad39ec..b931706 100644 --- a/src/lazy_ecs/features/container/ui.py +++ b/src/lazy_ecs/features/container/ui.py @@ -5,6 +5,7 @@ import queue import threading import time +from dataclasses import dataclass from datetime import datetime from typing import Any, cast @@ -25,6 +26,27 @@ VALID_ACTION_KEYS = (KEY_STOP, KEY_FILTER, KEY_CLEAR) +@dataclass +class LogEvent: + """Represents a log event from CloudWatch.""" + + timestamp: int | None + message: str + event_id: str | None = None + + @property + def key(self) -> tuple[Any, ...] | str: + """Get unique key for deduplication.""" + return self.event_id if self.event_id else (self.timestamp, self.message) + + def format(self) -> str: + """Format the log event for display.""" + if self.timestamp: + dt = datetime.fromtimestamp(self.timestamp / 1000) + return f"[{dt.strftime('%H:%M:%S')}] {self.message}" + return self.message + + class ContainerUI(BaseUIComponent): """UI component for container display.""" @@ -32,14 +54,6 @@ def __init__(self, container_service: ContainerService) -> None: super().__init__() self.container_service = container_service - @staticmethod - def _format_log_entry(timestamp: int | None, message: str) -> str: - """Format a log entry with timestamp.""" - if timestamp: - dt = datetime.fromtimestamp(timestamp / 1000) - return f"[{dt.strftime('%H:%M:%S')}] {message}" - return message - @staticmethod def _drain_queue(q: queue.Queue[Any]) -> None: """Drain all items from a queue without blocking.""" @@ -119,12 +133,14 @@ def _display_logs_with_tail( seen_logs = set() for event in events: - timestamp = event["timestamp"] - message = event["message"].rstrip() - console.print(self._format_log_entry(timestamp, message)) event_id = event.get("eventId") - key = event_id or (timestamp, message) - seen_logs.add(key) + log_event = LogEvent( + timestamp=event["timestamp"], + message=event["message"].rstrip(), + event_id=event_id if isinstance(event_id, str) else None, + ) + console.print(log_event.format()) + seen_logs.add(log_event.key) # Tail new logs with keyboard commands console.print("\nTailing logs... Press: (s)top (f)ilter (c)lear filter", style="bold cyan") @@ -191,14 +207,15 @@ def log_reader() -> None: # End of logs signal pass else: - event_map = event - event_id = event_map.get("eventId") - key_tuple = event_id or (event_map.get("timestamp"), event_map.get("message")) - if key_tuple not in seen_logs: - seen_logs.add(key_tuple) - timestamp = event_map.get("timestamp") - message = str(event_map.get("message")).rstrip() - console.print(self._format_log_entry(timestamp, message)) + event_id = event.get("eventId") + log_event = LogEvent( + timestamp=event.get("timestamp"), + message=str(event.get("message", "")).rstrip(), + event_id=event_id if isinstance(event_id, str) else None, + ) + if log_event.key not in seen_logs: + seen_logs.add(log_event.key) + console.print(log_event.format()) except queue.Empty: # No new logs, just wait a bit time.sleep(LOG_POLL_INTERVAL) # Small delay to avoid busy-waiting From 2849412e19316eecd57f71444db92856aa528526 Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Wed, 1 Oct 2025 11:06:06 +0300 Subject: [PATCH 08/11] refactor: use Action enum for cleaner action handling --- src/lazy_ecs/features/container/ui.py | 59 ++++++++++++++++----------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/src/lazy_ecs/features/container/ui.py b/src/lazy_ecs/features/container/ui.py index b931706..8121a35 100644 --- a/src/lazy_ecs/features/container/ui.py +++ b/src/lazy_ecs/features/container/ui.py @@ -7,6 +7,7 @@ import time from dataclasses import dataclass from datetime import datetime +from enum import Enum from typing import Any, cast from rich.console import Console @@ -17,13 +18,24 @@ console = Console() -# Constants SEPARATOR_WIDTH = 80 LOG_POLL_INTERVAL = 0.01 # seconds -KEY_STOP = "s" -KEY_FILTER = "f" -KEY_CLEAR = "c" -VALID_ACTION_KEYS = (KEY_STOP, KEY_FILTER, KEY_CLEAR) + + +class Action(Enum): + """Actions for log tailing interaction.""" + + STOP = "s" + FILTER = "f" + CLEAR = "c" + + @classmethod + def from_key(cls, key: str) -> Action | None: + """Convert keyboard key to action.""" + for action in cls: + if action.value == key: + return action + return None @dataclass @@ -83,10 +95,10 @@ def show_logs_live_tail(self, cluster_name: str, task_arn: str, container_name: container_name, log_group_name, log_stream_name, filter_pattern, lines ) - if action == KEY_STOP: + if action == Action.STOP: console.print("\nStopped tailing logs.", style="yellow") break - if action == KEY_FILTER: + if action == Action.FILTER: console.print("\n" + "=" * SEPARATOR_WIDTH, style="dim") console.print("šŸ” FILTER MODE - Enter CloudWatch filter pattern", style="bold cyan") console.print("Examples:", style="dim") @@ -98,7 +110,7 @@ def show_logs_live_tail(self, cluster_name: str, task_arn: str, container_name: if new_filter: filter_pattern = new_filter console.print(f"āœ“ Filter applied: {filter_pattern}", style="green") - elif action == KEY_CLEAR: + elif action == Action.CLEAR: filter_pattern = "" console.print("\nāœ“ Filter cleared", style="green") @@ -109,10 +121,10 @@ def _display_logs_with_tail( log_stream_name: str, filter_pattern: str, lines: int, - ) -> str: + ) -> Action | None: """Display historical logs then tail new logs with optional filtering. - Returns the action key pressed by the user (s=stop, f=filter, c=clear). + Returns the action taken by the user. """ # Show filter status if filter_pattern: @@ -180,23 +192,24 @@ def log_reader() -> None: log_thread = threading.Thread(target=log_reader, daemon=True) log_thread.start() - action_key = "" + action = None try: while True: # Check for keyboard input first (more responsive) try: key = key_queue.get_nowait() - if key in VALID_ACTION_KEYS: - action_key = key - stop_event.set() - self._drain_queue(key_queue) - # Give immediate feedback - if action_key == KEY_FILTER: - console.print("\n[Entering filter mode...]", style="cyan") - elif action_key == KEY_CLEAR: - console.print("\n[Clearing filter...]", style="green") - break + if key: + action = Action.from_key(key) + if action: + stop_event.set() + self._drain_queue(key_queue) + # Give immediate feedback + if action == Action.FILTER: + console.print("\n[Entering filter mode...]", style="cyan") + elif action == Action.CLEAR: + console.print("\n[Clearing filter...]", style="green") + break except queue.Empty: pass @@ -221,11 +234,11 @@ def log_reader() -> None: time.sleep(LOG_POLL_INTERVAL) # Small delay to avoid busy-waiting except KeyboardInterrupt: console.print("\nšŸ›‘ Interrupted.", style="yellow") - action_key = KEY_STOP + action = Action.STOP finally: stop_event.set() - return action_key # pyrefly: ignore[bad-return] - always assigned + return action def show_container_environment_variables(self, cluster_name: str, task_arn: str, container_name: str) -> None: with show_spinner(): From b56ba73bdcab3e338b7ce12ac17cc5f927eba303 Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Wed, 1 Oct 2025 11:12:41 +0300 Subject: [PATCH 09/11] handle log stream closing gracefully --- src/lazy_ecs/features/container/container.py | 23 +++++++++++++------- src/lazy_ecs/features/container/ui.py | 11 ++++++++-- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/lazy_ecs/features/container/container.py b/src/lazy_ecs/features/container/container.py index e06509d..a4cf19e 100644 --- a/src/lazy_ecs/features/container/container.py +++ b/src/lazy_ecs/features/container/container.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Generator +from contextlib import suppress from os import environ from typing import TYPE_CHECKING, Any @@ -142,14 +143,20 @@ def get_live_container_logs_tail( logEventFilterPattern=event_filter_pattern, ) response_stream = response.get("responseStream") - for event in response_stream: - if "sessionStart" in event: - continue - elif "sessionUpdate" in event: - log_events = event.get("sessionUpdate", {}).get("sessionResults", []) - yield from log_events - else: - yield event + try: + for event in response_stream: + if "sessionStart" in event: + continue + elif "sessionUpdate" in event: + log_events = event.get("sessionUpdate", {}).get("sessionResults", []) + yield from log_events + else: + yield event + finally: + # Properly close the response stream + if hasattr(response_stream, "close"): + with suppress(Exception): + response_stream.close() def list_log_groups(self, cluster_name: str, container_name: str) -> list[str]: if not self.logs_client: diff --git a/src/lazy_ecs/features/container/ui.py b/src/lazy_ecs/features/container/ui.py index 8121a35..ef93ce2 100644 --- a/src/lazy_ecs/features/container/ui.py +++ b/src/lazy_ecs/features/container/ui.py @@ -5,6 +5,7 @@ import queue import threading import time +from contextlib import suppress from dataclasses import dataclass from datetime import datetime from enum import Enum @@ -174,16 +175,22 @@ def keyboard_listener() -> None: def log_reader() -> None: """Read logs in separate thread to avoid blocking.""" + log_generator = None try: - for event in self.container_service.get_live_container_logs_tail( + log_generator = self.container_service.get_live_container_logs_tail( log_group_name, log_stream_name, filter_pattern - ): + ) + for event in log_generator: if stop_event.is_set(): break log_queue.put(cast(dict[str, Any], event)) except Exception: pass # Iterator exhausted or error finally: + # Ensure generator is properly closed + if log_generator and hasattr(log_generator, "close"): + with suppress(Exception): + log_generator.close() log_queue.put(None) # Signal end of logs keyboard_thread = threading.Thread(target=keyboard_listener, daemon=True) From e4e83480e02d2c956cdaded34b1e00d65aae1697 Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Wed, 1 Oct 2025 11:23:30 +0300 Subject: [PATCH 10/11] refactor: extract data models to separate module --- src/lazy_ecs/features/container/models.py | 45 +++++++++++++++++++++++ src/lazy_ecs/features/container/ui.py | 41 +-------------------- 2 files changed, 46 insertions(+), 40 deletions(-) create mode 100644 src/lazy_ecs/features/container/models.py diff --git a/src/lazy_ecs/features/container/models.py b/src/lazy_ecs/features/container/models.py new file mode 100644 index 0000000..a2546e9 --- /dev/null +++ b/src/lazy_ecs/features/container/models.py @@ -0,0 +1,45 @@ +"""Data models for container operations.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from typing import Any + + +class Action(Enum): + """Actions for log tailing interaction.""" + + STOP = "s" + FILTER = "f" + CLEAR = "c" + + @classmethod + def from_key(cls, key: str) -> Action | None: + """Convert keyboard key to action.""" + for action in cls: + if action.value == key: + return action + return None + + +@dataclass +class LogEvent: + """Represents a log event from CloudWatch.""" + + timestamp: int | None + message: str + event_id: str | None = None + + @property + def key(self) -> tuple[Any, ...] | str: + """Get unique key for deduplication.""" + return self.event_id if self.event_id else (self.timestamp, self.message) + + def format(self) -> str: + """Format the log event for display.""" + if self.timestamp: + dt = datetime.fromtimestamp(self.timestamp / 1000) + return f"[{dt.strftime('%H:%M:%S')}] {self.message}" + return self.message diff --git a/src/lazy_ecs/features/container/ui.py b/src/lazy_ecs/features/container/ui.py index ef93ce2..652827f 100644 --- a/src/lazy_ecs/features/container/ui.py +++ b/src/lazy_ecs/features/container/ui.py @@ -6,9 +6,6 @@ import threading import time from contextlib import suppress -from dataclasses import dataclass -from datetime import datetime -from enum import Enum from typing import Any, cast from rich.console import Console @@ -16,6 +13,7 @@ from ...core.base import BaseUIComponent from ...core.utils import print_error, show_spinner, wait_for_keypress from .container import ContainerService +from .models import Action, LogEvent console = Console() @@ -23,43 +21,6 @@ LOG_POLL_INTERVAL = 0.01 # seconds -class Action(Enum): - """Actions for log tailing interaction.""" - - STOP = "s" - FILTER = "f" - CLEAR = "c" - - @classmethod - def from_key(cls, key: str) -> Action | None: - """Convert keyboard key to action.""" - for action in cls: - if action.value == key: - return action - return None - - -@dataclass -class LogEvent: - """Represents a log event from CloudWatch.""" - - timestamp: int | None - message: str - event_id: str | None = None - - @property - def key(self) -> tuple[Any, ...] | str: - """Get unique key for deduplication.""" - return self.event_id if self.event_id else (self.timestamp, self.message) - - def format(self) -> str: - """Format the log event for display.""" - if self.timestamp: - dt = datetime.fromtimestamp(self.timestamp / 1000) - return f"[{dt.strftime('%H:%M:%S')}] {self.message}" - return self.message - - class ContainerUI(BaseUIComponent): """UI component for container display.""" From 4624f2733a0302b8714500c14de910eed038ddd6 Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Wed, 1 Oct 2025 11:26:25 +0300 Subject: [PATCH 11/11] bump version --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index aa7cb86..8b23b56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lazy-ecs" -version = "0.2.1" +version = "0.3.0" description = "A CLI tool for working with AWS services" readme = "README.md" authors = [ diff --git a/uv.lock b/uv.lock index 0540bb3..69b7101 100644 --- a/uv.lock +++ b/uv.lock @@ -381,7 +381,7 @@ wheels = [ [[package]] name = "lazy-ecs" -version = "0.2.1" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "boto3" },