llamactl 0.3.0a6__py3-none-any.whl → 0.3.0a8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,6 @@
2
2
 
3
3
  import dataclasses
4
4
  import logging
5
- import os
6
5
  import re
7
6
  from dataclasses import dataclass, field
8
7
  from pathlib import Path
@@ -14,14 +13,26 @@ from llama_deploy.cli.textual.deployment_help import (
14
13
  DeploymentHelpBackMessage,
15
14
  DeploymentHelpWidget,
16
15
  )
16
+ from llama_deploy.cli.textual.deployment_monitor import (
17
+ DeploymentMonitorWidget,
18
+ MonitorCloseMessage,
19
+ )
17
20
  from llama_deploy.cli.textual.git_validation import (
18
21
  GitValidationWidget,
19
22
  ValidationCancelMessage,
20
23
  ValidationResultMessage,
21
24
  )
22
25
  from llama_deploy.cli.textual.secrets_form import SecretsWidget
23
- from llama_deploy.core.deployment_config import DeploymentConfig
24
- from llama_deploy.core.git.git_util import get_current_branch, is_git_repo, list_remotes
26
+ from llama_deploy.core.deployment_config import (
27
+ DEFAULT_DEPLOYMENT_NAME,
28
+ read_deployment_config,
29
+ )
30
+ from llama_deploy.core.git.git_util import (
31
+ get_current_branch,
32
+ get_git_root,
33
+ is_git_repo,
34
+ list_remotes,
35
+ )
25
36
  from llama_deploy.core.schema.deployments import (
26
37
  DeploymentCreate,
27
38
  DeploymentResponse,
@@ -45,7 +56,7 @@ class DeploymentForm:
45
56
  id: str | None = None
46
57
  repo_url: str = ""
47
58
  git_ref: str = "main"
48
- deployment_file_path: str = "llama_deploy.yaml"
59
+ deployment_file_path: str = ""
49
60
  personal_access_token: str = ""
50
61
  # indicates if the deployment has a personal access token (value is unknown)
51
62
  has_existing_pat: bool = False
@@ -67,7 +78,7 @@ class DeploymentForm:
67
78
  id=deployment.id,
68
79
  repo_url=deployment.repo_url,
69
80
  git_ref=deployment.git_ref or "main",
70
- deployment_file_path=deployment.deployment_file_path or "llama_deploy.yaml",
81
+ deployment_file_path=deployment.deployment_file_path,
71
82
  personal_access_token="", # Always start empty for security
72
83
  has_existing_pat=deployment.has_personal_access_token,
73
84
  secrets={},
@@ -85,7 +96,7 @@ class DeploymentForm:
85
96
  data = DeploymentUpdate(
86
97
  repo_url=self.repo_url,
87
98
  git_ref=self.git_ref or "main",
88
- deployment_file_path=self.deployment_file_path or "llama_deploy.yaml",
99
+ deployment_file_path=self.deployment_file_path or None,
89
100
  personal_access_token=(
90
101
  ""
91
102
  if self.personal_access_token is None and not self.has_existing_pat
@@ -102,7 +113,7 @@ class DeploymentForm:
102
113
  return DeploymentCreate(
103
114
  name=self.name,
104
115
  repo_url=self.repo_url,
105
- deployment_file_path=self.deployment_file_path or "llama_deploy.yaml",
116
+ deployment_file_path=self.deployment_file_path or None,
106
117
  git_ref=self.git_ref or "main",
107
118
  personal_access_token=self.personal_access_token,
108
119
  secrets=self.secrets,
@@ -175,10 +186,10 @@ class DeploymentFormWidget(Widget):
175
186
  compact=True,
176
187
  )
177
188
 
178
- yield Label("Deployment File:", classes="form-label", shrink=True)
189
+ yield Label("Config File:", classes="form-label", shrink=True)
179
190
  yield Input(
180
191
  value=self.form_data.deployment_file_path,
181
- placeholder="llama_deploy.yaml",
192
+ placeholder="Optional path to config dir/file",
182
193
  id="deployment_file_path",
183
194
  compact=True,
184
195
  )
@@ -283,8 +294,7 @@ class DeploymentFormWidget(Widget):
283
294
  id=self.form_data.id,
284
295
  repo_url=repo_url_input.value.strip(),
285
296
  git_ref=git_ref_input.value.strip() or "main",
286
- deployment_file_path=deployment_file_input.value.strip()
287
- or "llama_deploy.yaml",
297
+ deployment_file_path=deployment_file_input.value.strip(),
288
298
  personal_access_token=pat_value,
289
299
  secrets=updated_secrets,
290
300
  initial_secrets=self.original_form_data.initial_secrets,
@@ -328,10 +338,11 @@ class DeploymentEditApp(App[DeploymentResponse | None]):
328
338
 
329
339
  CSS_PATH = Path(__file__).parent / "styles.tcss"
330
340
 
331
- # App states: 'form', 'validation', or 'help'
341
+ # App states: 'form', 'validation', 'help', or 'monitor'
332
342
  current_state: reactive[str] = reactive("form", recompose=True)
333
343
  form_data: reactive[DeploymentForm] = reactive(DeploymentForm())
334
344
  save_error: reactive[str] = reactive("", recompose=True)
345
+ saved_deployment = reactive[DeploymentResponse | None](None, recompose=True)
335
346
 
336
347
  def __init__(self, initial_data: DeploymentForm):
337
348
  super().__init__()
@@ -344,10 +355,14 @@ class DeploymentEditApp(App[DeploymentResponse | None]):
344
355
  def on_key(self, event) -> None:
345
356
  """Handle key events, including Ctrl+C"""
346
357
  if event.key == "ctrl+c":
347
- self.exit(None)
358
+ if self.current_state == "monitor" and self.saved_deployment is not None:
359
+ self.exit(self.saved_deployment)
360
+ else:
361
+ self.exit(None)
348
362
 
349
363
  def compose(self) -> ComposeResult:
350
- with Container(classes="form-container"):
364
+ is_slim = self.current_state != "monitor"
365
+ with Container(classes="form-container" if is_slim else ""):
351
366
  if self.current_state == "form":
352
367
  yield DeploymentFormWidget(self.form_data, self.save_error)
353
368
  elif self.current_state == "validation":
@@ -362,6 +377,11 @@ class DeploymentEditApp(App[DeploymentResponse | None]):
362
377
  )
363
378
  elif self.current_state == "help":
364
379
  yield DeploymentHelpWidget()
380
+ elif self.current_state == "monitor":
381
+ deployment_id = (
382
+ self.saved_deployment.id if self.saved_deployment else ""
383
+ )
384
+ yield DeploymentMonitorWidget(deployment_id)
365
385
  else:
366
386
  yield Static("Unknown state: " + self.current_state)
367
387
 
@@ -426,8 +446,15 @@ class DeploymentEditApp(App[DeploymentResponse | None]):
426
446
  )
427
447
  else:
428
448
  update_deployment = client.create_deployment(result.to_create())
429
- # Exit with result
430
- self.exit(update_deployment)
449
+ # Save and navigate to embedded monitor screen
450
+ self.saved_deployment = update_deployment
451
+ # Ensure form_data carries the new ID for any subsequent operations
452
+ if not result.is_editing and update_deployment.id:
453
+ updated_form = dataclasses.replace(self.form_data)
454
+ updated_form.id = update_deployment.id
455
+ updated_form.is_editing = True
456
+ self.form_data = updated_form
457
+ self.current_state = "monitor"
431
458
  except Exception as e:
432
459
  # Return to form and show error
433
460
  self.save_error = f"Error saving deployment: {e}"
@@ -441,6 +468,10 @@ class DeploymentEditApp(App[DeploymentResponse | None]):
441
468
  """Handle cancel message from form widget"""
442
469
  self.exit(None)
443
470
 
471
+ def on_monitor_close_message(self, _: MonitorCloseMessage) -> None:
472
+ """Handle close from embedded monitor by exiting with saved deployment."""
473
+ self.exit(self.saved_deployment)
474
+
444
475
 
445
476
  def edit_deployment_form(
446
477
  deployment: DeploymentResponse,
@@ -467,6 +498,7 @@ def _initialize_deployment_data() -> DeploymentForm:
467
498
  git_ref: str | None = None
468
499
  secrets: dict[str, str] = {}
469
500
  name: str | None = None
501
+ config_file_path: str | None = None
470
502
  if is_git_repo():
471
503
  seen = set[str]()
472
504
  remotes = list_remotes()
@@ -482,22 +514,25 @@ def _initialize_deployment_data() -> DeploymentForm:
482
514
  if preferred_origin:
483
515
  repo_url = preferred_origin[0]
484
516
  git_ref = get_current_branch()
517
+ root = get_git_root()
518
+ if root != Path.cwd():
519
+ config_file_path = str(Path.cwd().relative_to(root))
520
+
485
521
  if Path(".env").exists():
486
522
  secrets = load_env_secrets_from_string(Path(".env").read_text())
487
- for f in os.listdir("."):
488
- if f.endswith(".yml") or f.endswith(".yaml"):
489
- try:
490
- config = DeploymentConfig.from_yaml(Path(f))
491
- name = config.name
492
- break
493
- except Exception:
494
- pass
523
+ try:
524
+ config = read_deployment_config(Path("."), Path("."))
525
+ if config.name != DEFAULT_DEPLOYMENT_NAME:
526
+ name = config.name
527
+ except Exception:
528
+ pass
495
529
 
496
530
  form = DeploymentForm(
497
531
  name=name or "",
498
532
  repo_url=repo_url or "",
499
533
  git_ref=git_ref or "main",
500
534
  secrets=secrets,
535
+ deployment_file_path=config_file_path or "",
501
536
  )
502
537
  return form
503
538
 
@@ -36,8 +36,8 @@ class DeploymentHelpWidget(Widget):
36
36
  [b]Git Ref[/b]
37
37
  The git ref to deploy. This can be a branch, tag, or commit hash. If this is a branch, after deploying, run a `[slategrey reverse]llamactl deploy refresh[/]` to update the deployment to the latest git ref after you make updates.
38
38
 
39
- [b]Deployment File[/b]
40
- The `[slategrey reverse]llama_deploy.yaml[/]` file to use for the deployment. This is a yaml file that contains the configuration for how to serve the deployment.
39
+ [b]Config File[/b]
40
+ Path to a directory or file containing a `[slategrey reverse]pyproject.toml[/]` or `[slategrey reverse]llama_deploy.yaml[/]` containing the llama deploy configuration. Only necessary if you have the configuration not at the root of the repo, or you have an unconventional configuration file.
41
41
 
42
42
  [b]Secrets[/b]
43
43
  Secrets to add as environment variables to the deployment. e.g. to access a database or an API. Supports adding in `[slategrey reverse].env[/]` file format.
@@ -0,0 +1,429 @@
1
+ """Textual component to monitor a deployment and stream its logs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import threading
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Iterator
11
+
12
+ from llama_deploy.cli.client import Closer
13
+ from llama_deploy.cli.client import get_project_client as get_client
14
+ from llama_deploy.core.schema.base import LogEvent
15
+ from llama_deploy.core.schema.deployments import DeploymentResponse
16
+ from rich.text import Text
17
+ from textual.app import App, ComposeResult
18
+ from textual.containers import Container, HorizontalGroup, Widget
19
+ from textual.message import Message
20
+ from textual.reactive import reactive
21
+ from textual.widgets import Button, RichLog, Static
22
+
23
+
24
+ class DeploymentMonitorWidget(Widget):
25
+ """Widget that fetches deployment details once and streams logs.
26
+
27
+ Notes:
28
+ - Status is polled periodically
29
+ - Log stream is started with init container logs included on first connect
30
+ - If the stream ends or hangs, we reconnect with duration-aware backoff
31
+ """
32
+
33
+ DEFAULT_CSS = """
34
+ DeploymentMonitorWidget {
35
+ layout: vertical;
36
+ width: 1fr;
37
+ height: 1fr;
38
+ }
39
+
40
+ .monitor-container {
41
+ width: 1fr;
42
+ height: 1fr;
43
+ padding: 0;
44
+ margin: 0;
45
+ }
46
+
47
+ .details-grid {
48
+ layout: grid;
49
+ grid-size: 2;
50
+ grid-columns: auto 1fr;
51
+ grid-gutter: 0 1;
52
+ grid-rows: auto;
53
+ height: auto;
54
+ width: 1fr;
55
+ }
56
+
57
+ .log-header {
58
+ margin-top: 1;
59
+ }
60
+
61
+
62
+ """
63
+
64
+ deployment_id: str
65
+ deployment = reactive[DeploymentResponse | None](None, recompose=False)
66
+ error_message = reactive("", recompose=False)
67
+ wrap_enabled = reactive(False, recompose=False)
68
+ autoscroll_enabled = reactive(True, recompose=False)
69
+ stream_closer: Closer | None = None
70
+
71
+ def __init__(self, deployment_id: str) -> None:
72
+ super().__init__()
73
+ self.deployment_id = deployment_id
74
+ self._stop_stream = threading.Event()
75
+ # Persist content written to the RichLog across recomposes
76
+ self._log_buffer: list[Text] = []
77
+
78
+ def on_mount(self) -> None:
79
+ # Kick off initial fetch and start logs stream in background
80
+ self.run_worker(self._fetch_deployment(), exclusive=True)
81
+ self.run_worker(self._stream_logs, exclusive=False, thread=True)
82
+ # Start periodic polling of deployment status
83
+ self.run_worker(self._poll_deployment_status(), exclusive=False)
84
+
85
+ def compose(self) -> ComposeResult:
86
+ yield Static("Deployment Status", classes="primary-message")
87
+ yield Static("", classes="error-message", id="error_message")
88
+
89
+ # Single-line status bar with colored icon and deployment ID
90
+ with HorizontalGroup(classes="mb-1"):
91
+ yield Static(
92
+ self._render_status_line(), classes="status-line", id="status_line"
93
+ )
94
+
95
+ yield Static("Logs", classes="secondary-message log-header")
96
+ yield RichLog(
97
+ id="log_view",
98
+ classes="log-view mb-1",
99
+ auto_scroll=self.autoscroll_enabled,
100
+ wrap=self.wrap_enabled,
101
+ highlight=True,
102
+ )
103
+
104
+ with HorizontalGroup(classes="button-row"):
105
+ wrap_label = "Wrap: On" if self.wrap_enabled else "Wrap: Off"
106
+ auto_label = (
107
+ "Auto-scroll: On" if self.autoscroll_enabled else "Auto-scroll: Off"
108
+ )
109
+ yield Button(wrap_label, id="toggle_wrap", variant="default", compact=True)
110
+ yield Button(
111
+ auto_label, id="toggle_autoscroll", variant="default", compact=True
112
+ )
113
+ yield Button("Copy", id="copy_log", variant="default", compact=True)
114
+ yield Button("Close", id="close", variant="default", compact=True)
115
+
116
+ def on_button_pressed(self, event: Button.Pressed) -> None:
117
+ if event.button.id == "close":
118
+ # Signal parent app to close
119
+ self.post_message(MonitorCloseMessage())
120
+ elif event.button.id == "toggle_wrap":
121
+ self.wrap_enabled = not self.wrap_enabled
122
+ elif event.button.id == "toggle_autoscroll":
123
+ self.autoscroll_enabled = not self.autoscroll_enabled
124
+ elif event.button.id == "copy_log":
125
+ txt = "\n".join([str(x) for x in self._log_buffer])
126
+ self.app.copy_to_clipboard(txt)
127
+
128
+ async def _fetch_deployment(self) -> None:
129
+ try:
130
+ client = get_client()
131
+ self.deployment = client.get_deployment(self.deployment_id)
132
+ # Clear any previous error on success
133
+ self.error_message = ""
134
+ except Exception as e: # pragma: no cover - network errors
135
+ self.error_message = f"Failed to fetch deployment: {e}"
136
+
137
+ def _stream_logs(self) -> None:
138
+ """Consume the blocking log iterator in a single worker thread.
139
+
140
+ Cooperative cancellation uses `self._stop_stream` to exit cleanly.
141
+ """
142
+ client = get_client()
143
+
144
+ def _sleep_with_cancel(total_seconds: float) -> None:
145
+ step = 0.2
146
+ remaining = total_seconds
147
+ while remaining > 0 and not self._stop_stream.is_set():
148
+ time.sleep(min(step, remaining))
149
+ remaining -= step
150
+
151
+ base_backoff_seconds = 0.2
152
+ backoff_seconds = base_backoff_seconds
153
+ max_backoff_seconds = 30.0
154
+
155
+ while not self._stop_stream.is_set():
156
+ try:
157
+ connect_started_at = time.monotonic()
158
+ closer, stream = client.stream_deployment_logs(
159
+ self.deployment_id,
160
+ include_init_containers=True,
161
+ )
162
+ # On any (re)connect, clear existing content
163
+ self.app.call_from_thread(self._reset_log_view_for_reconnect)
164
+
165
+ buffered_stream = _buffer_log_lines(stream)
166
+
167
+ def close_stream():
168
+ try:
169
+ closer()
170
+ except Exception:
171
+ pass
172
+
173
+ self.stream_closer = close_stream
174
+ # Stream connected; consume until end
175
+ for events in buffered_stream:
176
+ if self._stop_stream.is_set():
177
+ break
178
+ # Marshal UI updates back to the main thread via the App
179
+ self.app.call_from_thread(self._handle_log_events, events)
180
+ if self._stop_stream.is_set():
181
+ break
182
+ # Stream ended without explicit error; attempt reconnect
183
+ self.app.call_from_thread(
184
+ self._set_error_message, "Log stream disconnected. Reconnecting..."
185
+ )
186
+ except Exception as e:
187
+ if self._stop_stream.is_set():
188
+ break
189
+ # Surface the error to the UI and attempt reconnect with backoff
190
+ self.app.call_from_thread(
191
+ self._set_error_message, f"Log stream failed: {e}. Reconnecting..."
192
+ )
193
+
194
+ # Duration-aware backoff: subtract how long the last connection lived
195
+ connection_lifetime = 0.0
196
+ try:
197
+ connection_lifetime = max(0.0, time.monotonic() - connect_started_at)
198
+ except Exception:
199
+ connection_lifetime = 0.0
200
+
201
+ # If the connection lived longer than the current backoff window,
202
+ # reset to base so the next reconnect is immediate.
203
+ if connection_lifetime >= backoff_seconds:
204
+ backoff_seconds = base_backoff_seconds
205
+ else:
206
+ backoff_seconds = min(backoff_seconds * 2.0, max_backoff_seconds)
207
+
208
+ delay = max(0.0, backoff_seconds - connection_lifetime)
209
+ if delay > 0:
210
+ _sleep_with_cancel(delay)
211
+
212
+ def _reset_log_view_for_reconnect(self) -> None:
213
+ """Clear UI and buffers so new stream replaces previous content."""
214
+ try:
215
+ log_widget = self.query_one("#log_view", RichLog)
216
+ except Exception:
217
+ log_widget = None
218
+ if log_widget is not None:
219
+ log_widget.clear()
220
+
221
+ def _set_error_message(self, message: str) -> None:
222
+ self.error_message = message
223
+
224
+ def _handle_log_events(self, events: list[LogEvent]) -> None:
225
+ def to_text(event: LogEvent) -> Text:
226
+ txt = Text()
227
+ txt.append(
228
+ f"[{event.container}] ", style=self._container_style(event.container)
229
+ )
230
+ txt.append(event.text)
231
+ return txt
232
+
233
+ texts = [to_text(event) for event in events]
234
+ if not texts:
235
+ return
236
+
237
+ log_widget = self.query_one("#log_view", RichLog)
238
+ for text in texts:
239
+ log_widget.write(text)
240
+ self._log_buffer.append(text)
241
+ # Clear any previous error once we successfully receive logs
242
+ if self.error_message:
243
+ self.error_message = ""
244
+
245
+ def _container_style(self, container_name: str) -> str:
246
+ palette = [
247
+ "bold magenta",
248
+ "bold cyan",
249
+ "bold blue",
250
+ "bold green",
251
+ "bold red",
252
+ "bold bright_blue",
253
+ ]
254
+ # Stable hash to pick a color per container name
255
+ h = int(hashlib.sha256(container_name.encode()).hexdigest(), 16)
256
+ return palette[h % len(palette)]
257
+
258
+ def _status_icon_and_style(self, phase: str) -> tuple[str, str]:
259
+ # Map deployment phase to a colored icon
260
+ phase = phase or "-"
261
+ green = "bold green"
262
+ yellow = "bold yellow"
263
+ red = "bold red"
264
+ gray = "grey50"
265
+ if phase in {"Running", "Succeeded"}:
266
+ return "●", green
267
+ if phase in {"Pending", "Syncing", "RollingOut"}:
268
+ return "●", yellow
269
+ if phase in {"Failed", "RolloutFailed"}:
270
+ return "●", red
271
+ return "●", gray
272
+
273
+ def _render_status_line(self) -> Text:
274
+ phase = self.deployment.status if self.deployment else "-"
275
+ icon, style = self._status_icon_and_style(phase)
276
+ line = Text()
277
+ line.append(icon, style=style)
278
+ line.append(" ")
279
+ line.append(f"Status: {phase} — Deployment ID: {self.deployment_id or '-'}")
280
+ return line
281
+
282
+ def on_unmount(self) -> None:
283
+ # Attempt to stop the streaming loop
284
+ self._stop_stream.set()
285
+ if self.stream_closer is not None:
286
+ self.stream_closer()
287
+ self.stream_closer = None
288
+
289
+ # Reactive watchers to update widgets in place instead of recomposing
290
+ def watch_error_message(self, message: str) -> None:
291
+ try:
292
+ widget = self.query_one("#error_message", Static)
293
+ except Exception:
294
+ return
295
+ widget.update(message)
296
+ widget.display = bool(message)
297
+
298
+ def watch_deployment(self, deployment: DeploymentResponse | None) -> None:
299
+ if deployment is None:
300
+ return
301
+ phase = deployment.status or "-"
302
+ last = getattr(self, "_last_phase", None)
303
+ if last == phase:
304
+ return
305
+ self._last_phase = phase
306
+ try:
307
+ widget = self.query_one("#status_line", Static)
308
+ except Exception:
309
+ return
310
+ widget.update(self._render_status_line())
311
+
312
+ def watch_wrap_enabled(self, enabled: bool) -> None:
313
+ try:
314
+ log_widget = self.query_one("#log_view", RichLog)
315
+ log_widget.wrap = enabled
316
+ # Clear existing lines; new wrap mode will apply to subsequent events
317
+ log_widget.clear()
318
+ for text in self._log_buffer:
319
+ log_widget.write(text)
320
+ except Exception:
321
+ pass
322
+ try:
323
+ btn = self.query_one("#toggle_wrap", Button)
324
+ btn.label = "Wrap: On" if enabled else "Wrap: Off"
325
+ except Exception:
326
+ pass
327
+
328
+ def watch_autoscroll_enabled(self, enabled: bool) -> None:
329
+ try:
330
+ log_widget = self.query_one("#log_view", RichLog)
331
+ log_widget.auto_scroll = enabled
332
+ except Exception:
333
+ pass
334
+ try:
335
+ btn = self.query_one("#toggle_autoscroll", Button)
336
+ btn.label = "Auto-scroll: On" if enabled else "Auto-scroll: Off"
337
+ except Exception:
338
+ pass
339
+
340
+ async def _poll_deployment_status(self) -> None:
341
+ """Periodically refresh deployment status to reflect updates in the UI."""
342
+ client = get_client()
343
+ while not self._stop_stream.is_set():
344
+ try:
345
+ self.deployment = client.get_deployment(self.deployment_id)
346
+ # Clear any previous error on success
347
+ if self.error_message:
348
+ self.error_message = ""
349
+ except Exception as e: # pragma: no cover - network errors
350
+ # Non-fatal; will try again on next interval
351
+ self.error_message = f"Failed to refresh status: {e}"
352
+ await asyncio.sleep(5)
353
+
354
+
355
+ class MonitorCloseMessage(Message):
356
+ pass
357
+
358
+
359
+ class DeploymentMonitorApp(App[None]):
360
+ """Standalone app wrapper around the monitor widget.
361
+
362
+ This allows easy reuse in other flows by embedding the widget.
363
+ """
364
+
365
+ CSS_PATH = Path(__file__).parent / "styles.tcss"
366
+
367
+ def __init__(self, deployment_id: str) -> None:
368
+ super().__init__()
369
+ self.deployment_id = deployment_id
370
+
371
+ def on_mount(self) -> None:
372
+ self.theme = "tokyo-night"
373
+
374
+ def compose(self) -> ComposeResult:
375
+ with Container():
376
+ yield DeploymentMonitorWidget(self.deployment_id)
377
+
378
+ def on_monitor_close_message(self, _: MonitorCloseMessage) -> None:
379
+ self.exit(None)
380
+
381
+ def on_key(self, event) -> None:
382
+ # Support Ctrl+C to exit, consistent with other screens and terminals
383
+ if event.key == "ctrl+c":
384
+ self.exit(None)
385
+
386
+
387
+ def monitor_deployment_screen(deployment_id: str) -> None:
388
+ """Launch the standalone deployment monitor screen."""
389
+ app = DeploymentMonitorApp(deployment_id)
390
+ app.run()
391
+
392
+
393
+ def _buffer_log_lines(iter: Iterator[LogEvent]) -> Iterator[list[LogEvent]]:
394
+ """Batch log events into small lists using a background reader.
395
+
396
+ This reduces UI churn while still reacting quickly. On shutdown we
397
+ absorb stream read errors that are expected when the connection is
398
+ closed from another thread.
399
+ """
400
+ buffer: list[LogEvent] = []
401
+ bg_error: Exception | None = None
402
+ done = threading.Event()
403
+
404
+ def pump() -> None:
405
+ nonlocal bg_error
406
+ try:
407
+ for event in iter:
408
+ buffer.append(event)
409
+ except Exception as e:
410
+ bg_error = e
411
+ finally:
412
+ done.set()
413
+
414
+ t = threading.Thread(target=pump, daemon=True)
415
+ t.start()
416
+ try:
417
+ while not done.is_set():
418
+ if buffer:
419
+ # Yield a snapshot and clear in-place to avoid reallocating list
420
+ yield list(buffer)
421
+ buffer.clear()
422
+ time.sleep(0.5)
423
+ if bg_error is not None:
424
+ raise bg_error
425
+ finally:
426
+ try:
427
+ t.join(timeout=0.1)
428
+ except Exception:
429
+ pass