llamactl 0.2.7a1__py3-none-any.whl → 0.3.0__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 +9 -22
- llama_deploy/cli/app.py +69 -0
- llama_deploy/cli/auth/client.py +362 -0
- llama_deploy/cli/client.py +47 -170
- llama_deploy/cli/commands/aliased_group.py +33 -0
- llama_deploy/cli/commands/auth.py +696 -0
- llama_deploy/cli/commands/deployment.py +300 -0
- llama_deploy/cli/commands/env.py +211 -0
- llama_deploy/cli/commands/init.py +313 -0
- llama_deploy/cli/commands/serve.py +239 -0
- llama_deploy/cli/config/_config.py +390 -0
- llama_deploy/cli/config/_migrations.py +65 -0
- llama_deploy/cli/config/auth_service.py +130 -0
- llama_deploy/cli/config/env_service.py +67 -0
- llama_deploy/cli/config/migrations/0001_init.sql +35 -0
- llama_deploy/cli/config/migrations/0002_add_auth_fields.sql +24 -0
- llama_deploy/cli/config/migrations/__init__.py +7 -0
- llama_deploy/cli/config/schema.py +61 -0
- llama_deploy/cli/env.py +5 -3
- llama_deploy/cli/interactive_prompts/session_utils.py +37 -0
- llama_deploy/cli/interactive_prompts/utils.py +6 -72
- llama_deploy/cli/options.py +27 -5
- llama_deploy/cli/py.typed +0 -0
- llama_deploy/cli/styles.py +10 -0
- llama_deploy/cli/textual/deployment_form.py +263 -36
- llama_deploy/cli/textual/deployment_help.py +53 -0
- llama_deploy/cli/textual/deployment_monitor.py +466 -0
- llama_deploy/cli/textual/git_validation.py +20 -21
- llama_deploy/cli/textual/github_callback_server.py +17 -14
- llama_deploy/cli/textual/llama_loader.py +13 -1
- llama_deploy/cli/textual/secrets_form.py +28 -8
- llama_deploy/cli/textual/styles.tcss +49 -8
- llama_deploy/cli/utils/env_inject.py +23 -0
- {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/METADATA +9 -6
- llamactl-0.3.0.dist-info/RECORD +38 -0
- {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/WHEEL +1 -1
- llama_deploy/cli/commands.py +0 -549
- llama_deploy/cli/config.py +0 -173
- llama_deploy/cli/textual/profile_form.py +0 -171
- llamactl-0.2.7a1.dist-info/RECORD +0 -19
- {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,466 @@
|
|
|
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 logging
|
|
8
|
+
import threading
|
|
9
|
+
import webbrowser
|
|
10
|
+
from collections.abc import AsyncGenerator
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from llama_deploy.cli.client import (
|
|
14
|
+
project_client_context,
|
|
15
|
+
)
|
|
16
|
+
from llama_deploy.core.iter_utils import merge_generators
|
|
17
|
+
from llama_deploy.core.schema import LogEvent
|
|
18
|
+
from llama_deploy.core.schema.deployments import DeploymentResponse
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
from textual import events
|
|
21
|
+
from textual.app import App, ComposeResult
|
|
22
|
+
from textual.containers import Container, HorizontalGroup, Widget
|
|
23
|
+
from textual.content import Content
|
|
24
|
+
from textual.css.query import NoMatches
|
|
25
|
+
from textual.message import Message
|
|
26
|
+
from textual.reactive import reactive
|
|
27
|
+
from textual.widgets import Button, RichLog, Static
|
|
28
|
+
from typing_extensions import Literal
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DeploymentMonitorWidget(Widget):
|
|
34
|
+
"""Widget that fetches deployment details once and streams logs.
|
|
35
|
+
|
|
36
|
+
Notes:
|
|
37
|
+
- Status is polled periodically
|
|
38
|
+
- Log stream is started with init container logs included on first connect
|
|
39
|
+
- If the stream ends or hangs, we reconnect with duration-aware backoff
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
DEFAULT_CSS = """
|
|
43
|
+
DeploymentMonitorWidget {
|
|
44
|
+
layout: vertical;
|
|
45
|
+
width: 1fr;
|
|
46
|
+
height: 1fr;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.monitor-container {
|
|
50
|
+
width: 1fr;
|
|
51
|
+
height: 1fr;
|
|
52
|
+
padding: 0;
|
|
53
|
+
margin: 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.details-grid {
|
|
57
|
+
layout: grid;
|
|
58
|
+
grid-size: 2;
|
|
59
|
+
grid-columns: auto 1fr;
|
|
60
|
+
grid-gutter: 0 1;
|
|
61
|
+
grid-rows: auto;
|
|
62
|
+
height: auto;
|
|
63
|
+
width: 1fr;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.log-header {
|
|
67
|
+
margin-top: 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.status-line .status-main {
|
|
71
|
+
width: auto;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.status-line .status-right {
|
|
75
|
+
width: 1fr;
|
|
76
|
+
text-align: right;
|
|
77
|
+
min-width: 12;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.deployment-link-label {
|
|
81
|
+
width: auto;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.deployment-link {
|
|
85
|
+
width: 1fr;
|
|
86
|
+
min-width: 16;
|
|
87
|
+
height: auto;
|
|
88
|
+
align: left middle;
|
|
89
|
+
text-align: left;
|
|
90
|
+
content-align: left middle;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.log-view-container {
|
|
94
|
+
width: 1fr;
|
|
95
|
+
height: 1fr;
|
|
96
|
+
padding: 0;
|
|
97
|
+
margin: 0;
|
|
98
|
+
}
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
deployment_id: str
|
|
102
|
+
deployment = reactive[DeploymentResponse | None](None, recompose=False)
|
|
103
|
+
error_message = reactive("", recompose=False)
|
|
104
|
+
wrap_enabled = reactive(False, recompose=False)
|
|
105
|
+
autoscroll_enabled = reactive(True, recompose=False)
|
|
106
|
+
|
|
107
|
+
def __init__(self, deployment_id: str) -> None:
|
|
108
|
+
super().__init__()
|
|
109
|
+
self.deployment_id = deployment_id
|
|
110
|
+
self._stop_stream = threading.Event()
|
|
111
|
+
# Persist content written to the RichLog across recomposes
|
|
112
|
+
self._log_buffer: list[Text] = []
|
|
113
|
+
self._log_stream_started = False
|
|
114
|
+
|
|
115
|
+
async def on_mount(self) -> None:
|
|
116
|
+
# Kick off initial fetch and start logs stream in background
|
|
117
|
+
self.run_worker(self._fetch_deployment())
|
|
118
|
+
# Force an initial layout, then start the log stream after that layout completes
|
|
119
|
+
self.refresh(layout=True)
|
|
120
|
+
self.call_after_refresh(lambda: self.run_worker(self._stream_logs()))
|
|
121
|
+
# Start periodic polling of deployment status
|
|
122
|
+
self.run_worker(self._poll_deployment_status())
|
|
123
|
+
|
|
124
|
+
def compose(self) -> ComposeResult:
|
|
125
|
+
yield Static("Deployment Status", classes="primary-message")
|
|
126
|
+
|
|
127
|
+
with HorizontalGroup(classes=""):
|
|
128
|
+
yield Static(" URL: ", classes="deployment-link-label")
|
|
129
|
+
yield Button(
|
|
130
|
+
"",
|
|
131
|
+
id="deployment_link_button",
|
|
132
|
+
classes="deployment-link",
|
|
133
|
+
compact=True,
|
|
134
|
+
variant="default",
|
|
135
|
+
)
|
|
136
|
+
yield Static("", classes="error-message", id="error_message")
|
|
137
|
+
|
|
138
|
+
# Single-line status bar with colored icon and deployment ID
|
|
139
|
+
with HorizontalGroup(classes="status-line"):
|
|
140
|
+
yield Static(
|
|
141
|
+
self._render_status_line(), classes="status-main", id="status_line"
|
|
142
|
+
)
|
|
143
|
+
yield Static("", classes="status-right", id="last_event_status")
|
|
144
|
+
yield Static("", classes="last-event", id="last_event_details")
|
|
145
|
+
yield Static("Logs", classes="secondary-message log-header")
|
|
146
|
+
yield HorizontalGroup(classes="log-view-container", id="log_view_container")
|
|
147
|
+
|
|
148
|
+
with HorizontalGroup(classes="button-row"):
|
|
149
|
+
wrap_label = "Wrap: On" if self.wrap_enabled else "Wrap: Off"
|
|
150
|
+
auto_label = "Scroll: Auto" if self.autoscroll_enabled else "Scroll: Off"
|
|
151
|
+
yield Button(wrap_label, id="toggle_wrap", variant="default", compact=True)
|
|
152
|
+
yield Button(
|
|
153
|
+
auto_label, id="toggle_autoscroll", variant="default", compact=True
|
|
154
|
+
)
|
|
155
|
+
yield Button("Copy", id="copy_log", variant="default", compact=True)
|
|
156
|
+
yield Button("Close", id="close", variant="default", compact=True)
|
|
157
|
+
|
|
158
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
159
|
+
if event.button.id == "close":
|
|
160
|
+
# Signal parent app to close
|
|
161
|
+
self.post_message(MonitorCloseMessage())
|
|
162
|
+
elif event.button.id == "toggle_wrap":
|
|
163
|
+
self.wrap_enabled = not self.wrap_enabled
|
|
164
|
+
elif event.button.id == "toggle_autoscroll":
|
|
165
|
+
self.autoscroll_enabled = not self.autoscroll_enabled
|
|
166
|
+
elif event.button.id == "copy_log":
|
|
167
|
+
txt = "\n".join([str(x) for x in self._log_buffer])
|
|
168
|
+
self.app.copy_to_clipboard(txt)
|
|
169
|
+
elif event.button.id == "deployment_link_button":
|
|
170
|
+
self.action_open_url()
|
|
171
|
+
|
|
172
|
+
async def _fetch_deployment(self) -> None:
|
|
173
|
+
try:
|
|
174
|
+
async with project_client_context() as client:
|
|
175
|
+
self.deployment = await client.get_deployment(
|
|
176
|
+
self.deployment_id, include_events=True
|
|
177
|
+
)
|
|
178
|
+
# Clear any previous error on success
|
|
179
|
+
self.error_message = ""
|
|
180
|
+
except Exception as e: # pragma: no cover - network errors
|
|
181
|
+
self.error_message = f"Failed to fetch deployment: {e}"
|
|
182
|
+
|
|
183
|
+
async def _stream_logs(self) -> None:
|
|
184
|
+
"""Consume the async log iterator, batch updates, and reconnect with backoff."""
|
|
185
|
+
|
|
186
|
+
async def _flush_signal(
|
|
187
|
+
frequency_seconds: float,
|
|
188
|
+
) -> AsyncGenerator[Literal["__FLUSH__"], None]:
|
|
189
|
+
while not self._stop_stream.is_set():
|
|
190
|
+
await asyncio.sleep(frequency_seconds)
|
|
191
|
+
yield "__FLUSH__"
|
|
192
|
+
|
|
193
|
+
failures = 0
|
|
194
|
+
while not self._stop_stream.is_set():
|
|
195
|
+
async with project_client_context() as client:
|
|
196
|
+
await asyncio.sleep(min(failures, 10))
|
|
197
|
+
batch: list[LogEvent] = []
|
|
198
|
+
try:
|
|
199
|
+
logger.info(f"Streaming logs for deployment {self.deployment_id}")
|
|
200
|
+
async for event in merge_generators(
|
|
201
|
+
client.stream_deployment_logs(
|
|
202
|
+
self.deployment_id,
|
|
203
|
+
include_init_containers=True,
|
|
204
|
+
tail_lines=10000,
|
|
205
|
+
),
|
|
206
|
+
_flush_signal(0.2),
|
|
207
|
+
):
|
|
208
|
+
if event == "__FLUSH__" and batch:
|
|
209
|
+
self._handle_log_events(batch)
|
|
210
|
+
batch = []
|
|
211
|
+
elif isinstance(event, LogEvent):
|
|
212
|
+
batch.append(event)
|
|
213
|
+
if len(batch) >= 1000:
|
|
214
|
+
self._handle_log_events(batch)
|
|
215
|
+
batch = []
|
|
216
|
+
except Exception as e:
|
|
217
|
+
if not self._stop_stream.is_set():
|
|
218
|
+
self._set_error_message(
|
|
219
|
+
f"Log stream failed: {e}. Reconnecting..."
|
|
220
|
+
)
|
|
221
|
+
failures += 1
|
|
222
|
+
finally:
|
|
223
|
+
if batch:
|
|
224
|
+
self._handle_log_events(batch)
|
|
225
|
+
|
|
226
|
+
def _reset_log_view_for_reconnect(self) -> None:
|
|
227
|
+
"""Clear UI and buffers so new stream replaces previous content."""
|
|
228
|
+
try:
|
|
229
|
+
log_widget = self.query_one("#log_view", RichLog)
|
|
230
|
+
except Exception:
|
|
231
|
+
log_widget = None
|
|
232
|
+
if log_widget is not None:
|
|
233
|
+
log_widget.clear()
|
|
234
|
+
|
|
235
|
+
def _set_error_message(self, message: str) -> None:
|
|
236
|
+
self.error_message = message
|
|
237
|
+
|
|
238
|
+
def _handle_log_events(self, events: list[LogEvent]) -> None:
|
|
239
|
+
def to_text(event: LogEvent) -> Text:
|
|
240
|
+
txt = Text()
|
|
241
|
+
txt.append(
|
|
242
|
+
f"[{event.container}] ", style=self._container_style(event.container)
|
|
243
|
+
)
|
|
244
|
+
txt.append(event.text)
|
|
245
|
+
return txt
|
|
246
|
+
|
|
247
|
+
texts = [to_text(event) for event in events]
|
|
248
|
+
if not texts:
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
# due to bugs in the the RichLog widget, defer mounting, otherwise it won't get a "ResizeEvent" (on_resize), and be waiting indefinitely
|
|
253
|
+
# before it renders (unless you manually resize the terminal window)
|
|
254
|
+
log_widget = self.query_one("#log_view", RichLog)
|
|
255
|
+
except NoMatches:
|
|
256
|
+
log_container = self.query_one("#log_view_container", HorizontalGroup)
|
|
257
|
+
log_widget = RichLog(
|
|
258
|
+
id="log_view",
|
|
259
|
+
classes="log-view mb-1",
|
|
260
|
+
auto_scroll=self.autoscroll_enabled,
|
|
261
|
+
wrap=self.wrap_enabled,
|
|
262
|
+
highlight=True,
|
|
263
|
+
)
|
|
264
|
+
log_container.mount(log_widget)
|
|
265
|
+
for text in texts:
|
|
266
|
+
log_widget.write(text)
|
|
267
|
+
self._log_buffer.append(text)
|
|
268
|
+
log_widget.refresh()
|
|
269
|
+
|
|
270
|
+
# One-time kick to ensure initial draw
|
|
271
|
+
# Clear any previous error once we successfully receive logs
|
|
272
|
+
if self.error_message:
|
|
273
|
+
self.error_message = ""
|
|
274
|
+
|
|
275
|
+
def _container_style(self, container_name: str) -> str:
|
|
276
|
+
palette = [
|
|
277
|
+
"bold magenta",
|
|
278
|
+
"bold cyan",
|
|
279
|
+
"bold blue",
|
|
280
|
+
"bold green",
|
|
281
|
+
"bold red",
|
|
282
|
+
"bold bright_blue",
|
|
283
|
+
]
|
|
284
|
+
# Stable hash to pick a color per container name
|
|
285
|
+
h = int(hashlib.sha256(container_name.encode()).hexdigest(), 16)
|
|
286
|
+
return palette[h % len(palette)]
|
|
287
|
+
|
|
288
|
+
def _status_icon_and_style(self, phase: str) -> tuple[str, str]:
|
|
289
|
+
# Map deployment phase to a colored icon
|
|
290
|
+
phase = phase or "-"
|
|
291
|
+
green = "bold green"
|
|
292
|
+
yellow = "bold yellow"
|
|
293
|
+
red = "bold red"
|
|
294
|
+
gray = "grey50"
|
|
295
|
+
if phase in {"Running", "Succeeded"}:
|
|
296
|
+
return "●", green
|
|
297
|
+
if phase in {"Pending", "Syncing", "RollingOut"}:
|
|
298
|
+
return "●", yellow
|
|
299
|
+
if phase in {"Failed", "RolloutFailed"}:
|
|
300
|
+
return "●", red
|
|
301
|
+
return "●", gray
|
|
302
|
+
|
|
303
|
+
def action_open_url(self) -> None:
|
|
304
|
+
if not self.deployment or not self.deployment.apiserver_url:
|
|
305
|
+
return
|
|
306
|
+
logger.debug(f"Opening URL: {self.deployment.apiserver_url}")
|
|
307
|
+
webbrowser.open(str(self.deployment.apiserver_url))
|
|
308
|
+
|
|
309
|
+
def _render_status_line(self) -> Text:
|
|
310
|
+
phase = self.deployment.status if self.deployment else "Unknown"
|
|
311
|
+
icon, style = self._status_icon_and_style(phase)
|
|
312
|
+
line = Text()
|
|
313
|
+
line.append(icon, style=style)
|
|
314
|
+
line.append(" ")
|
|
315
|
+
line.append(f"Status: {phase} — Deployment ID: {self.deployment_id or '-'}")
|
|
316
|
+
return line
|
|
317
|
+
|
|
318
|
+
def _render_last_event_details(self) -> Content:
|
|
319
|
+
if not self.deployment or not self.deployment.events:
|
|
320
|
+
return Content()
|
|
321
|
+
latest = self.deployment.events[-1]
|
|
322
|
+
txt = Text(f" {latest.message}", style="dim")
|
|
323
|
+
return Content.from_rich_text(txt)
|
|
324
|
+
|
|
325
|
+
def _render_last_event_status(self) -> Content:
|
|
326
|
+
if not self.deployment or not self.deployment.events:
|
|
327
|
+
return Content()
|
|
328
|
+
txt = Text()
|
|
329
|
+
# Pick the most recent by last_timestamp
|
|
330
|
+
latest = self.deployment.events[-1]
|
|
331
|
+
ts = None
|
|
332
|
+
ts = (latest.last_timestamp or latest.first_timestamp).strftime(
|
|
333
|
+
"%Y-%m-%d %H:%M:%S"
|
|
334
|
+
)
|
|
335
|
+
parts: list[str] = []
|
|
336
|
+
if latest.type:
|
|
337
|
+
parts.append(latest.type)
|
|
338
|
+
if latest.reason:
|
|
339
|
+
parts.append(latest.reason)
|
|
340
|
+
kind = "/".join(parts) if parts else None
|
|
341
|
+
if kind:
|
|
342
|
+
txt.append(f"{kind} ", style="medium_purple3")
|
|
343
|
+
txt.append(f"{ts}", style="dim")
|
|
344
|
+
return Content.from_rich_text(txt)
|
|
345
|
+
|
|
346
|
+
def on_unmount(self) -> None:
|
|
347
|
+
# Attempt to stop the streaming loop
|
|
348
|
+
self._stop_stream.set()
|
|
349
|
+
|
|
350
|
+
# Reactive watchers to update widgets in place instead of recomposing
|
|
351
|
+
def watch_error_message(self, message: str) -> None:
|
|
352
|
+
try:
|
|
353
|
+
widget = self.query_one("#error_message", Static)
|
|
354
|
+
except Exception:
|
|
355
|
+
return
|
|
356
|
+
widget.update(message)
|
|
357
|
+
widget.display = bool(message)
|
|
358
|
+
|
|
359
|
+
def watch_deployment(self, deployment: DeploymentResponse | None) -> None:
|
|
360
|
+
if deployment is None:
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
widget = self.query_one("#status_line", Static)
|
|
364
|
+
ev_widget = self.query_one("#last_event_status", Static)
|
|
365
|
+
ev_details_widget = self.query_one("#last_event_details", Static)
|
|
366
|
+
deployment_link_button = self.query_one("#deployment_link_button", Button)
|
|
367
|
+
widget.update(self._render_status_line())
|
|
368
|
+
deployment_link_button.label = f"{str(self.deployment.apiserver_url or '')}"
|
|
369
|
+
# Update last event line
|
|
370
|
+
ev_widget.update(self._render_last_event_status())
|
|
371
|
+
ev_details_widget.update(self._render_last_event_details())
|
|
372
|
+
ev_details_widget.display = bool(self.deployment and self.deployment.events)
|
|
373
|
+
|
|
374
|
+
def watch_wrap_enabled(self, enabled: bool) -> None:
|
|
375
|
+
try:
|
|
376
|
+
log_widget = self.query_one("#log_view", RichLog)
|
|
377
|
+
log_widget.wrap = enabled
|
|
378
|
+
# Clear existing lines; new wrap mode will apply to subsequent events
|
|
379
|
+
log_widget.clear()
|
|
380
|
+
for text in self._log_buffer:
|
|
381
|
+
log_widget.write(text)
|
|
382
|
+
except Exception:
|
|
383
|
+
pass
|
|
384
|
+
try:
|
|
385
|
+
btn = self.query_one("#toggle_wrap", Button)
|
|
386
|
+
btn.label = "Wrap: On" if enabled else "Wrap: Off"
|
|
387
|
+
except Exception:
|
|
388
|
+
pass
|
|
389
|
+
|
|
390
|
+
def watch_autoscroll_enabled(self, enabled: bool) -> None:
|
|
391
|
+
try:
|
|
392
|
+
log_widget = self.query_one("#log_view", RichLog)
|
|
393
|
+
log_widget.auto_scroll = enabled
|
|
394
|
+
except Exception:
|
|
395
|
+
pass
|
|
396
|
+
try:
|
|
397
|
+
btn = self.query_one("#toggle_autoscroll", Button)
|
|
398
|
+
btn.label = "Scroll: Auto" if enabled else "Scroll: Off"
|
|
399
|
+
except Exception:
|
|
400
|
+
pass
|
|
401
|
+
|
|
402
|
+
async def _poll_deployment_status(self) -> None:
|
|
403
|
+
"""Periodically refresh deployment status to reflect updates in the UI."""
|
|
404
|
+
while not self._stop_stream.is_set():
|
|
405
|
+
try:
|
|
406
|
+
async with project_client_context() as client:
|
|
407
|
+
self.deployment = await client.get_deployment(
|
|
408
|
+
self.deployment_id, include_events=True
|
|
409
|
+
)
|
|
410
|
+
# Clear any previous error on success
|
|
411
|
+
if self.error_message:
|
|
412
|
+
self.error_message = ""
|
|
413
|
+
except Exception as e: # pragma: no cover - network errors
|
|
414
|
+
# Non-fatal; will try again on next interval
|
|
415
|
+
self.error_message = f"Failed to refresh status: {e}"
|
|
416
|
+
await asyncio.sleep(5)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class MonitorCloseMessage(Message):
|
|
420
|
+
pass
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class LogBatchMessage(Message):
|
|
424
|
+
def __init__(self, events: list[LogEvent]) -> None:
|
|
425
|
+
super().__init__()
|
|
426
|
+
self.events = events
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class ErrorTextMessage(Message):
|
|
430
|
+
def __init__(self, text: str) -> None:
|
|
431
|
+
super().__init__()
|
|
432
|
+
self.text = text
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
class DeploymentMonitorApp(App[None]):
|
|
436
|
+
"""Standalone app wrapper around the monitor widget.
|
|
437
|
+
|
|
438
|
+
This allows easy reuse in other flows by embedding the widget.
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
CSS_PATH = Path(__file__).parent / "styles.tcss"
|
|
442
|
+
|
|
443
|
+
def __init__(self, deployment_id: str) -> None:
|
|
444
|
+
super().__init__()
|
|
445
|
+
self.deployment_id = deployment_id
|
|
446
|
+
|
|
447
|
+
def on_mount(self) -> None:
|
|
448
|
+
self.theme = "tokyo-night"
|
|
449
|
+
|
|
450
|
+
def compose(self) -> ComposeResult:
|
|
451
|
+
with Container():
|
|
452
|
+
yield DeploymentMonitorWidget(self.deployment_id)
|
|
453
|
+
|
|
454
|
+
def on_monitor_close_message(self, _: MonitorCloseMessage) -> None:
|
|
455
|
+
self.exit(None)
|
|
456
|
+
|
|
457
|
+
def on_key(self, event: events.Key) -> None:
|
|
458
|
+
# Support Ctrl+C to exit, consistent with other screens and terminals
|
|
459
|
+
if event.key == "ctrl+c":
|
|
460
|
+
self.exit(None)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def monitor_deployment_screen(deployment_id: str) -> None:
|
|
464
|
+
"""Launch the standalone deployment monitor screen."""
|
|
465
|
+
app = DeploymentMonitorApp(deployment_id)
|
|
466
|
+
app.run()
|
|
@@ -4,17 +4,16 @@ import logging
|
|
|
4
4
|
import webbrowser
|
|
5
5
|
from typing import Literal, cast
|
|
6
6
|
|
|
7
|
+
from llama_deploy.cli.client import get_project_client as get_client
|
|
8
|
+
from llama_deploy.cli.textual.github_callback_server import GitHubCallbackServer
|
|
9
|
+
from llama_deploy.cli.textual.llama_loader import PixelLlamaLoader
|
|
10
|
+
from llama_deploy.core.schema.git_validation import RepositoryValidationResponse
|
|
7
11
|
from textual.app import ComposeResult
|
|
8
12
|
from textual.containers import HorizontalGroup, Widget
|
|
9
|
-
from textual.widgets import Button, Input, Label, Static
|
|
10
|
-
from textual.message import Message
|
|
11
13
|
from textual.content import Content
|
|
14
|
+
from textual.message import Message
|
|
12
15
|
from textual.reactive import reactive
|
|
13
|
-
|
|
14
|
-
from llama_deploy.cli.client import get_client
|
|
15
|
-
from llama_deploy.core.schema.git_validation import RepositoryValidationResponse
|
|
16
|
-
from llama_deploy.cli.textual.llama_loader import PixelLlamaLoader
|
|
17
|
-
from llama_deploy.cli.textual.github_callback_server import GitHubCallbackServer
|
|
16
|
+
from textual.widgets import Button, Input, Label, Static
|
|
18
17
|
|
|
19
18
|
logger = logging.getLogger(__name__)
|
|
20
19
|
|
|
@@ -206,7 +205,6 @@ class GitValidationWidget(Widget):
|
|
|
206
205
|
yield Button(
|
|
207
206
|
"Continue", id="continue_success", variant="primary", compact=True
|
|
208
207
|
)
|
|
209
|
-
print("DEBUG: render cancel button")
|
|
210
208
|
# Always show cancel button
|
|
211
209
|
yield Button("Back to Edit", id="cancel", variant="default", compact=True)
|
|
212
210
|
|
|
@@ -226,7 +224,7 @@ class GitValidationWidget(Widget):
|
|
|
226
224
|
self.current_state = "options"
|
|
227
225
|
self.error_message = ""
|
|
228
226
|
elif event.button.id == "cancel_github_auth":
|
|
229
|
-
self._cancel_github_auth()
|
|
227
|
+
self.run_worker(self._cancel_github_auth())
|
|
230
228
|
elif event.button.id == "recheck_github":
|
|
231
229
|
self.run_worker(self._recheck_github_auth())
|
|
232
230
|
elif event.button.id == "continue_success":
|
|
@@ -238,7 +236,6 @@ class GitValidationWidget(Widget):
|
|
|
238
236
|
)
|
|
239
237
|
self.post_message(ValidationResultMessage(self.repo_url, pat_to_send))
|
|
240
238
|
elif event.button.id == "cancel":
|
|
241
|
-
print("DEBUG: cancel button pressed")
|
|
242
239
|
self.post_message(ValidationCancelMessage())
|
|
243
240
|
|
|
244
241
|
def _start_github_auth(self) -> None:
|
|
@@ -261,10 +258,10 @@ class GitValidationWidget(Widget):
|
|
|
261
258
|
self.github_callback_server = GitHubCallbackServer()
|
|
262
259
|
self.run_worker(self._wait_for_callback())
|
|
263
260
|
|
|
264
|
-
def _cancel_github_auth(self) -> None:
|
|
261
|
+
async def _cancel_github_auth(self) -> None:
|
|
265
262
|
"""Cancel GitHub authentication and return to options"""
|
|
266
263
|
if self.github_callback_server:
|
|
267
|
-
self.github_callback_server.stop()
|
|
264
|
+
await self.github_callback_server.stop()
|
|
268
265
|
self.github_callback_server = None
|
|
269
266
|
self.current_state = "options"
|
|
270
267
|
|
|
@@ -285,14 +282,14 @@ class GitValidationWidget(Widget):
|
|
|
285
282
|
self.error_message = ""
|
|
286
283
|
try:
|
|
287
284
|
client = get_client()
|
|
288
|
-
|
|
285
|
+
self.validation_response = await client.validate_repository(
|
|
289
286
|
repo_url=self.repo_url, deployment_id=self.deployment_id, pat=pat
|
|
290
287
|
)
|
|
291
|
-
self.validation_response = response
|
|
292
288
|
|
|
293
|
-
|
|
289
|
+
resp = self.validation_response
|
|
290
|
+
if resp and resp.accessible:
|
|
294
291
|
# Success - post result message with appropriate messaging
|
|
295
|
-
if
|
|
292
|
+
if resp.pat_is_obsolete:
|
|
296
293
|
# Show success message about PAT obsolescence before proceeding
|
|
297
294
|
self.current_state = "success"
|
|
298
295
|
self.error_message = "Repository accessible via GitHub App. Your Personal Access Token is now obsolete and will be removed."
|
|
@@ -311,22 +308,24 @@ class GitValidationWidget(Widget):
|
|
|
311
308
|
self.error_message = ""
|
|
312
309
|
try:
|
|
313
310
|
client = get_client()
|
|
314
|
-
|
|
311
|
+
self.validation_response = await client.validate_repository(
|
|
315
312
|
repo_url=self.repo_url, deployment_id=self.deployment_id
|
|
316
313
|
)
|
|
317
|
-
self.validation_response = response
|
|
318
314
|
|
|
319
|
-
|
|
315
|
+
resp = self.validation_response
|
|
316
|
+
if resp and resp.accessible:
|
|
320
317
|
# Success - post result message with appropriate messaging
|
|
321
318
|
self.current_state = "success"
|
|
322
319
|
self.post_message(
|
|
323
320
|
ValidationResultMessage(
|
|
324
|
-
self.repo_url, "" if
|
|
321
|
+
self.repo_url, "" if resp.pat_is_obsolete else None
|
|
325
322
|
)
|
|
326
323
|
)
|
|
327
324
|
else:
|
|
328
325
|
# Failed - stay in github_auth and show error
|
|
329
|
-
self.error_message =
|
|
326
|
+
self.error_message = (
|
|
327
|
+
f"Still not accessible: {resp.message if resp else ''}"
|
|
328
|
+
)
|
|
330
329
|
|
|
331
330
|
except Exception as e:
|
|
332
331
|
# Failed - stay in github_auth and show error
|
|
@@ -4,9 +4,12 @@ import asyncio
|
|
|
4
4
|
import logging
|
|
5
5
|
import webbrowser
|
|
6
6
|
from textwrap import dedent
|
|
7
|
-
from typing import
|
|
7
|
+
from typing import Any, Dict, cast
|
|
8
8
|
|
|
9
|
-
from aiohttp import
|
|
9
|
+
from aiohttp.web_app import Application
|
|
10
|
+
from aiohttp.web_request import Request
|
|
11
|
+
from aiohttp.web_response import Response
|
|
12
|
+
from aiohttp.web_runner import AppRunner, TCPSite
|
|
10
13
|
|
|
11
14
|
logger = logging.getLogger(__name__)
|
|
12
15
|
|
|
@@ -18,9 +21,9 @@ class GitHubCallbackServer:
|
|
|
18
21
|
self.port = port
|
|
19
22
|
self.callback_data: Dict[str, Any] = {}
|
|
20
23
|
self.callback_received = asyncio.Event()
|
|
21
|
-
self.app:
|
|
22
|
-
self.runner:
|
|
23
|
-
self.site:
|
|
24
|
+
self.app: Application | None = None
|
|
25
|
+
self.runner: AppRunner | None = None
|
|
26
|
+
self.site: TCPSite | None = None
|
|
24
27
|
|
|
25
28
|
async def start_and_wait(self, timeout: float = 300) -> Dict[str, Any]:
|
|
26
29
|
"""Start the server and wait for a callback with timeout"""
|
|
@@ -38,19 +41,19 @@ class GitHubCallbackServer:
|
|
|
38
41
|
|
|
39
42
|
async def _start_server(self) -> None:
|
|
40
43
|
"""Start the aiohttp server"""
|
|
41
|
-
self.app =
|
|
44
|
+
self.app = Application()
|
|
42
45
|
self.app.router.add_get("/", self._handle_callback)
|
|
43
46
|
self.app.router.add_get("/{path:.*}", self._handle_callback)
|
|
44
47
|
|
|
45
|
-
self.runner =
|
|
48
|
+
self.runner = AppRunner(self.app, logger=None) # Suppress server logs
|
|
46
49
|
await self.runner.setup()
|
|
47
50
|
|
|
48
|
-
self.site =
|
|
51
|
+
self.site = TCPSite(self.runner, "localhost", self.port)
|
|
49
52
|
await self.site.start()
|
|
50
53
|
|
|
51
54
|
logger.debug(f"GitHub callback server started on port {self.port}")
|
|
52
55
|
|
|
53
|
-
async def _handle_callback(self, request:
|
|
56
|
+
async def _handle_callback(self, request: Request) -> Response:
|
|
54
57
|
"""Handle the GitHub callback"""
|
|
55
58
|
# Capture query parameters
|
|
56
59
|
query_params: dict[str, str] = dict(cast(Any, request.query))
|
|
@@ -62,7 +65,7 @@ class GitHubCallbackServer:
|
|
|
62
65
|
|
|
63
66
|
# Return success page
|
|
64
67
|
html_response = self._get_success_html()
|
|
65
|
-
return
|
|
68
|
+
return Response(text=html_response, content_type="text/html")
|
|
66
69
|
|
|
67
70
|
async def stop(self) -> None:
|
|
68
71
|
"""Stop the server and cleanup"""
|
|
@@ -188,17 +191,17 @@ async def main():
|
|
|
188
191
|
server = GitHubCallbackServer(port=41010)
|
|
189
192
|
|
|
190
193
|
# Start server and open browser
|
|
191
|
-
|
|
192
|
-
|
|
194
|
+
logger.debug(f"Starting GitHub callback server on http://localhost:{server.port}")
|
|
195
|
+
logger.debug("Opening browser to show success page...")
|
|
193
196
|
|
|
194
197
|
# Open browser to success page to see the styling
|
|
195
198
|
webbrowser.open(f"http://localhost:{server.port}")
|
|
196
199
|
|
|
197
200
|
try:
|
|
198
201
|
# Wait for callback (or just keep server running)
|
|
199
|
-
|
|
202
|
+
logger.debug("Server running... Press Ctrl+C to stop")
|
|
200
203
|
callback_data = await server.start_and_wait(timeout=3600) # 1 hour timeout
|
|
201
|
-
|
|
204
|
+
logger.debug(f"Received callback data: {callback_data}")
|
|
202
205
|
finally:
|
|
203
206
|
await server.stop()
|
|
204
207
|
|
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import re
|
|
2
|
+
from typing import TypedDict
|
|
3
|
+
|
|
2
4
|
from textual.widgets import Static
|
|
3
5
|
|
|
4
6
|
|
|
7
|
+
class StaticKwargs(TypedDict, total=False):
|
|
8
|
+
expand: bool
|
|
9
|
+
shrink: bool
|
|
10
|
+
markup: bool
|
|
11
|
+
name: str | None
|
|
12
|
+
id: str | None
|
|
13
|
+
classes: str | None
|
|
14
|
+
disabled: bool
|
|
15
|
+
|
|
16
|
+
|
|
5
17
|
class PixelLlamaLoader(Static):
|
|
6
18
|
"""Pixelated llama loading animation using block characters"""
|
|
7
19
|
|
|
8
|
-
def __init__(self, **kwargs):
|
|
20
|
+
def __init__(self, **kwargs: StaticKwargs):
|
|
9
21
|
self.frame = 0
|
|
10
22
|
# Pixelated llama frames using Unicode block characters
|
|
11
23
|
self.frames = [
|