mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 16:54:32 +00:00
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)
This commit is contained in:
parent
565d91571b
commit
b2cbe72c4a
@ -22,6 +22,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// Splash screen removed — keep no reference
|
||||
private var appMenuToggleStatusBarItem: NSMenuItem?
|
||||
private var pfManager: PortForwardManager?
|
||||
private var preferencesWindowController: PreferencesWindowController?
|
||||
|
||||
// We keep the app in one of two simple modes.
|
||||
// - statusBar: shows the thin overlay near the macOS menu bar
|
||||
@ -116,6 +117,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// Build application menu (shown when activation policy is .regular)
|
||||
setupApplicationMenu()
|
||||
|
||||
// Observe config changes: refresh status on save
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(menuRefresh), name: Config.didChangeNotification, object: nil)
|
||||
|
||||
// Start in Application Window mode by default
|
||||
// Ensure main window is positioned at the left and visible immediately
|
||||
if let mainWin = mainWindowController.window { MainWindowController.positionWindowAtLeftEdge(mainWin) }
|
||||
@ -223,6 +227,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
appMenu.addItem(toggle)
|
||||
self.appMenuToggleStatusBarItem = toggle
|
||||
|
||||
// Preferences…
|
||||
let prefs = NSMenuItem(title: "Preferences…", action: #selector(menuPreferences), keyEquivalent: ",")
|
||||
prefs.keyEquivalentModifierMask = [.command]
|
||||
prefs.target = self
|
||||
appMenu.addItem(prefs)
|
||||
|
||||
appMenu.addItem(NSMenuItem.separator())
|
||||
|
||||
// Reset Port Forwards (Dev)
|
||||
@ -259,6 +269,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
@objc private func menuToggleMode() { toggleMode() }
|
||||
@objc private func menuRefresh() { refreshNow() }
|
||||
@objc private func menuResetPortForwards() { pfManager?.reset() }
|
||||
@objc private func menuPreferences() {
|
||||
if preferencesWindowController == nil {
|
||||
preferencesWindowController = PreferencesWindowController()
|
||||
}
|
||||
preferencesWindowController?.showWindow(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
// Invoked by overlay maximize button
|
||||
@objc private func showMainWindowRequested() {
|
||||
|
||||
@ -6,17 +6,27 @@ final class Config {
|
||||
static let shared = Config()
|
||||
|
||||
private var props: [String: String] = [:]
|
||||
static let didChangeNotification = Notification.Name("Config.didChange")
|
||||
|
||||
struct KubernetesEndpoint: Equatable, Codable {
|
||||
var host: String
|
||||
var port: Int
|
||||
}
|
||||
struct ServiceEndpoint: Equatable, Codable {
|
||||
var name: String
|
||||
var host: String
|
||||
var port: Int
|
||||
}
|
||||
struct PortMapping: Equatable, Codable {
|
||||
var service: String
|
||||
var namespace: String
|
||||
var exposePort: Int
|
||||
var internalPort: Int
|
||||
}
|
||||
|
||||
private init() {
|
||||
// Defaults
|
||||
props = [
|
||||
"svc.host": "svc.prole.org",
|
||||
"svc.port": "443",
|
||||
"k3s.retropie.host": "retropie.prole.org",
|
||||
"k3s.retropie.port": "6443",
|
||||
"k3d.local.host": "localhost",
|
||||
"k3d.local.port": "6443"
|
||||
]
|
||||
// Defaults kept minimal; lists below will ensure sane defaults
|
||||
props = [:]
|
||||
|
||||
// Load bundled defaults if present
|
||||
if let url = Bundle.main.url(forResource: "prole", withExtension: "properties") {
|
||||
@ -54,15 +64,96 @@ final class Config {
|
||||
func string(_ key: String, default def: String) -> String { props[key] ?? def }
|
||||
func int(_ key: String, default def: Int) -> Int { Int(props[key] ?? "") ?? def }
|
||||
|
||||
// Convenience accessors used by ServiceChecker/StatusView
|
||||
var svcHost: String { string("svc.host", default: "svc.prole.org") }
|
||||
var svcPort: Int { int("svc.port", default: 443) }
|
||||
// MARK: - New structured configuration
|
||||
// Kubernetes endpoints list (hostname + port). Defaults to 1 entry as requested.
|
||||
var kubernetes: [KubernetesEndpoint] {
|
||||
get {
|
||||
let items = loadIndexed(prefix: "kube") { idx in
|
||||
if let host = props["kube.\(idx).host"], !host.isEmpty {
|
||||
let port = Int(props["kube.\(idx).port"] ?? "") ?? 6443
|
||||
return KubernetesEndpoint(host: host, port: port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !items.isEmpty { return items }
|
||||
// Defaults
|
||||
return [KubernetesEndpoint(host: "retropie.prole.org", port: 6443)]
|
||||
}
|
||||
set {
|
||||
clearIndexed(prefix: "kube")
|
||||
for (i, it) in newValue.enumerated() {
|
||||
let idx = i + 1
|
||||
props["kube.\(idx).host"] = it.host
|
||||
props["kube.\(idx).port"] = String(it.port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var retropieHost: String { string("k3s.retropie.host", default: "retropie.prole.org") }
|
||||
var retropiePort: Int { int("k3s.retropie.port", default: 6443) }
|
||||
// Services list (name + hostname + port). Defaults: k3d + svc.prole.org
|
||||
var services: [ServiceEndpoint] {
|
||||
get {
|
||||
let items: [ServiceEndpoint] = loadIndexed(prefix: "svc") { idx in
|
||||
guard let name = props["svc.\(idx).name"], !name.isEmpty else { return nil }
|
||||
let host = props["svc.\(idx).host"] ?? name
|
||||
let port = Int(props["svc.\(idx).port"] ?? "") ?? 443
|
||||
return ServiceEndpoint(name: name, host: host, port: port)
|
||||
}
|
||||
if !items.isEmpty { return items }
|
||||
return [
|
||||
ServiceEndpoint(name: "k3d", host: "localhost", port: 6443),
|
||||
ServiceEndpoint(name: "svc.prole.org", host: "svc.prole.org", port: 443)
|
||||
]
|
||||
}
|
||||
set {
|
||||
clearIndexed(prefix: "svc")
|
||||
for (i, it) in newValue.enumerated() {
|
||||
let idx = i + 1
|
||||
props["svc.\(idx).name"] = it.name
|
||||
props["svc.\(idx).host"] = it.host
|
||||
props["svc.\(idx).port"] = String(it.port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var localHost: String { string("k3d.local.host", default: "localhost") }
|
||||
var localPort: Int { int("k3d.local.port", default: 6443) }
|
||||
// Port mappings (service name, namespace, expose, internal)
|
||||
var portMappings: [PortMapping] {
|
||||
get {
|
||||
let items: [PortMapping] = loadIndexed(prefix: "port") { idx in
|
||||
guard let svc = props["port.\(idx).service"], !svc.isEmpty else { return nil }
|
||||
let ns = props["port.\(idx).namespace"] ?? "default"
|
||||
let expose = Int(props["port.\(idx).expose"] ?? "") ?? 0
|
||||
let internalP = Int(props["port.\(idx).internal"] ?? "") ?? 0
|
||||
return PortMapping(service: svc, namespace: ns, exposePort: expose, internalPort: internalP)
|
||||
}
|
||||
if !items.isEmpty { return items }
|
||||
return [
|
||||
PortMapping(service: "svc/prometheus-community-kube-prometheus", namespace: "default", exposePort: 9090, internalPort: 9090),
|
||||
PortMapping(service: "svc/kubernetes-dashboard-kong-proxy", namespace: "kubernetes-dashboard", exposePort: 8443, internalPort: 443),
|
||||
PortMapping(service: "svc/prole-db-rw", namespace: "default", exposePort: 5432, internalPort: 5432),
|
||||
PortMapping(service: "svc/prometheus-community-grafana", namespace: "default", exposePort: 3000, internalPort: 80)
|
||||
]
|
||||
}
|
||||
set {
|
||||
clearIndexed(prefix: "port")
|
||||
for (i, it) in newValue.enumerated() {
|
||||
let idx = i + 1
|
||||
props["port.\(idx).service"] = it.service
|
||||
props["port.\(idx).namespace"] = it.namespace
|
||||
props["port.\(idx).expose"] = String(it.exposePort)
|
||||
props["port.\(idx).internal"] = String(it.internalPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience computed values used by Status/Checker
|
||||
var primaryService: ServiceEndpoint? {
|
||||
// Prefer "svc.prole.org" entry, else first
|
||||
return services.first(where: { $0.name == "svc.prole.org" }) ?? services.first
|
||||
}
|
||||
var localService: ServiceEndpoint? {
|
||||
return services.first(where: { $0.name.lowercased() == "k3d" })
|
||||
}
|
||||
var primaryKube: KubernetesEndpoint? { kubernetes.first }
|
||||
|
||||
// UI background config was removed along with the splash screen.
|
||||
|
||||
@ -132,4 +223,46 @@ final class Config {
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
// MARK: - Save & helpers
|
||||
func save() {
|
||||
// Ensure Application Support/Prole exists
|
||||
let fm = FileManager.default
|
||||
guard let appSupport = try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) else { return }
|
||||
let dir = appSupport.appendingPathComponent("Prole", isDirectory: true)
|
||||
if !fm.fileExists(atPath: dir.path) {
|
||||
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
}
|
||||
let url = dir.appendingPathComponent("prole.properties")
|
||||
var lines: [String] = []
|
||||
let sortedKeys = props.keys.sorted()
|
||||
for k in sortedKeys {
|
||||
if let v = props[k] { lines.append("\(k)=\(v)") }
|
||||
}
|
||||
let text = lines.joined(separator: "\n") + "\n"
|
||||
try? text.data(using: .utf8)?.write(to: url)
|
||||
NotificationCenter.default.post(name: Config.didChangeNotification, object: nil)
|
||||
}
|
||||
|
||||
// Collect 1..N until a gap of 3 is found
|
||||
private func loadIndexed<T>(prefix: String, map: (Int) -> T?) -> [T] {
|
||||
var items: [T] = []
|
||||
var idx = 1
|
||||
var gaps = 0
|
||||
while gaps < 3 {
|
||||
if let v = map(idx) {
|
||||
items.append(v)
|
||||
gaps = 0
|
||||
} else {
|
||||
gaps += 1
|
||||
}
|
||||
idx += 1
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private func clearIndexed(prefix: String) {
|
||||
let keys = props.keys.filter { $0.hasPrefix("\(prefix).") }
|
||||
for k in keys { props.removeValue(forKey: k) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -179,11 +179,16 @@ final class PortForwardManager: @unchecked Sendable {
|
||||
let task = Task.detached(priority: .utility) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let p = self.makeSimpleBashProcess(command: body, index: idx)
|
||||
p.terminationHandler = { [weak self] _ in
|
||||
p.terminationHandler = { [weak self] proc in
|
||||
guard let self = self else { return }
|
||||
// Capture the terminationStatus from the actual Process instance
|
||||
// provided by the handler to avoid races with self.processes[idx]
|
||||
// potentially pointing at a different/non-launched Process.
|
||||
let status = proc.terminationStatus
|
||||
let when = Date()
|
||||
self.queue.async {
|
||||
if idx < self.lastExitCodes.count { self.lastExitCodes[idx] = self.processes[safe: idx]?.terminationStatus ?? self.lastExitCodes[idx] }
|
||||
if idx < self.lastExitTimes.count { self.lastExitTimes[idx] = Date() }
|
||||
if idx < self.lastExitCodes.count { self.lastExitCodes[idx] = status }
|
||||
if idx < self.lastExitTimes.count { self.lastExitTimes[idx] = when }
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: PortForwardManager.statusDidChangeNotification, object: self)
|
||||
}
|
||||
|
||||
@ -8,6 +8,9 @@ final class ServiceChecker {
|
||||
|
||||
private let queue = DispatchQueue(label: "prole.status.checker")
|
||||
private var timer: DispatchSourceTimer?
|
||||
private var isRunning: Bool = false
|
||||
private var lastRefreshAt: Date = .distantPast
|
||||
private let refreshInterval: TimeInterval = 30
|
||||
|
||||
// Public reachability flags
|
||||
private(set) var svcReachable = false
|
||||
@ -27,11 +30,27 @@ final class ServiceChecker {
|
||||
private(set) var piError: String? = nil
|
||||
private(set) var localError: String? = nil
|
||||
|
||||
init() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(forceRefresh), name: Self.forceRefreshNotification, object: nil)
|
||||
// Generic per-endpoint state so UI can query dynamically from Preferences
|
||||
struct EndpointState: Equatable {
|
||||
var reachable: Bool
|
||||
var latencyMs: Int
|
||||
var error: String?
|
||||
var lastChecked: Date
|
||||
}
|
||||
|
||||
func start(interval: TimeInterval = 15) {
|
||||
// Cached states (by logical keys)
|
||||
// services: key = service name (Config.ServiceEndpoint.name)
|
||||
// kubes: key = host:port string
|
||||
private(set) var serviceStates: [String: EndpointState] = [:]
|
||||
private(set) var kubeStates: [String: EndpointState] = [:]
|
||||
|
||||
init() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(forceRefresh), name: Self.forceRefreshNotification, object: nil)
|
||||
// When configuration changes, invalidate cache and reload immediately
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(configDidChange), name: Config.didChangeNotification, object: nil)
|
||||
}
|
||||
|
||||
func start(interval: TimeInterval = 30) {
|
||||
timer?.cancel()
|
||||
let t = DispatchSource.makeTimerSource(queue: queue)
|
||||
t.schedule(deadline: .now(), repeating: interval)
|
||||
@ -43,25 +62,89 @@ final class ServiceChecker {
|
||||
|
||||
@objc private func forceRefresh() { queue.async { self.refreshAll() } }
|
||||
|
||||
@objc private func configDidChange() {
|
||||
queue.async {
|
||||
// Invalidate freshness so the next refresh runs immediately
|
||||
self.lastRefreshAt = .distantPast
|
||||
// Clear caches so UI doesn't briefly show stale dynamic entries
|
||||
self.serviceStates.removeAll()
|
||||
self.kubeStates.removeAll()
|
||||
self.refreshAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshAll() {
|
||||
// Ensure we don't overlap and we don't rerun faster than every 30s
|
||||
if isRunning {
|
||||
dlog("ServiceChecker: skip — previous refresh still running")
|
||||
return
|
||||
}
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastRefreshAt) < refreshInterval {
|
||||
dlog("ServiceChecker: skip — cache still fresh (< \(Int(refreshInterval))s)")
|
||||
return
|
||||
}
|
||||
isRunning = true
|
||||
dlog("ServiceChecker: begin refresh round")
|
||||
let group = DispatchGroup()
|
||||
|
||||
// clear previous errors before a new round
|
||||
// clear previous errors before a new round (legacy fields)
|
||||
svcError = nil; raspberryError = nil; piError = nil; localError = nil
|
||||
|
||||
let cfg = Config.shared
|
||||
group.enter(); tcpPing(host: cfg.svcHost, port: UInt16(cfg.svcPort)) { [weak self] ok, ms, err in
|
||||
self?.svcReachable = ok; self?.svcLatency = ms; self?.svcError = err; group.leave()
|
||||
// Iterate Services from Preferences
|
||||
for svc in cfg.services {
|
||||
group.enter()
|
||||
tcpPing(host: svc.host, port: UInt16(svc.port)) { [weak self] ok, ms, err in
|
||||
guard let self = self else { group.leave(); return }
|
||||
let key = svc.name
|
||||
let st = EndpointState(reachable: ok, latencyMs: ms, error: err, lastChecked: Date())
|
||||
self.serviceStates[key] = st
|
||||
// Update legacy convenience fields for UI compatibility
|
||||
if let primary = cfg.primaryService, primary.name == svc.name {
|
||||
self.svcReachable = ok
|
||||
self.svcLatency = ms
|
||||
self.svcError = err
|
||||
}
|
||||
if let local = cfg.localService, local.name == svc.name {
|
||||
self.localReachable = ok
|
||||
self.localLatency = ms
|
||||
self.localError = err
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
group.enter(); tcpPing(host: cfg.retropieHost, port: UInt16(cfg.retropiePort)) { [weak self] ok, ms, err in
|
||||
self?.raspberryReachable = ok; self?.raspberryLatency = ms; self?.raspberryError = err; group.leave()
|
||||
|
||||
// Iterate Kubernetes endpoints from Preferences
|
||||
let kubes = cfg.kubernetes
|
||||
if kubes.isEmpty {
|
||||
raspberryReachable = false; raspberryLatency = -1; raspberryError = "no kubernetes endpoints configured"
|
||||
piReachable = false; piLatency = -1; piError = nil
|
||||
}
|
||||
group.enter(); tcpPing(host: cfg.localHost, port: UInt16(cfg.localPort)) { [weak self] ok, ms, err in
|
||||
self?.localReachable = ok; self?.localLatency = ms; self?.localError = err; group.leave()
|
||||
for (idx, k) in kubes.enumerated() {
|
||||
group.enter()
|
||||
tcpPing(host: k.host, port: UInt16(k.port)) { [weak self] ok, ms, err in
|
||||
guard let self = self else { group.leave(); return }
|
||||
let key = "\(k.host):\(k.port)"
|
||||
let st = EndpointState(reachable: ok, latencyMs: ms, error: err, lastChecked: Date())
|
||||
self.kubeStates[key] = st
|
||||
// Maintain two-LED aggregate legacy fields for first two entries
|
||||
if idx == 0 {
|
||||
self.raspberryReachable = ok
|
||||
self.raspberryLatency = ms
|
||||
self.raspberryError = err
|
||||
} else if idx == 1 {
|
||||
self.piReachable = ok
|
||||
self.piLatency = ms
|
||||
self.piError = err
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: .main) {
|
||||
self.lastRefreshAt = Date()
|
||||
self.isRunning = false
|
||||
dlog("ServiceChecker: refresh round complete → posting statusDidChangeNotification")
|
||||
NotificationCenter.default.post(name: Self.statusDidChangeNotification, object: self)
|
||||
}
|
||||
@ -127,15 +210,16 @@ final class ServiceChecker {
|
||||
|
||||
// Tooltips
|
||||
func tooltipForSvc() -> String {
|
||||
let host = Config.shared.svcHost
|
||||
let port = Config.shared.svcPort
|
||||
if svcReachable { return "\(host):\(port) — reachable (\(svcLatency) ms)" }
|
||||
var s = "\(host):\(port) — unreachable"
|
||||
guard let svc = Config.shared.primaryService else { return "No primary service configured" }
|
||||
if svcReachable { return "\(svc.host):\(svc.port) — reachable (\(svcLatency) ms)" }
|
||||
var s = "\(svc.host):\(svc.port) — unreachable"
|
||||
if let e = svcError { s += "\nError: \(e)" }
|
||||
return s
|
||||
}
|
||||
func tooltipForAggregateK3s() -> String {
|
||||
let rHost = Config.shared.retropieHost
|
||||
let kubes = Config.shared.kubernetes
|
||||
let rHost = kubes.first?.host ?? "-"
|
||||
let pHost = kubes.count > 1 ? kubes[1].host : "-"
|
||||
var r = raspberryReachable ? "\(rHost) ✓ (\(raspberryLatency) ms)" : "\(rHost) ✗"
|
||||
var p = piReachable ? "\(pHost) ✓ (\(piLatency) ms)" : "\(pHost) ✗"
|
||||
if !raspberryReachable, let e = raspberryError { r += " — \(e)" }
|
||||
@ -149,10 +233,9 @@ final class ServiceChecker {
|
||||
return "k3s aggregate — \(overall)\n\(r)\n\(p)"
|
||||
}
|
||||
func tooltipForLocal() -> String {
|
||||
let host = Config.shared.localHost
|
||||
let port = Config.shared.localPort
|
||||
if localReachable { return "k3d \(host):\(port) — reachable (\(localLatency) ms)" }
|
||||
var s = "k3d \(host):\(port) — unreachable"
|
||||
guard let local = Config.shared.localService else { return "No local service configured" }
|
||||
if localReachable { return "k3d \(local.host):\(local.port) — reachable (\(localLatency) ms)" }
|
||||
var s = "k3d \(local.host):\(local.port) — unreachable"
|
||||
if let e = localError { s += "\nError: \(e)" }
|
||||
return s
|
||||
}
|
||||
|
||||
@ -139,9 +139,26 @@ final class StatusView: NSView {
|
||||
func aggregateStatusSummary() -> String {
|
||||
guard let c = checker else { return "No status" }
|
||||
let cfg = Config.shared
|
||||
let svc = c.svcReachable ? "\(cfg.svcHost):\(cfg.svcPort) ✓" : "\(cfg.svcHost):\(cfg.svcPort) ✗"
|
||||
let agg = "k3s: \(cfg.retropieHost) \(c.raspberryReachable ? "✓" : "✗") "
|
||||
let loc = c.localReachable ? "k3d \(cfg.localHost):\(cfg.localPort) ✓" : "k3d \(cfg.localHost):\(cfg.localPort) ✗"
|
||||
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: " • ")
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user