clue2app-cli 0.2.7__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.
@@ -0,0 +1,987 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Textual-based Deploy Wizard with CSS animations for Clue2App CLI.
4
+
5
+ This module provides a rich, animated TUI wizard experience using Textual,
6
+ with smooth transitions, CSS styling, and reactive components.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from dataclasses import dataclass, field
13
+ from typing import Optional, List, Dict, Any, Callable
14
+
15
+ from textual import on, work
16
+ from textual.app import App, ComposeResult
17
+ from textual.binding import Binding
18
+ from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
19
+ from textual.css.query import NoMatches
20
+ from textual.reactive import reactive
21
+ from textual.screen import Screen
22
+ from textual.widgets import (
23
+ Button, Footer, Header, Input, Label, Static,
24
+ Select, Switch, ProgressBar, Rule, LoadingIndicator,
25
+ Checkbox, RadioSet, RadioButton, TabbedContent, TabPane,
26
+ )
27
+ from textual.timer import Timer
28
+
29
+ from rich.text import Text
30
+ from rich.panel import Panel
31
+ from rich.table import Table
32
+
33
+
34
+ # ============================================================================
35
+ # CSS Styles
36
+ # ============================================================================
37
+
38
+ WIZARD_CSS = """
39
+ Screen {
40
+ background: $surface;
41
+ }
42
+
43
+ /* Step Indicator Styles */
44
+ #step-indicator-container {
45
+ height: auto;
46
+ padding: 0;
47
+ margin: 0;
48
+ }
49
+
50
+ #step-indicator {
51
+ height: 3;
52
+ width: 100%;
53
+ background: $panel;
54
+ border: solid $primary;
55
+ padding: 0 2;
56
+ }
57
+
58
+ .step-container {
59
+ width: 100%;
60
+ height: 3;
61
+ align: center middle;
62
+ }
63
+
64
+ .step {
65
+ width: auto;
66
+ height: 3;
67
+ padding: 0 1;
68
+ }
69
+
70
+ .step-circle {
71
+ width: 3;
72
+ height: 3;
73
+ content-align: center middle;
74
+ border: solid $secondary;
75
+ text-style: bold;
76
+ }
77
+
78
+ .step-circle.active {
79
+ background: $primary;
80
+ border: solid $primary;
81
+ color: $text;
82
+ text-style: bold;
83
+ }
84
+
85
+ .step-circle.completed {
86
+ background: $success;
87
+ border: solid $success;
88
+ color: $text;
89
+ }
90
+
91
+ .step-label {
92
+ padding: 0 1;
93
+ color: $text-muted;
94
+ }
95
+
96
+ .step-label.active {
97
+ color: $primary;
98
+ text-style: bold;
99
+ }
100
+
101
+ .step-label.completed {
102
+ color: $success;
103
+ }
104
+
105
+ .step-connector {
106
+ width: 5;
107
+ height: 1;
108
+ margin-top: 1;
109
+ background: $secondary;
110
+ }
111
+
112
+ .step-connector.completed {
113
+ background: $success;
114
+ }
115
+
116
+ /* Content Area */
117
+ #content-area {
118
+ height: 1fr;
119
+ padding: 0 1;
120
+ background: $surface;
121
+ }
122
+
123
+ .wizard-step {
124
+ width: 100%;
125
+ height: auto;
126
+ min-height: 10;
127
+ padding: 1 2;
128
+ background: $panel;
129
+ border: solid $primary-lighten-2;
130
+ }
131
+
132
+ .step-title {
133
+ text-style: bold;
134
+ color: $primary;
135
+ padding-bottom: 1;
136
+ }
137
+
138
+ .step-description {
139
+ color: $text-muted;
140
+ padding-bottom: 1;
141
+ }
142
+
143
+ .form-group {
144
+ margin-bottom: 1;
145
+ }
146
+
147
+ .form-label {
148
+ padding-bottom: 0;
149
+ color: $text;
150
+ }
151
+
152
+ .form-input {
153
+ margin-bottom: 1;
154
+ }
155
+
156
+ /* Footer Actions */
157
+ #footer-actions {
158
+ height: 5;
159
+ padding: 1 2;
160
+ background: $panel;
161
+ border-top: solid $primary-lighten-2;
162
+ align: center middle;
163
+ }
164
+
165
+ #footer-actions Button {
166
+ margin: 0 1;
167
+ }
168
+
169
+ .btn-primary {
170
+ background: $primary;
171
+ }
172
+
173
+ .btn-secondary {
174
+ background: $secondary;
175
+ }
176
+
177
+ .btn-success {
178
+ background: $success;
179
+ }
180
+
181
+ /* Build Progress */
182
+ #build-progress {
183
+ height: 100%;
184
+ padding: 2;
185
+ }
186
+
187
+ .progress-header {
188
+ text-style: bold;
189
+ color: $primary;
190
+ padding-bottom: 1;
191
+ }
192
+
193
+ .phase-item {
194
+ padding: 0 1;
195
+ margin-bottom: 0;
196
+ }
197
+
198
+ .phase-icon {
199
+ width: 3;
200
+ }
201
+
202
+ .phase-icon.pending {
203
+ color: $text-muted;
204
+ }
205
+
206
+ .phase-icon.running {
207
+ color: $primary;
208
+ }
209
+
210
+ .phase-icon.completed {
211
+ color: $success;
212
+ }
213
+
214
+ .phase-name {
215
+ padding-left: 1;
216
+ }
217
+
218
+ .log-container {
219
+ height: 8;
220
+ border: solid $secondary;
221
+ padding: 1;
222
+ margin-top: 1;
223
+ background: $surface-darken-1;
224
+ }
225
+
226
+ /* Animations - Textual transitions */
227
+ .fade-in {
228
+ opacity: 1;
229
+ }
230
+
231
+ .hidden {
232
+ display: none;
233
+ }
234
+
235
+ /* Review Table */
236
+ .review-section {
237
+ margin-bottom: 1;
238
+ padding: 1;
239
+ background: $surface;
240
+ border: solid $secondary;
241
+ }
242
+
243
+ .review-title {
244
+ text-style: bold;
245
+ color: $primary;
246
+ padding-bottom: 1;
247
+ }
248
+
249
+ .review-row {
250
+ padding: 0 1;
251
+ }
252
+
253
+ .review-label {
254
+ width: 20;
255
+ color: $text-muted;
256
+ }
257
+
258
+ .review-value {
259
+ color: $text;
260
+ }
261
+ """
262
+
263
+
264
+ # ============================================================================
265
+ # Step Indicator Widget
266
+ # ============================================================================
267
+
268
+ class StepIndicator(Static):
269
+ """Animated step indicator showing wizard progress."""
270
+
271
+ STEPS = ["Basics", "Source", "Registry", "Settings", "Review"]
272
+
273
+ current_step = reactive(0)
274
+ completed_steps = reactive(set())
275
+
276
+ def __init__(self, **kwargs):
277
+ super().__init__(**kwargs)
278
+ self.completed_steps = set()
279
+
280
+ def render(self) -> Text:
281
+ """Render the step indicator."""
282
+ text = Text()
283
+
284
+ for i, step_name in enumerate(self.STEPS):
285
+ # Determine state
286
+ if i in self.completed_steps:
287
+ icon = "✓"
288
+ icon_style = "bold green"
289
+ label_style = "green"
290
+ elif i == self.current_step:
291
+ icon = "●"
292
+ icon_style = "bold blue"
293
+ label_style = "bold blue"
294
+ else:
295
+ icon = "○"
296
+ icon_style = "dim"
297
+ label_style = "dim"
298
+
299
+ text.append(f" {icon} ", style=icon_style)
300
+ text.append(step_name, style=label_style)
301
+
302
+ # Add connector
303
+ if i < len(self.STEPS) - 1:
304
+ connector_style = "green" if i in self.completed_steps else "dim"
305
+ text.append(" ─── ", style=connector_style)
306
+
307
+ return text
308
+
309
+ def set_step(self, step: int):
310
+ """Set the current step."""
311
+ self.current_step = step
312
+ self.refresh()
313
+
314
+ def complete_step(self, step: int):
315
+ """Mark a step as completed."""
316
+ self.completed_steps = self.completed_steps | {step}
317
+ self.refresh()
318
+
319
+
320
+ # ============================================================================
321
+ # Form Widgets
322
+ # ============================================================================
323
+
324
+ class FormInput(Vertical):
325
+ """A styled form input with label."""
326
+
327
+ def __init__(self, label: str, input_id: str, placeholder: str = "", password: bool = False, **kwargs):
328
+ super().__init__(**kwargs)
329
+ self.label_text = label
330
+ self.input_id = input_id
331
+ self.placeholder = placeholder
332
+ self.password = password
333
+
334
+ def compose(self) -> ComposeResult:
335
+ yield Label(self.label_text, classes="form-label")
336
+ yield Input(
337
+ placeholder=self.placeholder,
338
+ password=self.password,
339
+ id=self.input_id,
340
+ classes="form-input",
341
+ )
342
+
343
+
344
+ class FormSelect(Vertical):
345
+ """A styled form select with label."""
346
+
347
+ def __init__(self, label: str, select_id: str, options: List[tuple], **kwargs):
348
+ super().__init__(**kwargs)
349
+ self.label_text = label
350
+ self.select_id = select_id
351
+ self.options = options
352
+
353
+ def compose(self) -> ComposeResult:
354
+ yield Label(self.label_text, classes="form-label")
355
+ yield Select(
356
+ options=self.options,
357
+ id=self.select_id,
358
+ classes="form-input",
359
+ )
360
+
361
+
362
+ # ============================================================================
363
+ # Wizard Steps
364
+ # ============================================================================
365
+
366
+ class BasicsStep(Vertical):
367
+ """Step 1: Application basics."""
368
+
369
+ def __init__(self, projects: List[Dict] = None, **kwargs):
370
+ super().__init__(**kwargs)
371
+ self.projects = projects or []
372
+
373
+ def compose(self) -> ComposeResult:
374
+ yield Static("📱 Application Basics", classes="step-title")
375
+ yield Static("Give your application a name and select target project", classes="step-description")
376
+ yield Rule()
377
+ yield FormInput(
378
+ "Application Name *",
379
+ "input-app-name",
380
+ placeholder="my-awesome-app",
381
+ )
382
+ # Project selection (if multiple projects available)
383
+ if len(self.projects) > 1:
384
+ project_options = [
385
+ (f"{p.get('name')} ({p.get('ns', 'default')})", p.get('id'))
386
+ for p in self.projects
387
+ ]
388
+ yield FormSelect("Target Project", "select-project", options=project_options)
389
+
390
+ def get_data(self) -> Dict[str, Any]:
391
+ data = {}
392
+ try:
393
+ app_name_input = self.query_one("#input-app-name", Input)
394
+ data["app_name"] = app_name_input.value
395
+ # Get selected project if available
396
+ try:
397
+ project_select = self.query_one("#select-project", Select)
398
+ if project_select.value:
399
+ data["project_id"] = project_select.value
400
+ except NoMatches:
401
+ pass
402
+ except NoMatches:
403
+ pass
404
+ return data
405
+
406
+
407
+ class SourceStep(Vertical):
408
+ """Step 2: Source code configuration."""
409
+
410
+ def compose(self) -> ComposeResult:
411
+ yield Static("📦 Source Code", classes="step-title")
412
+ yield Static("Configure your Git repository", classes="step-description")
413
+ yield Rule()
414
+ yield FormInput(
415
+ "Git Repository URL *",
416
+ "input-git-url",
417
+ placeholder="https://github.com/user/repo.git",
418
+ )
419
+ yield FormSelect(
420
+ "Branch",
421
+ "select-branch",
422
+ options=[("main", "main"), ("master", "master"), ("develop", "develop")],
423
+ )
424
+ with Horizontal():
425
+ yield Checkbox("Private Repository", id="private_repo")
426
+ yield Container(
427
+ FormInput("Git Token", "input-git-token", placeholder="ghp_...", password=True),
428
+ id="git-token-container",
429
+ classes="hidden",
430
+ )
431
+
432
+ @on(Checkbox.Changed, "#private_repo")
433
+ def toggle_token_field(self, event: Checkbox.Changed):
434
+ """Show/hide token field based on private repo checkbox."""
435
+ container = self.query_one("#git-token-container")
436
+ if event.value:
437
+ container.remove_class("hidden")
438
+ else:
439
+ container.add_class("hidden")
440
+
441
+ def get_data(self) -> Dict[str, Any]:
442
+ data = {}
443
+ try:
444
+ data["git_url"] = self.query_one("#input-git-url", Input).value
445
+ data["branch"] = self.query_one("#select-branch", Select).value or "main"
446
+ data["is_private"] = self.query_one("#private_repo", Checkbox).value
447
+ if data["is_private"]:
448
+ data["git_token"] = self.query_one("#input-git-token", Input).value
449
+ except NoMatches:
450
+ pass
451
+ return data
452
+
453
+
454
+ class RegistryStep(Vertical):
455
+ """Step 3: Container registry configuration."""
456
+
457
+ def compose(self) -> ComposeResult:
458
+ yield Static("🐳 Container Registry", classes="step-title")
459
+ yield Static("Choose where to store your container images", classes="step-description")
460
+ yield Rule()
461
+ yield RadioSet(
462
+ RadioButton("Platform Registry (Recommended)", id="platform_registry", value=True),
463
+ RadioButton("Private Registry", id="private_registry"),
464
+ id="registry_type",
465
+ )
466
+ yield Container(
467
+ FormInput("Registry URL", "input-registry-url", placeholder="docker.io"),
468
+ FormInput("Username", "input-registry-user", placeholder="username"),
469
+ FormInput("Password", "input-registry-pass", password=True),
470
+ id="private-registry-fields",
471
+ classes="hidden",
472
+ )
473
+
474
+ @on(RadioSet.Changed, "#registry_type")
475
+ def toggle_registry_fields(self, event: RadioSet.Changed):
476
+ """Show/hide private registry fields."""
477
+ container = self.query_one("#private-registry-fields")
478
+ if event.pressed and event.pressed.id == "private_registry":
479
+ container.remove_class("hidden")
480
+ else:
481
+ container.add_class("hidden")
482
+
483
+ def get_data(self) -> Dict[str, Any]:
484
+ data = {}
485
+ try:
486
+ radio_set = self.query_one("#registry_type", RadioSet)
487
+ pressed = radio_set.pressed_button
488
+ if pressed and pressed.id == "private_registry":
489
+ data["registry_type"] = "PRIVATE_REGISTRY"
490
+ data["registry_url"] = self.query_one("#input-registry-url", Input).value
491
+ data["registry_user"] = self.query_one("#input-registry-user", Input).value
492
+ data["registry_pass"] = self.query_one("#input-registry-pass", Input).value
493
+ else:
494
+ data["registry_type"] = "PLATFORM_REGISTRY"
495
+ except (NoMatches, AttributeError):
496
+ data["registry_type"] = "PLATFORM_REGISTRY"
497
+ return data
498
+
499
+
500
+ class SettingsStep(Vertical):
501
+ """Step 4: Application settings."""
502
+
503
+ def compose(self) -> ComposeResult:
504
+ yield Static("⚙️ Application Settings", classes="step-title")
505
+ yield Static("Configure deployment, scaling, and environment", classes="step-description")
506
+ yield Rule()
507
+ yield FormInput("Container Port *", "input-port", placeholder="8080")
508
+ yield FormSelect(
509
+ "Application Size",
510
+ "select-size",
511
+ options=[
512
+ ("TINY", "Tiny (256MB, 0.25 CPU)"),
513
+ ("SMALL", "Small (512MB, 0.5 CPU)"),
514
+ ("MEDIUM", "Medium (1GB, 1 CPU)"),
515
+ ("LARGE", "Large (2GB, 2 CPU)"),
516
+ ],
517
+ )
518
+ with Horizontal():
519
+ yield Checkbox("Deploy Automatically", id="auto_deploy", value=True)
520
+ yield Checkbox("Scale to Zero", id="scale_to_zero")
521
+
522
+ def get_data(self) -> Dict[str, Any]:
523
+ data = {}
524
+ try:
525
+ data["port"] = int(self.query_one("#input-port", Input).value or "8080")
526
+ data["size"] = self.query_one("#select-size", Select).value or "SMALL"
527
+ data["auto_deploy"] = self.query_one("#auto_deploy", Checkbox).value
528
+ data["scale_to_zero"] = self.query_one("#scale_to_zero", Checkbox).value
529
+ except (NoMatches, ValueError):
530
+ data["port"] = 8080
531
+ data["size"] = "SMALL"
532
+ data["auto_deploy"] = True
533
+ data["scale_to_zero"] = False
534
+ return data
535
+
536
+
537
+ class ReviewStep(Vertical):
538
+ """Step 5: Review and deploy."""
539
+
540
+ def __init__(self, wizard_data: Dict[str, Any], **kwargs):
541
+ super().__init__(**kwargs)
542
+ self.wizard_data = wizard_data
543
+
544
+ def compose(self) -> ComposeResult:
545
+ yield Static("🚀 Review & Deploy", classes="step-title")
546
+ yield Static("Review your configuration before deploying", classes="step-description")
547
+ yield Rule()
548
+
549
+ # Basic Info
550
+ yield Static("[bold]BASIC INFO[/bold]", classes="review-title")
551
+ yield Static(f" Application Name: {self.wizard_data.get('app_name', 'N/A')}")
552
+ if self.wizard_data.get('project_id'):
553
+ yield Static(f" Project: {self.wizard_data.get('project_id')}")
554
+
555
+ # Source Code
556
+ yield Static("\n[bold]SOURCE CODE[/bold]", classes="review-title")
557
+ yield Static(f" Git Repository: {self.wizard_data.get('git_url', 'N/A')}")
558
+ yield Static(f" Branch: {self.wizard_data.get('branch', 'main')}")
559
+ yield Static(f" Access: {'Private' if self.wizard_data.get('is_private') else 'Public'}")
560
+
561
+ # Registry
562
+ yield Static("\n[bold]CONTAINER REGISTRY[/bold]", classes="review-title")
563
+ registry = self.wizard_data.get('registry_type', 'PLATFORM_REGISTRY')
564
+ if registry == "PLATFORM_REGISTRY":
565
+ yield Static(" Registry: Platform Registry")
566
+ else:
567
+ yield Static(f" Registry: {self.wizard_data.get('registry_url', 'N/A')}")
568
+
569
+ # Settings
570
+ yield Static("\n[bold]SETTINGS[/bold]", classes="review-title")
571
+ yield Static(f" Container Port: {self.wizard_data.get('port', 8080)}")
572
+ yield Static(f" Application Size: {self.wizard_data.get('size', 'SMALL')}")
573
+ yield Static(f" Auto Deploy: {'Yes' if self.wizard_data.get('auto_deploy', True) else 'No'}")
574
+ yield Static(f" Scale to Zero: {'Yes' if self.wizard_data.get('scale_to_zero') else 'No'}")
575
+
576
+
577
+ # ============================================================================
578
+ # Build Progress Screen
579
+ # ============================================================================
580
+
581
+ class BuildProgressScreen(Screen):
582
+ """Screen showing live build progress."""
583
+
584
+ BINDINGS = [
585
+ Binding("escape", "cancel", "Run in Background"),
586
+ Binding("enter", "exit_screen", "Exit", show=False),
587
+ ]
588
+
589
+ def __init__(self, app_name: str, namespace: str, sdk_client=None, **kwargs):
590
+ super().__init__(**kwargs)
591
+ self.app_name = app_name
592
+ self.namespace = namespace
593
+ self.sdk_client = sdk_client
594
+ self.current_phase = 0
595
+ self.service_phase = 0
596
+ self.build_status = "BUILDING" # BUILDING, DEPLOYING, SUCCESS, FAILED
597
+ self.app_url: str = ""
598
+ self.error_message: str = ""
599
+ self.build_phases = [
600
+ ("FETCH", "Fetching source code"),
601
+ ("DETECT", "Detecting buildpacks"),
602
+ ("ANALYZE", "Analyzing dependencies"),
603
+ ("BUILD", "Building application"),
604
+ ("EXPORT", "Exporting container image"),
605
+ ]
606
+ self.service_phases = [
607
+ ("PENDING", "Service pending"),
608
+ ("CREATING", "Creating pods"),
609
+ ("CONFIGURING", "Configuring networking"),
610
+ ("SCALING", "Scaling replicas"),
611
+ ("READY", "Service ready"),
612
+ ]
613
+
614
+ def compose(self) -> ComposeResult:
615
+ yield Header(show_clock=True)
616
+ with Container(id="build-progress"):
617
+ yield Static(f"🚀 Deploying: {self.app_name}", classes="progress-header", id="main-header")
618
+ yield Static("", id="elapsed-time")
619
+ yield Rule()
620
+
621
+ # Build phases
622
+ yield Static("[bold]BUILD PROGRESS[/bold]", classes="section-header")
623
+ with Vertical(id="build-phases-list"):
624
+ for phase_name, phase_desc in self.build_phases:
625
+ with Horizontal(classes="phase-item"):
626
+ yield Static("○", classes="phase-icon pending", id=f"build-icon-{phase_name}")
627
+ yield Static(f"{phase_name}: {phase_desc}", classes="phase-name")
628
+
629
+ yield Rule()
630
+
631
+ # Service deployment phases
632
+ yield Static("[bold]SERVICE DEPLOYMENT[/bold]", classes="section-header")
633
+ with Vertical(id="service-phases-list"):
634
+ for phase_name, phase_desc in self.service_phases:
635
+ with Horizontal(classes="phase-item"):
636
+ yield Static("○", classes="phase-icon pending", id=f"svc-icon-{phase_name}")
637
+ yield Static(f"{phase_name}: {phase_desc}", classes="phase-name")
638
+
639
+ yield Rule()
640
+ yield Static("", id="app-url")
641
+ yield Static("", id="build-status")
642
+ yield ProgressBar(id="progress-bar", show_eta=False)
643
+ yield Footer()
644
+
645
+ def on_mount(self):
646
+ """Start the progress animation."""
647
+ self.start_time = asyncio.get_event_loop().time()
648
+ self.last_poll_time = 0
649
+ self.set_interval(0.5, self.update_ui)
650
+ self.set_interval(1.0, self.update_timer)
651
+ self.set_interval(3.0, self.poll_status) # Poll API every 3 seconds
652
+
653
+ def update_timer(self):
654
+ """Update elapsed time display."""
655
+ elapsed = int(asyncio.get_event_loop().time() - self.start_time)
656
+ minutes, seconds = divmod(elapsed, 60)
657
+ self.query_one("#elapsed-time", Static).update(f"Elapsed: {minutes}m {seconds}s")
658
+
659
+ def poll_status(self):
660
+ """Poll the API for build/service status."""
661
+ if self.build_status in ("SUCCESS", "FAILED"):
662
+ return
663
+
664
+ if not self.sdk_client:
665
+ # Demo mode - simulate progress
666
+ self._simulate_progress()
667
+ return
668
+
669
+ try:
670
+ image_name = f"{self.app_name}-image"
671
+ images = self.sdk_client.apps.get_images(namespace=self.namespace)
672
+
673
+ for img in images:
674
+ if image_name in img.get('metadata', {}).get('name', ''):
675
+ status = img.get('status', {})
676
+ conditions = status.get('conditions', [])
677
+
678
+ for cond in conditions:
679
+ if cond.get('type') == 'Ready':
680
+ if cond.get('status') == 'True':
681
+ if self.build_status == "BUILDING":
682
+ self.build_status = "DEPLOYING"
683
+ self.current_phase = len(self.build_phases)
684
+ elif self.build_status == "DEPLOYING":
685
+ self.build_status = "SUCCESS"
686
+ self.service_phase = len(self.service_phases)
687
+ self.app_url = f"https://{self.app_name}.{self.namespace}.apps.clue2.app"
688
+ else:
689
+ reason = cond.get('reason', '')
690
+ if 'Failed' in reason or 'Error' in reason:
691
+ self.build_status = "FAILED"
692
+ self.error_message = cond.get('message', 'Deployment failed')
693
+ break
694
+ break
695
+ except Exception as e:
696
+ self.error_message = str(e)
697
+
698
+ def _simulate_progress(self):
699
+ """Simulate progress for demo mode."""
700
+ elapsed = asyncio.get_event_loop().time() - self.start_time
701
+
702
+ if self.build_status == "BUILDING":
703
+ self.current_phase = min(int(elapsed / 3), len(self.build_phases))
704
+ if self.current_phase >= len(self.build_phases):
705
+ self.build_status = "DEPLOYING"
706
+ elif self.build_status == "DEPLOYING":
707
+ build_time = len(self.build_phases) * 3
708
+ deploy_elapsed = elapsed - build_time
709
+ self.service_phase = min(int(deploy_elapsed / 2), len(self.service_phases))
710
+ if self.service_phase >= len(self.service_phases):
711
+ self.build_status = "SUCCESS"
712
+ self.app_url = f"https://{self.app_name}.{self.namespace}.apps.clue2.app"
713
+
714
+ def update_ui(self):
715
+ """Update the UI based on current status."""
716
+ # Update build phase icons
717
+ for i, (phase_name, _) in enumerate(self.build_phases):
718
+ try:
719
+ icon = self.query_one(f"#build-icon-{phase_name}", Static)
720
+ if self.build_status in ("DEPLOYING", "SUCCESS"):
721
+ icon.update("[green]✓[/green]")
722
+ elif i < self.current_phase:
723
+ icon.update("[green]✓[/green]")
724
+ elif i == self.current_phase and self.build_status == "BUILDING":
725
+ icon.update("[blue]◉[/blue]")
726
+ else:
727
+ icon.update("○")
728
+ except NoMatches:
729
+ pass
730
+
731
+ # Update service phase icons
732
+ for i, (phase_name, _) in enumerate(self.service_phases):
733
+ try:
734
+ icon = self.query_one(f"#svc-icon-{phase_name}", Static)
735
+ if self.build_status == "SUCCESS":
736
+ icon.update("[green]✓[/green]")
737
+ elif self.build_status == "DEPLOYING":
738
+ if i < self.service_phase:
739
+ icon.update("[green]✓[/green]")
740
+ elif i == self.service_phase:
741
+ icon.update("[blue]◉[/blue]")
742
+ else:
743
+ icon.update("○")
744
+ else:
745
+ icon.update("○")
746
+ except NoMatches:
747
+ pass
748
+
749
+ # Update progress bar
750
+ total_phases = len(self.build_phases) + len(self.service_phases)
751
+ if self.build_status == "BUILDING":
752
+ progress = self.current_phase / total_phases
753
+ elif self.build_status == "DEPLOYING":
754
+ progress = (len(self.build_phases) + self.service_phase) / total_phases
755
+ else:
756
+ progress = 1.0 if self.build_status == "SUCCESS" else 0.5
757
+
758
+ try:
759
+ self.query_one("#progress-bar", ProgressBar).update(progress=progress * 100)
760
+ except NoMatches:
761
+ pass
762
+
763
+ # Update status message
764
+ try:
765
+ status_widget = self.query_one("#build-status", Static)
766
+ if self.build_status == "BUILDING":
767
+ status_widget.update("[blue]● Building application...[/blue]")
768
+ elif self.build_status == "DEPLOYING":
769
+ status_widget.update("[blue]● Deploying service...[/blue]")
770
+ elif self.build_status == "SUCCESS":
771
+ status_widget.update("[green]✓ Deployment complete![/green]")
772
+ elif self.build_status == "FAILED":
773
+ status_widget.update(f"[red]✗ Failed: {self.error_message}[/red]")
774
+ except NoMatches:
775
+ pass
776
+
777
+ # Update URL display
778
+ if self.app_url:
779
+ try:
780
+ url_widget = self.query_one("#app-url", Static)
781
+ url_widget.update(f"[bold]Application URL:[/bold] [cyan]{self.app_url}[/cyan]")
782
+ except NoMatches:
783
+ pass
784
+
785
+ # Update header
786
+ try:
787
+ header = self.query_one("#main-header", Static)
788
+ if self.build_status == "SUCCESS":
789
+ header.update(f"✓ Deployed: {self.app_name}")
790
+ elif self.build_status == "FAILED":
791
+ header.update(f"✗ Failed: {self.app_name}")
792
+ except NoMatches:
793
+ pass
794
+
795
+ def action_cancel(self):
796
+ """Exit and run in background."""
797
+ self.app.pop_screen()
798
+
799
+ def action_exit_screen(self):
800
+ """Exit when deployment is complete."""
801
+ if self.build_status in ("SUCCESS", "FAILED"):
802
+ self.app.pop_screen()
803
+
804
+
805
+ # ============================================================================
806
+ # Main Wizard App
807
+ # ============================================================================
808
+
809
+ class DeployWizardApp(App):
810
+ """Main Textual application for the deploy wizard."""
811
+
812
+ CSS = WIZARD_CSS
813
+ TITLE = "🚀 Deploy Application Wizard"
814
+
815
+ BINDINGS = [
816
+ Binding("escape", "quit", "Cancel"),
817
+ Binding("ctrl+c", "quit", "Cancel"),
818
+ Binding("ctrl+n", "next_step", "Next", show=True),
819
+ Binding("ctrl+b", "prev_step", "Back", show=True),
820
+ Binding("ctrl+enter", "next_step", "Next/Deploy"),
821
+ Binding("f2", "prev_step", "Previous"),
822
+ Binding("f3", "next_step", "Next"),
823
+ ]
824
+
825
+ current_step = reactive(0)
826
+ wizard_data: Dict[str, Any] = {}
827
+
828
+ def __init__(self, projects: List[Dict] = None, deploy_func: Callable = None, sdk_client=None, **kwargs):
829
+ super().__init__(**kwargs)
830
+ self.projects = projects or []
831
+ self.deploy_func = deploy_func
832
+ self.sdk_client = sdk_client
833
+ self.wizard_data = {}
834
+ self.steps = []
835
+
836
+ def compose(self) -> ComposeResult:
837
+ yield Header(show_clock=True)
838
+ yield Container(
839
+ StepIndicator(id="step-indicator"),
840
+ id="step-indicator-container",
841
+ )
842
+ yield ScrollableContainer(id="content-area")
843
+ with Horizontal(id="footer-actions"):
844
+ yield Button("Cancel", id="btn-cancel", variant="default")
845
+ yield Button("← Previous", id="btn-prev", variant="default", disabled=True)
846
+ yield Button("Next →", id="btn-next", variant="primary")
847
+ yield Footer()
848
+
849
+ def on_mount(self):
850
+ """Initialize the wizard."""
851
+ self.show_step(0)
852
+
853
+ def show_step(self, step: int):
854
+ """Display a specific step."""
855
+ self.current_step = step
856
+
857
+ # Update step indicator
858
+ indicator = self.query_one("#step-indicator", StepIndicator)
859
+ indicator.set_step(step)
860
+
861
+ # Update content
862
+ content = self.query_one("#content-area", ScrollableContainer)
863
+ content.remove_children()
864
+
865
+ if step == 0:
866
+ content.mount(BasicsStep(projects=self.projects, classes="wizard-step fade-in"))
867
+ elif step == 1:
868
+ content.mount(SourceStep(classes="wizard-step fade-in"))
869
+ elif step == 2:
870
+ content.mount(RegistryStep(classes="wizard-step fade-in"))
871
+ elif step == 3:
872
+ content.mount(SettingsStep(classes="wizard-step fade-in"))
873
+ elif step == 4:
874
+ content.mount(ReviewStep(self.wizard_data, classes="wizard-step fade-in"))
875
+
876
+ # Update buttons
877
+ self.query_one("#btn-prev", Button).disabled = (step == 0)
878
+ next_btn = self.query_one("#btn-next", Button)
879
+ if step == 4:
880
+ next_btn.label = "🚀 Deploy"
881
+ next_btn.variant = "success"
882
+ else:
883
+ next_btn.label = "Next →"
884
+ next_btn.variant = "primary"
885
+
886
+ def collect_step_data(self) -> Dict[str, Any]:
887
+ """Collect data from the current step."""
888
+ content = self.query_one("#content-area", ScrollableContainer)
889
+ children = list(content.children)
890
+ if children and hasattr(children[0], 'get_data'):
891
+ return children[0].get_data()
892
+ return {}
893
+
894
+ @on(Button.Pressed, "#btn-cancel")
895
+ def on_cancel(self):
896
+ """Handle cancel button."""
897
+ self.exit(None)
898
+
899
+ @on(Button.Pressed, "#btn-prev")
900
+ def on_previous(self):
901
+ """Handle previous button."""
902
+ self.action_prev_step()
903
+
904
+ def action_prev_step(self):
905
+ """Go to previous step (keyboard shortcut: Ctrl+P)."""
906
+ if self.current_step > 0:
907
+ self.show_step(self.current_step - 1)
908
+
909
+ @on(Button.Pressed, "#btn-next")
910
+ def on_next(self):
911
+ """Handle next button."""
912
+ self.action_next_step()
913
+
914
+ def action_next_step(self):
915
+ """Go to next step or deploy (keyboard shortcut: Ctrl+N or Ctrl+Enter)."""
916
+ # Collect current step data
917
+ step_data = self.collect_step_data()
918
+ self.wizard_data.update(step_data)
919
+
920
+ # Mark step as completed
921
+ indicator = self.query_one("#step-indicator", StepIndicator)
922
+ indicator.complete_step(self.current_step)
923
+
924
+ if self.current_step < 4:
925
+ # Go to next step
926
+ self.show_step(self.current_step + 1)
927
+ else:
928
+ # Deploy
929
+ self.wizard_data['confirmed'] = True
930
+ if self.deploy_func:
931
+ self.run_deployment()
932
+ else:
933
+ self.exit(self.wizard_data)
934
+
935
+ @work
936
+ async def run_deployment(self):
937
+ """Run the deployment and show progress."""
938
+ # Call deploy function first to initiate deployment
939
+ if self.deploy_func:
940
+ try:
941
+ self.deploy_func(self.wizard_data)
942
+ except Exception as e:
943
+ self.notify(f"Deployment error: {e}", severity="error")
944
+ return
945
+
946
+ # Get namespace from wizard data
947
+ namespace = self.wizard_data.get('namespace') or \
948
+ self.wizard_data.get('project', {}).get('ns', 'default')
949
+
950
+ # Push build progress screen with SDK client for live updates
951
+ await self.push_screen(BuildProgressScreen(
952
+ app_name=self.wizard_data.get('app_name', 'app'),
953
+ namespace=namespace,
954
+ sdk_client=self.sdk_client,
955
+ ))
956
+
957
+ # Wait for screen to complete (will be popped when done)
958
+ # The screen handles its own exit via action_exit_screen
959
+
960
+
961
+ # ============================================================================
962
+ # Public API
963
+ # ============================================================================
964
+
965
+ def run_textual_wizard(projects: List[Dict] = None, deploy_func: Callable = None, sdk_client=None) -> Optional[Dict[str, Any]]:
966
+ """
967
+ Run the Textual-based deploy wizard.
968
+
969
+ Args:
970
+ projects: List of available projects
971
+ deploy_func: Function to call for actual deployment
972
+ sdk_client: SDK client for API calls and live progress monitoring
973
+
974
+ Returns:
975
+ Wizard data if completed, None if cancelled
976
+ """
977
+ app = DeployWizardApp(projects=projects, deploy_func=deploy_func, sdk_client=sdk_client)
978
+ return app.run()
979
+
980
+
981
+ if __name__ == "__main__":
982
+ # Demo mode
983
+ result = run_textual_wizard()
984
+ if result:
985
+ print(f"Wizard completed with data: {result}")
986
+ else:
987
+ print("Wizard cancelled")