mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 12:03:59 +00:00
- 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/.
353 lines
13 KiB
Swift
353 lines
13 KiB
Swift
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 (don’t 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()
|
||
}
|
||
}
|