diff --git a/installer/build.py b/installer/build.py index 294bfef..e3809a0 100644 --- a/installer/build.py +++ b/installer/build.py @@ -6,7 +6,11 @@ UI-agnostic command composition for the Prole macOS app build. from __future__ import annotations from pathlib import Path +import tkinter as tk +from tkinter import ttk, scrolledtext +from . import main as inst_main +from . import screen as ui def get_build_command(project_root: Path, env: str | None) -> str: """Return the Prole app build command with an env comment suffix. @@ -17,3 +21,26 @@ def get_build_command(project_root: Path, env: str | None) -> str: env_label = (env or "Dev").strip().title() prole_root = str(project_root) return f"cd \"{prole_root}\" && ./prole-app/build.sh build # {env_label}" + + +# ---------------- Screen (UI) helpers ---------------- +def create_build_page(app): + """Create the Build page UI and register it via installer.main.""" + f = ttk.Frame(app.page_area) + f.place(x=0, y=0, relwidth=1, relheight=1) + + 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') + app.deploy_env_var = tk.StringVar(value='Dev') + ttk.Combobox(wrap, textvariable=app.deploy_env_var, values=['Dev', 'Service', 'Prod'], state='readonly', width=18).pack(side='left', padx=10) + + # Build output console + app.build_output = scrolledtext.ScrolledText(f, height=12, bg='#fafafa', fg='#1d1d1f') + app.build_output.pack(fill='both', expand=True, padx=24, pady=12) + + inst_main.register_page(app, 'build', f) + return f diff --git a/installer/deploy.py b/installer/deploy.py index 5a555f4..22ae632 100644 --- a/installer/deploy.py +++ b/installer/deploy.py @@ -8,7 +8,11 @@ from __future__ import annotations from pathlib import Path import platform import subprocess -from tkinter import messagebox +import tkinter as tk +from tkinter import ttk, messagebox + +from . import main as inst_main +from . import screen as ui def check_xcode_tools() -> bool: @@ -55,3 +59,59 @@ def build_prole_app(project_root: Path) -> None: messagebox.showinfo("Copied", f"Copied to {dest}") except Exception as copy_err: print(f"Copy to /Applications failed: {copy_err}") + + +# ---------------- Screen (UI) helpers ---------------- +def _draw_status(canvas: tk.Canvas, status: str): + canvas.delete('all') + if status == 'success' or status == 'completed': + 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') + + +def _create_deploy_row(app, parent, step: dict): + 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) + app.deploy_widgets[step['name']] = {'canvas': canvas, 'label': status, 'step': step} + _draw_status(canvas, 'pending') + + +def create_deploy_page(app): + """Create the Deploy page UI and register it via installer.main.""" + f = ttk.Frame(app.page_area) + f.place(x=0, y=0, relwidth=1, relheight=1) + + 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) + + app.deploy_steps = [ + {'name': 'Build Prole 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'}, + ] + app.deploy_widgets = {} + container = ttk.Frame(f) + container.pack(fill='both', expand=True, padx=16, pady=8) + for step in app.deploy_steps: + _create_deploy_row(app, container, step) + + inst_main.register_page(app, 'deploy', f) + return f diff --git a/prole-app/Sources/AppDelegate.swift b/prole-app/Sources/AppDelegate.swift index de2ce85..db3da71 100644 --- a/prole-app/Sources/AppDelegate.swift +++ b/prole-app/Sources/AppDelegate.swift @@ -19,6 +19,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var statusItemController: StatusItemController! private var splashTipController: SplashTipWindowController? private var appMenuToggleStatusBarItem: NSMenuItem? + private var pfManager: PortForwardManager? // We keep the app in one of two simple modes. // - statusBar: shows the thin overlay near the macOS menu bar @@ -42,8 +43,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // ServiceChecker does the lightweight TCP checks on a background timer serviceChecker = ServiceChecker() dlog("ServiceChecker created") + // Optional: Port-forward supervision (Dev) + if Config.shared.pfEnabled { + let cmds = Config.shared.pfCommands + if !cmds.isEmpty { + let mgr = PortForwardManager(commands: cmds) + self.pfManager = mgr + mgr.start() + } + } + // Overlay (Status Bar mode) — a thin, mostly click‑through window near the top - overlayWindowController = OverlayWindowController(serviceChecker: serviceChecker) + overlayWindowController = OverlayWindowController(serviceChecker: serviceChecker, pfManager: pfManager) dlog("OverlayWindowController created; computing initial frame/visibility") overlayWindowController.showIfMenuBarVisible() @@ -51,7 +62,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { mainWindowController = MainWindowController(serviceChecker: serviceChecker, actions: .init( onRefresh: { [weak self] in self?.refreshNow() }, onMinimizeToStatusBar: { [weak self] in self?.switchToStatusBarMode() } - )) + ), pfManager: pfManager) dlog("MainWindowController created") // Create status bar item with icon and click handler @@ -194,6 +205,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate { appMenu.addItem(NSMenuItem.separator()) + // Reset Port Forwards (Dev) + if pfManager != nil { + let resetPF = NSMenuItem(title: "Reset Port Forwards", action: #selector(menuResetPortForwards), keyEquivalent: "") + resetPF.target = self + appMenu.addItem(resetPF) + + // Details submenu with PID/state + let detailsItem = NSMenuItem(title: "Port Forward Details", action: nil, keyEquivalent: "") + let sub = NSMenu(title: "Port Forward Details") + if let mgr = pfManager { + for d in mgr.details { + let pid = d.pid != nil ? String(d.pid!) : "-" + let status = d.running ? "running" : "stopped" + let label = "PID \(pid) • \(status) — \(d.command)" + let it = NSMenuItem(title: label, action: nil, keyEquivalent: "") + sub.addItem(it) + } + if mgr.details.isEmpty { + sub.addItem(NSMenuItem(title: "No port-forwards configured", action: nil, keyEquivalent: "")) + } + } + detailsItem.submenu = sub + appMenu.addItem(detailsItem) + appMenu.addItem(NSMenuItem.separator()) + } + // Refresh let refresh = NSMenuItem(title: "Refresh Now", action: #selector(menuRefresh), keyEquivalent: "r") refresh.target = self @@ -202,7 +239,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { appMenu.addItem(NSMenuItem.separator()) // Quit - let quit = NSMenuItem(title: "Quit ProleStatus", action: #selector(NSApp.terminate(_:)), keyEquivalent: "q") + let quit = NSMenuItem(title: "Quit Prole", action: #selector(NSApp.terminate(_:)), keyEquivalent: "q") appMenu.addItem(quit) appMenuItem.submenu = appMenu @@ -219,6 +256,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc private func menuToggleMode() { toggleMode() } @objc private func menuRefresh() { refreshNow() } + @objc private func menuResetPortForwards() { pfManager?.reset() } // Invoked by overlay maximize button @objc private func showMainWindowRequested() { diff --git a/prole-app/Sources/AppStatusView.swift b/prole-app/Sources/AppStatusView.swift index c9c3ecb..110cf76 100644 --- a/prole-app/Sources/AppStatusView.swift +++ b/prole-app/Sources/AppStatusView.swift @@ -1,90 +1,3 @@ -import AppKit - -// Application-window specific status view: vertical, one line per item, no marquee. -final class AppStatusView: NSView { - private let lightSvc = TrafficLight() - private let lightK3s = TrafficLight() - private let lightLocal = TrafficLight() - - private let labelSvc = NSTextField(labelWithString: "") - private let labelK3s = NSTextField(labelWithString: "") - private let labelLocal = NSTextField(labelWithString: "") - - // Top-right timestamp - private let timestampLabel: NSTextField = { - let tf = NSTextField(labelWithString: "0000-00-00 00:00:00 Z") - tf.font = NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular) - tf.textColor = .secondaryLabelColor - tf.alignment = .right - tf.translatesAutoresizingMaskIntoConstraints = false - return tf - }() - - private var checker: ServiceChecker? - private let timeFormatter: DateFormatter = { - let df = DateFormatter() - df.locale = .current - df.timeZone = .current - df.dateFormat = "yyyy-MM-dd HH:mm:ss ZZZZZ" - return df - }() - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - translatesAutoresizingMaskIntoConstraints = false - - // Configure labels - [labelSvc, labelK3s, labelLocal].forEach { l in - l.font = NSFont.systemFont(ofSize: NSFont.systemFontSize(for: .regular)) - l.textColor = .labelColor - l.lineBreakMode = .byTruncatingTail - l.translatesAutoresizingMaskIntoConstraints = false - } - - // Rows: [light][label] - func row(light: NSView, label: NSTextField) -> NSStackView { - let s = NSStackView(views: [light, label]) - s.orientation = .horizontal - s.alignment = .centerY - s.spacing = 8 - s.translatesAutoresizingMaskIntoConstraints = false - return s - } - - let row1 = row(light: lightSvc, label: labelSvc) - let row2 = row(light: lightK3s, label: labelK3s) - let row3 = row(light: lightLocal, label: labelLocal) - - let vstack = NSStackView(views: [row1, row2, row3]) - vstack.orientation = .vertical - vstack.alignment = .leading - vstack.spacing = 10 - vstack.translatesAutoresizingMaskIntoConstraints = false - - addSubview(vstack) - addSubview(timestampLabel) - - NSLayoutConstraint.activate([ - vstack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), - vstack.topAnchor.constraint(equalTo: topAnchor, constant: 24), - - timestampLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), - timestampLabel.topAnchor.constraint(equalTo: topAnchor, constant: 10), - timestampLabel.leadingAnchor.constraint(greaterThanOrEqualTo: vstack.trailingAnchor, constant: 12) - ]) - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - func bindTo(serviceChecker: ServiceChecker) { - self.checker = serviceChecker - NotificationCenter.default.addObserver(self, selector: #selector(updateUI), name: ServiceChecker.statusDidChangeNotification, object: serviceChecker) - updateUI() - } - - @objc private func updateUI() { - guard let c = checker else { return } - let cfg = Config.shared // Timestamp let now = Date() @@ -106,6 +19,60 @@ final class AppStatusView: NSView { labelLocal.stringValue = "local: \(cfg.localHost):\(cfg.localPort) \(c.localReachable ? "✓" : "✗") \(c.localLatency >= 0 ? "(\(c.localLatency) ms)" : "")" labelLocal.toolTip = c.tooltipForLocal() + // Port forwards + if let m = pfManager { + let running = m.runningCount + let total = m.details.count + lightPF.state = m.allRunning ? .green : .red + labelPF.stringValue = total > 0 ? "kubectl port-forwards: \(running)/\(total) running" : "kubectl port-forwards: none configured" + labelPF.toolTip = "Managed processes: \(running)/\(total) running" + renderPFDetails(m) + } else { + lightPF.state = .red + labelPF.stringValue = "kubectl port-forwards: disabled" + labelPF.toolTip = "To enable, set pf.enabled=true in prole.properties" + clearPFDetails() + } + needsLayout = true } + + // MARK: - PF Details rendering + private func clearPFDetails() { + pfDetailsStack.arrangedSubviews.forEach { v in + pfDetailsStack.removeArrangedSubview(v) + v.removeFromSuperview() + } + } + + private func renderPFDetails(_ manager: PortForwardManager) { + clearPFDetails() + let details = manager.details + guard !details.isEmpty else { return } + + for d in details { + let dot = TrafficLight() + dot.state = d.running ? .green : .red + + let pidText: String + if let pid = d.pid, pid > 0 { + pidText = "PID \(pid)" + } else { + pidText = "PID -" + } + + let label = NSTextField(labelWithString: "\(pidText) • \(d.running ? "running" : "stopped") — \(d.command)") + label.font = NSFont.systemFont(ofSize: NSFont.systemFontSize(for: .small)) + label.textColor = .secondaryLabelColor + label.lineBreakMode = .byTruncatingMiddle + + let row = NSStackView(views: [dot, label]) + row.orientation = .horizontal + row.alignment = .centerY + row.spacing = 8 + row.translatesAutoresizingMaskIntoConstraints = false + + pfDetailsStack.addArrangedSubview(row) + } + } } diff --git a/prole-app/Sources/Config.swift b/prole-app/Sources/Config.swift index 2fc52bb..f4ab4b5 100644 --- a/prole-app/Sources/Config.swift +++ b/prole-app/Sources/Config.swift @@ -78,4 +78,26 @@ final class Config { } return raw } + + // Dev port-forward supervision + var pfEnabled: Bool { + let v = (props["pf.enabled"] ?? "").lowercased() + return v == "1" || v == "true" || v == "yes" || v == "on" + } + + var pfCommands: [String] { + // Collect keys pf.1, pf.2, ... in ascending order + let keys = props.keys + .filter { $0.hasPrefix("pf.") } + .compactMap { k -> (Int, String)? in + let tail = k.dropFirst(3) + if let idx = Int(tail) { return (idx, k) } + return nil + } + .sorted { $0.0 < $1.0 } + .map { $0.1 } + return keys.compactMap { props[$0] } + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } } diff --git a/prole-app/Sources/MainWindowController.swift b/prole-app/Sources/MainWindowController.swift index d81cb9b..b31a1cb 100644 --- a/prole-app/Sources/MainWindowController.swift +++ b/prole-app/Sources/MainWindowController.swift @@ -14,7 +14,7 @@ final class MainWindowController: NSWindowController { private let statusView = AppStatusView() private let actions: Actions - init(serviceChecker: ServiceChecker, actions: Actions) { + init(serviceChecker: ServiceChecker, actions: Actions, pfManager: PortForwardManager? = nil) { // Initialize stored properties before calling super.init self.actions = actions let style: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable] @@ -71,6 +71,7 @@ final class MainWindowController: NSWindowController { ]) statusView.bindTo(serviceChecker: serviceChecker) + statusView.setPortForwardManager(pfManager) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/prole-app/Sources/OverlayWindow.swift b/prole-app/Sources/OverlayWindow.swift index b3ac870..fac1454 100644 --- a/prole-app/Sources/OverlayWindow.swift +++ b/prole-app/Sources/OverlayWindow.swift @@ -2,9 +2,10 @@ import AppKit final class OverlayWindowController: NSWindowController { private let statusView = StatusView() + private var pfManager: PortForwardManager? // Fully transparent background; no visual effect overlay - init(serviceChecker: ServiceChecker) { + init(serviceChecker: ServiceChecker, pfManager: PortForwardManager? = nil) { let style: NSWindow.StyleMask = [.borderless] let window = OverlayWindow(contentRect: .zero, styleMask: style, backing: .buffered, defer: false) super.init(window: window) @@ -32,6 +33,8 @@ final class OverlayWindowController: NSWindowController { ]) statusView.bindTo(serviceChecker: serviceChecker) + self.pfManager = pfManager + statusView.setPortForwardManager(pfManager) // Initial placement recomputeFrameAndVisibility() @@ -99,12 +102,31 @@ final class OverlayWindowController: NSWindowController { dlog("Enabling mouse events for context menu interaction") window.ignoresMouseEvents = false } - let menu = NSMenu(title: "ProleStatus") + let menu = NSMenu(title: "Prole") menu.addItem(withTitle: statusView.aggregateStatusSummary(), action: nil, keyEquivalent: "") menu.addItem(.separator()) menu.addItem(withTitle: "Refresh Now", action: #selector(refreshNow), keyEquivalent: "r").target = self + if let mgr = pfManager { + menu.addItem(withTitle: "Reset Port Forwards", action: #selector(resetPortForwards), keyEquivalent: "").target = self + + // Details submenu + let detailsItem = NSMenuItem(title: "Port Forward Details", action: nil, keyEquivalent: "") + let sub = NSMenu(title: "Port Forward Details") + for d in mgr.details { + let pid = d.pid != nil ? String(d.pid!) : "-" + let status = d.running ? "running" : "stopped" + let label = "PID \(pid) • \(status) — \(d.command)" + let it = NSMenuItem(title: label, action: nil, keyEquivalent: "") + sub.addItem(it) + } + if mgr.details.isEmpty { + sub.addItem(NSMenuItem(title: "No port-forwards configured", action: nil, keyEquivalent: "")) + } + detailsItem.submenu = sub + menu.addItem(detailsItem) + } menu.addItem(.separator()) - menu.addItem(withTitle: "Quit ProleStatus", action: #selector(quit), keyEquivalent: "q").target = self + menu.addItem(withTitle: "Quit Prole", action: #selector(quit), keyEquivalent: "q").target = self let point = NSPoint(x: window.frame.midX, y: window.frame.minY) menu.popUp(positioning: nil, at: point, in: nil) @@ -123,6 +145,10 @@ final class OverlayWindowController: NSWindowController { dlog("Quit requested via context menu; terminating app") NSApp.terminate(nil) } + + @objc private func resetPortForwards() { + pfManager?.reset() + } } final class OverlayWindow: NSWindow { diff --git a/prole-app/Sources/StatusView.swift b/prole-app/Sources/StatusView.swift index 5fc8735..543c40c 100644 --- a/prole-app/Sources/StatusView.swift +++ b/prole-app/Sources/StatusView.swift @@ -4,6 +4,7 @@ final class StatusView: NSView { private let light1 = TrafficLight() private let light2 = TrafficLight() private let light3 = TrafficLight() + private let lightPF = TrafficLight() private let timestampLabel: NSTextField = { let tf = NSTextField(labelWithString: "0000-00-00 00:00:00 +00:00") tf.textColor = .secondaryLabelColor @@ -46,7 +47,7 @@ final class StatusView: NSView { wantsLayer = true translatesAutoresizingMaskIntoConstraints = false - let stack = NSStackView(views: [light1, light2, light3, timestampLabel, marquee, maximizeButton]) + let stack = NSStackView(views: [light1, light2, light3, lightPF, timestampLabel, marquee, maximizeButton]) stack.orientation = .horizontal stack.alignment = .centerY stack.distribution = .equalSpacing @@ -73,18 +74,32 @@ final class StatusView: NSView { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func hitTest(_ point: NSPoint) -> NSView? { - // Allow clicks only on our buttons; pass everything else through - let v = super.hitTest(point) - if let v = v as? NSButton { return v } + // Allow interaction with our inline controls and status lights (for tooltips), + // pass other clicks through to avoid blocking the menu bar. + if let v = super.hitTest(point) { + if v is NSButton { return v } + if v is TrafficLight { return v } + if let tl = v.toolTip, !tl.isEmpty { return v } + } return nil } + private var pfManager: PortForwardManager? + func bindTo(serviceChecker: ServiceChecker) { self.checker = serviceChecker NotificationCenter.default.addObserver(self, selector: #selector(updateLights), name: ServiceChecker.statusDidChangeNotification, object: serviceChecker) updateLights() } + func setPortForwardManager(_ manager: PortForwardManager?) { + self.pfManager = manager + if let m = manager { + NotificationCenter.default.addObserver(self, selector: #selector(updateLights), name: PortForwardManager.statusDidChangeNotification, object: m) + } + updateLights() + } + @objc private func updateLights() { guard let c = checker else { return } // Light 1: svc.prole.org:443 @@ -100,6 +115,15 @@ final class StatusView: NSView { light3.state = c.localReachable ? .green : .red light3.toolTip = c.tooltipForLocal() + // Light 4: port-forward supervision status (Dev) + if let m = pfManager { + lightPF.state = m.allRunning ? .green : .red + lightPF.toolTip = m.allRunning ? "kubectl port-forwards: running" : "kubectl port-forwards: not all running" + } else { + lightPF.state = .red + lightPF.toolTip = "kubectl port-forwards: disabled" + } + // Timestamp label let now = Date() timestampLabel.stringValue = timeFormatter.string(from: now)