prole/prole.app/Sources/StatusView.swift
2025-11-16 21:54:01 -08:00

96 lines
3.4 KiB
Swift

import AppKit
final class StatusView: NSView {
private let light1 = TrafficLight()
private let light2 = TrafficLight()
private let light3 = TrafficLight()
private var checker: ServiceChecker?
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
translatesAutoresizingMaskIntoConstraints = false
let stack = NSStackView(views: [light1, light2, light3])
stack.orientation = .horizontal
stack.alignment = .centerY
stack.distribution = .equalSpacing
stack.spacing = 16
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
NSLayoutConstraint.activate([
stack.centerXAnchor.constraint(equalTo: centerXAnchor),
stack.centerYAnchor.constraint(equalTo: centerYAnchor)
])
// Tracking for hover tooltips
addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect], owner: self, userInfo: nil))
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func hitTest(_ point: NSPoint) -> NSView? {
// Pass all clicks through to apps beneath
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 }
// 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()
needsDisplay = true
}
func aggregateStatusSummary() -> String {
guard let c = checker else { return "No status" }
let svc = c.svcReachable ? "svc.prole.org:443 ✓" : "svc.prole.org:443 ✗"
let agg = "k3s: raspberry \(c.raspberryReachable ? "✓" : "✗"), pi \(c.piReachable ? "✓" : "✗")"
let loc = c.localReachable ? "k3d localhost:6443 ✓" : "k3d localhost:6443 ✗"
return [svc, agg, loc].joined(separator: " • ")
}
}
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()
}
}