From cbf707ba278d2c3638b8fdd014d2e7d04c62dc3e Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Wed, 1 Oct 2025 08:45:19 +0300 Subject: [PATCH 1/2] merge show logs and tail logs --- src/lazy_ecs/__init__.py | 1 - src/lazy_ecs/features/container/ui.py | 53 ++++++------------ src/lazy_ecs/features/task/ui.py | 10 +--- src/lazy_ecs/ui.py | 8 +-- tests/test_container_ui.py | 81 ++++++++++----------------- tests/test_core_navigation.py | 4 +- tests/test_task_ui.py | 6 +- tests/test_ui.py | 19 +++---- 8 files changed, 65 insertions(+), 117 deletions(-) diff --git a/src/lazy_ecs/__init__.py b/src/lazy_ecs/__init__.py index cc79f06..ecf68c9 100644 --- a/src/lazy_ecs/__init__.py +++ b/src/lazy_ecs/__init__.py @@ -161,7 +161,6 @@ def _handle_task_features( if selection_type == "container_action": # Map action names to methods action_methods = { - "show_logs": navigator.show_container_logs, "tail_logs": navigator.show_container_logs_live_tail, "show_env": navigator.show_container_environment_variables, "show_secrets": navigator.show_container_secrets, diff --git a/src/lazy_ecs/features/container/ui.py b/src/lazy_ecs/features/container/ui.py index f760c9c..16d52ef 100644 --- a/src/lazy_ecs/features/container/ui.py +++ b/src/lazy_ecs/features/container/ui.py @@ -21,9 +21,9 @@ def __init__(self, container_service: ContainerService) -> None: super().__init__() self.container_service = container_service - def show_container_logs(self, cluster_name: str, task_arn: str, container_name: str, lines: int = 50) -> None: - with show_spinner(): - log_config = self.container_service.get_log_config(cluster_name, task_arn, container_name) + 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.""" + 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}'") console.print("Available log groups:", style="dim") @@ -32,49 +32,32 @@ def show_container_logs(self, cluster_name: str, task_arn: str, container_name: console.print(f" • {group}", style="cyan") return - log_group_name = log_config["log_group"] - log_stream_name = log_config["log_stream"] + 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) - if not events: - console.print( - f"šŸ“ No logs found for container '{container_name}' in stream '{log_stream_name}'", style="yellow" - ) - return - - console.print(f"\nšŸ“‹ Last {len(events)} log entries for container '{container_name}':", style="bold cyan") + 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") + seen_logs = set() for event in events: - timestamp = datetime.fromtimestamp(event["timestamp"] / 1000) + timestamp = event["timestamp"] message = event["message"].rstrip() - console.print(f"[{timestamp.strftime('%H:%M:%S')}] {message}") - - console.print("=" * 80, style="dim") - - def show_logs_live_tail(self, cluster_name: str, task_arn: str, container_name: str) -> None: - """Display logs in real time for a container.""" - 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}'") - console.print("Available log groups:", style="dim") - log_groups = self.container_service.list_log_groups(cluster_name, container_name) - for group in log_groups: - console.print(f" • {group}", style="cyan") - return - - log_group_name = log_config.get("log_group") - log_stream_name = log_config.get("log_stream") - console.print(f"\nšŸš€ Tailing logs 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("Press Ctrl+C to stop.", style="dim") + 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") console.print("=" * 80, style="dim") - seen_logs = set() try: for event in self.container_service.get_live_container_logs_tail(log_group_name, log_stream_name): event_map = cast(dict, event) diff --git a/src/lazy_ecs/features/task/ui.py b/src/lazy_ecs/features/task/ui.py index aa6e812..8a82afa 100644 --- a/src/lazy_ecs/features/task/ui.py +++ b/src/lazy_ecs/features/task/ui.py @@ -126,11 +126,7 @@ def select_task_feature(self, task_details: TaskDetails | None) -> str | None: choices.extend( [ { - "name": f"Show logs for '{container_name}'", - "value": f"container_action:show_logs:{container_name}", - }, - { - "name": f"Show logs live tail for container '{container_name}'", + "name": f"Show logs (tail) for container '{container_name}'", "value": f"container_action:tail_logs:{container_name}", }, { @@ -276,8 +272,8 @@ def _build_task_feature_choices(containers: list[dict[str, Any]]) -> list[dict[s choices.extend( [ { - "name": f"Show logs for '{container_name}'", - "value": f"container_action:show_logs:{container_name}", + "name": f"Show logs (tail) for container '{container_name}'", + "value": f"container_action:tail_logs:{container_name}", }, { "name": f"Show environment variables for '{container_name}'", diff --git a/src/lazy_ecs/ui.py b/src/lazy_ecs/ui.py index 2d0ff92..196f268 100644 --- a/src/lazy_ecs/ui.py +++ b/src/lazy_ecs/ui.py @@ -74,11 +74,8 @@ def display_task_details(self, task_details: TaskDetails | None) -> None: def select_task_feature(self, task_details: TaskDetails | None) -> str | None: return self._task_ui.select_task_feature(task_details) - def show_container_logs(self, cluster_name: str, task_arn: str, container_name: str, lines: int = 50) -> None: - return self._container_ui.show_container_logs(cluster_name, task_arn, container_name, lines) - def show_container_logs_live_tail(self, cluster_name: str, task_arn: str, container_name: str) -> None: - """Stream logs for a container.""" + """Display recent logs then stream new logs for a container.""" return self._container_ui.show_logs_live_tail(cluster_name, task_arn, container_name) def show_container_environment_variables(self, cluster_name: str, task_arn: str, container_name: str) -> None: @@ -106,8 +103,7 @@ def show_task_history(self, cluster_name: str, service_name: str) -> None: def _build_task_feature_choices(containers: list[dict[str, Any]]) -> list[dict[str, str]]: """Build feature menu choices for containers plus navigation options.""" actions = [ - ("Show tail of logs for container: {name}", "container_action", "show_logs"), - ("Show logs live tail for container: {name}", "container_action", "tail_logs"), + ("Show logs (tail) for container: {name}", "container_action", "tail_logs"), ("Show environment variables for container: {name}", "container_action", "show_env"), ("Show secrets for container: {name}", "container_action", "show_secrets"), ("Show port mappings for container: {name}", "container_action", "show_ports"), diff --git a/tests/test_container_ui.py b/tests/test_container_ui.py index 895ad1d..7354cca 100644 --- a/tests/test_container_ui.py +++ b/tests/test_container_ui.py @@ -27,51 +27,50 @@ def container_ui(mock_ecs_client, mock_task_service): return ContainerUI(container_service) -def test_show_container_logs_success(container_ui): - """Test displaying container logs successfully.""" - log_config = {"log_group": "test-log-group", "log_stream": "test-stream"} - events = [ - {"timestamp": 1234567890000, "message": "Test log message 1"}, - {"timestamp": 1234567891000, "message": "Test log message 2"}, - ] - - container_ui.container_service.get_log_config = Mock(return_value=log_config) - container_ui.container_service.get_container_logs = Mock(return_value=events) - - container_ui.show_container_logs("test-cluster", "task-arn", "web-container", 50) - - 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) - - def test_show_logs_live_tail_success(container_ui): - """Test displaying live tail container logs successfully.""" + """Test displaying recent logs then streaming live tail container logs successfully.""" log_config = {"log_group": "test-log-group", "log_stream": "test-stream"} - events = [ + 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": "event3", "timestamp": 1234567892000, "message": "Live tail log message 3"}, - {"eventId": "event4", "timestamp": 1234567893000, "message": "Live tail log message 4"}, ] container_ui.container_service.get_log_config = Mock(return_value=log_config) - container_ui.container_service.get_live_container_logs_tail = Mock(return_value=iter(events)) + 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") 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("\nšŸš€ Tailing logs for container 'web-container':", style="bold cyan"), - call("log group: test-log-group", style="dim"), - call("log stream: test-stream", style="dim"), - call("Press Ctrl+C to stop.", style="dim"), + 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"), ] - for event in events: + 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}")) + + expected_calls.extend( + [ + call("\nNow tailing new logs (Press Ctrl+C to stop)...", style="bold cyan"), + call("=" * 80, style="dim"), + ] + ) + + for event in live_events: timestamp = cast(int, event["timestamp"]) dt = datetime.fromtimestamp(timestamp / 1000) message = cast(str, event["message"]).rstrip() @@ -84,12 +83,16 @@ def test_show_logs_live_tail_success(container_ui): def test_show_logs_live_tail_keyboard_interrupt(container_ui): """Test handling keyboard interruptions with Ctrl+C during live logs tail.""" log_config = {"log_group": "test-log-group", "log_stream": "test-stream"} + recent_events = [ + {"timestamp": 1234567888000, "message": "Recent log message 1"}, + ] 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()) with patch("rich.console.Console.print") as mock_console_print: @@ -113,30 +116,6 @@ def test_show_logs_live_tail_no_config(container_ui): mock_console_print.assert_any_call("Available log groups:", style="dim") -def test_show_container_logs_no_config(container_ui): - """Test displaying container logs with no log configuration.""" - container_ui.container_service.get_log_config = Mock(return_value=None) - container_ui.container_service.list_log_groups = Mock(return_value=["group1", "group2"]) - - container_ui.show_container_logs("test-cluster", "task-arn", "web-container", 50) - - container_ui.container_service.get_log_config.assert_called_once_with("test-cluster", "task-arn", "web-container") - container_ui.container_service.list_log_groups.assert_called_once_with("test-cluster", "web-container") - - -def test_show_container_logs_no_events(container_ui): - """Test displaying container logs with no events.""" - log_config = {"log_group": "test-log-group", "log_stream": "test-stream"} - - container_ui.container_service.get_log_config = Mock(return_value=log_config) - container_ui.container_service.get_container_logs = Mock(return_value=[]) - - container_ui.show_container_logs("test-cluster", "task-arn", "web-container", 50) - - 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) - - def test_show_container_environment_variables_success(container_ui): """Test displaying container environment variables successfully.""" context = {"container_definition": {"environment": [{"name": "ENV_VAR", "value": "value"}]}} diff --git a/tests/test_core_navigation.py b/tests/test_core_navigation.py index 46cec01..a148242 100644 --- a/tests/test_core_navigation.py +++ b/tests/test_core_navigation.py @@ -15,8 +15,8 @@ def test_parse_selection_with_container_action(): """Test parsing container action selection with three parts.""" - result = parse_selection("container_action:show_logs:web") - assert result == ("container_action", "show_logs", "web") + result = parse_selection("container_action:tail_logs:web") + assert result == ("container_action", "tail_logs", "web") def test_parse_selection_with_two_parts(): diff --git a/tests/test_task_ui.py b/tests/test_task_ui.py index 93db783..cab0291 100644 --- a/tests/test_task_ui.py +++ b/tests/test_task_ui.py @@ -139,13 +139,13 @@ def test_select_task_with_many_tasks(mock_select, task_ui): def test_select_task_feature_with_many_containers(mock_select, task_ui): containers = [{"name": f"container-{i}"} for i in range(10)] task_details = {"containers": containers} - mock_select.return_value = "container_action:show_logs:container-5" + mock_select.return_value = "container_action:tail_logs:container-5" result = task_ui.select_task_feature(task_details) - assert result == "container_action:show_logs:container-5" + assert result == "container_action:tail_logs:container-5" mock_select.assert_called_once() call_args = mock_select.call_args choices = call_args[0][1] - assert len(choices) == 62 + assert len(choices) == 52 diff --git a/tests/test_ui.py b/tests/test_ui.py index f848ca5..61f469b 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -114,7 +114,7 @@ def test_select_task_feature_with_containers(mock_select, mock_ecs_service) -> N """Test task feature selection with containers.""" from lazy_ecs.core.types import TaskDetails - mock_select.return_value = "container_action:show_logs:web" + mock_select.return_value = "container_action:tail_logs:web" navigator = ECSNavigator(mock_ecs_service) task_details: TaskDetails = { @@ -130,7 +130,7 @@ def test_select_task_feature_with_containers(mock_select, mock_ecs_service) -> N selected = navigator.select_task_feature(task_details) - assert selected == "container_action:show_logs:web" + assert selected == "container_action:tail_logs:web" mock_select.assert_called_once() @@ -148,7 +148,6 @@ def test_container_methods_delegate_to_container_ui(mock_ecs_service) -> None: navigator = ECSNavigator(mock_ecs_service) # Mock all ContainerUI methods - navigator._container_ui.show_container_logs = Mock() navigator._container_ui.show_logs_live_tail = Mock() navigator._container_ui.show_container_environment_variables = Mock() navigator._container_ui.show_container_secrets = Mock() @@ -156,7 +155,6 @@ def test_container_methods_delegate_to_container_ui(mock_ecs_service) -> None: navigator._container_ui.show_container_volume_mounts = Mock() # Test delegation - navigator.show_container_logs("cluster", "task", "container", 100) navigator.show_container_logs_live_tail("cluster", "task", "container") navigator.show_container_environment_variables("cluster", "task", "container") navigator.show_container_secrets("cluster", "task", "container") @@ -164,7 +162,6 @@ def test_container_methods_delegate_to_container_ui(mock_ecs_service) -> None: navigator.show_container_volume_mounts("cluster", "task", "container") # Verify delegation - navigator._container_ui.show_container_logs.assert_called_once_with("cluster", "task", "container", 100) navigator._container_ui.show_logs_live_tail.assert_called_once_with("cluster", "task", "container") navigator._container_ui.show_container_environment_variables.assert_called_once_with("cluster", "task", "container") navigator._container_ui.show_container_secrets.assert_called_once_with("cluster", "task", "container") @@ -194,15 +191,13 @@ def test_build_task_feature_choices() -> None: choice_values = [choice["value"] for choice in choices] # Check container actions are present - assert "Show tail of logs for container: web" in choice_names - assert "Show logs live tail for container: web" in choice_names + assert "Show logs (tail) for container: web" in choice_names assert "Show environment variables for container: web" in choice_names assert "Show secrets for container: web" in choice_names assert "Show port mappings for container: web" in choice_names assert "Show volume mounts for container: web" in choice_names - assert "Show tail of logs for container: sidecar" in choice_names - assert "Show logs live tail for container: sidecar" in choice_names + assert "Show logs (tail) for container: sidecar" in choice_names assert "Show environment variables for container: sidecar" in choice_names assert "Show secrets for container: sidecar" in choice_names assert "Show port mappings for container: sidecar" in choice_names @@ -213,10 +208,10 @@ def test_build_task_feature_choices() -> None: assert "āŒ Exit" in choice_names # Check values - assert "container_action:show_logs:web" in choice_values + assert "container_action:tail_logs:web" in choice_values assert "container_action:show_env:web" in choice_values assert "navigation:back" in choice_values assert "navigation:exit" in choice_values - # Total: 6 actions x 2 containers + 2 navigation = 14 - assert len(choices) == 14 + # Total: 5 actions x 2 containers + 2 navigation = 12 + assert len(choices) == 12 From d1cef9ae6875099ae867a5c656ac882d14ba65e3 Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Wed, 1 Oct 2025 08:54:05 +0300 Subject: [PATCH 2/2] bump version number to 0.2.1 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dcb2e0a..aa7cb86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lazy-ecs" -version = "0.2.0" +version = "0.2.1" description = "A CLI tool for working with AWS services" readme = "README.md" authors = [ diff --git a/uv.lock b/uv.lock index f0dcc07..0540bb3 100644 --- a/uv.lock +++ b/uv.lock @@ -381,7 +381,7 @@ wheels = [ [[package]] name = "lazy-ecs" -version = "0.2.0" +version = "0.2.1" source = { editable = "." } dependencies = [ { name = "boto3" },