diff --git a/README.md b/README.md index d3bdff9..77762e0 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ lazy-ecs will automatically use the standard AWS credentials chain: ### Quality of Life Features -- ⬜ **Open resource in AWS console** - One-key shortcut to open current cluster/service/task in browser +- āœ… **Open resource in AWS console** - One-key shortcut to open current cluster/service/task in browser ## Development diff --git a/pyproject.toml b/pyproject.toml index 7d7d3e4..e5f0971 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lazy-ecs" -version = "0.4.0" +version = "0.5.0" description = "A CLI tool for working with AWS services" readme = "README.md" authors = [ diff --git a/src/lazy_ecs/__init__.py b/src/lazy_ecs/__init__.py index 89e1374..86ed4fd 100644 --- a/src/lazy_ecs/__init__.py +++ b/src/lazy_ecs/__init__.py @@ -163,6 +163,10 @@ def _navigate_services(navigator: ECSNavigator, ecs_service: ECSService, cluster navigator.show_service_metrics(cluster_name, selected_service) # Continue the loop to show the menu again + elif selection_type == "action" and action_name == "open_console": + navigator.open_service_in_console(cluster_name, selected_service) + # Continue the loop to show the menu again + def _handle_task_features( navigator: ECSNavigator, cluster_name: str, task_arn: str, task_details: TaskDetails | None, service_name: str @@ -195,6 +199,8 @@ def _handle_task_features( navigator.show_task_history(cluster_name, service_name) elif action_name == "show_details": navigator.display_task_details(task_details) + elif action_name == "open_console": + navigator.open_task_in_console(cluster_name, task_arn) if __name__ == "__main__": diff --git a/src/lazy_ecs/aws_service.py b/src/lazy_ecs/aws_service.py index beb7d6f..66aafa8 100644 --- a/src/lazy_ecs/aws_service.py +++ b/src/lazy_ecs/aws_service.py @@ -141,3 +141,7 @@ def get_service_metrics(self, cluster_name: str, service_name: str, hours: int = from .features.service.metrics import get_service_metrics return get_service_metrics(self.cloudwatch_client, cluster_name, service_name, hours) + + def get_region(self) -> str: + """Get the AWS region from the ECS client.""" + return self.ecs_client.meta.region_name diff --git a/src/lazy_ecs/core/aws_console.py b/src/lazy_ecs/core/aws_console.py new file mode 100644 index 0000000..3157a18 --- /dev/null +++ b/src/lazy_ecs/core/aws_console.py @@ -0,0 +1,26 @@ +"""AWS Console URL construction for ECS resources.""" + +from __future__ import annotations + + +def build_cluster_url(region: str, cluster_name: str) -> str: + """Build AWS console URL for an ECS cluster.""" + return f"https://{region}.console.aws.amazon.com/ecs/v2/clusters/{cluster_name}" + + +def build_service_url(region: str, cluster_name: str, service_name: str) -> str: + """Build AWS console URL for an ECS service.""" + return f"https://{region}.console.aws.amazon.com/ecs/v2/clusters/{cluster_name}/services/{service_name}" + + +def build_task_url(region: str, cluster_name: str, task_arn: str) -> str: + """Build AWS console URL for an ECS task.""" + task_id = _extract_task_id(task_arn) + return f"https://{region}.console.aws.amazon.com/ecs/v2/clusters/{cluster_name}/tasks/{task_id}" + + +def _extract_task_id(task_arn: str) -> str: + """Extract task ID from task ARN or return as-is if already an ID.""" + if task_arn.startswith("arn:aws:ecs:"): + return task_arn.split("/")[-1] + return task_arn diff --git a/src/lazy_ecs/features/service/ui.py b/src/lazy_ecs/features/service/ui.py index 2a45e8d..4a110ec 100644 --- a/src/lazy_ecs/features/service/ui.py +++ b/src/lazy_ecs/features/service/ui.py @@ -46,6 +46,7 @@ def select_service_action(self, service_name: str, task_info: list[TaskInfo]) -> choices.append({"name": "šŸ“‹ Show service events", "value": "action:show_events"}) choices.append({"name": "šŸ“Š Show metrics", "value": "action:show_metrics"}) + choices.append({"name": "🌐 Open in AWS console", "value": "action:open_console"}) choices.append({"name": "šŸš€ Force new deployment", "value": "action:force_deployment"}) return select_with_auto_pagination( diff --git a/src/lazy_ecs/features/task/ui.py b/src/lazy_ecs/features/task/ui.py index 8a82afa..579d6e4 100644 --- a/src/lazy_ecs/features/task/ui.py +++ b/src/lazy_ecs/features/task/ui.py @@ -118,6 +118,7 @@ def select_task_feature(self, task_details: TaskDetails | None) -> str | None: [ {"name": "Show task details", "value": "task_action:show_details"}, {"name": "Show task history and failures", "value": "task_action:show_history"}, + {"name": "🌐 Open in AWS console", "value": "task_action:open_console"}, ] ) diff --git a/src/lazy_ecs/ui.py b/src/lazy_ecs/ui.py index 3b4e7ff..d59be28 100644 --- a/src/lazy_ecs/ui.py +++ b/src/lazy_ecs/ui.py @@ -113,6 +113,28 @@ def show_service_metrics(self, cluster_name: str, service_name: str) -> None: def show_task_history(self, cluster_name: str, service_name: str) -> None: self._task_ui.display_task_history(cluster_name, service_name) + def open_service_in_console(self, cluster_name: str, service_name: str) -> None: + """Open the service in AWS console.""" + import webbrowser + + from .core.aws_console import build_service_url + + region = self.ecs_service.get_region() + url = build_service_url(region, cluster_name, service_name) + console.print(f"\n🌐 Opening service in AWS console: {url}", style="cyan") + webbrowser.open(url) + + def open_task_in_console(self, cluster_name: str, task_arn: str) -> None: + """Open the task in AWS console.""" + import webbrowser + + from .core.aws_console import build_task_url + + region = self.ecs_service.get_region() + url = build_task_url(region, cluster_name, task_arn) + console.print(f"\n🌐 Opening task in AWS console: {url}", style="cyan") + webbrowser.open(url) + def _build_task_feature_choices(containers: list[dict[str, Any]]) -> list[dict[str, str]]: """Build feature menu choices for containers plus navigation options.""" diff --git a/tests/test_aws_console.py b/tests/test_aws_console.py new file mode 100644 index 0000000..bc3ca01 --- /dev/null +++ b/tests/test_aws_console.py @@ -0,0 +1,45 @@ +"""Tests for AWS console URL construction.""" + +from lazy_ecs.core.aws_console import build_cluster_url, build_service_url, build_task_url + + +def test_build_cluster_url(): + url = build_cluster_url(region="us-east-1", cluster_name="production") + assert url == "https://us-east-1.console.aws.amazon.com/ecs/v2/clusters/production" + + +def test_build_cluster_url_with_special_characters(): + url = build_cluster_url(region="eu-west-1", cluster_name="test-cluster-123") + assert url == "https://eu-west-1.console.aws.amazon.com/ecs/v2/clusters/test-cluster-123" + + +def test_build_service_url(): + url = build_service_url(region="us-east-1", cluster_name="production", service_name="web-api") + expected = "https://us-east-1.console.aws.amazon.com/ecs/v2/clusters/production/services/web-api" + assert url == expected + + +def test_build_service_url_with_special_characters(): + url = build_service_url(region="ap-southeast-2", cluster_name="test-cluster", service_name="worker-service-v2") + expected = "https://ap-southeast-2.console.aws.amazon.com/ecs/v2/clusters/test-cluster/services/worker-service-v2" + assert url == expected + + +def test_build_task_url(): + task_arn = "arn:aws:ecs:us-east-1:123456789012:task/production/abc123def456" + url = build_task_url(region="us-east-1", cluster_name="production", task_arn=task_arn) + expected = "https://us-east-1.console.aws.amazon.com/ecs/v2/clusters/production/tasks/abc123def456" + assert url == expected + + +def test_build_task_url_extracts_task_id_from_arn(): + task_arn = "arn:aws:ecs:eu-west-1:987654321098:task/my-cluster/xyz789abc123" + url = build_task_url(region="eu-west-1", cluster_name="my-cluster", task_arn=task_arn) + expected = "https://eu-west-1.console.aws.amazon.com/ecs/v2/clusters/my-cluster/tasks/xyz789abc123" + assert url == expected + + +def test_build_task_url_with_just_task_id(): + url = build_task_url(region="us-west-2", cluster_name="staging", task_arn="simple-task-id-123") + expected = "https://us-west-2.console.aws.amazon.com/ecs/v2/clusters/staging/tasks/simple-task-id-123" + assert url == expected diff --git a/tests/test_service_ui.py b/tests/test_service_ui.py index 93b200b..24bcbc6 100644 --- a/tests/test_service_ui.py +++ b/tests/test_service_ui.py @@ -125,7 +125,7 @@ def test_select_service_action_with_many_tasks(mock_select, service_ui): call_args = mock_select.call_args choices = call_args[0][1] - assert len(choices) == 103 # 100 tasks + 3 actions (events, metrics, deployment) + assert len(choices) == 104 # 100 tasks + 4 actions (events, metrics, console, deployment) @patch("lazy_ecs.features.service.ui.select_with_auto_pagination") diff --git a/tests/test_task_ui.py b/tests/test_task_ui.py index cab0291..1ce2fe2 100644 --- a/tests/test_task_ui.py +++ b/tests/test_task_ui.py @@ -148,4 +148,4 @@ def test_select_task_feature_with_many_containers(mock_select, task_ui): call_args = mock_select.call_args choices = call_args[0][1] - assert len(choices) == 52 + assert len(choices) == 53 # 3 task actions (details, history, console) + 10 containers * 5 actions each diff --git a/uv.lock b/uv.lock index 2315c8b..2f6c636 100644 --- a/uv.lock +++ b/uv.lock @@ -384,7 +384,7 @@ wheels = [ [[package]] name = "lazy-ecs" -version = "0.4.0" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "boto3" },