Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changes/next-release/enhancement-ecs-96384.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "enhancement",
"category": "``ecs``",
"description": "Introduces text only mode to ECS Express Mode Monitoring commands and addressing issues exiting interactive mode."
}
2 changes: 1 addition & 1 deletion awscli/customizations/ecs/expressgateway/color_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class ColorUtils:

def __init__(self):
# Initialize colorama
init(autoreset=True, strip=False)
init(autoreset=False, strip=False)

def make_green(self, text, use_color=True):
if not use_color:
Expand Down
206 changes: 206 additions & 0 deletions awscli/customizations/ecs/expressgateway/display_strategy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#
# http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
# ANY KIND, either express or implied. See the License for the specific
# language governing permissions and limitations under the License.

"""Display strategy implementations for ECS Express Gateway Service monitoring."""

import asyncio
import time

from botocore.exceptions import ClientError

from awscli.customizations.ecs.exceptions import MonitoringError
from awscli.customizations.ecs.expressgateway.stream_display import (
StreamDisplay,
)
from awscli.customizations.utils import uni_print


class DisplayStrategy:
"""Base class for display strategies.

Each strategy controls its own execution model, timing, and output format.
"""

def execute(self, collector, start_time, timeout_minutes):
"""Execute the monitoring loop.

Args:
collector: ServiceViewCollector instance for data fetching
start_time: Start timestamp for timeout calculation
timeout_minutes: Maximum monitoring duration in minutes
"""
raise NotImplementedError


class InteractiveDisplayStrategy(DisplayStrategy):
"""Interactive display strategy with async spinner and keyboard navigation.

Uses dual async tasks:
- Data task: Polls ECS APIs every 5 seconds
- Spinner task: Updates display every 100ms with rotating spinner
"""

def __init__(self, display, use_color):
self.display = display
self.use_color = use_color

def execute(self, collector, start_time, timeout_minutes):
"""Execute async monitoring with spinner and keyboard controls."""
final_output, timed_out = asyncio.run(
self._execute_async(collector, start_time, timeout_minutes)
)
if timed_out:
uni_print(final_output + "\nMonitoring timed out!\n")
else:
uni_print(final_output + "\nMonitoring Complete!\n")

async def _execute_async(self, collector, start_time, timeout_minutes):
"""Async execution with dual tasks for data and spinner."""
spinner_chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
spinner_index = 0
current_output = "Waiting for initial data"
timed_out = False

async def update_data():
nonlocal current_output, timed_out
while True:
current_time = time.time()
if current_time - start_time > timeout_minutes * 60:
timed_out = True
self.display.app.exit()
break

try:
loop = asyncio.get_event_loop()
new_output = await loop.run_in_executor(
None, collector.get_current_view, "{SPINNER}"
)
current_output = new_output
except ClientError as e:
if (
e.response.get('Error', {}).get('Code')
== 'InvalidParameterException'
):
error_message = e.response.get('Error', {}).get(
'Message', ''
)
if (
"Cannot call DescribeServiceRevisions for a service that is INACTIVE"
in error_message
):
current_output = "Service is inactive"
else:
raise
else:
raise

await asyncio.sleep(5.0)

async def update_spinner():
nonlocal spinner_index
while True:
spinner_char = spinner_chars[spinner_index]
display_output = current_output.replace(
"{SPINNER}", spinner_char
)
status_text = f"Getting updates... {spinner_char} | up/down to scroll, q to quit"
self.display.display(display_output, status_text)
spinner_index = (spinner_index + 1) % len(spinner_chars)
await asyncio.sleep(0.1)

data_task = asyncio.create_task(update_data())
spinner_task = asyncio.create_task(update_spinner())
display_task = None

try:
display_task = asyncio.create_task(self.display.run())

done, pending = await asyncio.wait(
[display_task, data_task], return_when=asyncio.FIRST_COMPLETED
)

if data_task in done:
await data_task

finally:
spinner_task.cancel()
if display_task is not None and not display_task.done():
display_task.cancel()
try:
await display_task
except asyncio.CancelledError:
pass

return current_output.replace("{SPINNER}", ""), timed_out


class TextOnlyDisplayStrategy(DisplayStrategy):
"""Text-only display strategy with diff detection and timestamped output.

Uses synchronous polling loop with change detection to output only
individual resource changes with timestamps.
"""

def __init__(self, use_color):
self.stream_display = StreamDisplay(use_color)

def execute(self, collector, start_time, timeout_minutes):
"""Execute synchronous monitoring with text output."""
self.stream_display.show_startup_message()

try:
while True:
current_time = time.time()
if current_time - start_time > timeout_minutes * 60:
self.stream_display.show_timeout_message()
break

try:
self.stream_display.show_polling_message()

collector.get_current_view("")

# Extract cached result for diff detection
managed_resources, info = collector.cached_monitor_result

self.stream_display.show_monitoring_data(
managed_resources, info
)

except ClientError as e:
if (
e.response.get('Error', {}).get('Code')
== 'InvalidParameterException'
):
error_message = e.response.get('Error', {}).get(
'Message', ''
)
if (
"Cannot call DescribeServiceRevisions for a service that is INACTIVE"
in error_message
):
self.stream_display.show_service_inactive_message()
break
else:
raise
else:
raise

time.sleep(5.0)

except KeyboardInterrupt:
self.stream_display.show_user_stop_message()
except MonitoringError as e:
self.stream_display.show_error_message(e)
finally:
self.stream_display.show_completion_message()
72 changes: 62 additions & 10 deletions awscli/customizations/ecs/expressgateway/managedresource.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,52 @@ def get_status_string(self, spinner_char, depth=0, use_color=True):
lines.append("")
return '\n'.join(lines)

def get_stream_string(self, timestamp, use_color=True):
"""Returns the resource information formatted for stream/text-only display.

Args:
timestamp (str): Timestamp string to prefix the output
use_color (bool): Whether to use ANSI color codes (default: True)

Returns:
str: Formatted string with timestamp prefix and bracket-enclosed status
"""
lines = []
parts = [f"[{timestamp}]"]

if self.resource_type:
parts.append(
self.color_utils.make_cyan(self.resource_type, use_color)
)

if self.identifier:
colored_id = self.color_utils.color_by_status(
self.identifier, self.status, use_color
)
parts.append(colored_id)

if self.status:
status_text = self.color_utils.color_by_status(
self.status, self.status, use_color
)
parts.append(f"[{status_text}]")

lines.append(" ".join(parts))

if self.reason:
lines.append(f" Reason: {self.reason}")

if self.updated_at:
updated_time = datetime.fromtimestamp(self.updated_at).strftime(
"%Y-%m-%d %H:%M:%S"
)
lines.append(f" Last Updated At: {updated_time}")

if self.additional_info:
lines.append(f" Info: {self.additional_info}")

return "\n".join(lines)

def combine(self, other_resource):
"""Returns the version of the resource which has the most up to date timestamp.

Expand All @@ -130,22 +176,28 @@ def combine(self, other_resource):
else other_resource
)

def diff(self, other_resource):
"""Returns a tuple of (self_diff, other_diff) for resources that are different.
def compare_properties(self, other_resource):
"""Compares individual resource properties to detect changes.

This compares properties like status, reason, updated_at, additional_info
to detect if a resource has changed between polls.

Args:
other_resource (ManagedResource): Resource to compare against

Returns:
tuple: (self_diff, other_diff) where:
- self_diff (ManagedResource): This resource if different, None if same
- other_diff (ManagedResource): Other resource if different, None if same
bool: True if properties differ, False if same
"""
if not other_resource:
return (self, None)
if (
# No previous resource means it's new/different
return True

# Resources are different if any field differs
return (
self.resource_type != other_resource.resource_type
or self.identifier != other_resource.identifier
):
return (self, other_resource)
return (None, None)
or self.status != other_resource.status
or self.reason != other_resource.reason
or self.updated_at != other_resource.updated_at
or self.additional_info != other_resource.additional_info
)
Loading
Loading