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.
clue2app_cli/wizard.py ADDED
@@ -0,0 +1,1079 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Interactive Deploy Wizard with animations for Clue2App CLI.
4
+
5
+ This module provides a step-by-step wizard experience similar to the console UI,
6
+ with animated transitions and rich visual feedback.
7
+ """
8
+
9
+ import time
10
+ import sys
11
+ from typing import Optional, List, Dict, Any, Callable
12
+ from dataclasses import dataclass, field
13
+
14
+ import questionary
15
+ from questionary import Style
16
+ from rich.console import Console, Group
17
+ from rich.panel import Panel
18
+ from rich.table import Table
19
+ from rich.text import Text
20
+ from rich.live import Live
21
+ from rich.layout import Layout
22
+ from rich.align import Align
23
+ from rich.box import ROUNDED, DOUBLE, HEAVY
24
+ from rich.spinner import Spinner
25
+ from rich.progress import Progress, SpinnerColumn, TextColumn
26
+
27
+ console = Console()
28
+
29
+ # Custom questionary style matching Clue2App theme
30
+ WIZARD_STYLE = Style([
31
+ ('qmark', 'fg:#3b82f6 bold'), # Blue question mark
32
+ ('question', 'bold'), # Bold question
33
+ ('answer', 'fg:#22c55e bold'), # Green answer
34
+ ('pointer', 'fg:#3b82f6 bold'), # Blue pointer
35
+ ('highlighted', 'fg:#3b82f6 bold'), # Blue highlighted
36
+ ('selected', 'fg:#22c55e'), # Green selected
37
+ ('separator', 'fg:#64748b'), # Gray separator
38
+ ('instruction', 'fg:#94a3b8'), # Light gray instructions
39
+ ('text', ''), # Default text
40
+ ('disabled', 'fg:#64748b italic'), # Disabled option
41
+ ])
42
+
43
+
44
+ @dataclass
45
+ class WizardStep:
46
+ """Represents a single step in the wizard."""
47
+ name: str
48
+ title: str
49
+ description: str
50
+ icon: str = "○"
51
+ fields: List[Dict[str, Any]] = field(default_factory=list)
52
+
53
+
54
+ @dataclass
55
+ class WizardState:
56
+ """Holds the current state of the wizard."""
57
+ current_step: int = 0
58
+ total_steps: int = 5
59
+ data: Dict[str, Any] = field(default_factory=dict)
60
+ completed_steps: List[int] = field(default_factory=list)
61
+
62
+
63
+ class AnimatedStepIndicator:
64
+ """Renders an animated step indicator like the UI wizard."""
65
+
66
+ STEP_ICONS = {
67
+ 'pending': '○',
68
+ 'active': '●',
69
+ 'completed': '✓',
70
+ }
71
+
72
+ COLORS = {
73
+ 'pending': 'dim white',
74
+ 'active': 'bold blue',
75
+ 'completed': 'bold green',
76
+ 'connector_done': 'green',
77
+ 'connector_pending': 'dim white',
78
+ }
79
+
80
+ def __init__(self, steps: List[WizardStep], current: int = 0, completed: List[int] = None):
81
+ self.steps = steps
82
+ self.current = current
83
+ self.completed = completed or []
84
+
85
+ def render(self) -> Panel:
86
+ """Render the step indicator as a Rich Panel."""
87
+ parts = []
88
+
89
+ for i, step in enumerate(self.steps):
90
+ # Determine step state
91
+ if i in self.completed:
92
+ state = 'completed'
93
+ icon = self.STEP_ICONS['completed']
94
+ color = self.COLORS['completed']
95
+ elif i == self.current:
96
+ state = 'active'
97
+ icon = self.STEP_ICONS['active']
98
+ color = self.COLORS['active']
99
+ else:
100
+ state = 'pending'
101
+ icon = self.STEP_ICONS['pending']
102
+ color = self.COLORS['pending']
103
+
104
+ # Build step text
105
+ step_text = Text()
106
+ step_text.append(f" {icon} ", style=color)
107
+ step_text.append(f"{step.name}", style=color if state != 'pending' else 'dim')
108
+ parts.append(step_text)
109
+
110
+ # Add connector (except for last step)
111
+ if i < len(self.steps) - 1:
112
+ connector_color = self.COLORS['connector_done'] if i in self.completed else self.COLORS['connector_pending']
113
+ connector = Text(" ─── ", style=connector_color)
114
+ parts.append(connector)
115
+
116
+ # Combine all parts
117
+ combined = Text()
118
+ for part in parts:
119
+ combined.append(part)
120
+
121
+ return Panel(
122
+ Align.center(combined),
123
+ border_style="blue",
124
+ box=ROUNDED,
125
+ padding=(0, 1),
126
+ )
127
+
128
+
129
+ class DeployWizard:
130
+ """Interactive deployment wizard with animations."""
131
+
132
+ STEPS = [
133
+ WizardStep(
134
+ name="Basics",
135
+ title="Application Basics",
136
+ description="Give your application a name and select target cluster",
137
+ icon="📱",
138
+ ),
139
+ WizardStep(
140
+ name="Source",
141
+ title="Source Code",
142
+ description="Configure your Git repository",
143
+ icon="📦",
144
+ ),
145
+ WizardStep(
146
+ name="Registry",
147
+ title="Container Registry",
148
+ description="Choose where to store your container images",
149
+ icon="🐳",
150
+ ),
151
+ WizardStep(
152
+ name="Settings",
153
+ title="Application Settings",
154
+ description="Configure deployment, scaling, and environment",
155
+ icon="⚙️",
156
+ ),
157
+ WizardStep(
158
+ name="Review",
159
+ title="Review & Deploy",
160
+ description="Review your configuration and deploy",
161
+ icon="🚀",
162
+ ),
163
+ ]
164
+
165
+ def __init__(self, projects: List[Dict] = None):
166
+ self.state = WizardState(total_steps=len(self.STEPS))
167
+ self.projects = projects or []
168
+ self.indicator = AnimatedStepIndicator(self.STEPS)
169
+
170
+ def clear_screen(self):
171
+ """Clear the terminal screen."""
172
+ console.clear()
173
+
174
+ def show_header(self):
175
+ """Display the wizard header with step indicator."""
176
+ self.indicator.current = self.state.current_step
177
+ self.indicator.completed = self.state.completed_steps
178
+
179
+ # Title
180
+ title = Text()
181
+ title.append("🚀 ", style="bold")
182
+ title.append("Deploy Application Wizard", style="bold blue")
183
+
184
+ console.print()
185
+ console.print(Align.center(title))
186
+ console.print()
187
+ console.print(self.indicator.render())
188
+ console.print()
189
+
190
+ def animate_transition(self, direction: str = "forward"):
191
+ """Animate the transition between steps."""
192
+ frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
193
+
194
+ with Live(console=console, refresh_per_second=20) as live:
195
+ for i, frame in enumerate(frames):
196
+ text = Text()
197
+ text.append(f" {frame} ", style="blue")
198
+ if direction == "forward":
199
+ text.append("Loading next step...", style="dim")
200
+ else:
201
+ text.append("Going back...", style="dim")
202
+ live.update(Align.center(text))
203
+ time.sleep(0.03)
204
+
205
+ def step_basics(self) -> bool:
206
+ """Step 1: Application basics."""
207
+ self.clear_screen()
208
+ self.show_header()
209
+
210
+ step = self.STEPS[0]
211
+ console.print(Panel(
212
+ f"[bold]{step.icon} {step.title}[/bold]\n[dim]{step.description}[/dim]",
213
+ border_style="blue",
214
+ box=ROUNDED,
215
+ ))
216
+ console.print()
217
+
218
+ # Application name
219
+ app_name = questionary.text(
220
+ "Application Name:",
221
+ instruction="(lowercase, alphanumeric, hyphens allowed)",
222
+ validate=lambda x: len(x) >= 2 or "Name must be at least 2 characters",
223
+ style=WIZARD_STYLE,
224
+ ).ask()
225
+
226
+ if app_name is None: # User cancelled
227
+ return False
228
+
229
+ self.state.data['app_name'] = app_name.lower().replace(' ', '-').replace('_', '-')
230
+
231
+ # Project selection (if multiple projects available)
232
+ if self.projects:
233
+ project_choices = [
234
+ questionary.Choice(
235
+ title=f"{p.get('name')} ({p.get('ns', 'default')})",
236
+ value=p
237
+ ) for p in self.projects
238
+ ]
239
+
240
+ selected_project = questionary.select(
241
+ "Select Project:",
242
+ choices=project_choices,
243
+ style=WIZARD_STYLE,
244
+ ).ask()
245
+
246
+ if selected_project is None:
247
+ return False
248
+
249
+ self.state.data['project'] = selected_project
250
+
251
+ return True
252
+
253
+ def step_source(self) -> bool:
254
+ """Step 2: Source code configuration."""
255
+ self.clear_screen()
256
+ self.show_header()
257
+
258
+ step = self.STEPS[1]
259
+ console.print(Panel(
260
+ f"[bold]{step.icon} {step.title}[/bold]\n[dim]{step.description}[/dim]",
261
+ border_style="blue",
262
+ box=ROUNDED,
263
+ ))
264
+ console.print()
265
+
266
+ # Git URL
267
+ git_url = questionary.text(
268
+ "Git Repository URL:",
269
+ instruction="(e.g., https://github.com/user/repo.git)",
270
+ validate=lambda x: x.startswith(('http://', 'https://', 'git@')) or "Enter a valid Git URL",
271
+ style=WIZARD_STYLE,
272
+ ).ask()
273
+
274
+ if git_url is None:
275
+ return False
276
+
277
+ self.state.data['git_url'] = git_url
278
+
279
+ # Branch
280
+ branch = questionary.select(
281
+ "Git Branch:",
282
+ choices=[
283
+ questionary.Choice("main", value="main"),
284
+ questionary.Choice("master", value="master"),
285
+ questionary.Choice("Custom...", value="custom"),
286
+ ],
287
+ style=WIZARD_STYLE,
288
+ ).ask()
289
+
290
+ if branch is None:
291
+ return False
292
+
293
+ if branch == "custom":
294
+ branch = questionary.text(
295
+ "Enter branch name:",
296
+ style=WIZARD_STYLE,
297
+ ).ask()
298
+ if branch is None:
299
+ return False
300
+
301
+ self.state.data['branch'] = branch
302
+
303
+ # Private repo?
304
+ is_private = questionary.confirm(
305
+ "Is this a private repository?",
306
+ default=False,
307
+ style=WIZARD_STYLE,
308
+ ).ask()
309
+
310
+ if is_private is None:
311
+ return False
312
+
313
+ self.state.data['is_private'] = is_private
314
+
315
+ if is_private:
316
+ git_token = questionary.password(
317
+ "Git Token/Password:",
318
+ style=WIZARD_STYLE,
319
+ ).ask()
320
+
321
+ if git_token is None:
322
+ return False
323
+
324
+ self.state.data['git_token'] = git_token
325
+
326
+ return True
327
+
328
+ def step_registry(self) -> bool:
329
+ """Step 3: Container registry configuration."""
330
+ self.clear_screen()
331
+ self.show_header()
332
+
333
+ step = self.STEPS[2]
334
+ console.print(Panel(
335
+ f"[bold]{step.icon} {step.title}[/bold]\n[dim]{step.description}[/dim]",
336
+ border_style="blue",
337
+ box=ROUNDED,
338
+ ))
339
+ console.print()
340
+
341
+ registry_type = questionary.select(
342
+ "Container Registry:",
343
+ choices=[
344
+ questionary.Choice(
345
+ "Platform Registry (Recommended)",
346
+ value="PLATFORM_REGISTRY",
347
+ ),
348
+ questionary.Choice(
349
+ "Private Registry",
350
+ value="PRIVATE_REGISTRY",
351
+ ),
352
+ ],
353
+ style=WIZARD_STYLE,
354
+ ).ask()
355
+
356
+ if registry_type is None:
357
+ return False
358
+
359
+ self.state.data['registry_type'] = registry_type
360
+
361
+ if registry_type == "PRIVATE_REGISTRY":
362
+ registry_url = questionary.text(
363
+ "Registry URL:",
364
+ instruction="(e.g., docker.io, gcr.io, your-registry.com)",
365
+ style=WIZARD_STYLE,
366
+ ).ask()
367
+
368
+ if registry_url is None:
369
+ return False
370
+
371
+ self.state.data['registry_url'] = registry_url
372
+
373
+ registry_user = questionary.text(
374
+ "Registry Username:",
375
+ style=WIZARD_STYLE,
376
+ ).ask()
377
+
378
+ registry_pass = questionary.password(
379
+ "Registry Password:",
380
+ style=WIZARD_STYLE,
381
+ ).ask()
382
+
383
+ if registry_user is None or registry_pass is None:
384
+ return False
385
+
386
+ self.state.data['registry_user'] = registry_user
387
+ self.state.data['registry_pass'] = registry_pass
388
+
389
+ return True
390
+
391
+ def step_settings(self) -> bool:
392
+ """Step 4: Application settings."""
393
+ self.clear_screen()
394
+ self.show_header()
395
+
396
+ step = self.STEPS[3]
397
+ console.print(Panel(
398
+ f"[bold]{step.icon} {step.title}[/bold]\n[dim]{step.description}[/dim]",
399
+ border_style="blue",
400
+ box=ROUNDED,
401
+ ))
402
+ console.print()
403
+
404
+ # Container port
405
+ port = questionary.text(
406
+ "Container Port:",
407
+ default="8080",
408
+ validate=lambda x: x.isdigit() and 1 <= int(x) <= 65535 or "Enter a valid port (1-65535)",
409
+ style=WIZARD_STYLE,
410
+ ).ask()
411
+
412
+ if port is None:
413
+ return False
414
+
415
+ self.state.data['port'] = int(port)
416
+
417
+ # Application size
418
+ size = questionary.select(
419
+ "Application Size:",
420
+ choices=[
421
+ questionary.Choice("Tiny (256MB RAM, 0.25 CPU)", value="TINY"),
422
+ questionary.Choice("Small (512MB RAM, 0.5 CPU)", value="SMALL"),
423
+ questionary.Choice("Medium (1GB RAM, 1 CPU)", value="MEDIUM"),
424
+ questionary.Choice("Large (2GB RAM, 2 CPU)", value="LARGE"),
425
+ ],
426
+ default="SMALL",
427
+ style=WIZARD_STYLE,
428
+ ).ask()
429
+
430
+ if size is None:
431
+ return False
432
+
433
+ self.state.data['size'] = size
434
+
435
+ # Deploy automatically?
436
+ auto_deploy = questionary.confirm(
437
+ "Deploy automatically after build?",
438
+ default=True,
439
+ style=WIZARD_STYLE,
440
+ ).ask()
441
+
442
+ if auto_deploy is None:
443
+ return False
444
+
445
+ self.state.data['auto_deploy'] = auto_deploy
446
+
447
+ # Scale to zero?
448
+ scale_to_zero = questionary.confirm(
449
+ "Enable scale to zero? (saves resources when idle)",
450
+ default=False,
451
+ style=WIZARD_STYLE,
452
+ ).ask()
453
+
454
+ if scale_to_zero is None:
455
+ return False
456
+
457
+ self.state.data['scale_to_zero'] = scale_to_zero
458
+
459
+ # Environment variables
460
+ add_env = questionary.confirm(
461
+ "Add environment variables?",
462
+ default=False,
463
+ style=WIZARD_STYLE,
464
+ ).ask()
465
+
466
+ if add_env:
467
+ env_vars = {}
468
+ while True:
469
+ env_input = questionary.text(
470
+ "Environment variable (KEY=VALUE, or empty to finish):",
471
+ style=WIZARD_STYLE,
472
+ ).ask()
473
+
474
+ if not env_input:
475
+ break
476
+
477
+ if '=' in env_input:
478
+ key, value = env_input.split('=', 1)
479
+ env_vars[key.strip()] = value.strip()
480
+ console.print(f" [green]✓[/green] Added: {key.strip()}")
481
+
482
+ self.state.data['env_vars'] = env_vars
483
+
484
+ return True
485
+
486
+ def step_review(self) -> bool:
487
+ """Step 5: Review and deploy."""
488
+ self.clear_screen()
489
+ self.show_header()
490
+
491
+ step = self.STEPS[4]
492
+ console.print(Panel(
493
+ f"[bold]{step.icon} {step.title}[/bold]\n[dim]{step.description}[/dim]",
494
+ border_style="blue",
495
+ box=ROUNDED,
496
+ ))
497
+ console.print()
498
+
499
+ # Build review table
500
+ table = Table(
501
+ show_header=False,
502
+ box=ROUNDED,
503
+ border_style="blue",
504
+ padding=(0, 2),
505
+ expand=True,
506
+ )
507
+ table.add_column("Property", style="cyan", width=20)
508
+ table.add_column("Value", style="white")
509
+
510
+ # Basic Info section
511
+ table.add_row("[bold]BASIC INFO[/bold]", "")
512
+ table.add_row("Application Name", self.state.data.get('app_name', 'N/A'))
513
+ if 'project' in self.state.data:
514
+ project = self.state.data['project']
515
+ table.add_row("Project", f"{project.get('name')} ({project.get('ns')})")
516
+
517
+ # Source Code section
518
+ table.add_row("", "")
519
+ table.add_row("[bold]SOURCE CODE[/bold]", "")
520
+ table.add_row("Git Repository", self.state.data.get('git_url', 'N/A'))
521
+ table.add_row("Branch", self.state.data.get('branch', 'main'))
522
+ table.add_row("Access", "[yellow]Private[/yellow]" if self.state.data.get('is_private') else "[green]Public[/green]")
523
+
524
+ # Registry section
525
+ table.add_row("", "")
526
+ table.add_row("[bold]CONTAINER REGISTRY[/bold]", "")
527
+ registry = self.state.data.get('registry_type', 'PLATFORM_REGISTRY')
528
+ if registry == 'PLATFORM_REGISTRY':
529
+ table.add_row("Registry", "[green]Platform Registry[/green]")
530
+ else:
531
+ table.add_row("Registry", self.state.data.get('registry_url', 'N/A'))
532
+
533
+ # Settings section
534
+ table.add_row("", "")
535
+ table.add_row("[bold]SETTINGS[/bold]", "")
536
+ table.add_row("Container Port", str(self.state.data.get('port', 8080)))
537
+ table.add_row("Application Size", self.state.data.get('size', 'SMALL'))
538
+ table.add_row("Auto Deploy", "[green]Yes[/green]" if self.state.data.get('auto_deploy', True) else "[yellow]No[/yellow]")
539
+ table.add_row("Scale to Zero", "[green]Yes[/green]" if self.state.data.get('scale_to_zero') else "[dim]No[/dim]")
540
+
541
+ # Environment variables
542
+ env_vars = self.state.data.get('env_vars', {})
543
+ if env_vars:
544
+ table.add_row("", "")
545
+ table.add_row("[bold]ENVIRONMENT[/bold]", "")
546
+ for key, value in env_vars.items():
547
+ # Mask sensitive values
548
+ display_value = value if len(value) <= 20 else value[:17] + "..."
549
+ table.add_row(f" {key}", display_value)
550
+
551
+ console.print(table)
552
+ console.print()
553
+
554
+ # Confirm deployment
555
+ action = questionary.select(
556
+ "What would you like to do?",
557
+ choices=[
558
+ questionary.Choice("🚀 Deploy Application", value="deploy"),
559
+ questionary.Choice("✏️ Edit Configuration", value="edit"),
560
+ questionary.Choice("❌ Cancel", value="cancel"),
561
+ ],
562
+ style=WIZARD_STYLE,
563
+ ).ask()
564
+
565
+ if action == "deploy":
566
+ self.state.data['confirmed'] = True
567
+ return True
568
+ elif action == "edit":
569
+ # Go back to first step
570
+ self.state.current_step = 0
571
+ self.state.completed_steps = []
572
+ return self.run_wizard()
573
+ else:
574
+ return False
575
+
576
+ def run_wizard(self) -> Optional[Dict[str, Any]]:
577
+ """Run the complete wizard flow."""
578
+ step_functions = [
579
+ self.step_basics,
580
+ self.step_source,
581
+ self.step_registry,
582
+ self.step_settings,
583
+ self.step_review,
584
+ ]
585
+
586
+ while self.state.current_step < len(step_functions):
587
+ step_func = step_functions[self.state.current_step]
588
+
589
+ # Run the step
590
+ result = step_func()
591
+
592
+ if result:
593
+ # Mark step as completed and move to next
594
+ if self.state.current_step not in self.state.completed_steps:
595
+ self.state.completed_steps.append(self.state.current_step)
596
+
597
+ self.state.current_step += 1
598
+
599
+ # Animate transition (except after last step)
600
+ if self.state.current_step < len(step_functions):
601
+ self.animate_transition("forward")
602
+ else:
603
+ # User cancelled or went back
604
+ if self.state.current_step > 0:
605
+ # Ask if they want to go back
606
+ go_back = questionary.confirm(
607
+ "Go back to previous step?",
608
+ default=True,
609
+ style=WIZARD_STYLE,
610
+ ).ask()
611
+
612
+ if go_back:
613
+ self.state.current_step -= 1
614
+ self.animate_transition("backward")
615
+ else:
616
+ # Cancel wizard
617
+ return None
618
+ else:
619
+ # Cancel wizard from first step
620
+ return None
621
+
622
+ # Wizard completed
623
+ if self.state.data.get('confirmed'):
624
+ return self.state.data
625
+
626
+ return None
627
+
628
+ def show_deployment_progress(self, deploy_func: Callable) -> bool:
629
+ """Show animated deployment progress."""
630
+ self.clear_screen()
631
+
632
+ console.print()
633
+ console.print(Align.center(Text("🚀 Deploying Application", style="bold blue")))
634
+ console.print()
635
+
636
+ stages = [
637
+ ("Validating configuration...", 0.5),
638
+ ("Creating application...", 1.0),
639
+ ("Setting up Git source...", 0.8),
640
+ ("Configuring registry...", 0.5),
641
+ ("Initiating build...", 1.5),
642
+ ("Finalizing deployment...", 0.5),
643
+ ]
644
+
645
+ with Progress(
646
+ SpinnerColumn(),
647
+ TextColumn("[progress.description]{task.description}"),
648
+ console=console,
649
+ ) as progress:
650
+
651
+ for stage_text, duration in stages:
652
+ task = progress.add_task(stage_text, total=None)
653
+ time.sleep(duration)
654
+ progress.update(task, description=f"[green]✓[/green] {stage_text}")
655
+ progress.stop_task(task)
656
+
657
+ # Actually deploy
658
+ console.print()
659
+ try:
660
+ result = deploy_func(self.state.data)
661
+
662
+ console.print()
663
+ console.print(Panel(
664
+ "[bold green]✓ Deployment Initiated Successfully![/bold green]\n\n"
665
+ f"[dim]Application:[/dim] [bold]{self.state.data.get('app_name')}[/bold]\n"
666
+ f"[dim]Status:[/dim] Building\n\n"
667
+ "[dim]Next steps:[/dim]\n"
668
+ " • Run [bold]c2a builds list[/bold] to monitor build progress\n"
669
+ " • Run [bold]c2a services list[/bold] to see running services",
670
+ border_style="green",
671
+ box=DOUBLE,
672
+ ))
673
+
674
+ return True
675
+
676
+ except Exception as e:
677
+ console.print()
678
+ console.print(Panel(
679
+ f"[bold red]✗ Deployment Failed[/bold red]\n\n"
680
+ f"[red]{str(e)}[/red]",
681
+ border_style="red",
682
+ box=DOUBLE,
683
+ ))
684
+ return False
685
+
686
+
687
+ class LiveBuildMonitor:
688
+ """Real-time build progress monitor with animations."""
689
+
690
+ BUILD_PHASES = [
691
+ ("PREPARE", "Preparing build environment"),
692
+ ("DETECT", "Detecting buildpacks"),
693
+ ("ANALYZE", "Analyzing source code"),
694
+ ("RESTORE", "Restoring cached layers"),
695
+ ("BUILD", "Building application"),
696
+ ("EXPORT", "Exporting container image"),
697
+ ("CACHE", "Caching layers"),
698
+ ("COMPLETION", "Completing build"),
699
+ ]
700
+
701
+ PHASE_ICONS = {
702
+ "pending": "○",
703
+ "running": "◉",
704
+ "completed": "✓",
705
+ "failed": "✗",
706
+ }
707
+
708
+ def __init__(self, app_name: str, namespace: str = None):
709
+ self.app_name = app_name
710
+ self.namespace = namespace
711
+ self.current_phase = 0
712
+ self.build_status = "BUILDING"
713
+ self.logs: List[str] = []
714
+ self.start_time = time.time()
715
+
716
+ def render_progress(self) -> Panel:
717
+ """Render the current build progress."""
718
+ # Header
719
+ elapsed = int(time.time() - self.start_time)
720
+ elapsed_str = f"{elapsed // 60}m {elapsed % 60}s"
721
+
722
+ header = Text()
723
+ header.append("🔨 ", style="bold")
724
+ header.append("Building: ", style="bold blue")
725
+ header.append(self.app_name, style="bold cyan")
726
+ header.append(f" [dim]({elapsed_str})[/dim]")
727
+
728
+ # Phase progress
729
+ phases_text = Text()
730
+ for i, (phase_name, phase_desc) in enumerate(self.BUILD_PHASES):
731
+ if i < self.current_phase:
732
+ icon = self.PHASE_ICONS["completed"]
733
+ style = "green"
734
+ elif i == self.current_phase:
735
+ icon = self.PHASE_ICONS["running"]
736
+ style = "blue bold"
737
+ else:
738
+ icon = self.PHASE_ICONS["pending"]
739
+ style = "dim"
740
+
741
+ phases_text.append(f"\n {icon} ", style=style)
742
+ phases_text.append(f"{phase_name}: ", style=style)
743
+ phases_text.append(phase_desc, style="dim" if style == "dim" else "")
744
+
745
+ # Recent logs
746
+ logs_text = Text()
747
+ if self.logs:
748
+ logs_text.append("\n\n[dim]Recent logs:[/dim]\n")
749
+ for log in self.logs[-5:]: # Show last 5 log lines
750
+ logs_text.append(f" [dim]{log[:80]}[/dim]\n")
751
+
752
+ # Status bar
753
+ status_text = Text()
754
+ if self.build_status == "BUILDING":
755
+ status_text.append("\n[blue]● Building...[/blue]")
756
+ elif self.build_status == "SUCCESS":
757
+ status_text.append("\n[green]✓ Build completed successfully![/green]")
758
+ elif self.build_status == "FAILED":
759
+ status_text.append("\n[red]✗ Build failed[/red]")
760
+
761
+ content = Group(header, phases_text, logs_text, status_text)
762
+
763
+ return Panel(
764
+ content,
765
+ border_style="blue" if self.build_status == "BUILDING" else ("green" if self.build_status == "SUCCESS" else "red"),
766
+ box=ROUNDED,
767
+ title="[bold]Build Progress[/bold]",
768
+ subtitle="[dim]Press Ctrl+C to run in background[/dim]",
769
+ )
770
+
771
+ def update_from_status(self, status: Dict[str, Any]):
772
+ """Update monitor state from API status response."""
773
+ conditions = status.get('conditions', [])
774
+
775
+ for cond in conditions:
776
+ if cond.get('type') == 'Ready':
777
+ if cond.get('status') == 'True':
778
+ self.build_status = "SUCCESS"
779
+ self.current_phase = len(self.BUILD_PHASES)
780
+ elif cond.get('reason') == 'BuildFailed':
781
+ self.build_status = "FAILED"
782
+ break
783
+
784
+ # Try to determine phase from latest build reason
785
+ reason = status.get('latestBuildReason', '')
786
+ if 'CONFIG' in reason or 'TRIGGER' in reason:
787
+ self.current_phase = min(1, self.current_phase)
788
+ elif 'STACK' in reason:
789
+ self.current_phase = min(2, self.current_phase)
790
+ elif 'BUILDPACK' in reason:
791
+ self.current_phase = min(4, self.current_phase)
792
+
793
+ def add_log(self, log_line: str):
794
+ """Add a log line to the display."""
795
+ self.logs.append(log_line)
796
+ if len(self.logs) > 100: # Keep last 100 lines
797
+ self.logs = self.logs[-100:]
798
+
799
+
800
+ class LiveServiceMonitor:
801
+ """Real-time service deployment monitor."""
802
+
803
+ SERVICE_STATES = [
804
+ ("PENDING", "Service pending"),
805
+ ("CREATING", "Creating pods"),
806
+ ("CONFIGURING", "Configuring networking"),
807
+ ("SCALING", "Scaling replicas"),
808
+ ("READY", "Service ready"),
809
+ ]
810
+
811
+ def __init__(self, service_name: str, namespace: str = None):
812
+ self.service_name = service_name
813
+ self.namespace = namespace
814
+ self.current_state = 0
815
+ self.status = "DEPLOYING"
816
+ self.url: Optional[str] = None
817
+ self.pods: int = 0
818
+ self.ready_pods: int = 0
819
+ self.start_time = time.time()
820
+
821
+ def render_progress(self) -> Panel:
822
+ """Render the current service deployment progress."""
823
+ elapsed = int(time.time() - self.start_time)
824
+ elapsed_str = f"{elapsed // 60}m {elapsed % 60}s"
825
+
826
+ # Header
827
+ header = Text()
828
+ header.append("🚀 ", style="bold")
829
+ header.append("Deploying: ", style="bold blue")
830
+ header.append(self.service_name, style="bold cyan")
831
+ header.append(f" [dim]({elapsed_str})[/dim]")
832
+
833
+ # State progress
834
+ states_text = Text()
835
+ for i, (state_name, state_desc) in enumerate(self.SERVICE_STATES):
836
+ if i < self.current_state:
837
+ icon = "✓"
838
+ style = "green"
839
+ elif i == self.current_state:
840
+ icon = "◉"
841
+ style = "blue bold"
842
+ else:
843
+ icon = "○"
844
+ style = "dim"
845
+
846
+ states_text.append(f"\n {icon} ", style=style)
847
+ states_text.append(f"{state_name}: ", style=style)
848
+ states_text.append(state_desc, style="dim" if style == "dim" else "")
849
+
850
+ # Pod status
851
+ pod_text = Text()
852
+ pod_text.append(f"\n\n Pods: ")
853
+ if self.pods > 0:
854
+ pod_text.append(f"[green]{self.ready_pods}[/green]/{self.pods} ready")
855
+ else:
856
+ pod_text.append("[dim]waiting...[/dim]")
857
+
858
+ # URL
859
+ url_text = Text()
860
+ if self.url:
861
+ url_text.append(f"\n URL: [blue]{self.url}[/blue]")
862
+
863
+ # Status
864
+ status_text = Text()
865
+ if self.status == "DEPLOYING":
866
+ status_text.append("\n\n[blue]● Deploying...[/blue]")
867
+ elif self.status == "READY":
868
+ status_text.append("\n\n[green]✓ Service is ready![/green]")
869
+ elif self.status == "FAILED":
870
+ status_text.append("\n\n[red]✗ Deployment failed[/red]")
871
+
872
+ content = Group(header, states_text, pod_text, url_text, status_text)
873
+
874
+ border_color = "blue" if self.status == "DEPLOYING" else ("green" if self.status == "READY" else "red")
875
+
876
+ return Panel(
877
+ content,
878
+ border_style=border_color,
879
+ box=ROUNDED,
880
+ title="[bold]Service Deployment[/bold]",
881
+ subtitle="[dim]Press Ctrl+C to run in background[/dim]",
882
+ )
883
+
884
+ def update_from_status(self, status: Dict[str, Any]):
885
+ """Update from Knative service status."""
886
+ conditions = status.get('conditions', [])
887
+
888
+ # Check Ready condition
889
+ for cond in conditions:
890
+ if cond.get('type') == 'Ready':
891
+ if cond.get('status') == 'True':
892
+ self.status = "READY"
893
+ self.current_state = len(self.SERVICE_STATES)
894
+ elif cond.get('status') == 'False':
895
+ reason = cond.get('reason', '')
896
+ if 'Failed' in reason or 'Error' in reason:
897
+ self.status = "FAILED"
898
+ break
899
+
900
+ # Get URL
901
+ self.url = status.get('url')
902
+
903
+ # Estimate state from conditions
904
+ ready_count = sum(1 for c in conditions if c.get('status') == 'True')
905
+ self.current_state = min(ready_count, len(self.SERVICE_STATES) - 1)
906
+
907
+
908
+ def monitor_build_live(
909
+ sdk_client,
910
+ image_name: str,
911
+ namespace: str,
912
+ timeout: int = 600,
913
+ poll_interval: float = 2.0
914
+ ) -> bool:
915
+ """
916
+ Monitor a build in real-time with live updates.
917
+
918
+ Args:
919
+ sdk_client: The SDK client for API calls
920
+ image_name: Name of the image being built
921
+ namespace: Kubernetes namespace
922
+ timeout: Maximum time to wait (seconds)
923
+ poll_interval: Time between status checks
924
+
925
+ Returns:
926
+ True if build succeeded, False otherwise
927
+ """
928
+ monitor = LiveBuildMonitor(image_name, namespace)
929
+ start_time = time.time()
930
+
931
+ with Live(monitor.render_progress(), console=console, refresh_per_second=4) as live:
932
+ while time.time() - start_time < timeout:
933
+ try:
934
+ # Get image status
935
+ images = sdk_client.apps.get_images(namespace=namespace)
936
+ image = next((img for img in images if image_name in img.get('metadata', {}).get('name', '')), None)
937
+
938
+ if image:
939
+ monitor.update_from_status(image.get('status', {}))
940
+
941
+ # Simulate phase progress based on time for demo
942
+ elapsed = time.time() - start_time
943
+ if monitor.build_status == "BUILDING":
944
+ monitor.current_phase = min(int(elapsed / 10), len(monitor.BUILD_PHASES) - 1)
945
+
946
+ live.update(monitor.render_progress())
947
+
948
+ if monitor.build_status in ("SUCCESS", "FAILED"):
949
+ time.sleep(1) # Brief pause to show final state
950
+ break
951
+
952
+ time.sleep(poll_interval)
953
+
954
+ except KeyboardInterrupt:
955
+ console.print("\n[yellow]Continuing in background...[/yellow]")
956
+ console.print(f" Run [bold]c2a builds logs {image_name}[/bold] to view logs")
957
+ return True
958
+
959
+ except Exception as e:
960
+ monitor.add_log(f"Error: {str(e)}")
961
+ live.update(monitor.render_progress())
962
+ time.sleep(poll_interval)
963
+
964
+ return monitor.build_status == "SUCCESS"
965
+
966
+
967
+ def monitor_service_live(
968
+ sdk_client,
969
+ service_name: str,
970
+ namespace: str,
971
+ timeout: int = 300,
972
+ poll_interval: float = 2.0
973
+ ) -> bool:
974
+ """
975
+ Monitor a service deployment in real-time.
976
+
977
+ Args:
978
+ sdk_client: The SDK client for API calls
979
+ service_name: Name of the service
980
+ namespace: Kubernetes namespace
981
+ timeout: Maximum time to wait
982
+ poll_interval: Time between checks
983
+
984
+ Returns:
985
+ True if deployment succeeded, False otherwise
986
+ """
987
+ monitor = LiveServiceMonitor(service_name, namespace)
988
+ start_time = time.time()
989
+
990
+ with Live(monitor.render_progress(), console=console, refresh_per_second=4) as live:
991
+ while time.time() - start_time < timeout:
992
+ try:
993
+ # In a real implementation, this would call the service status API
994
+ # For now, simulate progress
995
+ elapsed = time.time() - start_time
996
+
997
+ if elapsed > 5:
998
+ monitor.pods = 1
999
+ if elapsed > 10:
1000
+ monitor.ready_pods = 1
1001
+ if elapsed > 15:
1002
+ monitor.url = f"https://{service_name}.{namespace}.apps.clue2.app"
1003
+ if elapsed > 20:
1004
+ monitor.status = "READY"
1005
+ monitor.current_state = len(monitor.SERVICE_STATES)
1006
+
1007
+ live.update(monitor.render_progress())
1008
+
1009
+ if monitor.status in ("READY", "FAILED"):
1010
+ time.sleep(1)
1011
+ break
1012
+
1013
+ time.sleep(poll_interval)
1014
+
1015
+ except KeyboardInterrupt:
1016
+ console.print("\n[yellow]Continuing in background...[/yellow]")
1017
+ console.print(f" Run [bold]c2a services logs {service_name}[/bold] to view logs")
1018
+ return True
1019
+
1020
+ except Exception as e:
1021
+ live.update(monitor.render_progress())
1022
+ time.sleep(poll_interval)
1023
+
1024
+ return monitor.status == "READY"
1025
+
1026
+
1027
+ def run_deploy_wizard(projects: List[Dict] = None, deploy_func: Callable = None, sdk_client=None) -> Optional[Dict[str, Any]]:
1028
+ """
1029
+ Run the interactive deploy wizard.
1030
+
1031
+ Args:
1032
+ projects: List of available projects
1033
+ deploy_func: Function to call for actual deployment
1034
+ sdk_client: SDK client for live monitoring
1035
+
1036
+ Returns:
1037
+ Wizard data if completed, None if cancelled
1038
+ """
1039
+ wizard = DeployWizard(projects=projects)
1040
+
1041
+ try:
1042
+ result = wizard.run_wizard()
1043
+
1044
+ if result and deploy_func:
1045
+ # Show initial deployment progress
1046
+ success = wizard.show_deployment_progress(deploy_func)
1047
+
1048
+ if success and sdk_client:
1049
+ # Monitor build progress
1050
+ app_name = result.get('app_name')
1051
+ namespace = result.get('project', {}).get('ns', 'default')
1052
+
1053
+ console.print()
1054
+ console.print("[bold]Monitoring build progress...[/bold]")
1055
+ console.print("[dim]Press Ctrl+C to run in background[/dim]")
1056
+ console.print()
1057
+
1058
+ build_success = monitor_build_live(
1059
+ sdk_client,
1060
+ f"{app_name}-image",
1061
+ namespace,
1062
+ )
1063
+
1064
+ if build_success and result.get('auto_deploy', True):
1065
+ console.print()
1066
+ console.print("[bold]Monitoring service deployment...[/bold]")
1067
+ console.print()
1068
+
1069
+ monitor_service_live(
1070
+ sdk_client,
1071
+ app_name,
1072
+ namespace,
1073
+ )
1074
+
1075
+ return result
1076
+
1077
+ except KeyboardInterrupt:
1078
+ console.print("\n[yellow]Wizard cancelled.[/yellow]")
1079
+ return None