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.
- llama_deploy/cli/__init__.py +2 -1
- llama_deploy/cli/client.py +112 -15
- llama_deploy/cli/commands/deployment.py +14 -4
- llama_deploy/cli/commands/init.py +210 -0
- llama_deploy/cli/commands/serve.py +8 -2
- llama_deploy/cli/config.py +1 -1
- llama_deploy/cli/interactive_prompts/utils.py +0 -2
- llama_deploy/cli/textual/deployment_form.py +59 -24
- llama_deploy/cli/textual/deployment_help.py +2 -2
- llama_deploy/cli/textual/deployment_monitor.py +429 -0
- llama_deploy/cli/textual/github_callback_server.py +12 -9
- llama_deploy/cli/textual/profile_form.py +0 -1
- llama_deploy/cli/textual/secrets_form.py +1 -1
- {llamactl-0.3.0a6.dist-info → llamactl-0.3.0a8.dist-info}/METADATA +4 -4
- llamactl-0.3.0a8.dist-info/RECORD +26 -0
- llamactl-0.3.0a6.dist-info/RECORD +0 -24
- {llamactl-0.3.0a6.dist-info → llamactl-0.3.0a8.dist-info}/WHEEL +0 -0
- {llamactl-0.3.0a6.dist-info → llamactl-0.3.0a8.dist-info}/entry_points.txt +0 -0
|
@@ -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
|
|
24
|
-
|
|
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 = "
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
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="
|
|
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 '
|
|
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.
|
|
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
|
-
|
|
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
|
-
#
|
|
430
|
-
self.
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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]
|
|
40
|
-
|
|
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
|