Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
1 change: 0 additions & 1 deletion src/lazy_ecs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
53 changes: 18 additions & 35 deletions src/lazy_ecs/features/container/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand Down
10 changes: 3 additions & 7 deletions src/lazy_ecs/features/task/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
},
{
Expand Down Expand Up @@ -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}'",
Expand Down
8 changes: 2 additions & 6 deletions src/lazy_ecs/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"),
Expand Down
81 changes: 30 additions & 51 deletions tests/test_container_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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:
Expand All @@ -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"}]}}
Expand Down
4 changes: 2 additions & 2 deletions tests/test_core_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
6 changes: 3 additions & 3 deletions tests/test_task_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 7 additions & 12 deletions tests/test_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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()


Expand All @@ -148,23 +148,20 @@ 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()
navigator._container_ui.show_container_port_mappings = Mock()
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")
navigator.show_container_port_mappings("cluster", "task", "container")
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")
Expand Down Expand Up @@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading