mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 12:03:59 +00:00
2346 lines
100 KiB
Python
Executable File
2346 lines
100 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Prole Service Dependencies Installer
|
|
Desktop application for installing, deploying, and validating Prole services
|
|
"""
|
|
|
|
import tkinter as tk
|
|
from tkinter import ttk, scrolledtext, messagebox
|
|
import subprocess
|
|
import threading
|
|
import os
|
|
import sys
|
|
import webbrowser
|
|
import time
|
|
import platform
|
|
from pathlib import Path
|
|
import shlex
|
|
import socket
|
|
import signal
|
|
|
|
# Refactor: import shared helpers from the new root-level installer package
|
|
from installer import config as inst_config
|
|
from installer.build import get_build_command as inst_get_build_command
|
|
from installer.workstation import (
|
|
get_vagrant_up_command as inst_get_vagrant_up_command,
|
|
select_vagrantfile as inst_select_vagrantfile,
|
|
)
|
|
from installer import deploy as inst_deploy
|
|
|
|
# Get the project root directory (kept local for clarity in this legacy entry)
|
|
PROJECT_ROOT = Path(__file__).parent.absolute()
|
|
|
|
|
|
def is_apple_silicon():
|
|
"""Check if running on Apple Silicon (ARM64)."""
|
|
return inst_config.is_apple_silicon()
|
|
|
|
|
|
def get_docker_build_platform_args():
|
|
"""Get Docker build platform arguments for Apple Silicon (from config)."""
|
|
return inst_config.get_docker_build_platform_args()
|
|
|
|
|
|
class ProleInstaller:
|
|
def __init__(self, root):
|
|
self.root = root
|
|
self.root.title("Prole Installer")
|
|
# Best-effort: set app identity (menu title / dock icon) early
|
|
try:
|
|
self._set_app_identity()
|
|
except Exception:
|
|
pass
|
|
|
|
# Center window on screen
|
|
window_width = 1000
|
|
window_height = 700
|
|
screen_width = root.winfo_screenwidth()
|
|
screen_height = root.winfo_screenheight()
|
|
center_x = int(screen_width / 2 - window_width / 2)
|
|
center_y = int(screen_height / 2 - window_height / 2)
|
|
self.root.geometry(f"{window_width}x{window_height}+{center_x}+{center_y}")
|
|
|
|
# Light background overall
|
|
self.root.configure(bg='#f5f5f7')
|
|
|
|
# Style configuration
|
|
self.style = ttk.Style()
|
|
# Force a light, stable theme to avoid dark/black background artifacts.
|
|
# macOS Aqua can flip to dark backgrounds depending on system appearance.
|
|
try:
|
|
self.style.theme_use('clam')
|
|
except Exception:
|
|
# Fallback to whatever default exists
|
|
pass
|
|
self.configure_styles()
|
|
|
|
# Create main container
|
|
self.container = ttk.Frame(root)
|
|
self.container.pack(fill='both', expand=True)
|
|
|
|
# "Slide" area with a full-frame background on the lowest layer
|
|
self.slide_area = ttk.Frame(self.container)
|
|
self.slide_area.pack(fill='both', expand=True)
|
|
|
|
# Background canvas paints the image as a full-cover background
|
|
self._bg_pil = None
|
|
self._bg_tk = None
|
|
self.bg_canvas = tk.Canvas(self.slide_area, highlightthickness=0, bd=0)
|
|
self.bg_canvas.pack(fill='both', expand=True)
|
|
self._bg_item = None
|
|
try:
|
|
from PIL import Image, ImageTk # optional
|
|
# Use the full-frame background image per latest spec
|
|
bg_path = PROJECT_ROOT / 'img' / 'proleLogoSepia.png'
|
|
if bg_path.exists():
|
|
self._bg_pil = Image.open(str(bg_path)).convert('RGBA')
|
|
|
|
def _render_bg(event=None):
|
|
if not self._bg_pil:
|
|
return
|
|
cw = max(1, self.bg_canvas.winfo_width())
|
|
ch = max(1, self.bg_canvas.winfo_height())
|
|
iw, ih = self._bg_pil.size
|
|
# Cover algorithm: scale so that image covers the canvas fully
|
|
scale = max(cw / iw, ch / ih)
|
|
nw, nh = max(1, int(iw * scale)), max(1, int(ih * scale))
|
|
img = self._bg_pil.resize((nw, nh), Image.LANCZOS)
|
|
# center crop (no need to crop since canvas can clip)
|
|
self._bg_tk = ImageTk.PhotoImage(img)
|
|
self.bg_canvas.delete('all')
|
|
self._bg_item = self.bg_canvas.create_image(cw // 2, ch // 2, anchor='center', image=self._bg_tk)
|
|
|
|
self.bg_canvas.bind('<Configure>', _render_bg)
|
|
# Initial render after window shows
|
|
self.root.after(100, _render_bg)
|
|
except Exception:
|
|
# If PIL not available or image missing, leave plain background
|
|
pass
|
|
|
|
# Initialize validation attributes before creating screens
|
|
self.validation_running = False
|
|
self.validation_thread = None
|
|
# Capture expected host (short hostname) at app start for safety guards
|
|
try:
|
|
self.expected_host = (platform.node() or socket.gethostname()).split('.')[0]
|
|
except Exception:
|
|
self.expected_host = None
|
|
|
|
# Footer navigation (Prev / Next / Finish)
|
|
self.footer = ttk.Frame(self.container)
|
|
self.footer.pack(fill='x', side='bottom')
|
|
self.prev_button = ttk.Button(self.footer, text='Prev', command=self.on_prev)
|
|
self.next_button = ttk.Button(self.footer, text='Next', command=self.on_next)
|
|
self.finish_button = ttk.Button(self.footer, text='Finish', command=self.on_finish)
|
|
# Layout: right-aligned
|
|
spacer = ttk.Frame(self.footer)
|
|
spacer.pack(side='left', expand=True, fill='x')
|
|
self.prev_button.pack(side='right', padx=(0, 8), pady=12)
|
|
self.next_button.pack(side='right', padx=(0, 8), pady=12)
|
|
self.finish_button.pack(side='right', padx=(0, 20), pady=12)
|
|
|
|
# Wizard pages setup
|
|
self.pages = [] # list of (page_id, frame)
|
|
self.page_index = 0
|
|
|
|
# Content will be rendered directly on the background canvas to avoid
|
|
# any opaque rectangles obscuring the image.
|
|
# Maintain a tiny overlay layer only for small interactive widgets (if any).
|
|
self.wizard_frame = None # deprecated overlay frame
|
|
self._canvas_page = None
|
|
self._canvas_items = []
|
|
self.canvas_renderers = {}
|
|
# Keep track of small overlay widgets placed above the canvas so we can
|
|
# cleanly remove them on page switches (e.g., radiobuttons, consoles)
|
|
self._overlay_widgets = []
|
|
# Cursor blink timer id for command preview
|
|
self._cursor_blink_after_id = None
|
|
self._cursor_blink_visible = False
|
|
|
|
# Shared dependency catalog from installer.config
|
|
self.dependencies = list(inst_config.DEPENDENCIES)
|
|
|
|
# Create pages
|
|
self.page_frames = {}
|
|
self.verify_mode = tk.BooleanVar(value=False)
|
|
# Register page ids (renderers will draw directly on canvas)
|
|
self._register_canvas_renderer('welcome', self._render_welcome_page)
|
|
self._register_canvas_renderer('deps_summary', self._render_deps_summary_page)
|
|
for dep in self.dependencies:
|
|
self._register_canvas_renderer(f"dep_{dep['id']}", lambda d=dep: self._render_dependency_page(d))
|
|
self._register_canvas_renderer('build', self._render_build_page)
|
|
# New second build page for the Vagrant workstation
|
|
self._register_canvas_renderer('build_vm', self._render_build_vm_page)
|
|
self._register_canvas_renderer('build_summary', self._render_build_summary_page)
|
|
self._register_canvas_renderer('deploy', self._render_deploy_page)
|
|
|
|
# Mirror old pages list ordering for navigation
|
|
self._register_page('welcome', None)
|
|
self._register_page('deps_summary', None)
|
|
for dep in self.dependencies:
|
|
self._register_page(f"dep_{dep['id']}", None)
|
|
self._register_page('build', None)
|
|
self._register_page('build_vm', None)
|
|
self._register_page('build_summary', None)
|
|
self._register_page('deploy', None)
|
|
|
|
# Initialize page and footer
|
|
self.show_page(0)
|
|
self.update_footer()
|
|
|
|
# ---------------- App identity (title, Dock icon) ----------------
|
|
def _set_app_identity(self):
|
|
"""Set the app name shown by Tk and attempt to set the macOS Dock icon.
|
|
|
|
Notes:
|
|
- tk appname affects Tk's internal application name and may improve
|
|
display in some OS integrations. On macOS, fully changing the menu bar
|
|
app name from "Python" typically requires running as a bundled app
|
|
with CFBundleName set, but we still set the Tk appname here.
|
|
- For the Dock icon on macOS, we try to use the ProleStatus.app icns
|
|
(capital P icon). If PyObjC (AppKit) isn't available, we fall back to
|
|
setting a Tk window icon from a PNG/GIF in img/.
|
|
"""
|
|
# Set Tk application name
|
|
try:
|
|
self.root.tk.call('tk', 'appname', 'Prole Installer')
|
|
except Exception:
|
|
pass
|
|
|
|
# macOS Dock icon via AppKit (preferred)
|
|
if platform.system() == 'Darwin':
|
|
icns_candidates = [
|
|
PROJECT_ROOT / 'proleStatus' / 'dist' / 'ProleStatus.app' / 'Contents' / 'Resources' / 'AppIcon.icns',
|
|
PROJECT_ROOT / 'proleStatus' / 'dist' / 'ProleStatus.app' / 'Contents' / 'Resources' / 'ProleStatus.icns',
|
|
]
|
|
icns_path = next((p for p in icns_candidates if p.exists()), None)
|
|
if icns_path is not None:
|
|
try:
|
|
# Use PyObjC if available
|
|
from AppKit import NSApplication, NSImage
|
|
img = NSImage.alloc().initWithContentsOfFile_(str(icns_path))
|
|
if img is not None:
|
|
NSApplication.sharedApplication().setApplicationIconImage_(img)
|
|
return
|
|
except Exception:
|
|
pass
|
|
|
|
# Fallback: Tk icon from image assets (PNG/GIF)
|
|
img_candidates = [
|
|
PROJECT_ROOT / 'img' / 'prole-type.gif',
|
|
PROJECT_ROOT / 'img' / 'prole-type.png',
|
|
PROJECT_ROOT / 'img' / 'ProleStatus.png',
|
|
PROJECT_ROOT / 'img' / 'proleLogoSepia.png',
|
|
]
|
|
for p in img_candidates:
|
|
try:
|
|
if p.exists():
|
|
self._app_iconphoto = tk.PhotoImage(file=str(p))
|
|
try:
|
|
self.root.iconphoto(True, self._app_iconphoto)
|
|
except Exception:
|
|
pass
|
|
break
|
|
except Exception:
|
|
continue
|
|
|
|
def configure_styles(self):
|
|
"""Configure ttk styles"""
|
|
base_bg = '#f5f5f7'
|
|
self.style.configure('Title.TLabel', background=base_bg, foreground='#1d1d1f', font=('Helvetica', 22, 'bold'))
|
|
self.style.configure('Body.TLabel', background=base_bg, foreground='#1d1d1f', font=('Helvetica', 12))
|
|
self.style.configure('Dim.TLabel', background=base_bg, foreground='#6e6e73', font=('Helvetica', 11))
|
|
self.style.configure('Card.TFrame', background=base_bg, relief='flat')
|
|
# Buttons: keep native look; ensure readable foreground
|
|
try:
|
|
self.style.configure('TButton', foreground='#1d1d1f')
|
|
except Exception:
|
|
pass
|
|
# Ensure checkbuttons and other common controls inherit light background
|
|
try:
|
|
self.style.configure('TCheckbutton', background=base_bg, foreground='#1d1d1f')
|
|
self.style.configure('TCombobox', fieldbackground='white', background=base_bg)
|
|
except Exception:
|
|
pass
|
|
|
|
def create_navigation(self):
|
|
# Deprecated top navigation retained for compatibility; not used in wizard redesign
|
|
pass
|
|
|
|
def show_screen(self, screen_id):
|
|
# No-op in wizard redesign
|
|
return
|
|
|
|
# ---------------- Wizard pages ----------------
|
|
def _page_container(self):
|
|
# Deprecated: pages are drawn directly on the canvas to avoid opaque overlays.
|
|
return None
|
|
|
|
def _register_page(self, page_id, frame):
|
|
self.pages.append((page_id, frame))
|
|
self.page_frames[page_id] = frame
|
|
|
|
def show_page(self, index_or_id):
|
|
# Clear any previously drawn canvas content
|
|
self._clear_canvas_page()
|
|
# Hide any legacy frames if they exist
|
|
for _, f in self.pages:
|
|
try:
|
|
|
|
if f is not None:
|
|
f.place_forget()
|
|
f.pack_forget()
|
|
except Exception:
|
|
pass
|
|
# Resolve index
|
|
if isinstance(index_or_id, int):
|
|
idx = max(0, min(index_or_id, len(self.pages) - 1))
|
|
else:
|
|
idx = next((i for i, (pid, _) in enumerate(self.pages) if pid == index_or_id), 0)
|
|
self.page_index = idx
|
|
pid, frame = self.pages[self.page_index]
|
|
# Render the page directly on the canvas if we have a renderer
|
|
if pid in self.canvas_renderers:
|
|
try:
|
|
self.canvas_renderers[pid]()
|
|
except Exception as e:
|
|
# Fallback: show an error message on canvas
|
|
self._canvas_items.append(
|
|
self.bg_canvas.create_text(
|
|
32, 32, anchor='nw', text=f"Error rendering page '{pid}': {e}",
|
|
fill='#1d1d1f', font=('Helvetica', 12)
|
|
)
|
|
)
|
|
elif frame is not None:
|
|
# Legacy fallback (should not be used)
|
|
frame.place(relx=0.5, rely=0.5, anchor='center', relwidth=0.94, relheight=0.9)
|
|
self.update_footer()
|
|
|
|
# ---------------- Canvas page rendering ----------------
|
|
def _register_canvas_renderer(self, page_id, func):
|
|
self.canvas_renderers[page_id] = func
|
|
|
|
def _clear_canvas_page(self):
|
|
if self._canvas_items:
|
|
for item in self._canvas_items:
|
|
try:
|
|
self.bg_canvas.delete(item)
|
|
except Exception:
|
|
pass
|
|
self._canvas_items = []
|
|
# Also unbind any page-specific bindings
|
|
self.bg_canvas.unbind('<Button-1>')
|
|
self.bg_canvas.config(cursor='')
|
|
# Remove any overlay widgets we placed for the previous page
|
|
if getattr(self, '_overlay_widgets', None):
|
|
for w in self._overlay_widgets:
|
|
try:
|
|
w.place_forget()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
w.destroy()
|
|
except Exception:
|
|
pass
|
|
self._overlay_widgets = []
|
|
# Stop any blinking cursor if active
|
|
if getattr(self, '_cursor_blink_after_id', None):
|
|
try:
|
|
self.root.after_cancel(self._cursor_blink_after_id)
|
|
except Exception:
|
|
pass
|
|
self._cursor_blink_after_id = None
|
|
self._cursor_blink_visible = False
|
|
# Stop any page-specific resize binding for overlays
|
|
if getattr(self, '_overlay_bind_id', None):
|
|
try:
|
|
self.slide_area.unbind('<Configure>', self._overlay_bind_id)
|
|
except Exception:
|
|
pass
|
|
self._overlay_bind_id = None
|
|
# Unbind Enter/Return shortcuts that may have been set by a page
|
|
try:
|
|
self.root.unbind('<Return>')
|
|
self.root.unbind('<KP_Enter>')
|
|
except Exception:
|
|
pass
|
|
# If a build process is running and we leave the page, terminate it safely
|
|
if getattr(self, '_running_process', None):
|
|
try:
|
|
self._terminate_running_process()
|
|
except Exception:
|
|
pass
|
|
|
|
def _render_title(self, text, y=40):
|
|
self._canvas_items.append(
|
|
self.bg_canvas.create_text(48, y, anchor='nw', text=text,
|
|
fill='#1d1d1f', font=('Helvetica', 22, 'bold'))
|
|
)
|
|
|
|
def _render_paragraph(self, text, y, wrap=860):
|
|
self._canvas_items.append(
|
|
self.bg_canvas.create_text(48, y, anchor='nw', text=text, width=wrap,
|
|
fill='#1d1d1f', font=('Helvetica', 12), justify='left')
|
|
)
|
|
|
|
def _render_welcome_page(self):
|
|
self._render_title('Welcome to Prole', y=40)
|
|
msg = 'Thanks for joining Prole. We will prepare your system and install the software needed to build and run Prole.'
|
|
self._render_paragraph(msg, y=90)
|
|
|
|
def _render_deps_summary_page(self):
|
|
self._render_title('Dependencies', y=40)
|
|
y = 90
|
|
# Draw each dependency row: status dot, name, info
|
|
row_gap = 28
|
|
left = 56
|
|
text_x = left + 28
|
|
# Try to get recent results by checking synchronously (cheap) or show pending
|
|
results = {}
|
|
any_missing = False
|
|
for dep in self.dependencies:
|
|
ok, location, version = self.get_dep_info(dep)
|
|
results[dep['id']] = (ok, location, version)
|
|
for dep in self.dependencies:
|
|
did = dep['id']
|
|
ok, _, version = results.get(did, (False, None, None))
|
|
# status dot
|
|
if ok:
|
|
fill = '#34c759'
|
|
else:
|
|
fill = '#ff3b30'
|
|
any_missing = True
|
|
self._canvas_items.append(self.bg_canvas.create_oval(left, y, left+18, y+18, fill=fill, outline=''))
|
|
# name
|
|
self._canvas_items.append(self.bg_canvas.create_text(text_x, y-2, anchor='nw', text=dep['name'],
|
|
fill='#1d1d1f', font=('Helvetica', 12, 'bold')))
|
|
# info
|
|
info_text = ''
|
|
if ok:
|
|
info_text = self.normalize_version(version) if version else ''
|
|
else:
|
|
info_text = 'Not installed'
|
|
if info_text:
|
|
self._canvas_items.append(self.bg_canvas.create_text(text_x + 150, y, anchor='nw', text=info_text,
|
|
fill='#6e6e73', font=('Helvetica', 11)))
|
|
y += row_gap
|
|
|
|
# Verify all checkbox (canvas-drawn toggle)
|
|
toggle_y = y + 10
|
|
box_x = 52
|
|
box = self.bg_canvas.create_rectangle(box_x, toggle_y, box_x+16, toggle_y+16, outline='#6e6e73', width=2)
|
|
self._canvas_items.append(box)
|
|
label = self.bg_canvas.create_text(box_x+24, toggle_y-2, anchor='nw', text='Verify all dependencies',
|
|
fill='#1d1d1f', font=('Helvetica', 12))
|
|
self._canvas_items.append(label)
|
|
# check mark if enabled
|
|
if self.verify_mode.get():
|
|
self._canvas_items.append(self.bg_canvas.create_line(box_x+3, toggle_y+9, box_x+7, toggle_y+13, fill='#1d1d1f', width=2))
|
|
self._canvas_items.append(self.bg_canvas.create_line(box_x+7, toggle_y+13, box_x+14, toggle_y+5, fill='#1d1d1f', width=2))
|
|
|
|
# Message line
|
|
msg_y = toggle_y + 28
|
|
msg_text = ''
|
|
if any_missing:
|
|
missing = [d['name'] for d in self.dependencies if not results.get(d['id'], (False, None, None))[0]]
|
|
if missing:
|
|
msg_text = f"Preparing to install ... {', '.join(missing)}"
|
|
else:
|
|
msg_text = 'All dependencies installed.'
|
|
if msg_text:
|
|
self._canvas_items.append(self.bg_canvas.create_text(48, msg_y, anchor='nw', text=msg_text,
|
|
fill='#6e6e73', font=('Helvetica', 11)))
|
|
|
|
# Bind toggle click
|
|
def _on_click(event):
|
|
ex, ey = event.x, event.y
|
|
if box_x <= ex <= box_x+16 and toggle_y <= ey <= toggle_y+16:
|
|
self.verify_mode.set(not self.verify_mode.get())
|
|
self._clear_canvas_page()
|
|
self._render_deps_summary_page()
|
|
self.update_footer()
|
|
self.bg_canvas.bind('<Button-1>', _on_click)
|
|
|
|
def _render_dependency_page(self, dep):
|
|
self._render_title(dep['name'], y=40)
|
|
self._render_paragraph(dep['description'], y=80)
|
|
# Status
|
|
ok, location, version = self.get_dep_info(dep)
|
|
y = 140
|
|
left = 56
|
|
if ok:
|
|
self._canvas_items.append(self.bg_canvas.create_oval(left, y, left+18, y+18, fill='#34c759', outline=''))
|
|
self._canvas_items.append(self.bg_canvas.create_text(left+26, y-2, anchor='nw', text='Installed', fill='#1d1d1f', font=('Helvetica', 12, 'bold')))
|
|
if location:
|
|
self._canvas_items.append(self.bg_canvas.create_text(left+26, y+26, anchor='nw', text=f'Location: {location}', fill='#6e6e73', font=('Helvetica', 11)))
|
|
if version:
|
|
ver = self.normalize_version(version)
|
|
self._canvas_items.append(self.bg_canvas.create_text(left+26, y+46, anchor='nw', text=f'Version: {ver}', fill='#6e6e73', font=('Helvetica', 11)))
|
|
else:
|
|
self._canvas_items.append(self.bg_canvas.create_oval(left, y, left+18, y+18, fill='#ff9f0a', outline=''))
|
|
self._canvas_items.append(self.bg_canvas.create_text(left+26, y-2, anchor='nw', text='Not installed', fill='#1d1d1f', font=('Helvetica', 12, 'bold')))
|
|
# Click to install link (if available)
|
|
if dep.get('install_cmd'):
|
|
link_y = y + 30
|
|
link_text = self.bg_canvas.create_text(left+26, link_y, anchor='nw', text='Click to install', fill='#0a84ff', font=('Helvetica', 12, 'underline'))
|
|
self._canvas_items.append(link_text)
|
|
self.bg_canvas.config(cursor='hand2')
|
|
|
|
def _on_click(event):
|
|
ex, ey = event.x, event.y
|
|
bbox = self.bg_canvas.bbox(link_text)
|
|
if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]:
|
|
self.open_terminal_with_command(dep.get('install_cmd'))
|
|
self.bg_canvas.bind('<Button-1>', _on_click)
|
|
|
|
def _render_build_page(self):
|
|
# Title and instructions on canvas (fully transparent background)
|
|
self._render_title('Build', y=40)
|
|
self._render_paragraph('Choose a target and build the artifacts.', y=90)
|
|
|
|
# Canvas-drawn radio buttons (no ttk widgets to avoid grey/white boxes)
|
|
if not hasattr(self, 'deploy_env_value'):
|
|
self.deploy_env_value = 'Dev'
|
|
|
|
radio_y = 130
|
|
left = 56
|
|
spacing = 110
|
|
|
|
# Draw three radio options
|
|
self._build_radio_items = []
|
|
options = [('Dev', left), ('Service', left + spacing), ('Prod', left + spacing * 2)]
|
|
for label, x in options:
|
|
# outer circle
|
|
r = 9
|
|
circle = self.bg_canvas.create_oval(x, radio_y, x + 2*r, radio_y + 2*r, outline='#1d1d1f', width=2)
|
|
self._canvas_items.append(circle)
|
|
# selected dot
|
|
if self.deploy_env_value == label:
|
|
dot = self.bg_canvas.create_oval(x+4, radio_y+4, x+2*r-4, radio_y+2*r-4, fill='#1d1d1f', outline='')
|
|
self._canvas_items.append(dot)
|
|
text = self.bg_canvas.create_text(x + 2*r + 8, radio_y - 2, anchor='nw', text=label,
|
|
fill='#1d1d1f', font=('Helvetica', 12))
|
|
self._canvas_items.append(text)
|
|
self._build_radio_items.append((label, circle, text))
|
|
|
|
# Click handling for radio selection
|
|
def _on_click(event):
|
|
ex, ey = event.x, event.y
|
|
for label, circle, text in self._build_radio_items:
|
|
bbox_c = self.bg_canvas.bbox(circle)
|
|
bbox_t = self.bg_canvas.bbox(text)
|
|
hit = False
|
|
if bbox_c and bbox_c[0] <= ex <= bbox_c[2] and bbox_c[1] <= ey <= bbox_c[3]:
|
|
hit = True
|
|
if bbox_t and bbox_t[0] <= ex <= bbox_t[2] and bbox_t[1] <= ey <= bbox_t[3]:
|
|
hit = True
|
|
if hit:
|
|
self.deploy_env_value = label
|
|
# Re-render only radios by re-drawing the page
|
|
self._clear_canvas_page()
|
|
self._render_build_page()
|
|
self.update_footer()
|
|
break
|
|
self.bg_canvas.bind('<Button-1>', _on_click)
|
|
|
|
# Embedded console overlay (semi-transparent black backdrop + scrolled text)
|
|
self._ensure_console_overlay(radio_bottom_y=radio_y + 24)
|
|
|
|
# Show a command preview with PS1-style prompt and blinking cursor
|
|
preview = self._compose_build_preview()
|
|
self._console_set_preview(preview)
|
|
# Ensure state holders exist
|
|
if not hasattr(self, 'last_build_log_path'):
|
|
self.last_build_log_path = None
|
|
# Update Next button label/state
|
|
self.update_footer()
|
|
# Bind Enter to trigger Build on this page
|
|
def _enter_build(_evt=None):
|
|
self.perform_build()
|
|
try:
|
|
self.root.bind('<Return>', _enter_build)
|
|
self.root.bind('<KP_Enter>', _enter_build)
|
|
except Exception:
|
|
pass
|
|
|
|
def _render_build_vm_page(self):
|
|
# Title and instructions
|
|
self._render_title('Build Workstation (Vagrant)', y=40)
|
|
self._render_paragraph('We will bring up the workstation VM using Vagrant. If a Vagrantfile specific to your target (dev/service/prod) exists, it will be used; otherwise we will use workstation/Vagrantfile.', y=90)
|
|
|
|
# Reuse the same environment selection chosen on Build page
|
|
if not hasattr(self, 'deploy_env_value'):
|
|
self.deploy_env_value = 'Dev'
|
|
|
|
# Less verbose per request
|
|
left = 56
|
|
y = 130
|
|
self._canvas_items.append(self.bg_canvas.create_text(left, y, anchor='nw', text="Building Prole Workstation", fill='#1d1d1f', font=('Helvetica', 13, 'bold')))
|
|
|
|
# Embedded console overlay under this text
|
|
self._ensure_console_overlay(radio_bottom_y=y + 24)
|
|
# Command preview for Vagrant up with PS1 prompt and blinking cursor
|
|
v_preview = self._compose_vagrant_preview()
|
|
self._console_set_preview(v_preview)
|
|
self.update_footer()
|
|
# Bind Enter to trigger the Vagrant build on this page
|
|
def _enter_vagrant(_evt=None):
|
|
self.perform_vagrant_build()
|
|
try:
|
|
self.root.bind('<Return>', _enter_vagrant)
|
|
self.root.bind('<KP_Enter>', _enter_vagrant)
|
|
except Exception:
|
|
pass
|
|
|
|
def _render_deploy_page(self):
|
|
self._render_title('Deploy', y=40)
|
|
self._render_paragraph('Preparing to deploy. We will verify steps and perform actions as needed.', y=90)
|
|
# Render simple list of steps (static view). Runtime updates can redraw as needed.
|
|
y = 140
|
|
left = 56
|
|
if not hasattr(self, 'deploy_steps'):
|
|
self.deploy_steps = [
|
|
{'name': 'Build ProleStatus macOS app', 'status': 'pending'},
|
|
{'name': 'Build workstation Vagrant VM', 'status': 'pending'},
|
|
{'name': 'Check Docker is running', 'status': 'pending'},
|
|
{'name': 'Check or configure container registry', 'status': 'pending'},
|
|
{'name': 'Ensure target cluster', 'status': 'pending'},
|
|
{'name': 'Build prole-db Docker image', 'status': 'pending'},
|
|
{'name': 'Tag Docker image for registry', 'status': 'pending'},
|
|
{'name': 'Push image to registry', 'status': 'pending'},
|
|
{'name': 'Import image to k3d cluster (Dev only)', 'status': 'pending'},
|
|
]
|
|
for step in self.deploy_steps:
|
|
# status circle (pending empty)
|
|
self._canvas_items.append(self.bg_canvas.create_oval(left, y, left+18, y+18, outline='#b0b0b0'))
|
|
self._canvas_items.append(self.bg_canvas.create_text(left+26, y-2, anchor='nw', text=step['name'], fill='#1d1d1f', font=('Helvetica', 12)))
|
|
y += 26
|
|
|
|
def on_prev(self):
|
|
# Custom prev navigation for dependency pages when filtering
|
|
current_id = self.pages[self.page_index][0]
|
|
# Close Terminal if leaving build summary via Prev
|
|
if current_id == 'build_summary':
|
|
try:
|
|
self.close_build_terminal()
|
|
except Exception:
|
|
pass
|
|
if current_id.startswith('dep_'):
|
|
seq = self._dep_navigation_sequence()
|
|
try:
|
|
i = seq.index(current_id)
|
|
except ValueError:
|
|
i = -1
|
|
if i > 0:
|
|
self.show_page(seq[i - 1])
|
|
return
|
|
else:
|
|
# Go back to summary if there is no previous in sequence
|
|
self.show_page('deps_summary')
|
|
return
|
|
# Default prev
|
|
if self.page_index > 0:
|
|
self.show_page(self.page_index - 1)
|
|
|
|
def on_next(self):
|
|
# Special handling for dynamic labels
|
|
current_id = self.pages[self.page_index][0]
|
|
if current_id == 'deps_summary':
|
|
# Determine where to go from summary
|
|
if self.verify_mode.get():
|
|
# Start with first dependency page regardless of status
|
|
seq = self._dep_navigation_sequence(force_all=True)
|
|
target = seq[0] if seq else 'build'
|
|
self.show_page(target)
|
|
return
|
|
else:
|
|
if self.all_dependencies_installed():
|
|
# Jump directly to build
|
|
self.show_page('build')
|
|
return
|
|
# Go to first missing dependency page
|
|
seq = self._dep_navigation_sequence()
|
|
target = seq[0] if seq else 'build'
|
|
self.show_page(target)
|
|
return
|
|
if current_id == 'build':
|
|
# Treat Next as Build for ProleStatus
|
|
self.perform_build()
|
|
return
|
|
if current_id == 'build_vm':
|
|
# Build the workstation VM
|
|
self.perform_vagrant_build()
|
|
return
|
|
# No longer using external Terminal; nothing special to close on summary
|
|
if current_id == 'deploy':
|
|
# Start deployment steps when moving next
|
|
self.start_deployment()
|
|
return
|
|
if current_id.startswith('dep_'):
|
|
# Navigate within dependency sequence
|
|
seq = self._dep_navigation_sequence()
|
|
try:
|
|
i = seq.index(current_id)
|
|
except ValueError:
|
|
i = -1
|
|
if i >= 0 and i < len(seq) - 1:
|
|
self.show_page(seq[i + 1])
|
|
return
|
|
else:
|
|
# After last relevant dep page, go to build
|
|
self.show_page('build')
|
|
return
|
|
if self.page_index < len(self.pages) - 1:
|
|
self.show_page(self.page_index + 1)
|
|
|
|
def on_finish(self):
|
|
# Close app on Finish
|
|
self.root.quit()
|
|
|
|
def update_footer(self):
|
|
# Default hidden states
|
|
self.prev_button.state(['!disabled'])
|
|
self.next_button.state(['!disabled'])
|
|
self.finish_button.state(['!disabled'])
|
|
|
|
first = self.page_index == 0
|
|
last = self.page_index == len(self.pages) - 1
|
|
# Base labels
|
|
self.next_button.configure(text='Next')
|
|
self.finish_button.configure(text='Finish')
|
|
|
|
# Page-specific adjustments
|
|
pid = self.pages[self.page_index][0]
|
|
if pid == 'deps_summary' and self.all_dependencies_installed():
|
|
# If verify mode is enabled, invite user to Verify instead of Build
|
|
self.next_button.configure(text='Verify' if self.verify_mode.get() else 'Build')
|
|
if pid == 'build':
|
|
# Build page has Prev + Build
|
|
self.next_button.configure(text='Build')
|
|
if pid == 'build_vm':
|
|
self.next_button.configure(text='Build')
|
|
if pid == 'deploy':
|
|
self.next_button.configure(text='Deploy')
|
|
if pid == 'build_summary':
|
|
self.next_button.configure(text='Deploy')
|
|
|
|
# Visibility rules
|
|
if first:
|
|
self.prev_button.pack_forget()
|
|
self.finish_button.pack_forget()
|
|
if not self.next_button.winfo_ismapped():
|
|
self.next_button.pack(side='right', padx=(0, 8), pady=12)
|
|
elif last:
|
|
if not self.prev_button.winfo_ismapped():
|
|
self.prev_button.pack(side='right', padx=(0, 8), pady=12)
|
|
self.next_button.pack_forget()
|
|
if not self.finish_button.winfo_ismapped():
|
|
self.finish_button.pack(side='right', padx=(0, 20), pady=12)
|
|
else:
|
|
# Middle pages: Prev + Next
|
|
if not self.prev_button.winfo_ismapped():
|
|
self.prev_button.pack(side='right', padx=(0, 8), pady=12)
|
|
if not self.next_button.winfo_ismapped():
|
|
self.next_button.pack(side='right', padx=(0, 8), pady=12)
|
|
self.finish_button.pack_forget()
|
|
|
|
def _create_page_welcome(self):
|
|
f = self._page_container()
|
|
ttk.Label(f, text='Welcome to Prole', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6))
|
|
msg = (
|
|
'Thanks for joining Prole. We will prepare your system and install the software needed to build and run Prole.'
|
|
)
|
|
ttk.Label(f, text=msg, style='Body.TLabel', wraplength=800, justify='left').pack(anchor='w', padx=24)
|
|
self._register_page('welcome', f)
|
|
|
|
def _create_page_dependencies_summary(self):
|
|
f = self._page_container()
|
|
ttk.Label(f, text='Dependencies', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6))
|
|
self.deps_container = ttk.Frame(f)
|
|
self.deps_container.pack(fill='both', expand=True, padx=16, pady=8)
|
|
|
|
self.dep_status = {}
|
|
for dep in self.dependencies:
|
|
row = ttk.Frame(self.deps_container)
|
|
row.pack(fill='x', pady=6)
|
|
indicator = tk.Canvas(row, width=18, height=18, highlightthickness=0)
|
|
indicator.pack(side='left', padx=8)
|
|
name = ttk.Label(row, text=dep['name'], style='Body.TLabel')
|
|
name.pack(side='left')
|
|
info = ttk.Label(row, text='', style='Dim.TLabel')
|
|
info.pack(side='left', padx=10)
|
|
self.dep_status[dep['id']] = {'canvas': indicator, 'info': info, 'dep': dep}
|
|
# Verify all checkbox (controls navigation behavior)
|
|
chk = ttk.Checkbutton(f, text='Verify all dependencies', variable=self.verify_mode, command=self.update_footer)
|
|
chk.pack(anchor='w', padx=24, pady=(4, 0))
|
|
|
|
# Message label must be created before refresh to avoid AttributeError
|
|
self.deps_msg = ttk.Label(f, text='', style='Dim.TLabel')
|
|
self.deps_msg.pack(anchor='w', padx=24, pady=(8, 8))
|
|
# Now we can safely populate the UI. Start async scan to avoid startup delay
|
|
self.deps_msg.configure(text='Checking dependencies...')
|
|
self.start_dependency_scan()
|
|
self._register_page('deps_summary', f)
|
|
|
|
def start_dependency_scan(self):
|
|
"""Scan dependencies in a background thread to avoid blocking UI startup."""
|
|
if self.validation_running:
|
|
return
|
|
self.validation_running = True
|
|
|
|
results = {}
|
|
|
|
def worker():
|
|
for dep in self.dependencies:
|
|
try:
|
|
ok, location, version = self.get_dep_info(dep)
|
|
except Exception:
|
|
ok, location, version = False, None, None
|
|
results[dep['id']] = (ok, location, version)
|
|
# marshal back to UI thread
|
|
self.root.after(0, lambda: self._apply_dependency_scan(results))
|
|
|
|
t = threading.Thread(target=worker, daemon=True)
|
|
self.validation_thread = t
|
|
t.start()
|
|
|
|
def _apply_dependency_scan(self, results: dict):
|
|
"""Apply scan results to the summary UI and footer labels."""
|
|
self.validation_running = False
|
|
any_missing = False
|
|
for did, slot in self.dep_status.items():
|
|
ok, location, version = results.get(did, (False, None, None))
|
|
self._draw_status(slot['canvas'], 'success' if ok else 'error')
|
|
if ok:
|
|
norm_ver = self.normalize_version(version) if version else ''
|
|
slot['info'].configure(text=(norm_ver or ''))
|
|
else:
|
|
any_missing = True
|
|
slot['info'].configure(text='Not installed')
|
|
|
|
has_msg = hasattr(self, 'deps_msg') and self.deps_msg is not None
|
|
if any_missing:
|
|
missing = [slot['dep']['name'] for did, slot in self.dep_status.items() if not results.get(did, (False, None, None))[0]]
|
|
if missing and has_msg:
|
|
self.deps_msg.configure(text=f"Preparing to install ... {', '.join(missing)}")
|
|
else:
|
|
if has_msg:
|
|
self.deps_msg.configure(text='All dependencies installed.')
|
|
self.update_footer()
|
|
|
|
def _create_page_dependency(self, dep):
|
|
f = self._page_container()
|
|
ttk.Label(f, text=dep['name'], style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6))
|
|
ttk.Label(f, text=dep['description'], style='Body.TLabel', wraplength=800, justify='left').pack(anchor='w', padx=24)
|
|
status_var = tk.StringVar(value='Checking...')
|
|
loc_var = tk.StringVar(value='')
|
|
ver_var = tk.StringVar(value='')
|
|
|
|
info_frame = ttk.Frame(f)
|
|
info_frame.pack(fill='x', padx=24, pady=12)
|
|
# Pretty status row with an icon
|
|
status_row = ttk.Frame(info_frame)
|
|
status_row.pack(anchor='w', fill='x')
|
|
status_icon = tk.Canvas(status_row, width=18, height=18, highlightthickness=0)
|
|
status_icon.pack(side='left', padx=(0, 8), pady=(2, 0))
|
|
ttk.Label(status_row, textvariable=status_var, style='Body.TLabel').pack(side='left')
|
|
|
|
# Detail rows (hidden when not installed)
|
|
loc_label = ttk.Label(info_frame, textvariable=loc_var, style='Dim.TLabel')
|
|
ver_label = ttk.Label(info_frame, textvariable=ver_var, style='Dim.TLabel')
|
|
loc_label.pack(anchor='w')
|
|
ver_label.pack(anchor='w')
|
|
|
|
link = ttk.Label(f, text='Click to install', foreground='#0a84ff', cursor='hand2', style='Body.TLabel')
|
|
link.pack(anchor='w', padx=24, pady=(12, 0))
|
|
|
|
def _draw_status_icon(canvas, status):
|
|
canvas.delete('all')
|
|
if status == 'installed':
|
|
canvas.create_oval(1, 1, 17, 17, fill='#34c759', outline='')
|
|
canvas.create_line(4, 9, 8, 13, fill='white', width=2)
|
|
canvas.create_line(8, 13, 15, 5, fill='white', width=2)
|
|
elif status == 'missing':
|
|
# Friendly warning dot with exclamation
|
|
canvas.create_oval(1, 1, 17, 17, fill='#ff9f0a', outline='')
|
|
canvas.create_line(9, 5, 9, 11, fill='white', width=2)
|
|
canvas.create_oval(8, 13, 10, 15, fill='white', outline='white')
|
|
|
|
def check_then_update():
|
|
ok, location, version = self.get_dep_info(dep)
|
|
if ok:
|
|
status_var.set('Installed')
|
|
_draw_status_icon(status_icon, 'installed')
|
|
loc_var.set(f'Location: {location or ""}')
|
|
norm_ver = self.normalize_version(version) if version else None
|
|
ver_var.set(f'Version: {norm_ver or ""}')
|
|
# Ensure details visible
|
|
try:
|
|
loc_label.pack_configure()
|
|
ver_label.pack_configure()
|
|
link.pack_forget()
|
|
except Exception:
|
|
pass
|
|
else:
|
|
status_var.set('Not installed')
|
|
_draw_status_icon(status_icon, 'missing')
|
|
# Hide undefined details instead of showing dashes
|
|
try:
|
|
loc_label.pack_forget()
|
|
ver_label.pack_forget()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
# Keep install link visible if we have a command
|
|
if dep.get('install_cmd') and not link.winfo_ismapped():
|
|
link.pack(anchor='w', padx=24, pady=(12, 0))
|
|
except Exception:
|
|
pass
|
|
|
|
self.root.after(50, check_then_update)
|
|
|
|
def do_install(event=None):
|
|
self.open_terminal_with_command(dep.get('install_cmd'))
|
|
|
|
if dep.get('install_cmd'):
|
|
link.bind('<Button-1>', do_install)
|
|
else:
|
|
try:
|
|
link.pack_forget()
|
|
except Exception:
|
|
pass
|
|
|
|
self._register_page(f'dep_{dep["id"]}', f)
|
|
|
|
def _dep_navigation_sequence(self, force_all: bool = False):
|
|
"""Return a list of dependency page ids to traverse next.
|
|
- If force_all is True or verify_mode is True: include all dep pages in defined order.
|
|
- Else: include only missing dependency pages based on current checks.
|
|
"""
|
|
if force_all or self.verify_mode.get():
|
|
return [f'dep_{d["id"]}' for d in self.dependencies]
|
|
seq = []
|
|
for d in self.dependencies:
|
|
ok, _, _ = self.get_dep_info(d)
|
|
if not ok:
|
|
seq.append(f'dep_{d["id"]}')
|
|
return seq
|
|
|
|
def _create_page_build(self):
|
|
f = self._page_container()
|
|
ttk.Label(f, text='Build', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6))
|
|
ttk.Label(f, text='Choose a target and build the artifacts.', style='Body.TLabel').pack(anchor='w', padx=24)
|
|
wrap = ttk.Frame(f)
|
|
wrap.pack(anchor='w', padx=24, pady=12)
|
|
ttk.Label(wrap, text='Target Environment:', style='Body.TLabel').pack(side='left')
|
|
self.deploy_env_var = tk.StringVar(value='Dev')
|
|
ttk.Combobox(wrap, textvariable=self.deploy_env_var, values=['Dev', 'Service', 'Prod'], state='readonly', width=18).pack(side='left', padx=10)
|
|
|
|
# Build output console
|
|
self.build_output = scrolledtext.ScrolledText(f, height=12, bg='#fafafa', fg='#1d1d1f')
|
|
self.build_output.pack(fill='both', expand=True, padx=24, pady=12)
|
|
|
|
self._register_page('build', f)
|
|
|
|
def _create_page_deploy(self):
|
|
f = self._page_container()
|
|
ttk.Label(f, text='Deploy', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6))
|
|
ttk.Label(f, text='Preparing to deploy. We will verify steps and perform actions as needed.', style='Body.TLabel', wraplength=800).pack(anchor='w', padx=24)
|
|
|
|
# Reuse existing deploy steps UI but on light theme
|
|
self.deploy_steps = [
|
|
{'name': 'Build ProleStatus macOS app', 'status': 'pending'},
|
|
{'name': 'Check Docker is running', 'status': 'pending'},
|
|
{'name': 'Check or configure container registry', 'status': 'pending'},
|
|
{'name': 'Ensure target cluster', 'status': 'pending'},
|
|
{'name': 'Build prole-db Docker image', 'status': 'pending'},
|
|
{'name': 'Tag Docker image for registry', 'status': 'pending'},
|
|
{'name': 'Push image to registry', 'status': 'pending'},
|
|
{'name': 'Import image to k3d cluster (Dev only)', 'status': 'pending'},
|
|
]
|
|
self.deploy_widgets = {}
|
|
container = ttk.Frame(f)
|
|
container.pack(fill='both', expand=True, padx=16, pady=8)
|
|
for step in self.deploy_steps:
|
|
self._create_deploy_row(container, step)
|
|
|
|
self._register_page('deploy', f)
|
|
|
|
def _create_deploy_row(self, parent, step):
|
|
row = ttk.Frame(parent)
|
|
row.pack(fill='x', pady=6)
|
|
canvas = tk.Canvas(row, width=20, height=20, highlightthickness=0)
|
|
canvas.pack(side='left', padx=8)
|
|
lbl = ttk.Label(row, text=step['name'], style='Body.TLabel')
|
|
lbl.pack(side='left')
|
|
status = ttk.Label(row, text='Pending', style='Dim.TLabel')
|
|
status.pack(side='right', padx=8)
|
|
self.deploy_widgets[step['name']] = {'canvas': canvas, 'label': status, 'step': step}
|
|
self._draw_status(canvas, 'pending')
|
|
|
|
def _draw_status(self, canvas, status):
|
|
canvas.delete('all')
|
|
if status == 'success':
|
|
canvas.create_oval(2, 2, 18, 18, fill='#34c759', outline='')
|
|
canvas.create_line(5, 10, 9, 14, fill='white', width=2)
|
|
canvas.create_line(9, 14, 16, 6, fill='white', width=2)
|
|
elif status == 'running':
|
|
canvas.create_oval(2, 2, 18, 18, fill='#ffd60a', outline='')
|
|
elif status == 'error':
|
|
canvas.create_oval(2, 2, 18, 18, fill='#ff3b30', outline='')
|
|
else:
|
|
canvas.create_oval(2, 2, 18, 18, outline='#b0b0b0')
|
|
|
|
# --------------- Dependency helpers ---------------
|
|
def refresh_dependencies_ui(self):
|
|
"""Deprecated synchronous refresh retained for compatibility.
|
|
Prefer start_dependency_scan -> _apply_dependency_scan.
|
|
"""
|
|
self.start_dependency_scan()
|
|
|
|
def all_dependencies_installed(self):
|
|
for dep in self.dependencies:
|
|
ok, _, _ = self.get_dep_info(dep)
|
|
if not ok:
|
|
return False
|
|
return True
|
|
|
|
def get_dep_info(self, dep):
|
|
"""Delegate dependency probing to installer.config.get_dep_info."""
|
|
return inst_config.get_dep_info(dep)
|
|
|
|
def normalize_version(self, text: str) -> str:
|
|
"""Normalize versions via installer.config.normalize_version."""
|
|
return inst_config.normalize_version(text)
|
|
|
|
def open_terminal_with_command(self, command: str | None):
|
|
if not command:
|
|
return
|
|
try:
|
|
# Always create a brand-new Terminal window and paste via clipboard
|
|
if platform.system() == 'Darwin':
|
|
win_id = self._terminal_create_new_window()
|
|
if win_id:
|
|
# Position reasonably
|
|
l, t, r, b = self._compute_terminal_bounds(radio_bottom_y=120)
|
|
self._terminal_set_bounds_by_id(win_id, l, t, r, b)
|
|
self._terminal_paste_by_id(win_id, command, press_enter=False)
|
|
return
|
|
# Fallback: copy to clipboard and open Terminal
|
|
subprocess.run(['bash', '-lc', f'printf %s {shlex.quote(command)} | pbcopy && open -a Terminal'])
|
|
except Exception:
|
|
webbrowser.open_new_tab('https://brew.sh')
|
|
|
|
# ---------------- Build integration ----------------
|
|
def perform_build(self):
|
|
"""Run ProleStatus build in an embedded console (Scopped bash subprocess)."""
|
|
# Prepare logs dir and file
|
|
logs_dir = PROJECT_ROOT / 'logs'
|
|
try:
|
|
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
except Exception:
|
|
pass
|
|
ts = time.strftime('%Y%m%d-%H%M%S')
|
|
log_path = logs_dir / f'build-{ts}.log'
|
|
self.last_build_log_path = str(log_path)
|
|
|
|
# Compose build command
|
|
env = getattr(self, 'deploy_env_value', 'Dev')
|
|
base_cmd = self.get_build_command(env)
|
|
# Hostname safety guard: ensure commands run only on the machine that launched the installer
|
|
guard = ''
|
|
if getattr(self, 'expected_host', None):
|
|
eh = self.expected_host
|
|
guard = f'host=$(hostname -s); if [ "$host" != "{eh}" ]; then echo "ERROR: wrong host $host (expected {eh})"; exit 1; fi; '
|
|
# Simulate pressing Enter on the previewed command; do not echo a duplicate command
|
|
self._console_press_enter()
|
|
full_cmd = guard + base_cmd
|
|
|
|
# Run in embedded console
|
|
self._run_in_console(full_cmd, self.last_build_log_path, on_complete=lambda rc: self._on_build_complete(rc))
|
|
|
|
def _on_build_complete(self, returncode: int):
|
|
# After ProleStatus build completes, enable Next to proceed to the VM build page
|
|
try:
|
|
self.next_button.configure(text='Next')
|
|
self.next_button.state(['!disabled'])
|
|
except Exception:
|
|
pass
|
|
# Automatically navigate to the VM build page
|
|
self.show_page('build_vm')
|
|
|
|
def perform_vagrant_build(self):
|
|
# Prepare logs
|
|
logs_dir = PROJECT_ROOT / 'logs'
|
|
try:
|
|
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
except Exception:
|
|
pass
|
|
ts = time.strftime('%Y%m%d-%H%M%S')
|
|
v_log_path = logs_dir / f'vagrant-{ts}.log'
|
|
self.last_vagrant_log_path = str(v_log_path)
|
|
|
|
env = getattr(self, 'deploy_env_value', 'Dev')
|
|
cmd = self.get_vagrant_up_command(env)
|
|
# Hostname guard
|
|
guard = ''
|
|
if getattr(self, 'expected_host', None):
|
|
eh = self.expected_host
|
|
guard = f'host=$(hostname -s); if [ "$host" != "{eh}" ]; then echo "ERROR: wrong host $host (expected {eh})"; exit 1; fi; '
|
|
# Simulate pressing Enter on the previewed command; do not echo a duplicate command
|
|
self._console_press_enter()
|
|
full_cmd = guard + cmd
|
|
|
|
self._run_in_console(full_cmd, self.last_vagrant_log_path, on_complete=lambda rc: self._on_vagrant_build_complete(rc))
|
|
|
|
def _on_vagrant_build_complete(self, returncode: int):
|
|
# After VM build, go to summary
|
|
try:
|
|
self.next_button.configure(text='Summary')
|
|
self.next_button.state(['!disabled'])
|
|
except Exception:
|
|
pass
|
|
self.show_page('build_summary')
|
|
|
|
# ---------------- Embedded console helpers ----------------
|
|
def _ensure_console_overlay(self, radio_bottom_y: int = 160):
|
|
"""Create semi-transparent black backdrop and a ScrolledText console overlay.
|
|
The overlay is placed within slide_area between given top and bottom margins.
|
|
"""
|
|
# Compute geometry within slide area
|
|
geom = self._compute_console_geometry(radio_bottom_y)
|
|
left, top, width, height = geom
|
|
# Draw a stippled rectangle on the canvas to simulate ~60% opacity
|
|
rect = self.bg_canvas.create_rectangle(left, top, left + width, top + height,
|
|
fill='#000000', outline='', stipple='gray50')
|
|
self._canvas_items.append(rect)
|
|
# Create ScrolledText overlay
|
|
txt = scrolledtext.ScrolledText(self.slide_area, wrap='word',
|
|
bg='#000000', fg='#ffffff', insertbackground='#ffffff',
|
|
font=('Menlo', 11), relief='flat', bd=0, highlightthickness=0)
|
|
txt.place(x=left + 8, y=top + 8, width=max(50, width - 16), height=max(50, height - 16))
|
|
self._overlay_widgets.append(txt)
|
|
self._console_text = txt
|
|
# Keep overlay positioned on resize
|
|
def _on_resize(_evt=None):
|
|
l, t, w, h = self._compute_console_geometry(radio_bottom_y)
|
|
try:
|
|
self.bg_canvas.coords(rect, l, t, l + w, t + h)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
txt.place(x=l + 8, y=t + 8, width=max(50, w - 16), height=max(50, h - 16))
|
|
except Exception:
|
|
pass
|
|
# Bind to slide area; store bind id to unbind later
|
|
self._overlay_bind_id = self.slide_area.bind('<Configure>', _on_resize)
|
|
|
|
def _compute_console_geometry(self, radio_bottom_y: int) -> tuple[int, int, int, int]:
|
|
"""Return (left, top, width, height) for the console overlay area."""
|
|
try:
|
|
w = self.slide_area.winfo_width()
|
|
h = self.slide_area.winfo_height()
|
|
except Exception:
|
|
w, h = 1000, 620
|
|
margin = 24
|
|
top = max(radio_bottom_y + 10, 120)
|
|
bottom = max(top + 180, h - 16) # ensure some height
|
|
height = max(160, bottom - top - 80) if bottom - top > 260 else max(140, h - top - 24)
|
|
# Recompute bottom based on height
|
|
bottom = min(h - 24, top + height)
|
|
left = margin
|
|
width = max(300, w - 2 * margin)
|
|
return (left, top, width, bottom - top)
|
|
|
|
def _append_console(self, text: str):
|
|
txt = getattr(self, '_console_text', None)
|
|
if not txt:
|
|
return
|
|
try:
|
|
txt.configure(state='normal')
|
|
txt.insert('end', text)
|
|
txt.see('end')
|
|
txt.configure(state='disabled')
|
|
except Exception:
|
|
pass
|
|
|
|
def _console_press_enter(self):
|
|
"""Simulate pressing Enter on the console preview line: remove blinking cursor if present and add a newline."""
|
|
txt = getattr(self, '_console_text', None)
|
|
if not txt:
|
|
return
|
|
# Stop cursor blinking
|
|
if getattr(self, '_cursor_blink_after_id', None):
|
|
try:
|
|
self.root.after_cancel(self._cursor_blink_after_id)
|
|
except Exception:
|
|
pass
|
|
self._cursor_blink_after_id = None
|
|
self._cursor_blink_visible = False
|
|
try:
|
|
txt.configure(state='normal')
|
|
# If last char is our fake cursor, remove it
|
|
try:
|
|
last_char = txt.get('end-2c', 'end-1c')
|
|
if last_char in ('_', '|'):
|
|
txt.delete('end-2c', 'end-1c')
|
|
except Exception:
|
|
pass
|
|
txt.insert('end', '\n')
|
|
txt.see('end')
|
|
txt.configure(state='disabled')
|
|
except Exception:
|
|
pass
|
|
|
|
# ----- Command preview & blinking cursor helpers -----
|
|
def _get_user_host(self) -> tuple[str, str]:
|
|
try:
|
|
user = os.environ.get('USER') or os.getlogin()
|
|
except Exception:
|
|
user = 'user'
|
|
host = getattr(self, 'expected_host', None) or (platform.node() or 'host').split('.')[0]
|
|
return user, host
|
|
|
|
def _compose_build_preview(self) -> str:
|
|
env = getattr(self, 'deploy_env_value', 'Dev')
|
|
cmd = self.get_build_command(env)
|
|
user, host = self._get_user_host()
|
|
return f"[{user}@{host}]# {cmd}"
|
|
|
|
def _compose_vagrant_preview(self) -> str:
|
|
env = getattr(self, 'deploy_env_value', 'Dev')
|
|
cmd = self.get_vagrant_up_command(env)
|
|
user, host = self._get_user_host()
|
|
return f"[{user}@{host}]# {cmd}"
|
|
|
|
def _console_set_preview(self, line: str):
|
|
"""Clear console and show a single-line preview with blinking cursor."""
|
|
txt = getattr(self, '_console_text', None)
|
|
if not txt:
|
|
return
|
|
# Stop any previous blinking first
|
|
if getattr(self, '_cursor_blink_after_id', None):
|
|
try:
|
|
self.root.after_cancel(self._cursor_blink_after_id)
|
|
except Exception:
|
|
pass
|
|
self._cursor_blink_after_id = None
|
|
self._cursor_blink_visible = False
|
|
try:
|
|
txt.configure(state='normal')
|
|
txt.delete('1.0', 'end')
|
|
txt.insert('end', line)
|
|
txt.see('end')
|
|
txt.configure(state='disabled')
|
|
except Exception:
|
|
return
|
|
|
|
# Start blinking cursor at end of line
|
|
def blink():
|
|
t = getattr(self, '_console_text', None)
|
|
if t is None:
|
|
self._cursor_blink_after_id = None
|
|
return
|
|
try:
|
|
t.configure(state='normal')
|
|
# Remove existing cursor
|
|
if self._cursor_blink_visible:
|
|
# Delete last character if it's our cursor
|
|
end_index = t.index('end-1c')
|
|
if end_index and end_index != '1.0':
|
|
last_char = t.get('end-2c', 'end-1c')
|
|
if last_char in ('_', '|'):
|
|
t.delete('end-2c', 'end-1c')
|
|
self._cursor_blink_visible = False
|
|
else:
|
|
# Append cursor
|
|
t.insert('end', '_')
|
|
self._cursor_blink_visible = True
|
|
t.see('end')
|
|
t.configure(state='disabled')
|
|
except Exception:
|
|
self._cursor_blink_after_id = None
|
|
return
|
|
# schedule next toggle
|
|
self._cursor_blink_after_id = self.root.after(600, blink)
|
|
|
|
self._cursor_blink_after_id = self.root.after(600, blink)
|
|
|
|
def _run_in_console(self, command: str, log_path: str, on_complete=None):
|
|
"""Run a bash -lc command in a background subprocess and stream output to the console and a log file.
|
|
This function does not echo the command into the console so that the UI behaves like pressing Enter
|
|
on the previously previewed command line.
|
|
"""
|
|
# Ensure console exists
|
|
if not getattr(self, '_console_text', None):
|
|
self._ensure_console_overlay(160)
|
|
# Stop cursor blinking before starting execution
|
|
if getattr(self, '_cursor_blink_after_id', None):
|
|
try:
|
|
self.root.after_cancel(self._cursor_blink_after_id)
|
|
except Exception:
|
|
pass
|
|
self._cursor_blink_after_id = None
|
|
self._cursor_blink_visible = False
|
|
# Terminate any previous process
|
|
if getattr(self, '_running_process', None):
|
|
self._terminate_running_process()
|
|
# Open log file
|
|
try:
|
|
self._console_log_fp = open(log_path, 'a', buffering=1, encoding='utf-8')
|
|
except Exception:
|
|
self._console_log_fp = None
|
|
# Start process group for safe termination
|
|
def preexec():
|
|
try:
|
|
os.setsid()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
proc = subprocess.Popen(['bash', '-lc', command], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
text=True, bufsize=1, preexec_fn=preexec)
|
|
self._running_process = proc
|
|
except Exception as e:
|
|
self._append_console(f"Failed to start process: {e}\n")
|
|
if self._console_log_fp:
|
|
try:
|
|
self._console_log_fp.write(f"Failed to start process: {e}\n")
|
|
except Exception:
|
|
pass
|
|
self._running_process = None
|
|
return
|
|
# Disable Next while running
|
|
try:
|
|
self.next_button.configure(text='Building…')
|
|
self.next_button.state(['disabled'])
|
|
except Exception:
|
|
pass
|
|
# Reader thread
|
|
def reader():
|
|
rc = None
|
|
try:
|
|
for line in proc.stdout:
|
|
if line is None:
|
|
break
|
|
self.root.after(0, lambda s=line: self._append_console(s))
|
|
if self._console_log_fp:
|
|
try:
|
|
self._console_log_fp.write(line)
|
|
except Exception:
|
|
pass
|
|
rc = proc.wait()
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
if self._console_log_fp:
|
|
try:
|
|
self._console_log_fp.flush()
|
|
self._console_log_fp.close()
|
|
except Exception:
|
|
pass
|
|
self._console_log_fp = None
|
|
self._running_process = None
|
|
if on_complete:
|
|
self.root.after(0, lambda: on_complete(rc if rc is not None else -1))
|
|
t = threading.Thread(target=reader, daemon=True)
|
|
t.start()
|
|
|
|
def _terminate_running_process(self):
|
|
proc = getattr(self, '_running_process', None)
|
|
if not proc:
|
|
return
|
|
try:
|
|
pgid = os.getpgid(proc.pid)
|
|
os.killpg(pgid, signal.SIGTERM)
|
|
except Exception:
|
|
try:
|
|
proc.terminate()
|
|
except Exception:
|
|
pass
|
|
# best-effort kill after short delay
|
|
try:
|
|
for _ in range(10):
|
|
if proc.poll() is not None:
|
|
break
|
|
time.sleep(0.05)
|
|
if proc.poll() is None:
|
|
try:
|
|
pgid = os.getpgid(proc.pid)
|
|
os.killpg(pgid, signal.SIGKILL)
|
|
except Exception:
|
|
proc.kill()
|
|
except Exception:
|
|
pass
|
|
self._running_process = None
|
|
|
|
# ---------------- Build helpers (Terminal window management) ----------------
|
|
def _terminal_create_new_window(self) -> str | None:
|
|
"""Create a brand-new Terminal window (never reuse existing) and return its id."""
|
|
if platform.system() != 'Darwin':
|
|
return None
|
|
osa = '''
|
|
tell application "Terminal" to activate
|
|
delay 0.05
|
|
tell application "System Events"
|
|
if exists process "Terminal" then
|
|
tell process "Terminal"
|
|
set frontmost to true
|
|
try
|
|
click menu item "New Window" of menu "Shell" of menu bar 1
|
|
on error
|
|
keystroke "n" using {command down}
|
|
end try
|
|
end tell
|
|
end if
|
|
end tell
|
|
delay 0.1
|
|
tell application "Terminal"
|
|
try
|
|
set _w to front window
|
|
set _id to id of _w
|
|
do script "" in _w
|
|
return _id
|
|
on error
|
|
return ""
|
|
end try
|
|
end tell
|
|
'''
|
|
try:
|
|
result = subprocess.run(['osascript', '-e', osa], capture_output=True, text=True)
|
|
if result.returncode == 0:
|
|
sid = result.stdout.strip()
|
|
return sid or None
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
def _terminal_set_bounds_by_id(self, win_id: str, l: int, t: int, r: int, b: int):
|
|
if platform.system() != 'Darwin' or not win_id:
|
|
return
|
|
osa = f'''tell application "Terminal" to try
|
|
set the bounds of every window whose id is {win_id} to {{{l}, {t}, {r}, {b}}}
|
|
end try'''
|
|
try:
|
|
subprocess.run(['osascript', '-e', osa])
|
|
except Exception:
|
|
pass
|
|
|
|
def _terminal_paste_by_id(self, win_id: str, text: str, press_enter: bool = False):
|
|
if platform.system() != 'Darwin' or not win_id:
|
|
return
|
|
# Put text on clipboard and paste into our specific window
|
|
try:
|
|
subprocess.run(['bash', '-lc', f'printf %s {shlex.quote(text)} | pbcopy'])
|
|
except Exception:
|
|
pass
|
|
osa = '''
|
|
tell application "Terminal"
|
|
try
|
|
set _wins to every window whose id is {win_id}
|
|
if (count of _wins) > 0 then set front window to item 1 of _wins
|
|
end try
|
|
activate
|
|
end tell
|
|
delay 0.05
|
|
tell application "System Events"
|
|
keystroke "v" using {command down}
|
|
end tell
|
|
'''
|
|
if press_enter:
|
|
osa += '\n' + 'tell application "System Events" to key code 36'
|
|
try:
|
|
subprocess.run(['osascript', '-e', osa])
|
|
except Exception:
|
|
pass
|
|
def _compute_terminal_bounds(self, radio_bottom_y: int = 160) -> tuple[int, int, int, int]:
|
|
"""Compute terminal window bounds (left, top, right, bottom) to fit inside
|
|
the installer window between the radio buttons and the footer."""
|
|
try:
|
|
# Window absolute position
|
|
x0 = self.root.winfo_rootx()
|
|
y0 = self.root.winfo_rooty()
|
|
w = self.root.winfo_width()
|
|
h = self.root.winfo_height()
|
|
except Exception:
|
|
# Reasonable defaults
|
|
x0, y0, w, h = 200, 200, 1000, 700
|
|
margin = 24
|
|
top = y0 + radio_bottom_y + 10
|
|
bottom = y0 + h - 90 # leave space for footer
|
|
left = x0 + margin
|
|
right = x0 + w - margin
|
|
# Ensure minimum height
|
|
if bottom - top < 160:
|
|
bottom = top + 160
|
|
return (left, top, right, bottom)
|
|
|
|
def open_build_terminal_for_canvas_area(self, radio_bottom_y: int = 160):
|
|
"""Open a brand-new Terminal.app window and size it to nestle inside the installer."""
|
|
if platform.system() != 'Darwin':
|
|
return
|
|
l, t, r, b = self._compute_terminal_bounds(radio_bottom_y)
|
|
win_id = self._terminal_create_new_window()
|
|
if win_id:
|
|
self._terminal_set_bounds_by_id(win_id, l, t, r, b)
|
|
self.build_terminal_window_id = win_id
|
|
|
|
def _start_terminal_follow(self, radio_bottom_y: int = 160):
|
|
"""Bind window Configure to keep Terminal bounds anchored to installer area."""
|
|
if platform.system() != 'Darwin':
|
|
return
|
|
self._terminal_follow_rby = radio_bottom_y
|
|
if getattr(self, '_terminal_follow_bound', False):
|
|
return
|
|
|
|
def _follow(_evt=None):
|
|
# Debounce slightly
|
|
if getattr(self, '_terminal_follow_after', None):
|
|
try:
|
|
self.root.after_cancel(self._terminal_follow_after)
|
|
except Exception:
|
|
pass
|
|
def _do():
|
|
if not getattr(self, 'build_terminal_window_id', None):
|
|
return
|
|
l, t, r, b = self._compute_terminal_bounds(self._terminal_follow_rby)
|
|
self._terminal_set_bounds_by_id(self.build_terminal_window_id, l, t, r, b)
|
|
self._terminal_follow_after = self.root.after(60, _do)
|
|
|
|
self.root.bind('<Configure>', _follow)
|
|
self._terminal_follow_bound = True
|
|
|
|
def _stop_terminal_follow(self):
|
|
if getattr(self, '_terminal_follow_bound', False):
|
|
try:
|
|
self.root.unbind('<Configure>')
|
|
except Exception:
|
|
pass
|
|
self._terminal_follow_bound = False
|
|
|
|
def paste_into_terminal(self, text: str, press_enter: bool = False):
|
|
"""Paste text into Terminal by targeting the build window id if available."""
|
|
if platform.system() != 'Darwin':
|
|
return
|
|
win_id = getattr(self, 'build_terminal_window_id', None)
|
|
if win_id:
|
|
self._terminal_paste_by_id(win_id, text, press_enter=press_enter)
|
|
return
|
|
# Fallback to generic new window
|
|
win_id = self._terminal_create_new_window()
|
|
if win_id:
|
|
self._terminal_paste_by_id(win_id, text, press_enter=press_enter)
|
|
|
|
def close_build_terminal(self):
|
|
if platform.system() != 'Darwin':
|
|
return
|
|
# stop following window
|
|
self._stop_terminal_follow()
|
|
win_id = getattr(self, 'build_terminal_window_id', None)
|
|
if not win_id:
|
|
# attempt to close front window politely
|
|
osa = 'tell application "Terminal" to if (count of windows) > 0 then close front window'
|
|
else:
|
|
osa = f'tell application "Terminal" to try\nclose (every window whose id is {win_id})\nend try'
|
|
try:
|
|
subprocess.run(['osascript', '-e', osa])
|
|
except Exception:
|
|
pass
|
|
|
|
def get_build_command(self, env: str) -> str:
|
|
"""Delegate to installer.build.get_build_command."""
|
|
return inst_get_build_command(PROJECT_ROOT, env)
|
|
|
|
def get_workstation_build_command(self) -> str:
|
|
"""Deprecated: kept for compatibility. Not used in embedded console flow."""
|
|
return f"cd \"{PROJECT_ROOT / 'workstation'}\" && vagrant up"
|
|
|
|
def _select_vagrantfile(self, env: str) -> str:
|
|
"""Select Vagrantfile via installer.workstation.select_vagrantfile."""
|
|
return str(inst_select_vagrantfile(PROJECT_ROOT, env))
|
|
|
|
def get_vagrant_up_command(self, env: str) -> str:
|
|
"""Delegate to installer.workstation.get_vagrant_up_command."""
|
|
return inst_get_vagrant_up_command(PROJECT_ROOT, env)
|
|
|
|
# ---------------- Build Summary page ----------------
|
|
def _render_build_summary_page(self):
|
|
self._render_title('Build summary', y=40)
|
|
logp = getattr(self, 'last_build_log_path', None)
|
|
vlog = getattr(self, 'last_vagrant_log_path', None)
|
|
if logp or vlog:
|
|
self._render_paragraph('Build output was captured from the embedded console.', y=90)
|
|
if logp:
|
|
self._render_paragraph('ProleStatus log:', y=120)
|
|
self._render_paragraph(logp, y=140)
|
|
y_next = 180 if logp else 120
|
|
if vlog:
|
|
self._render_paragraph('Workstation (Vagrant) log:', y=y_next)
|
|
self._render_paragraph(vlog, y=y_next+20)
|
|
# clickable link
|
|
link_y = (y_next + 56) if vlog else 200
|
|
if logp:
|
|
link1 = self.bg_canvas.create_text(56, link_y, anchor='nw', text='Open ProleStatus log', fill='#0a84ff', font=('Helvetica', 12, 'underline'))
|
|
self._canvas_items.append(link1)
|
|
if vlog:
|
|
link2 = self.bg_canvas.create_text(56, link_y + 28, anchor='nw', text='Open Vagrant log', fill='#0a84ff', font=('Helvetica', 12, 'underline'))
|
|
self._canvas_items.append(link2)
|
|
|
|
def _open_log(event):
|
|
ex, ey = event.x, event.y
|
|
if logp:
|
|
bbox = self.bg_canvas.bbox(link1)
|
|
if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]:
|
|
try:
|
|
if platform.system() == 'Darwin':
|
|
subprocess.run(['open', logp])
|
|
else:
|
|
webbrowser.open(f'file://{logp}')
|
|
except Exception:
|
|
pass
|
|
return
|
|
if vlog:
|
|
bbox2 = self.bg_canvas.bbox(link2)
|
|
if bbox2 and bbox2[0] <= ex <= bbox2[2] and bbox2[1] <= ey <= bbox2[3]:
|
|
try:
|
|
if platform.system() == 'Darwin':
|
|
subprocess.run(['open', vlog])
|
|
else:
|
|
webbrowser.open(f'file://{vlog}')
|
|
except Exception:
|
|
pass
|
|
return
|
|
self.bg_canvas.bind('<Button-1>', _open_log)
|
|
else:
|
|
self._render_paragraph('No build log path available.', y=100)
|
|
|
|
def create_install_screen(self):
|
|
"""Create the Install screen"""
|
|
frame = tk.Frame(self.container, bg='#1a1a1a')
|
|
self.screens['install'] = frame
|
|
|
|
# Title
|
|
title = ttk.Label(frame, text="Install Dependencies", style='Title.TLabel')
|
|
title.pack(pady=(0, 30))
|
|
|
|
# Instructions
|
|
instructions = tk.Label(frame,
|
|
text="Install the following dependencies to proceed with Prole deployment:",
|
|
bg='#1a1a1a',
|
|
fg='#aaaaaa',
|
|
font=('Helvetica', 11))
|
|
instructions.pack(pady=(0, 20))
|
|
|
|
# Dependencies list
|
|
deps_frame = tk.Frame(frame, bg='#1a1a1a')
|
|
deps_frame.pack(fill='both', expand=True)
|
|
|
|
dependencies = [
|
|
{
|
|
'name': 'Docker',
|
|
'description': 'Container platform for running Prole services',
|
|
'url': 'https://www.docker.com/products/docker-desktop',
|
|
'install_cmd': None,
|
|
'check_cmd': 'docker --version'
|
|
},
|
|
{
|
|
'name': 'Homebrew',
|
|
'description': 'Package manager for macOS',
|
|
'url': 'https://brew.sh',
|
|
'install_cmd': '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
|
|
'check_cmd': 'brew --version'
|
|
},
|
|
{
|
|
'name': 'k3d',
|
|
'description': 'Lightweight wrapper to run k3s in Docker',
|
|
'url': 'https://k3d.io',
|
|
'install_cmd': 'brew install k3d',
|
|
'check_cmd': 'k3d --version'
|
|
},
|
|
{
|
|
'name': 'kubectl',
|
|
'description': 'Kubernetes command-line tool',
|
|
'url': 'https://kubernetes.io/docs/tasks/tools/',
|
|
'install_cmd': 'brew install kubectl',
|
|
'check_cmd': 'kubectl version --client'
|
|
},
|
|
{
|
|
'name': 'Helm',
|
|
'description': 'Kubernetes package manager',
|
|
'url': 'https://helm.sh',
|
|
'install_cmd': 'brew install helm',
|
|
'check_cmd': 'helm version'
|
|
},
|
|
{
|
|
'name': 'krew',
|
|
'description': 'Kubectl plugin manager',
|
|
'url': 'https://krew.sigs.k8s.io',
|
|
'install_cmd': 'brew install krew',
|
|
'check_cmd': 'kubectl krew version'
|
|
},
|
|
{
|
|
'name': 'cmctl',
|
|
'description': 'cert-manager CLI tool',
|
|
'url': 'https://cert-manager.io',
|
|
'install_cmd': 'brew install cmctl',
|
|
'check_cmd': 'cmctl version'
|
|
}
|
|
]
|
|
|
|
self.dep_status = {}
|
|
for dep in dependencies:
|
|
self.create_dependency_card(deps_frame, dep)
|
|
|
|
# Generate installer script button
|
|
script_btn = tk.Button(frame,
|
|
text="Generate Installer Script",
|
|
command=self.generate_installer_script,
|
|
bg='#4a9eff',
|
|
fg='white',
|
|
activebackground='#3a8eef',
|
|
font=('Helvetica', 12, 'bold'),
|
|
padx=30,
|
|
pady=15,
|
|
cursor='hand2',
|
|
relief='flat')
|
|
script_btn.pack(pady=20)
|
|
|
|
def create_dependency_card(self, parent, dep):
|
|
"""Create a dependency card with status and download link"""
|
|
card = tk.Frame(parent, bg='#2a2a2a', relief='flat', bd=1)
|
|
card.pack(fill='x', pady=5, padx=10)
|
|
|
|
# Left side - info
|
|
info_frame = tk.Frame(card, bg='#2a2a2a')
|
|
info_frame.pack(side='left', fill='both', expand=True, padx=15, pady=15)
|
|
|
|
name_label = tk.Label(info_frame,
|
|
text=dep['name'],
|
|
bg='#2a2a2a',
|
|
fg='#ffffff',
|
|
font=('Helvetica', 13, 'bold'),
|
|
anchor='w')
|
|
name_label.pack(fill='x')
|
|
|
|
desc_label = tk.Label(info_frame,
|
|
text=dep['description'],
|
|
bg='#2a2a2a',
|
|
fg='#aaaaaa',
|
|
font=('Helvetica', 10),
|
|
anchor='w')
|
|
desc_label.pack(fill='x', pady=(5, 0))
|
|
|
|
# Right side - status and actions
|
|
action_frame = tk.Frame(card, bg='#2a2a2a')
|
|
action_frame.pack(side='right', padx=15, pady=15)
|
|
|
|
# Status indicator
|
|
status_label = tk.Label(action_frame,
|
|
text="Checking...",
|
|
bg='#2a2a2a',
|
|
fg='#ffaa00',
|
|
font=('Helvetica', 10))
|
|
status_label.pack(side='left', padx=10)
|
|
self.dep_status[dep['name']] = {'label': status_label, 'dep': dep}
|
|
|
|
# Download button
|
|
download_btn = tk.Button(action_frame,
|
|
text="Download",
|
|
command=lambda url=dep['url']: webbrowser.open(url),
|
|
bg='#28a745',
|
|
fg='white',
|
|
activebackground='#218838',
|
|
font=('Helvetica', 10),
|
|
padx=15,
|
|
pady=5,
|
|
cursor='hand2',
|
|
relief='flat')
|
|
download_btn.pack(side='left', padx=5)
|
|
|
|
# Check status
|
|
self.check_dependency(dep['name'])
|
|
|
|
def check_dependency(self, name):
|
|
"""Check if a dependency is installed"""
|
|
dep_info = self.dep_status[name]
|
|
dep = dep_info['dep']
|
|
label = dep_info['label']
|
|
|
|
def check():
|
|
try:
|
|
result = subprocess.run(dep['check_cmd'].split(),
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5)
|
|
if result.returncode == 0:
|
|
label.configure(text="✓ Installed", fg='#28a745')
|
|
else:
|
|
label.configure(text="✗ Not Installed", fg='#dc3545')
|
|
except Exception:
|
|
label.configure(text="✗ Not Installed", fg='#dc3545')
|
|
|
|
threading.Thread(target=check, daemon=True).start()
|
|
|
|
def generate_installer_script(self):
|
|
"""Generate installer script with all commands"""
|
|
script_path = PROJECT_ROOT / 'install_dependencies.sh'
|
|
|
|
script_content = """#!/bin/bash
|
|
# Prole Dependencies Installer Script
|
|
# Generated by Prole Installer
|
|
|
|
set -e
|
|
|
|
echo "Installing Prole dependencies..."
|
|
|
|
# Check and install Homebrew
|
|
if ! command -v brew &> /dev/null; then
|
|
echo "Installing Homebrew..."
|
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
|
else
|
|
echo "Homebrew already installed. Updating..."
|
|
brew update
|
|
brew upgrade
|
|
fi
|
|
|
|
# Install k3d
|
|
if ! command -v k3d &> /dev/null; then
|
|
echo "Installing k3d..."
|
|
brew install k3d
|
|
fi
|
|
|
|
# Install kubectl
|
|
if ! command -v kubectl &> /dev/null; then
|
|
echo "Installing kubectl..."
|
|
brew install kubectl
|
|
fi
|
|
|
|
# Install Helm
|
|
if ! command -v helm &> /dev/null; then
|
|
echo "Installing Helm..."
|
|
brew install helm
|
|
fi
|
|
|
|
# Install krew
|
|
if ! command -v kubectl krew &> /dev/null; then
|
|
echo "Installing krew..."
|
|
brew install krew
|
|
kubectl krew update
|
|
fi
|
|
|
|
# Install cmctl
|
|
if ! command -v cmctl &> /dev/null; then
|
|
echo "Installing cmctl..."
|
|
brew install cmctl
|
|
fi
|
|
|
|
# Install kubectl plugins
|
|
echo "Installing kubectl plugins..."
|
|
kubectl krew install view-secret
|
|
|
|
echo "All dependencies installed successfully!"
|
|
"""
|
|
|
|
try:
|
|
with open(script_path, 'w') as f:
|
|
f.write(script_content)
|
|
os.chmod(script_path, 0o755)
|
|
messagebox.showinfo("Success",
|
|
f"Installer script generated at:\n{script_path}\n\n"
|
|
"You can run it with: ./install_dependencies.sh")
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Failed to generate script: {str(e)}")
|
|
|
|
def create_deploy_screen(self):
|
|
"""Create the Deploy screen"""
|
|
frame = tk.Frame(self.container, bg='#1a1a1a')
|
|
self.screens['deploy'] = frame
|
|
|
|
# Title
|
|
title = ttk.Label(frame, text="Build and Deploy", style='Title.TLabel')
|
|
title.pack(pady=(0, 30))
|
|
|
|
# Instructions
|
|
instructions = tk.Label(frame,
|
|
text="Build and deploy Prole services to k3d cluster",
|
|
bg='#1a1a1a',
|
|
fg='#aaaaaa',
|
|
font=('Helvetica', 11))
|
|
instructions.pack(pady=(0, 20))
|
|
|
|
# Environment selector
|
|
env_frame = tk.Frame(frame, bg='#1a1a1a')
|
|
env_frame.pack(pady=(0, 10), fill='x')
|
|
env_label = tk.Label(env_frame, text="Target Environment:", bg='#1a1a1a', fg='#dddddd', font=('Helvetica', 11))
|
|
env_label.pack(side='left', padx=(0, 10))
|
|
self.deploy_env_var = tk.StringVar(value='Dev')
|
|
env_combo = ttk.Combobox(env_frame, textvariable=self.deploy_env_var, values=['Dev', 'Service', 'Prod'], state='readonly', width=18)
|
|
env_combo.pack(side='left')
|
|
env_help = tk.Label(env_frame,
|
|
text="Dev = local k3d (prole-dev-cluster), Service = remote k3s at retropie.prole.org:6443, Prod = stretch of prole-service-cluster",
|
|
bg='#1a1a1a', fg='#888888', font=('Helvetica', 9))
|
|
env_help.pack(side='left', padx=(12, 0))
|
|
|
|
# Deploy button
|
|
deploy_btn = tk.Button(frame,
|
|
text="Build and Deploy",
|
|
command=self.start_deployment,
|
|
bg='#4a9eff',
|
|
fg='white',
|
|
activebackground='#3a8eef',
|
|
font=('Helvetica', 16, 'bold'),
|
|
padx=40,
|
|
pady=20,
|
|
cursor='hand2',
|
|
relief='flat')
|
|
deploy_btn.pack(pady=20)
|
|
|
|
# Progress frame
|
|
progress_frame = tk.Frame(frame, bg='#1a1a1a')
|
|
progress_frame.pack(fill='both', expand=True, pady=20)
|
|
|
|
# Progress list
|
|
# Initialize deploy steps; some names are updated dynamically when deployment starts
|
|
self.deploy_steps = [
|
|
{'name': 'Build ProleStatus macOS app', 'status': 'pending'},
|
|
{'name': 'Check Docker is running', 'status': 'pending'},
|
|
{'name': 'Check or configure container registry', 'status': 'pending'},
|
|
{'name': 'Ensure target cluster', 'status': 'pending'},
|
|
{'name': 'Build prole-db Docker image', 'status': 'pending'},
|
|
{'name': 'Tag Docker image for registry', 'status': 'pending'},
|
|
{'name': 'Push image to registry', 'status': 'pending'},
|
|
{'name': 'Import image to k3d cluster (Dev only)', 'status': 'pending'},
|
|
]
|
|
|
|
self.deploy_widgets = {}
|
|
for step in self.deploy_steps:
|
|
self.create_deploy_step_widget(progress_frame, step)
|
|
|
|
def create_deploy_step_widget(self, parent, step):
|
|
"""Create a widget for a deployment step"""
|
|
step_frame = tk.Frame(parent, bg='#2a2a2a', relief='flat')
|
|
step_frame.pack(fill='x', pady=5, padx=10)
|
|
|
|
# Status indicator
|
|
status_canvas = tk.Canvas(step_frame, width=30, height=30, bg='#2a2a2a', highlightthickness=0)
|
|
status_canvas.pack(side='left', padx=15, pady=15)
|
|
|
|
# Step name
|
|
name_label = tk.Label(step_frame,
|
|
text=step['name'],
|
|
bg='#2a2a2a',
|
|
fg='#ffffff',
|
|
font=('Helvetica', 11),
|
|
anchor='w')
|
|
name_label.pack(side='left', fill='x', expand=True, padx=10)
|
|
|
|
# Status text
|
|
status_label = tk.Label(step_frame,
|
|
text="Pending",
|
|
bg='#2a2a2a',
|
|
fg='#aaaaaa',
|
|
font=('Helvetica', 10))
|
|
status_label.pack(side='right', padx=15)
|
|
|
|
self.deploy_widgets[step['name']] = {
|
|
'canvas': status_canvas,
|
|
'label': status_label,
|
|
'step': step
|
|
}
|
|
|
|
# Draw initial pending state
|
|
self.update_deploy_step_status(step['name'], 'pending')
|
|
|
|
def update_deploy_step_status(self, step_name, status):
|
|
"""Update the status of a deployment step"""
|
|
widget = self.deploy_widgets[step_name]
|
|
canvas = widget['canvas']
|
|
label = widget['label']
|
|
step = widget['step']
|
|
|
|
step['status'] = status
|
|
canvas.delete('all')
|
|
|
|
if status == 'pending':
|
|
canvas.create_oval(5, 5, 25, 25, outline='#666', width=2)
|
|
label.configure(text="Pending", fg='#aaaaaa')
|
|
elif status == 'running':
|
|
canvas.create_oval(5, 5, 25, 25, outline='#ffaa00', width=2, fill='#ffaa00')
|
|
label.configure(text="Running...", fg='#ffaa00')
|
|
elif status == 'completed':
|
|
canvas.create_oval(5, 5, 25, 25, outline='#28a745', width=2, fill='#28a745')
|
|
canvas.create_text(15, 15, text='✓', fill='white', font=('Helvetica', 16, 'bold'))
|
|
label.configure(text="Completed", fg='#28a745')
|
|
elif status == 'error':
|
|
canvas.create_oval(5, 5, 25, 25, outline='#dc3545', width=2, fill='#dc3545')
|
|
canvas.create_text(15, 15, text='✗', fill='white', font=('Helvetica', 16, 'bold'))
|
|
label.configure(text="Error", fg='#dc3545')
|
|
elif status == 'skipped':
|
|
canvas.create_oval(5, 5, 25, 25, outline='#666', width=2, fill='#444444')
|
|
label.configure(text="Skipped", fg='#888888')
|
|
|
|
def start_deployment(self):
|
|
"""Start the deployment process"""
|
|
threading.Thread(target=self.run_deployment, daemon=True).start()
|
|
|
|
def run_deployment(self):
|
|
"""Run the deployment steps"""
|
|
try:
|
|
# Capture environment selection and prepare dynamic labels
|
|
env = self.deploy_env_var.get().strip()
|
|
if env not in ('Dev', 'Service', 'Prod'):
|
|
env = 'Dev'
|
|
# Update step labels to reflect environment
|
|
self.deploy_widgets['Ensure target cluster']['step']['name'] = f"Ensure target cluster ({env})"
|
|
self.deploy_widgets['Ensure target cluster']['label'].master.master.children['!label'].configure(text=f"Ensure target cluster ({env})")
|
|
|
|
# Step 0: Build ProleStatus macOS app
|
|
self.update_deploy_step_status('Build ProleStatus macOS app', 'running')
|
|
self.build_prole_status_app()
|
|
self.update_deploy_step_status('Build ProleStatus macOS app', 'completed')
|
|
|
|
# Step 1: Check Docker
|
|
self.update_deploy_step_status('Check Docker is running', 'running')
|
|
if not self.check_docker_running():
|
|
self.update_deploy_step_status('Check Docker is running', 'error')
|
|
messagebox.showerror("Error", "Docker is not running. Please start Docker Desktop.")
|
|
return
|
|
self.update_deploy_step_status('Check Docker is running', 'completed')
|
|
|
|
# Step 2: Registry discovery/config
|
|
self.update_deploy_step_status('Check or configure container registry', 'running')
|
|
self.registry_url = self.ensure_registry_available(env)
|
|
self.update_deploy_step_status('Check or configure container registry', 'completed')
|
|
|
|
# Step 3: Ensure target cluster as per environment
|
|
self.update_deploy_step_status('Ensure target cluster', 'running')
|
|
self.create_or_select_cluster(env)
|
|
self.update_deploy_step_status('Ensure target cluster', 'completed')
|
|
|
|
# Step 3: Build Docker image
|
|
self.update_deploy_step_status('Build prole-db Docker image', 'running')
|
|
self.build_docker_image()
|
|
self.update_deploy_step_status('Build prole-db Docker image', 'completed')
|
|
|
|
# Step 4: Tag image
|
|
self.update_deploy_step_status('Tag Docker image for registry', 'running')
|
|
self.tag_docker_image()
|
|
self.update_deploy_step_status('Tag Docker image for registry', 'completed')
|
|
|
|
# Step 5: Push to registry
|
|
self.update_deploy_step_status('Push image to registry', 'running')
|
|
self.push_docker_image()
|
|
self.update_deploy_step_status('Push image to registry', 'completed')
|
|
|
|
# Step 6: Import to k3d (Dev only)
|
|
if env == 'Dev':
|
|
self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'running')
|
|
self.import_k3d_image(cluster_name='prole-dev-cluster')
|
|
self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'completed')
|
|
else:
|
|
self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'skipped')
|
|
|
|
messagebox.showinfo("Success", "Deployment completed successfully!")
|
|
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Deployment failed: {str(e)}")
|
|
|
|
|
|
def check_xcode_tools(self):
|
|
"""Check if Xcode Command Line Tools are installed via deploy helper."""
|
|
return inst_deploy.check_xcode_tools()
|
|
|
|
def build_prole_status_app(self):
|
|
"""Delegate building the native ProleStatus app to deploy helper."""
|
|
return inst_deploy.build_prole_status_app(PROJECT_ROOT)
|
|
|
|
def check_docker_running(self):
|
|
"""Check if Docker is running"""
|
|
try:
|
|
result = subprocess.run(['docker', 'ps'],
|
|
capture_output=True,
|
|
timeout=10)
|
|
return result.returncode == 0
|
|
except Exception:
|
|
return False
|
|
|
|
def ensure_registry_available(self, env: str) -> str:
|
|
"""Detect if k8s.prole.org:5000 is reachable; if so use it. Otherwise ensure a local registry is available.
|
|
Returns the registry URL (host:port) to be used for tagging/pushing.
|
|
For Dev, may create a local k3d-managed registry; for other envs, still prefer the external if reachable.
|
|
"""
|
|
def http_ping_registry(host: str, port: int) -> bool:
|
|
try:
|
|
import http.client
|
|
conn = http.client.HTTPConnection(host, port, timeout=3)
|
|
conn.request('GET', '/v2/')
|
|
resp = conn.getresponse()
|
|
# Docker registry typically returns 200 or 401 for /v2/
|
|
return resp.status in (200, 401)
|
|
except Exception:
|
|
return False
|
|
|
|
# Prefer the shared registry if reachable
|
|
if http_ping_registry('k8s.prole.org', 5000):
|
|
return 'k8s.prole.org:5000'
|
|
|
|
# Otherwise, ensure a local registry (localhost:5000) exists/started
|
|
# Use k3d registry helper for Dev; for non-Dev, we still create/use local for pushing
|
|
reg_name = 'prole-registry'
|
|
# Check if k3d is installed
|
|
k3d_exists = subprocess.run(['which', 'k3d'], capture_output=True).returncode == 0
|
|
if k3d_exists:
|
|
# List registries
|
|
lst = subprocess.run(['k3d', 'registry', 'list'], capture_output=True, text=True)
|
|
if reg_name not in (lst.stdout or ''):
|
|
# Create registry exposed on 0.0.0.0:5000
|
|
subprocess.run(['k3d', 'registry', 'create', reg_name, '--port', '0.0.0.0:5000'], check=True)
|
|
return 'localhost:5000'
|
|
|
|
def create_or_select_cluster(self, env: str):
|
|
"""Ensure target cluster depending on environment selection."""
|
|
if env == 'Dev':
|
|
self.create_or_recreate_k3d_dev_cluster()
|
|
elif env in ('Service', 'Prod'):
|
|
# For now, just check kubectl availability and inform user. Real connectivity requires kubeconfig.
|
|
kubectl = subprocess.run(['which', 'kubectl'], capture_output=True)
|
|
if kubectl.returncode != 0:
|
|
raise Exception("kubectl not found. Please install kubectl and configure access to the target cluster.")
|
|
# Optionally, try a quick cluster-info; don't fail hard on auth errors.
|
|
subprocess.run(['kubectl', 'version', '--client'], check=True)
|
|
else:
|
|
raise Exception(f"Unknown environment: {env}")
|
|
|
|
def create_or_recreate_k3d_dev_cluster(self):
|
|
"""Create or restart local k3d cluster named prole-dev-cluster and wire it to the chosen registry."""
|
|
cluster_name = 'prole-dev-cluster'
|
|
result = subprocess.run(['k3d', 'cluster', 'list'], capture_output=True, text=True)
|
|
if cluster_name in (result.stdout or ''):
|
|
subprocess.run(['k3d', 'cluster', 'delete', cluster_name], check=True)
|
|
|
|
# Determine registry integration args
|
|
reg_args = []
|
|
if getattr(self, 'registry_url', None):
|
|
# If using the local k3d registry, we want to create or use it
|
|
if self.registry_url.startswith('localhost:5000'):
|
|
# Creating with --registry-create ensures it's available and integrated
|
|
reg_args = ['--registry-create', f'prole-registry:0.0.0.0:5000']
|
|
else:
|
|
reg_args = ['--registry-use', self.registry_url]
|
|
|
|
cmd = ['k3d', 'cluster', 'create', cluster_name, '-a', '2', '--wait'] + reg_args + ['--timestamps']
|
|
subprocess.run(cmd, check=True, cwd=PROJECT_ROOT)
|
|
|
|
def build_docker_image(self):
|
|
"""Build prole-db Docker image"""
|
|
# Base local image tag (before pushing to registry)
|
|
image_tag = 'prole-db:17.5-027'
|
|
build_cmd = ['docker', 'build', '-t', image_tag]
|
|
|
|
# Add platform flag for Apple Silicon (ARM64 needs amd64 for compatibility)
|
|
build_cmd.extend(get_docker_build_platform_args())
|
|
|
|
build_cmd.append('.')
|
|
|
|
result = subprocess.run(build_cmd,
|
|
check=True,
|
|
cwd=PROJECT_ROOT / 'prole-db',
|
|
capture_output=True,
|
|
text=True)
|
|
if result.returncode != 0:
|
|
raise Exception(f"Failed to build image: {result.stderr}")
|
|
# Keep a reference for later steps
|
|
self.local_image_tag = image_tag
|
|
|
|
def build_mssql_docker_image(self, image_tag='prole-mssql-db:latest'):
|
|
"""Build mssql Docker image (with platform detection for Apple Silicon)"""
|
|
build_cmd = ['docker', 'build', '-t', image_tag]
|
|
|
|
# Add platform flag for Apple Silicon (mssql requires amd64)
|
|
build_cmd.extend(get_docker_build_platform_args())
|
|
|
|
build_cmd.append('.')
|
|
|
|
result = subprocess.run(build_cmd,
|
|
check=True,
|
|
cwd=PROJECT_ROOT / 'mssql',
|
|
capture_output=True,
|
|
text=True)
|
|
if result.returncode != 0:
|
|
raise Exception(f"Failed to build mssql image: {result.stderr}")
|
|
return result
|
|
|
|
def tag_docker_image(self):
|
|
"""Tag Docker image for registry"""
|
|
registry = getattr(self, 'registry_url', 'localhost:5000')
|
|
image = getattr(self, 'local_image_tag', 'prole-db:17.5-027')
|
|
self.remote_image_tag = f"{registry}/prole-db:17.5-027"
|
|
subprocess.run(['docker', 'tag', image, self.remote_image_tag], check=True, capture_output=True)
|
|
|
|
def push_docker_image(self):
|
|
"""Push Docker image to registry"""
|
|
remote_tag = getattr(self, 'remote_image_tag', None)
|
|
if not remote_tag:
|
|
raise Exception('Remote image tag not set')
|
|
result = subprocess.run(['docker', 'push', remote_tag], check=True, capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
raise Exception(f"Failed to push image: {result.stderr}")
|
|
|
|
def import_k3d_image(self, cluster_name='prole-dev-cluster'):
|
|
"""Import image to k3d cluster (only for Dev)."""
|
|
subprocess.run(['k3d', 'image', 'import', 'prole-db:17.5-027', '-c', cluster_name], check=True, capture_output=True)
|
|
|
|
def create_validate_screen(self):
|
|
"""Create the Validate screen"""
|
|
frame = tk.Frame(self.container, bg='#1a1a1a')
|
|
self.screens['validate'] = frame
|
|
|
|
# Title
|
|
title = ttk.Label(frame, text="Validate Deployment", style='Title.TLabel')
|
|
title.pack(pady=(0, 20))
|
|
|
|
# Prometheus link
|
|
prometheus_frame = tk.Frame(frame, bg='#1a1a1a')
|
|
prometheus_frame.pack(pady=(0, 20))
|
|
|
|
prometheus_label = tk.Label(prometheus_frame,
|
|
text="Prometheus: ",
|
|
bg='#1a1a1a',
|
|
fg='#aaaaaa',
|
|
font=('Helvetica', 11))
|
|
prometheus_label.pack(side='left')
|
|
|
|
prometheus_link = tk.Label(prometheus_frame,
|
|
text="http://localhost:9090",
|
|
bg='#1a1a1a',
|
|
fg='#4a9eff',
|
|
font=('Helvetica', 11, 'underline'),
|
|
cursor='hand2')
|
|
prometheus_link.pack(side='left')
|
|
prometheus_link.bind('<Button-1>', lambda e: webbrowser.open('http://localhost:9090'))
|
|
|
|
# Status display
|
|
status_frame = tk.Frame(frame, bg='#1a1a1a')
|
|
status_frame.pack(fill='both', expand=True, pady=10)
|
|
|
|
status_label = ttk.Label(status_frame, text="Cluster Status", style='Heading.TLabel')
|
|
status_label.pack(anchor='w', pady=(0, 10))
|
|
|
|
# Status text area
|
|
self.status_text = scrolledtext.ScrolledText(status_frame,
|
|
bg='#0a0a0a',
|
|
fg='#4a9eff',
|
|
font=('Courier', 10),
|
|
wrap='word',
|
|
relief='flat',
|
|
bd=1)
|
|
self.status_text.pack(fill='both', expand=True)
|
|
|
|
# Auto-refresh checkbox
|
|
refresh_frame = tk.Frame(frame, bg='#1a1a1a')
|
|
refresh_frame.pack(pady=10)
|
|
|
|
self.auto_refresh_var = tk.BooleanVar(value=True)
|
|
refresh_check = tk.Checkbutton(refresh_frame,
|
|
text="Auto-refresh every 10 seconds",
|
|
variable=self.auto_refresh_var,
|
|
bg='#1a1a1a',
|
|
fg='#aaaaaa',
|
|
selectcolor='#2a2a2a',
|
|
activebackground='#1a1a1a',
|
|
activeforeground='#aaaaaa',
|
|
font=('Helvetica', 10),
|
|
command=self.toggle_auto_refresh)
|
|
refresh_check.pack(side='left', padx=10)
|
|
|
|
# Manual refresh button
|
|
refresh_btn = tk.Button(refresh_frame,
|
|
text="Refresh Now",
|
|
command=self.refresh_status,
|
|
bg='#4a9eff',
|
|
fg='white',
|
|
activebackground='#3a8eef',
|
|
font=('Helvetica', 10),
|
|
padx=15,
|
|
pady=5,
|
|
cursor='hand2',
|
|
relief='flat')
|
|
refresh_btn.pack(side='left', padx=10)
|
|
|
|
# Start auto-refresh
|
|
self.refresh_status()
|
|
self.toggle_auto_refresh()
|
|
|
|
def toggle_auto_refresh(self):
|
|
"""Toggle auto-refresh"""
|
|
if self.auto_refresh_var.get():
|
|
if not self.validation_running:
|
|
self.validation_running = True
|
|
self.validation_thread = threading.Thread(target=self.auto_refresh_loop, daemon=True)
|
|
self.validation_thread.start()
|
|
else:
|
|
self.validation_running = False
|
|
|
|
def auto_refresh_loop(self):
|
|
"""Auto-refresh loop"""
|
|
while self.validation_running:
|
|
time.sleep(10)
|
|
if self.validation_running:
|
|
self.root.after(0, self.refresh_status)
|
|
|
|
def refresh_status(self):
|
|
"""Refresh the cluster status"""
|
|
def update():
|
|
try:
|
|
full_status = ""
|
|
|
|
# Get k3d cluster list
|
|
try:
|
|
cluster_result = subprocess.run(['k3d', 'cluster', 'list'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5)
|
|
full_status += f"=== k3d Cluster Status ===\n{cluster_result.stdout}\n\n"
|
|
except Exception as e:
|
|
full_status += f"=== k3d Cluster Status ===\nError: {str(e)}\n\n"
|
|
|
|
# Get kubectl cnpg status
|
|
try:
|
|
result = subprocess.run(['kubectl', 'cnpg', 'status', 'prole-db'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10)
|
|
if result.returncode == 0:
|
|
status_output = result.stdout
|
|
else:
|
|
status_output = f"Error: {result.stderr}\n\nNote: Make sure kubectl cnpg plugin is installed:\n kubectl krew install cnpg"
|
|
|
|
full_status += f"=== CloudNativePG Status ===\n{status_output}\n"
|
|
except FileNotFoundError:
|
|
full_status += "=== CloudNativePG Status ===\nError: kubectl not found. Please install dependencies first.\n"
|
|
except subprocess.TimeoutExpired:
|
|
full_status += "=== CloudNativePG Status ===\nError: Command timed out\n"
|
|
except Exception as e:
|
|
full_status += f"=== CloudNativePG Status ===\nError: {str(e)}\n"
|
|
|
|
full_status += f"\nLast updated: {time.strftime('%Y-%m-%d %H:%M:%S')}"
|
|
|
|
self.status_text.delete('1.0', tk.END)
|
|
self.status_text.insert('1.0', full_status)
|
|
|
|
except Exception as e:
|
|
self.status_text.delete('1.0', tk.END)
|
|
self.status_text.insert('1.0', f"Error: {str(e)}")
|
|
|
|
threading.Thread(target=update, daemon=True).start()
|
|
|
|
|
|
def main():
|
|
root = tk.Tk()
|
|
app = ProleInstaller(root)
|
|
root.mainloop()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|
|
|