prole/prole-app/Sources/StatusView.swift
chrisfu 197c72dd7a Refactor prole-app and establish temporary release process
- Moved prole-tools-app to prole-app at the project root to make it self-contained for transition to its own repository.
- Created prole-tools-app/dist/ directory to host build artifacts.
- Generated distribution artifacts (Prole Tools.app and Prole Tools.zip) using prole-app/build.sh package.
- Checked in the generated artifacts to prole-tools-app/dist/ (bypassing .gitignore for temporary release process).
Changes Summary
•
Renamed directory prole-tools-app/ to prole-app/.
•
Populated prole-tools-app/dist/ with the latest build output from prole-app/build.sh.
•
Staged all changes, including the forced addition of ignored artifacts in prole-tools-app/dist/.
2026-01-18 15:04:23 -08:00

353 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 light4 = TrafficLight()
private let light5 = TrafficLight()
private let light6 = TrafficLight()
private let marquee = MarqueeView()
private let maximizeButton: NSButton = {
let b = NSButton(title: "", target: nil, action: nil)
b.bezelStyle = .texturedRounded
b.setButtonType(.momentaryPushIn)
b.toolTip = "Show Main Window"
b.isEnabled = true
b.refusesFirstResponder = true // Don't take focus away from other apps
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, light4, light5, light6, 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()
// Ensure button responds to mouse down immediately for better responsiveness in overlays
maximizeButton.sendAction(on: [.leftMouseDown, .leftMouseUp])
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func hitTest(_ point: NSPoint) -> NSView? {
// Allow interaction with the maximize button.
let pointInButton = convert(point, to: maximizeButton)
if maximizeButton.bounds.contains(pointInButton) {
return maximizeButton
}
// Allow interaction with traffic lights for tooltips
for light in [light1, light2, light3, light4, light5, light6] {
let pointInLight = convert(point, to: light)
if light.bounds.contains(pointInLight) {
return light
}
}
return nil
}
func bindTo(serviceChecker: ServiceChecker) {
self.checker = serviceChecker
NotificationCenter.default.addObserver(self, selector: #selector(updateLights), name: ServiceChecker.statusDidChangeNotification, object: serviceChecker)
updateLights()
}
@objc private func updateLights() {
guard let c = checker else { return }
let services = Config.shared.services
// Light 1: K3D
if let s = services.first(where: { $0.name.uppercased() == "K3D" }) {
let state = c.serviceStates[s.name]
light1.state = (state?.reachable ?? false) ? .green : .red
light1.toolTip = c.tooltipFor(name: s.name)
}
// Light 2: Prometheus
if let s = services.first(where: { $0.name.uppercased() == "PROMETHEUS" }) {
let state = c.serviceStates[s.name]
light2.state = (state?.reachable ?? false) ? .green : .red
light2.toolTip = c.tooltipFor(name: s.name)
}
// Light 3: Grafana
if let s = services.first(where: { $0.name.uppercased() == "GRAFANA" }) {
let state = c.serviceStates[s.name]
light3.state = (state?.reachable ?? false) ? .green : .red
light3.toolTip = c.tooltipFor(name: s.name)
}
// Light 4: OpenBAO
if let s = services.first(where: { $0.name.uppercased() == "OPENBAO" }) {
let state = c.serviceStates[s.name]
light4.state = (state?.reachable ?? false) ? .green : .red
light4.toolTip = c.tooltipFor(name: s.name)
}
// Light 5: Ollama
if let s = services.first(where: { $0.name.uppercased() == "OLLAMA" }) {
let state = c.serviceStates[s.name]
light5.state = (state?.reachable ?? false) ? .green : .red
light5.toolTip = c.tooltipFor(name: s.name)
}
// Light 6: PostgreSQL
if let s = services.first(where: { $0.name.uppercased() == "POSTGRESQL" }) {
let state = c.serviceStates[s.name]
light6.state = (state?.reachable ?? false) ? .green : .red
light6.toolTip = c.tooltipFor(name: s.name)
}
// Update marquee text with status summary and ensure it scrolls
// For the scrolling status, use new contents: scroll the output of kubectl cnpg status | head -10
DispatchQueue.global(qos: .utility).async {
let res = PFScriptBridge.cnpgStatus()
let msg = res.out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Waiting for PostgreSQL status..." : res.out.replacingOccurrences(of: "\n", with: "")
DispatchQueue.main.async { [weak self] in
self?.marquee.setText(msg)
self?.marquee.toolTip = msg
self?.needsDisplay = true
}
}
}
// MARK: - Button actions
@objc private func didTapMaximize() {
dlog("StatusView: didTapMaximize button clicked")
// 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: 10, 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: 10, 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: 10, 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()
}
}