Skip to content

Commit 11385a4

Browse files
authored
Merge pull request #26 from vertti/filter-logs
Interactive Log Filtering for Container Log
2 parents 7f90d1a + 4624f27 commit 11385a4

8 files changed

Lines changed: 405 additions & 102 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ lazy-ecs will automatically use the standard AWS credentials chain:
7878
### Container-Level Features 🚀
7979

8080
-**Container log viewing** - Display recent logs with timestamps from CloudWatch
81-
-**Container log live tail viewing** - Display logs live tail with timestamps from CloudWatch
81+
-**Container log live tail viewing** - Real-time log streaming with instant keyboard shortcuts
82+
-**Log filtering** - CloudWatch filter patterns (include/exclude) during live tail
8283
-**Basic container details** - Show container name, image, CPU/memory configuration
8384
-**Show environment variables & secrets** - Display environment variables and secrets configuration (without exposing secret values)
8485
-**Show port mappings** - Display container port configurations and networking
@@ -124,8 +125,8 @@ lazy-ecs will automatically use the standard AWS credentials chain:
124125
### Advanced Features 🎯
125126

126127
-**Enhanced log features**:
127-
- Search/filter logs by keywords or time range
128-
- ✅ Follow logs in real-time (tail -f style) - complex UI implementation
128+
- Search/filter logs by keywords (CloudWatch patterns with include/exclude)
129+
- ✅ Follow logs in real-time (tail -f style) with responsive keyboard shortcuts
129130
- ⬜ Download logs to file
130131
-**Monitoring integration**:
131132
- ⬜ Show CloudWatch metrics for containers/tasks

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.1"
3+
version = "0.3.0"
44
description = "A CLI tool for working with AWS services"
55
readme = "README.md"
66
authors = [

src/lazy_ecs/core/utils.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,38 @@
22

33
from __future__ import annotations
44

5+
import atexit
6+
import select
7+
import sys
8+
import threading
59
from collections.abc import Iterator
6-
from contextlib import contextmanager
10+
from contextlib import contextmanager, suppress
711
from typing import TYPE_CHECKING, Literal
812

913
from rich.console import Console
1014
from rich.spinner import Spinner
1115

16+
# Try to import Unix-specific terminal control modules
17+
try:
18+
import termios
19+
import tty
20+
21+
HAS_TERMIOS = True
22+
# Store original terminal settings globally
23+
_original_terminal_settings = None
24+
if sys.stdin.isatty():
25+
_original_terminal_settings = termios.tcgetattr(sys.stdin.fileno())
26+
27+
# Register cleanup on exit
28+
def restore_terminal() -> None:
29+
if _original_terminal_settings and sys.stdin.isatty():
30+
with suppress(Exception):
31+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _original_terminal_settings)
32+
33+
atexit.register(restore_terminal)
34+
except ImportError:
35+
HAS_TERMIOS = False
36+
1237
if TYPE_CHECKING:
1338
from mypy_boto3_ecs.client import ECSClient
1439

@@ -79,3 +104,46 @@ def paginate_aws_list(
79104
results.extend(page.get(result_key, []))
80105

81106
return results
107+
108+
109+
def wait_for_keypress(stop_event: threading.Event) -> str | None:
110+
"""Wait for a single keypress in a non-blocking manner.
111+
112+
Returns the key pressed, or None if stop_event is set.
113+
This runs in a separate thread to allow checking for keypresses without blocking.
114+
"""
115+
if HAS_TERMIOS and sys.stdin.isatty():
116+
# Unix/Linux/macOS with terminal support
117+
fd = sys.stdin.fileno()
118+
old_settings = termios.tcgetattr(fd)
119+
try:
120+
# Set terminal to cbreak mode for single-character input
121+
tty.setcbreak(fd)
122+
123+
# Check for input with timeout
124+
while not stop_event.is_set():
125+
# Use select to check if input is available (0.01 second timeout for responsiveness)
126+
if select.select([sys.stdin], [], [], 0.01)[0]:
127+
char = sys.stdin.read(1)
128+
# Handle Ctrl-C properly
129+
if char == "\x03": # Ctrl-C
130+
raise KeyboardInterrupt()
131+
return char
132+
133+
return None
134+
except (Exception, KeyboardInterrupt):
135+
# Restore settings before re-raising
136+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
137+
if isinstance(sys.exc_info()[1], KeyboardInterrupt):
138+
raise
139+
return None
140+
finally:
141+
# Always restore terminal settings
142+
with suppress(Exception):
143+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
144+
else:
145+
# Fallback for Windows or when termios is not available
146+
try:
147+
return sys.stdin.read(1)
148+
except Exception:
149+
return None

src/lazy_ecs/features/container/container.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from collections.abc import Generator
6+
from contextlib import suppress
67
from os import environ
78
from typing import TYPE_CHECKING, Any
89

@@ -15,6 +16,7 @@
1516
from mypy_boto3_ecs.type_defs import ContainerDefinitionOutputTypeDef, TaskDefinitionTypeDef
1617
from mypy_boto3_logs.client import CloudWatchLogsClient
1718
from mypy_boto3_logs.type_defs import (
19+
FilteredLogEventTypeDef,
1820
LiveTailSessionLogEventTypeDef,
1921
OutputLogEventTypeDef,
2022
StartLiveTailResponseStreamTypeDef,
@@ -106,6 +108,20 @@ def get_container_logs(self, log_group: str, log_stream: str, lines: int = 50) -
106108
)
107109
return response.get("events", [])
108110

111+
def get_container_logs_filtered(
112+
self, log_group: str, log_stream: str, filter_pattern: str, lines: int = 50
113+
) -> list[FilteredLogEventTypeDef]:
114+
"""Get container logs with CloudWatch filter pattern applied."""
115+
if not self.logs_client:
116+
return []
117+
response = self.logs_client.filter_log_events(
118+
logGroupName=log_group,
119+
logStreamNames=[log_stream],
120+
filterPattern=filter_pattern,
121+
limit=lines,
122+
)
123+
return response.get("events", [])
124+
109125
def get_live_container_logs_tail(
110126
self, log_group: str, log_stream: str, event_filter_pattern: str = ""
111127
) -> Generator[StartLiveTailResponseStreamTypeDef | LiveTailSessionLogEventTypeDef]:
@@ -127,14 +143,20 @@ def get_live_container_logs_tail(
127143
logEventFilterPattern=event_filter_pattern,
128144
)
129145
response_stream = response.get("responseStream")
130-
for event in response_stream:
131-
if "sessionStart" in event:
132-
continue
133-
elif "sessionUpdate" in event:
134-
log_events = event.get("sessionUpdate", {}).get("sessionResults", [])
135-
yield from log_events
136-
else:
137-
yield event
146+
try:
147+
for event in response_stream:
148+
if "sessionStart" in event:
149+
continue
150+
elif "sessionUpdate" in event:
151+
log_events = event.get("sessionUpdate", {}).get("sessionResults", [])
152+
yield from log_events
153+
else:
154+
yield event
155+
finally:
156+
# Properly close the response stream
157+
if hasattr(response_stream, "close"):
158+
with suppress(Exception):
159+
response_stream.close()
138160

139161
def list_log_groups(self, cluster_name: str, container_name: str) -> list[str]:
140162
if not self.logs_client:
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Data models for container operations."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from datetime import datetime
7+
from enum import Enum
8+
from typing import Any
9+
10+
11+
class Action(Enum):
12+
"""Actions for log tailing interaction."""
13+
14+
STOP = "s"
15+
FILTER = "f"
16+
CLEAR = "c"
17+
18+
@classmethod
19+
def from_key(cls, key: str) -> Action | None:
20+
"""Convert keyboard key to action."""
21+
for action in cls:
22+
if action.value == key:
23+
return action
24+
return None
25+
26+
27+
@dataclass
28+
class LogEvent:
29+
"""Represents a log event from CloudWatch."""
30+
31+
timestamp: int | None
32+
message: str
33+
event_id: str | None = None
34+
35+
@property
36+
def key(self) -> tuple[Any, ...] | str:
37+
"""Get unique key for deduplication."""
38+
return self.event_id if self.event_id else (self.timestamp, self.message)
39+
40+
def format(self) -> str:
41+
"""Format the log event for display."""
42+
if self.timestamp:
43+
dt = datetime.fromtimestamp(self.timestamp / 1000)
44+
return f"[{dt.strftime('%H:%M:%S')}] {self.message}"
45+
return self.message

0 commit comments

Comments
 (0)