Skip to content

Commit 7f90d1a

Browse files
authored
Merge pull request #25 from vertti/merge-log-watch
Combine show logs and tail logs to single action
2 parents b6fbaed + d1cef9a commit 7f90d1a

10 files changed

Lines changed: 67 additions & 119 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "lazy-ecs"
3-
version = "0.2.0"
3+
version = "0.2.1"
44
description = "A CLI tool for working with AWS services"
55
readme = "README.md"
66
authors = [

src/lazy_ecs/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,6 @@ def _handle_task_features(
161161
if selection_type == "container_action":
162162
# Map action names to methods
163163
action_methods = {
164-
"show_logs": navigator.show_container_logs,
165164
"tail_logs": navigator.show_container_logs_live_tail,
166165
"show_env": navigator.show_container_environment_variables,
167166
"show_secrets": navigator.show_container_secrets,

src/lazy_ecs/features/container/ui.py

Lines changed: 18 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ def __init__(self, container_service: ContainerService) -> None:
2121
super().__init__()
2222
self.container_service = container_service
2323

24-
def show_container_logs(self, cluster_name: str, task_arn: str, container_name: str, lines: int = 50) -> None:
25-
with show_spinner():
26-
log_config = self.container_service.get_log_config(cluster_name, task_arn, container_name)
24+
def show_logs_live_tail(self, cluster_name: str, task_arn: str, container_name: str, lines: int = 50) -> None:
25+
"""Display recent logs then continue streaming in real time for a container."""
26+
log_config = self.container_service.get_log_config(cluster_name, task_arn, container_name)
2727
if not log_config:
2828
print_error(f"Could not find log configuration for container '{container_name}'")
2929
console.print("Available log groups:", style="dim")
@@ -32,49 +32,32 @@ def show_container_logs(self, cluster_name: str, task_arn: str, container_name:
3232
console.print(f" • {group}", style="cyan")
3333
return
3434

35-
log_group_name = log_config["log_group"]
36-
log_stream_name = log_config["log_stream"]
35+
log_group_name = log_config.get("log_group")
36+
log_stream_name = log_config.get("log_stream")
3737

38+
# First, fetch and display recent logs
3839
events = self.container_service.get_container_logs(log_group_name, log_stream_name, lines)
3940

40-
if not events:
41-
console.print(
42-
f"📝 No logs found for container '{container_name}' in stream '{log_stream_name}'", style="yellow"
43-
)
44-
return
45-
46-
console.print(f"\n📋 Last {len(events)} log entries for container '{container_name}':", style="bold cyan")
41+
console.print(f"\nLast {len(events)} log entries for container '{container_name}':", style="bold cyan")
4742
console.print(f"Log group: {log_group_name}", style="dim")
4843
console.print(f"Log stream: {log_stream_name}", style="dim")
4944
console.print("=" * 80, style="dim")
5045

46+
seen_logs = set()
5147
for event in events:
52-
timestamp = datetime.fromtimestamp(event["timestamp"] / 1000)
48+
timestamp = event["timestamp"]
5349
message = event["message"].rstrip()
54-
console.print(f"[{timestamp.strftime('%H:%M:%S')}] {message}")
55-
56-
console.print("=" * 80, style="dim")
57-
58-
def show_logs_live_tail(self, cluster_name: str, task_arn: str, container_name: str) -> None:
59-
"""Display logs in real time for a container."""
60-
log_config = self.container_service.get_log_config(cluster_name, task_arn, container_name)
61-
if not log_config:
62-
print_error(f"Could not find log configuration for container '{container_name}'")
63-
console.print("Available log groups:", style="dim")
64-
log_groups = self.container_service.list_log_groups(cluster_name, container_name)
65-
for group in log_groups:
66-
console.print(f" • {group}", style="cyan")
67-
return
68-
69-
log_group_name = log_config.get("log_group")
70-
log_stream_name = log_config.get("log_stream")
71-
console.print(f"\n🚀 Tailing logs for container '{container_name}':", style="bold cyan")
72-
console.print(f"log group: {log_group_name}", style="dim")
73-
console.print(f"log stream: {log_stream_name}", style="dim")
74-
console.print("Press Ctrl+C to stop.", style="dim")
50+
dt = datetime.fromtimestamp(timestamp / 1000)
51+
console.print(f"[{dt.strftime('%H:%M:%S')}] {message}")
52+
# Track these to avoid duplicates when tailing
53+
event_id = event.get("eventId")
54+
key = event_id or (timestamp, message)
55+
seen_logs.add(key)
56+
57+
# Then continue with live tail
58+
console.print("\nNow tailing new logs (Press Ctrl+C to stop)...", style="bold cyan")
7559
console.print("=" * 80, style="dim")
7660

77-
seen_logs = set()
7861
try:
7962
for event in self.container_service.get_live_container_logs_tail(log_group_name, log_stream_name):
8063
event_map = cast(dict, event)

src/lazy_ecs/features/task/ui.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,7 @@ def select_task_feature(self, task_details: TaskDetails | None) -> str | None:
126126
choices.extend(
127127
[
128128
{
129-
"name": f"Show logs for '{container_name}'",
130-
"value": f"container_action:show_logs:{container_name}",
131-
},
132-
{
133-
"name": f"Show logs live tail for container '{container_name}'",
129+
"name": f"Show logs (tail) for container '{container_name}'",
134130
"value": f"container_action:tail_logs:{container_name}",
135131
},
136132
{
@@ -276,8 +272,8 @@ def _build_task_feature_choices(containers: list[dict[str, Any]]) -> list[dict[s
276272
choices.extend(
277273
[
278274
{
279-
"name": f"Show logs for '{container_name}'",
280-
"value": f"container_action:show_logs:{container_name}",
275+
"name": f"Show logs (tail) for container '{container_name}'",
276+
"value": f"container_action:tail_logs:{container_name}",
281277
},
282278
{
283279
"name": f"Show environment variables for '{container_name}'",

src/lazy_ecs/ui.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,8 @@ def display_task_details(self, task_details: TaskDetails | None) -> None:
7474
def select_task_feature(self, task_details: TaskDetails | None) -> str | None:
7575
return self._task_ui.select_task_feature(task_details)
7676

77-
def show_container_logs(self, cluster_name: str, task_arn: str, container_name: str, lines: int = 50) -> None:
78-
return self._container_ui.show_container_logs(cluster_name, task_arn, container_name, lines)
79-
8077
def show_container_logs_live_tail(self, cluster_name: str, task_arn: str, container_name: str) -> None:
81-
"""Stream logs for a container."""
78+
"""Display recent logs then stream new logs for a container."""
8279
return self._container_ui.show_logs_live_tail(cluster_name, task_arn, container_name)
8380

8481
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:
106103
def _build_task_feature_choices(containers: list[dict[str, Any]]) -> list[dict[str, str]]:
107104
"""Build feature menu choices for containers plus navigation options."""
108105
actions = [
109-
("Show tail of logs for container: {name}", "container_action", "show_logs"),
110-
("Show logs live tail for container: {name}", "container_action", "tail_logs"),
106+
("Show logs (tail) for container: {name}", "container_action", "tail_logs"),
111107
("Show environment variables for container: {name}", "container_action", "show_env"),
112108
("Show secrets for container: {name}", "container_action", "show_secrets"),
113109
("Show port mappings for container: {name}", "container_action", "show_ports"),

tests/test_container_ui.py

Lines changed: 30 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -27,51 +27,50 @@ def container_ui(mock_ecs_client, mock_task_service):
2727
return ContainerUI(container_service)
2828

2929

30-
def test_show_container_logs_success(container_ui):
31-
"""Test displaying container logs successfully."""
32-
log_config = {"log_group": "test-log-group", "log_stream": "test-stream"}
33-
events = [
34-
{"timestamp": 1234567890000, "message": "Test log message 1"},
35-
{"timestamp": 1234567891000, "message": "Test log message 2"},
36-
]
37-
38-
container_ui.container_service.get_log_config = Mock(return_value=log_config)
39-
container_ui.container_service.get_container_logs = Mock(return_value=events)
40-
41-
container_ui.show_container_logs("test-cluster", "task-arn", "web-container", 50)
42-
43-
container_ui.container_service.get_log_config.assert_called_once_with("test-cluster", "task-arn", "web-container")
44-
container_ui.container_service.get_container_logs.assert_called_once_with("test-log-group", "test-stream", 50)
45-
46-
4730
def test_show_logs_live_tail_success(container_ui):
48-
"""Test displaying live tail container logs successfully."""
31+
"""Test displaying recent logs then streaming live tail container logs successfully."""
4932
log_config = {"log_group": "test-log-group", "log_stream": "test-stream"}
50-
events = [
33+
recent_events = [
34+
{"timestamp": 1234567888000, "message": "Recent log message 1"},
35+
{"timestamp": 1234567889000, "message": "Recent log message 2"},
36+
]
37+
live_events = [
5138
{"eventId": "event1", "timestamp": 1234567890000, "message": "Live tail log message 1"},
5239
{"eventId": "event2", "timestamp": 1234567891000, "message": "Live tail log message 2"},
53-
{"eventId": "event3", "timestamp": 1234567892000, "message": "Live tail log message 3"},
54-
{"eventId": "event4", "timestamp": 1234567893000, "message": "Live tail log message 4"},
5540
]
5641

5742
container_ui.container_service.get_log_config = Mock(return_value=log_config)
58-
container_ui.container_service.get_live_container_logs_tail = Mock(return_value=iter(events))
43+
container_ui.container_service.get_container_logs = Mock(return_value=recent_events)
44+
container_ui.container_service.get_live_container_logs_tail = Mock(return_value=iter(live_events))
5945

6046
with patch("rich.console.Console.print") as mock_console_print:
6147
container_ui.show_logs_live_tail("test-cluster", "task-arn", "web-container")
6248

6349
container_ui.container_service.get_log_config.assert_called_once_with("test-cluster", "task-arn", "web-container")
50+
container_ui.container_service.get_container_logs.assert_called_once_with("test-log-group", "test-stream", 50)
6451
container_ui.container_service.get_live_container_logs_tail.assert_called_once_with("test-log-group", "test-stream")
6552

6653
expected_calls = [
67-
call("\n🚀 Tailing logs for container 'web-container':", style="bold cyan"),
68-
call("log group: test-log-group", style="dim"),
69-
call("log stream: test-stream", style="dim"),
70-
call("Press Ctrl+C to stop.", style="dim"),
54+
call("\nLast 2 log entries for container 'web-container':", style="bold cyan"),
55+
call("Log group: test-log-group", style="dim"),
56+
call("Log stream: test-stream", style="dim"),
7157
call("=" * 80, style="dim"),
7258
]
7359

74-
for event in events:
60+
for event in recent_events:
61+
timestamp = cast(int, event["timestamp"])
62+
dt = datetime.fromtimestamp(timestamp / 1000)
63+
message = cast(str, event["message"]).rstrip()
64+
expected_calls.append(call(f"[{dt.strftime('%H:%M:%S')}] {message}"))
65+
66+
expected_calls.extend(
67+
[
68+
call("\nNow tailing new logs (Press Ctrl+C to stop)...", style="bold cyan"),
69+
call("=" * 80, style="dim"),
70+
]
71+
)
72+
73+
for event in live_events:
7574
timestamp = cast(int, event["timestamp"])
7675
dt = datetime.fromtimestamp(timestamp / 1000)
7776
message = cast(str, event["message"]).rstrip()
@@ -84,12 +83,16 @@ def test_show_logs_live_tail_success(container_ui):
8483
def test_show_logs_live_tail_keyboard_interrupt(container_ui):
8584
"""Test handling keyboard interruptions with Ctrl+C during live logs tail."""
8685
log_config = {"log_group": "test-log-group", "log_stream": "test-stream"}
86+
recent_events = [
87+
{"timestamp": 1234567888000, "message": "Recent log message 1"},
88+
]
8789

8890
def mock_generator() -> Generator[dict[str, Any], None, None]:
8991
yield {"eventId": "event1", "timestamp": 1234567890000, "message": "Live tail log message 1"}
9092
raise KeyboardInterrupt()
9193

9294
container_ui.container_service.get_log_config = Mock(return_value=log_config)
95+
container_ui.container_service.get_container_logs = Mock(return_value=recent_events)
9396
container_ui.container_service.get_live_container_logs_tail = Mock(return_value=mock_generator())
9497

9598
with patch("rich.console.Console.print") as mock_console_print:
@@ -113,30 +116,6 @@ def test_show_logs_live_tail_no_config(container_ui):
113116
mock_console_print.assert_any_call("Available log groups:", style="dim")
114117

115118

116-
def test_show_container_logs_no_config(container_ui):
117-
"""Test displaying container logs with no log configuration."""
118-
container_ui.container_service.get_log_config = Mock(return_value=None)
119-
container_ui.container_service.list_log_groups = Mock(return_value=["group1", "group2"])
120-
121-
container_ui.show_container_logs("test-cluster", "task-arn", "web-container", 50)
122-
123-
container_ui.container_service.get_log_config.assert_called_once_with("test-cluster", "task-arn", "web-container")
124-
container_ui.container_service.list_log_groups.assert_called_once_with("test-cluster", "web-container")
125-
126-
127-
def test_show_container_logs_no_events(container_ui):
128-
"""Test displaying container logs with no events."""
129-
log_config = {"log_group": "test-log-group", "log_stream": "test-stream"}
130-
131-
container_ui.container_service.get_log_config = Mock(return_value=log_config)
132-
container_ui.container_service.get_container_logs = Mock(return_value=[])
133-
134-
container_ui.show_container_logs("test-cluster", "task-arn", "web-container", 50)
135-
136-
container_ui.container_service.get_log_config.assert_called_once_with("test-cluster", "task-arn", "web-container")
137-
container_ui.container_service.get_container_logs.assert_called_once_with("test-log-group", "test-stream", 50)
138-
139-
140119
def test_show_container_environment_variables_success(container_ui):
141120
"""Test displaying container environment variables successfully."""
142121
context = {"container_definition": {"environment": [{"name": "ENV_VAR", "value": "value"}]}}

tests/test_core_navigation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515

1616
def test_parse_selection_with_container_action():
1717
"""Test parsing container action selection with three parts."""
18-
result = parse_selection("container_action:show_logs:web")
19-
assert result == ("container_action", "show_logs", "web")
18+
result = parse_selection("container_action:tail_logs:web")
19+
assert result == ("container_action", "tail_logs", "web")
2020

2121

2222
def test_parse_selection_with_two_parts():

tests/test_task_ui.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,13 +139,13 @@ def test_select_task_with_many_tasks(mock_select, task_ui):
139139
def test_select_task_feature_with_many_containers(mock_select, task_ui):
140140
containers = [{"name": f"container-{i}"} for i in range(10)]
141141
task_details = {"containers": containers}
142-
mock_select.return_value = "container_action:show_logs:container-5"
142+
mock_select.return_value = "container_action:tail_logs:container-5"
143143

144144
result = task_ui.select_task_feature(task_details)
145145

146-
assert result == "container_action:show_logs:container-5"
146+
assert result == "container_action:tail_logs:container-5"
147147
mock_select.assert_called_once()
148148

149149
call_args = mock_select.call_args
150150
choices = call_args[0][1]
151-
assert len(choices) == 62
151+
assert len(choices) == 52

tests/test_ui.py

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ def test_select_task_feature_with_containers(mock_select, mock_ecs_service) -> N
114114
"""Test task feature selection with containers."""
115115
from lazy_ecs.core.types import TaskDetails
116116

117-
mock_select.return_value = "container_action:show_logs:web"
117+
mock_select.return_value = "container_action:tail_logs:web"
118118

119119
navigator = ECSNavigator(mock_ecs_service)
120120
task_details: TaskDetails = {
@@ -130,7 +130,7 @@ def test_select_task_feature_with_containers(mock_select, mock_ecs_service) -> N
130130

131131
selected = navigator.select_task_feature(task_details)
132132

133-
assert selected == "container_action:show_logs:web"
133+
assert selected == "container_action:tail_logs:web"
134134
mock_select.assert_called_once()
135135

136136

@@ -148,23 +148,20 @@ def test_container_methods_delegate_to_container_ui(mock_ecs_service) -> None:
148148
navigator = ECSNavigator(mock_ecs_service)
149149

150150
# Mock all ContainerUI methods
151-
navigator._container_ui.show_container_logs = Mock()
152151
navigator._container_ui.show_logs_live_tail = Mock()
153152
navigator._container_ui.show_container_environment_variables = Mock()
154153
navigator._container_ui.show_container_secrets = Mock()
155154
navigator._container_ui.show_container_port_mappings = Mock()
156155
navigator._container_ui.show_container_volume_mounts = Mock()
157156

158157
# Test delegation
159-
navigator.show_container_logs("cluster", "task", "container", 100)
160158
navigator.show_container_logs_live_tail("cluster", "task", "container")
161159
navigator.show_container_environment_variables("cluster", "task", "container")
162160
navigator.show_container_secrets("cluster", "task", "container")
163161
navigator.show_container_port_mappings("cluster", "task", "container")
164162
navigator.show_container_volume_mounts("cluster", "task", "container")
165163

166164
# Verify delegation
167-
navigator._container_ui.show_container_logs.assert_called_once_with("cluster", "task", "container", 100)
168165
navigator._container_ui.show_logs_live_tail.assert_called_once_with("cluster", "task", "container")
169166
navigator._container_ui.show_container_environment_variables.assert_called_once_with("cluster", "task", "container")
170167
navigator._container_ui.show_container_secrets.assert_called_once_with("cluster", "task", "container")
@@ -194,15 +191,13 @@ def test_build_task_feature_choices() -> None:
194191
choice_values = [choice["value"] for choice in choices]
195192

196193
# Check container actions are present
197-
assert "Show tail of logs for container: web" in choice_names
198-
assert "Show logs live tail for container: web" in choice_names
194+
assert "Show logs (tail) for container: web" in choice_names
199195
assert "Show environment variables for container: web" in choice_names
200196
assert "Show secrets for container: web" in choice_names
201197
assert "Show port mappings for container: web" in choice_names
202198
assert "Show volume mounts for container: web" in choice_names
203199

204-
assert "Show tail of logs for container: sidecar" in choice_names
205-
assert "Show logs live tail for container: sidecar" in choice_names
200+
assert "Show logs (tail) for container: sidecar" in choice_names
206201
assert "Show environment variables for container: sidecar" in choice_names
207202
assert "Show secrets for container: sidecar" in choice_names
208203
assert "Show port mappings for container: sidecar" in choice_names
@@ -213,10 +208,10 @@ def test_build_task_feature_choices() -> None:
213208
assert "❌ Exit" in choice_names
214209

215210
# Check values
216-
assert "container_action:show_logs:web" in choice_values
211+
assert "container_action:tail_logs:web" in choice_values
217212
assert "container_action:show_env:web" in choice_values
218213
assert "navigation:back" in choice_values
219214
assert "navigation:exit" in choice_values
220215

221-
# Total: 6 actions x 2 containers + 2 navigation = 14
222-
assert len(choices) == 14
216+
# Total: 5 actions x 2 containers + 2 navigation = 12
217+
assert len(choices) == 12

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)