prole/prole-app/Sources/StatusView.swift
chrisfu b2cbe72c4a ServiceChecker: background refresh + Preferences reload; PF crash fix; Prefs Save commit inputs
- ServiceChecker now runs as a background timer task (30s), skips overlapping runs, and caches per-endpoint state from Preferences (services + kubernetes). Legacy UI flags are kept in sync for StatusView/Overlay.
- Auto-reload on Preferences save: ServiceChecker listens to Config.didChange, clears caches, invalidates freshness, and triggers an immediate refresh.
- Preferences: ensure in-progress text edits are committed before saving (endEditing) for Kubernetes, Services, and Ports tabs.
- PortForwardManager: fix crash in termination handler by reading terminationStatus from the provided Process instance, then updating state on the manager queue.
- Minor: keep existing UI wiring; logging intact for diagnostics.

TODO: remove (list unused code paths)
- Config.primaryKube (unused in prole-app)
- Config.string(_:, default:) (unused in prole-app)
- Config.int(_:, default:) (unused in prole-app)
- PortForwardManager.parsePFCommand tuple parts `modeBackground` and `keepAlive` (never read by callers)
- Config.pfModeBackground and Config.pfKeepAlive (currently ineffective because their values aren’t used downstream)
2025-12-09 18:16:14 -08:00

358 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import AppKit
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
tf.alignment = .left
tf.font = NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular)
tf.setContentHuggingPriority(.required, for: .horizontal)
tf.setContentCompressionResistancePriority(.required, for: .horizontal)
return tf
}()
private let marquee = MarqueeView()
private let maximizeButton: NSButton = {
let b = NSButton(title: "", target: nil, action: nil)
b.bezelStyle = .texturedRounded
b.toolTip = "Show Main Window"
b.setContentHuggingPriority(.required, for: .horizontal)
b.setContentCompressionResistancePriority(.required, for: .horizontal)
return b
}()
private var checker: ServiceChecker?
private let timeFormatter: DateFormatter = {
let df = DateFormatter()
df.locale = .current
df.timeZone = .current
// Include full date and timezone offset (e.g., 2025-11-17 20:31:05 -08:00)
df.dateFormat = "yyyy-MM-dd HH:mm:ss ZZZZZ"
return df
}()
private let fullFormatter: DateFormatter = {
let df = DateFormatter()
df.locale = .current
df.timeZone = .current
df.dateStyle = .medium
df.timeStyle = .medium
return df
}()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
translatesAutoresizingMaskIntoConstraints = false
let stack = NSStackView(views: [light1, light2, light3, lightPF, timestampLabel, marquee, maximizeButton])
stack.orientation = .horizontal
stack.alignment = .centerY
stack.distribution = .equalSpacing
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
NSLayoutConstraint.activate([
stack.centerXAnchor.constraint(equalTo: centerXAnchor),
stack.centerYAnchor.constraint(equalTo: centerYAnchor)
])
// Wire button actions
maximizeButton.target = self
maximizeButton.action = #selector(didTapMaximize)
// Tracking for hover tooltips
addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect], owner: self, userInfo: nil))
// Configure marquee width to be ~64 monospace characters
configureMarqueeWidth()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func hitTest(_ point: NSPoint) -> NSView? {
// 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
light1.state = c.svcReachable ? .green : .red
light1.toolTip = c.tooltipForSvc()
// Light 2: aggregate raspberry + pi
let count = (c.raspberryReachable ? 1 : 0) + (c.piReachable ? 1 : 0)
light2.state = count == 2 ? .green : (count == 1 ? .yellow : .red)
light2.toolTip = c.tooltipForAggregateK3s()
// Light 3: localhost k3d
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)
timestampLabel.toolTip = "Last updated: " + fullFormatter.string(from: now)
// Update marquee text with status summary and ensure it scrolls
let msg = aggregateStatusSummary()
marquee.setText(msg)
marquee.toolTip = msg
needsDisplay = true
}
func aggregateStatusSummary() -> String {
guard let c = checker else { return "No status" }
let cfg = Config.shared
let svcText: String = {
if let svc = cfg.primaryService {
return c.svcReachable ? "\(svc.host):\(svc.port)" : "\(svc.host):\(svc.port)"
}
return "svc (unset) ✗"
}()
let aggText: String = {
let kube = cfg.kubernetes.first
let name = kube?.host ?? "k3s"
return "k3s: \(name) \(c.raspberryReachable ? "" : "")"
}()
let locText: String = {
if let local = cfg.localService {
return c.localReachable ? "k3d \(local.host):\(local.port)" : "k3d \(local.host):\(local.port)"
}
return "k3d (unset) ✗"
}()
let svc = svcText
let agg = aggText
let loc = locText
return [svc, agg, loc].joined(separator: "")
}
// MARK: - Button actions
@objc private func didTapMaximize() {
// Explicitly request the main window to show (dont toggle modes implicitly)
NotificationCenter.default.post(name: .showMainWindow, object: nil)
}
}
// MARK: - MarqueeView
final class MarqueeView: NSView {
private let scrollClip = NSView()
private let label1 = NSTextField(labelWithString: "")
private let label2 = NSTextField(labelWithString: "")
private var timer: Timer?
// Slow down by one third: 60 40 pts/sec
private var speedPointsPerSec: CGFloat = 40.0 // horizontal speed
private var lastTick: TimeInterval = CACurrentMediaTime()
private var currentText: String = ""
// Public width constraint can be set by container; we keep hugging low so it expands to fixed width
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
translatesAutoresizingMaskIntoConstraints = false
wantsLayer = false
scrollClip.wantsLayer = false
scrollClip.translatesAutoresizingMaskIntoConstraints = false
addSubview(scrollClip)
// Configure labels
for lbl in [label1, label2] {
lbl.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular)
lbl.textColor = .secondaryLabelColor
lbl.alignment = .left
lbl.backgroundColor = .clear
lbl.isBezeled = false
lbl.drawsBackground = false
lbl.lineBreakMode = .byClipping
lbl.translatesAutoresizingMaskIntoConstraints = true // we will manage frames manually
}
scrollClip.addSubview(label1)
scrollClip.addSubview(label2)
// Clip to bounds so text scrolls inside
wantsLayer = true
layer?.masksToBounds = true
setContentHuggingPriority(.defaultLow, for: .horizontal)
setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
NSLayoutConstraint.activate([
scrollClip.leadingAnchor.constraint(equalTo: leadingAnchor),
scrollClip.trailingAnchor.constraint(equalTo: trailingAnchor),
scrollClip.topAnchor.constraint(equalTo: topAnchor),
scrollClip.bottomAnchor.constraint(equalTo: bottomAnchor)
])
start()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window != nil { start() } else { stop() }
}
override func layout() {
super.layout()
layoutLabelsIfNeeded()
}
override func hitTest(_ point: NSPoint) -> NSView? { nil } // click-through
func setText(_ text: String) {
if text == currentText { return }
currentText = text
label1.stringValue = text + String(repeating: " ", count: 8)
label2.stringValue = label1.stringValue
layoutLabelsIfNeeded(resetOffset: true)
}
private func layoutLabelsIfNeeded(resetOffset: Bool = false) {
let h = bounds.height
let y = (h - intrinsicLineHeight())/2.0
let size = label1.intrinsicContentSize
let w = size.width
// Place labels back-to-back for seamless loop
if resetOffset {
label1.frame = NSRect(x: 0, y: y, width: w, height: size.height)
label2.frame = NSRect(x: w, y: y, width: w, height: size.height)
} else if label1.frame.size.width == 0 || label2.frame.size.width == 0 {
label1.frame = NSRect(x: label1.frame.origin.x, y: y, width: w, height: size.height)
label2.frame = NSRect(x: label2.frame.origin.x, y: y, width: w, height: size.height)
} else {
// Keep vertically centered
label1.frame.origin.y = y
label2.frame.origin.y = y
}
}
private func intrinsicLineHeight() -> CGFloat {
let f = label1.font ?? NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular)
return ceil(f.ascender - f.descender)
}
private func tick() {
let now = CACurrentMediaTime()
let dt = now - lastTick
lastTick = now
let dx = CGFloat(dt) * speedPointsPerSec
// Move both labels left by dx
label1.frame.origin.x -= dx
label2.frame.origin.x -= dx
// When a label fully leaves on the left, move it to the right of the other
if label1.frame.maxX <= 0 {
label1.frame.origin.x = label2.frame.maxX
}
if label2.frame.maxX <= 0 {
label2.frame.origin.x = label1.frame.maxX
}
}
func start() {
stop()
lastTick = CACurrentMediaTime()
timer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
self?.tick()
}
RunLoop.main.add(timer!, forMode: .common)
}
func stop() {
timer?.invalidate()
timer = nil
}
deinit { stop() }
}
// Utility to compute width for N monospace characters
private func widthForMonospaceCharacters(_ count: Int, font: NSFont) -> CGFloat {
let sample = String(repeating: "0", count: max(1, count))
let attrs: [NSAttributedString.Key: Any] = [.font: font]
let w = (sample as NSString).size(withAttributes: attrs).width
return ceil(w)
}
private extension StatusView {
func configureMarqueeWidth() {
// Match font with timestamp for visual cohesion
let font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular)
marquee.heightAnchor.constraint(equalToConstant: ceil(font.capHeight * 1.8)).isActive = true
let width = widthForMonospaceCharacters(64, font: font)
let wConstraint = marquee.widthAnchor.constraint(equalToConstant: width)
wConstraint.priority = .required
wConstraint.isActive = true
}
}
// MARK: - Notification bridge
extension Notification.Name {
static let showMainWindow = Notification.Name("ProleStatus.showMainWindow")
static let toggleMode = Notification.Name("ProleStatus.toggleMode")
}
final class TrafficLight: NSView {
enum State { case green, yellow, red }
var state: State = .red { didSet { needsDisplay = true } }
override var intrinsicContentSize: NSSize { NSSize(width: 14, height: 14) }
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
let rect = bounds.insetBy(dx: 1, dy: 1)
let path = NSBezierPath(ovalIn: rect)
let color: NSColor
switch state {
case .green: color = NSColor.systemGreen
case .yellow: color = NSColor.systemYellow
case .red: color = NSColor.systemRed
}
color.setFill()
path.fill()
// subtle ring for better contrast against menu material
NSColor.black.withAlphaComponent(0.12).setStroke()
path.lineWidth = 1
path.stroke()
}
}