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
36 changes: 26 additions & 10 deletions campers/logging/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,33 @@

import logging
import threading
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Protocol

from textual.message import Message
from textual.widgets import RichLog

if TYPE_CHECKING:
from campers.tui import CampersTUI

logger = logging.getLogger(__name__)


class LogWidget(Protocol):
"""Protocol for log widget objects.

Defines the interface required for widgets to work with TuiLogHandler.
"""

def write(self, content: str) -> None:
"""Write content to the log widget.

Parameters
----------
content : str
Content to write to the log
"""
...


class TuiLogMessage(Message):
"""Message delivering a log line to the TUI log widget."""

Expand All @@ -22,32 +38,32 @@ def __init__(self, text: str) -> None:


class TuiLogHandler(logging.Handler):
"""Logging handler that writes to a Textual RichLog widget.
"""Logging handler that writes to a Textual log widget.

Parameters
----------
app : CampersTUI
Textual app instance
log_widget : RichLog
RichLog widget to write to
log_widget : LogWidget
Log widget to write to (RichLog or SelectableLog)

Attributes
----------
app : CampersTUI
Textual app instance
log_widget : RichLog
RichLog widget to write to
log_widget : LogWidget
Log widget to write to (RichLog or SelectableLog)
"""

def __init__(self, app: "CampersTUI", log_widget: RichLog) -> None:
def __init__(self, app: "CampersTUI", log_widget: LogWidget) -> None:
"""Initialize TuiLogHandler.

Parameters
----------
app : CampersTUI
Textual app instance
log_widget : RichLog
RichLog widget to write to
log_widget : LogWidget
Log widget to write to (RichLog or SelectableLog)
"""
super().__init__()
self.app = app
Expand Down
149 changes: 110 additions & 39 deletions campers/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from textual import events
from textual.app import App, ComposeResult
from textual.containers import Container
from textual.widgets import RichLog, Static
from textual.widgets import Static

from campers.constants import (
CTRL_C_DOUBLE_PRESS_THRESHOLD_SECONDS,
Expand All @@ -26,11 +26,15 @@
)
from campers.logging import StreamFormatter, TuiLogHandler, TuiLogMessage
from campers.providers.exceptions import ProviderCredentialsError
from campers.tui import widgets
from campers.tui.exit_modal import ExitModal
from campers.tui.instance_overview_widget import InstanceOverviewWidget
from campers.tui.styling import TUI_CSS
from campers.tui.terminal import detect_terminal_background
from campers.tui.widgets import WidgetID
from campers.tui.widgets.context_menu import ContextMenu
from campers.tui.widgets.labeled_value import LabeledValue
from campers.tui.widgets.search_input import SearchClosed, SearchInput, SearchQueryChanged
from campers.tui.widgets.selectable_log import SelectableLog

if TYPE_CHECKING:
from campers import Campers
Expand Down Expand Up @@ -101,7 +105,7 @@ def __init__(
self.worker_exit_code = 0
self.instance_start_time: datetime | None = None
self.last_ctrl_c_time: float = 0.0
self.log_widget: RichLog | None = None
self.log_widget: SelectableLog | None = None
self.fatal_error_message: str | None = None
self._running = True
self._thread_id = threading.get_ident()
Expand All @@ -115,29 +119,33 @@ def compose(self) -> ComposeResult:
Container
Status panel container with static widgets
Container
Log panel container with log widget
Log panel container with log widget and search input
ContextMenu
Context menu for SelectableLog actions
"""
with Container(id="status-panel"):
yield InstanceOverviewWidget(self.campers)
yield Static("SSH: loading...", id=WidgetID.SSH)
yield Static("Status: launching...", id=WidgetID.STATUS)
yield Static("Uptime: 0s", id=WidgetID.UPTIME)
yield Static("Instance Type: loading...", id=WidgetID.INSTANCE_TYPE)
yield Static("Region: loading...", id=WidgetID.REGION)
yield Static("Camp Name: loading...", id=WidgetID.CAMP_NAME)
yield Static("Command: loading...", id=WidgetID.COMMAND)
yield Static("File sync: Not syncing", id=WidgetID.MUTAGEN)
yield Static("Port forwarding: none", id=WidgetID.PORTFORWARD)
yield Static("", id=WidgetID.PUBLIC_PORTS, classes="hidden")
yield LabeledValue("SSH", "loading...", id=widgets.WidgetID.SSH)
yield LabeledValue("Status", "launching...", id=widgets.WidgetID.STATUS)
yield LabeledValue("Uptime", "0s", id=widgets.WidgetID.UPTIME)
yield LabeledValue("Instance Type", "loading...", id=widgets.WidgetID.INSTANCE_TYPE)
yield LabeledValue("Region", "loading...", id=widgets.WidgetID.REGION)
yield LabeledValue("Camp Name", "loading...", id=widgets.WidgetID.CAMP_NAME)
yield LabeledValue("Command", "loading...", id=widgets.WidgetID.COMMAND)
yield LabeledValue("File sync", "Not syncing", id=widgets.WidgetID.MUTAGEN)
yield LabeledValue("Port forwarding", "none", id=widgets.WidgetID.PORTFORWARD)
yield Static("", id=widgets.WidgetID.PUBLIC_PORTS, classes="hidden")
with Container(id="log-panel"):
yield RichLog(markup=True)
yield SelectableLog()
yield SearchInput()
yield ContextMenu(items=["Copy"])

def on_mount(self) -> None:
"""Handle mount event - setup logging, start worker, and timer."""
root_logger = logging.getLogger()
self.original_handlers = root_logger.handlers[:]

log_widget = self.query_one(RichLog)
log_widget = self.query_one(SelectableLog)
self.log_widget = log_widget
tui_handler = TuiLogHandler(self, log_widget)
tui_handler.setFormatter(StreamFormatter("%(message)s"))
Expand Down Expand Up @@ -176,6 +184,61 @@ async def on_tui_log_message(self, message: TuiLogMessage) -> None:

self.log_widget.write(message.text)

async def on_context_menu_item_selected(self, message: ContextMenu.ItemSelected) -> None:
"""Handle context menu item selection.

Parameters
----------
message : ContextMenu.ItemSelected
Message containing the selected action
"""
target = message.target_widget

if message.action == "copy" and hasattr(target, "action_copy"):
target.action_copy()
elif message.action == "search":
search_input = self.query_one(SearchInput)
search_input.show()
elif message.action == "clear" and hasattr(target, "clear"):
target.clear()

async def on_search_query_changed(self, message: SearchQueryChanged) -> None:
"""Handle search query change.

Updates the log widget with the new search results and updates
the match count display.

Parameters
----------
message : SearchQueryChanged
Message containing the new search query
"""
log_widget = self.query_one(SelectableLog)
log_widget.start_search(message.query)

search_input = self.query_one(SearchInput)
search_input.update_match_count(
log_widget.current_match_index, len(log_widget.search_matches)
)

async def on_search_closed(self, message: SearchClosed) -> None:
"""Handle search input closed.

Clears search if keep_matches is False, otherwise preserves matches
for continued navigation. Returns focus to the log widget.

Parameters
----------
message : SearchClosed
Message indicating whether to keep matches
"""
log_widget = self.query_one(SelectableLog)

if not message.keep_matches:
log_widget.clear_search()

log_widget.focus()

def check_for_updates(self) -> None:
"""Check queue for updates and update widgets accordingly.

Expand Down Expand Up @@ -232,7 +295,7 @@ def update_uptime(self) -> None:
uptime_str = f"{seconds}s"

try:
self.query_one(f"#{WidgetID.UPTIME}").update(f"Uptime: {uptime_str}")
self.query_one(f"#{widgets.WidgetID.UPTIME}", LabeledValue).value = uptime_str
except (ValueError, AttributeError) as e:
logging.error("Failed to update uptime widget: %s", e)

Expand All @@ -248,7 +311,7 @@ def update_status(self, payload: dict[str, Any]) -> None:
status = payload["status"]

try:
self.query_one(f"#{WidgetID.STATUS}").update(f"Status: {status}")
self.query_one(f"#{widgets.WidgetID.STATUS}", LabeledValue).value = status
except (ValueError, AttributeError) as e:
logging.error("Failed to update status widget: %s", e)

Expand All @@ -263,23 +326,20 @@ def update_mutagen_status(self, payload: dict[str, Any]) -> None:
status_text = payload.get("status_text")

if status_text is not None:
if status_text == "idle":
display_text = "File sync: idle"
else:
display_text = f"File sync: {status_text}"
value = status_text
else:
state = payload.get("state", "unknown")
files_synced = payload.get("files_synced")

if state == "not_configured":
display_text = "File sync: Not syncing"
value = "Not syncing"
elif files_synced is not None:
display_text = f"File sync: {state} ({files_synced} files)"
value = f"{state} ({files_synced} files)"
else:
display_text = f"File sync: {state}"
value = state

try:
self.query_one(f"#{WidgetID.MUTAGEN}").update(display_text)
self.query_one(f"#{widgets.WidgetID.MUTAGEN}", LabeledValue).value = value
except (ValueError, AttributeError) as e:
logging.error("Failed to update mutagen widget: %s", e)

Expand All @@ -306,7 +366,7 @@ def update_portforward_status(self, payload: dict[str, Any]) -> None:
text = "Port forwarding: none"

try:
self.query_one(f"#{WidgetID.PORTFORWARD}").update(text)
self.query_one(f"#{widgets.WidgetID.PORTFORWARD}").update(text)
except (ValueError, AttributeError) as e:
logging.error("Failed to update portforward widget: %s", e)

Expand Down Expand Up @@ -334,34 +394,45 @@ def update_from_config(self, config: dict[str, Any]) -> None:

if "instance_type" in config:
try:
self.query_one(f"#{WidgetID.INSTANCE_TYPE}").update(
f"Instance Type: {config['instance_type']}"
widget = self.query_one(
f"#{widgets.WidgetID.INSTANCE_TYPE}", LabeledValue
)
widget.value = config['instance_type']
except (ValueError, AttributeError, RuntimeError) as e:
logging.error("Failed to update instance type widget: %s", e)

if "region" in config:
try:
self.query_one(f"#{WidgetID.REGION}").update(f"Region: {config['region']}")
widget = self.query_one(
f"#{widgets.WidgetID.REGION}", LabeledValue
)
widget.value = config['region']
except (ValueError, AttributeError, RuntimeError) as e:
logging.error("Failed to update region widget: %s", e)

camp_name = config.get("camp_name", "ad-hoc")

try:
self.query_one(f"#{WidgetID.CAMP_NAME}").update(f"Camp Name: {camp_name}")
widget = self.query_one(
f"#{widgets.WidgetID.CAMP_NAME}", LabeledValue
)
widget.value = camp_name
except (ValueError, AttributeError, RuntimeError) as e:
logging.error("Failed to update camp name widget: %s", e)

if "command" in config:
try:
self.query_one(f"#{WidgetID.COMMAND}").update(f"Command: {config['command']}")
cmd = config["command"]
widget = self.query_one(
f"#{widgets.WidgetID.COMMAND}", LabeledValue
)
widget.value = cmd
except (ValueError, AttributeError, RuntimeError) as e:
logging.error("Failed to update command widget: %s", e)

public_ports = config.get("public_ports", [])
try:
public_ports_widget = self.query_one(f"#{WidgetID.PUBLIC_PORTS}")
public_ports_widget = self.query_one(f"#{widgets.WidgetID.PUBLIC_PORTS}")
if public_ports:
instance_details = self.campers._resources.get("instance_details", {})
public_ip = instance_details.get("public_ip")
Expand All @@ -388,7 +459,7 @@ def update_from_instance_details(self, details: dict[str, Any]) -> None:
"""
if "state" in details:
try:
self.query_one(f"#{WidgetID.STATUS}").update(f"Status: {details['state']}")
self.query_one(f"#{widgets.WidgetID.STATUS}", LabeledValue).value = details['state']
except (ValueError, AttributeError, RuntimeError) as e:
logging.error("Failed to update status widget: %s", e)

Expand All @@ -404,7 +475,7 @@ def update_from_instance_details(self, details: dict[str, Any]) -> None:
ssh_username = details.get("ssh_username", DEFAULT_SSH_USERNAME)
key_file = details.get("key_file", "key.pem")
ssh_string = f"ssh -o IdentitiesOnly=yes -i {key_file} {ssh_username}@{public_ip}"
self.query_one(f"#{WidgetID.SSH}").update(f"SSH: {ssh_string}")
self.query_one(f"#{widgets.WidgetID.SSH}", LabeledValue).value = ssh_string
except (ValueError, AttributeError, RuntimeError) as e:
logging.error("Failed to update SSH widget: %s", e)

Expand All @@ -416,7 +487,7 @@ def update_from_instance_details(self, details: dict[str, Any]) -> None:
protocol = "https" if port == 443 else "http"
urls.append(f"{protocol}://{public_ip}:{port}")
public_ports_text = f"Public IP: {public_ip} | URLs: " + ", ".join(urls)
public_ports_widget = self.query_one(f"#{WidgetID.PUBLIC_PORTS}")
public_ports_widget = self.query_one(f"#{widgets.WidgetID.PUBLIC_PORTS}")
public_ports_widget.update(public_ports_text)
public_ports_widget.remove_class("hidden")
except (ValueError, AttributeError, RuntimeError) as e:
Expand Down Expand Up @@ -579,7 +650,7 @@ def on_key(self, event: events.Key) -> None:
and (current_time - self.last_ctrl_c_time) < CTRL_C_DOUBLE_PRESS_THRESHOLD_SECONDS
):
try:
log_widget = self.query_one(RichLog)
log_widget = self.query_one(SelectableLog)
log_widget.write("[red]Force exit - skipping cleanup![/red]")
except (ValueError, AttributeError, RuntimeError) as e:
logger.debug("Failed to write to log widget during force exit: %s", e)
Expand Down Expand Up @@ -647,12 +718,12 @@ def handle_exit_choice(action: str | None) -> None:
self._selected_exit_action = action

try:
self.query_one(f"#{WidgetID.STATUS}").update("Status: shutting down")
self.query_one(f"#{widgets.WidgetID.STATUS}", LabeledValue).value = "shutting down"
except (ValueError, AttributeError, RuntimeError) as e:
logger.debug("Failed to update status widget during quit: %s", e)

try:
log_widget = self.query_one(RichLog)
log_widget = self.query_one(SelectableLog)
msg = "Graceful shutdown initiated (press Ctrl+C again to force exit)"
log_widget.write(msg)
except (ValueError, AttributeError, RuntimeError) as e:
Expand Down
Loading