This commit is contained in:
Mark Gregory
2026-10-21 14:39:25 +01:00
parent f76eec0b70
commit 7692274602
63 changed files with 582 additions and 722 deletions
+142 -104
View File
@@ -4,53 +4,13 @@ import UserNotifications
enum HealthStatus: String, Codable {
case ok, warn, fail
var title: String {
switch self {
case .ok: "Healthy"
case .warn: "Warning"
case .fail: "Critical"
}
}
var symbol: String {
switch self {
case .ok: "checkmark.circle.fill"
case .warn: "exclamationmark.triangle.fill"
case .fail: "xmark.circle.fill"
}
}
var color: Color {
switch self {
case .ok: .green
case .warn: .orange
case .fail: .red
}
}
var title: String { switch self { case .ok: "Healthy"; case .warn: "Warning"; case .fail: "Critical" } }
var symbol: String { switch self { case .ok: "checkmark.circle.fill"; case .warn: "exclamationmark.triangle.fill"; case .fail: "xmark.circle.fill" } }
var color: Color { switch self { case .ok: .green; case .warn: .orange; case .fail: .red } }
}
struct HealthCounts: Codable {
let ok: Int
let warn: Int
let fail: Int
}
struct HealthGroup: Codable, Identifiable {
let name: String
let status: HealthStatus
let missing: [String]
var id: String { name }
}
struct HealthCheck: Codable, Identifiable {
let name: String
let status: HealthStatus
let detail: String
let section: String
var id: String { "\(section)/\(name)" }
}
struct HealthCounts: Codable { let ok: Int; let warn: Int; let fail: Int }
struct HealthGroup: Codable, Identifiable { let name: String; let status: HealthStatus; let missing: [String]; var id: String { name } }
struct HealthCheck: Codable, Identifiable { let name: String; let status: HealthStatus; let detail: String; let section: String; var id: String { "\(section)/\(name)" } }
struct HealthReport: Codable {
let schema: Int
let host: String?
@@ -62,71 +22,153 @@ struct HealthReport: Codable {
let diagnostics: String?
}
struct SSHSettings {
@AppStorage("server.host") var host = "192.168.1.2"
@AppStorage("server.user") var user = "mag"
@AppStorage("server.port") var port = "22"
@AppStorage("server.keyFile") var keyFile = ""
@AppStorage("monitor.intervalMinutes") var intervalMinutes = 5
@AppStorage("monitor.notifications") var notifications = true
struct ServerProfile: Identifiable, Codable, Equatable {
var id: UUID = UUID()
var name: String = "New Server"
var host: String = ""
var user: String = ""
var port: String = "22"
var keyFile: String = ""
var remoteCommand: String = "/usr/local/bin/pegasus-monitor-health-json"
var enabled: Bool = true
static func legacy() -> ServerProfile {
let defaults = UserDefaults.standard
return ServerProfile(name: "Pegasus",
host: defaults.string(forKey: "server.host") ?? "192.168.1.2",
user: defaults.string(forKey: "server.user") ?? "mag",
port: defaults.string(forKey: "server.port") ?? "22",
keyFile: defaults.string(forKey: "server.keyFile") ?? "",
remoteCommand: "sudo -n /usr/local/bin/pegasus-health-json")
}
}
struct ServerResult {
var report: HealthReport?
var error: String?
var lastChecked: Date?
var refreshing = false
var status: HealthStatus { error != nil ? .fail : (report?.overall ?? .warn) }
}
@MainActor
final class ServerMonitor: ObservableObject {
@Published private(set) var report: HealthReport?
@Published private(set) var status: HealthStatus = .warn
@Published private(set) var isRefreshing = false
@Published private(set) var lastChecked: Date?
@Published private(set) var lastError: String?
@Published var showDiagnostics = false
@Published var showSettings = false
private var settings = SSHSettings()
@Published private(set) var servers: [ServerProfile] = []
@Published private(set) var results: [UUID: ServerResult] = [:]
@Published var selectedID: UUID?
@Published var intervalMinutes: Int { didSet { UserDefaults.standard.set(intervalMinutes, forKey: "monitor.intervalMinutes"); configureTimer() } }
@Published var notifications: Bool { didSet { UserDefaults.standard.set(notifications, forKey: "monitor.notifications") } }
private var timer: Timer?
private var everLoaded = false
private var visited = Set<UUID>()
private let profilesKey = "monitor.serverProfiles.v1"
private let selectedKey = "monitor.selectedServer.v1"
init() {
let defaults = UserDefaults.standard
intervalMinutes = max(1, defaults.object(forKey: "monitor.intervalMinutes") as? Int ?? 5)
notifications = defaults.object(forKey: "monitor.notifications") as? Bool ?? true
if let bytes = defaults.data(forKey: profilesKey), let loaded = try? JSONDecoder().decode([ServerProfile].self, from: bytes) {
servers = loaded
} else {
servers = [.legacy()]
}
if let stored = defaults.string(forKey: selectedKey), let id = UUID(uuidString: stored), servers.contains(where: { $0.id == id }) {
selectedID = id
} else {
selectedID = servers.first?.id
}
configureTimer()
Task { await refresh() }
Task { await refreshAll() }
}
var selected: ServerProfile? { servers.first(where: { $0.id == selectedID }) }
var selectedResult: ServerResult? { selectedID.flatMap { results[$0] } }
var report: HealthReport? { selectedResult?.report }
var status: HealthStatus {
let statuses = servers.filter(\.enabled).map { results[$0.id]?.status ?? .warn }
if statuses.contains(.fail) { return .fail }
if statuses.contains(.warn) { return .warn }
return statuses.isEmpty ? .warn : .ok
}
var aggregate: HealthCounts {
let active = servers.filter(\.enabled)
return HealthCounts(ok: active.reduce(0) { $0 + (results[$1.id]?.report?.counts.ok ?? 0) },
warn: active.reduce(0) { $0 + (results[$1.id]?.report?.counts.warn ?? 0) },
fail: active.reduce(0) { $0 + (results[$1.id]?.report?.counts.fail ?? (results[$1.id]?.error == nil ? 0 : 1)) })
}
var offlineCount: Int { servers.filter { $0.enabled && results[$0.id]?.error != nil }.count }
func select(_ id: UUID) {
guard servers.contains(where: { $0.id == id }) else { return }
selectedID = id
UserDefaults.standard.set(id.uuidString, forKey: selectedKey)
}
func save(_ profile: ServerProfile) {
if let index = servers.firstIndex(where: { $0.id == profile.id }) { servers[index] = profile }
else { servers.append(profile) }
persist()
select(profile.id)
Task { await refresh(id: profile.id) }
}
func remove(_ id: UUID) {
servers.removeAll { $0.id == id }
results.removeValue(forKey: id)
visited.remove(id)
if selectedID == id { selectedID = servers.first?.id }
persist()
}
func move(from source: IndexSet, to destination: Int) {
servers.move(fromOffsets: source, toOffset: destination)
persist()
}
private func persist() {
if let bytes = try? JSONEncoder().encode(servers) { UserDefaults.standard.set(bytes, forKey: profilesKey) }
UserDefaults.standard.set(selectedID?.uuidString, forKey: selectedKey)
}
func configureTimer() {
timer?.invalidate()
let minutes = max(1, settings.intervalMinutes)
timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(minutes * 60), repeats: true) { [weak self] _ in
Task { @MainActor in await self?.refresh() }
timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(max(1, intervalMinutes) * 60), repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in await self?.refreshAll() }
}
}
func refresh() async {
guard !isRefreshing else { return }
isRefreshing = true
defer { isRefreshing = false; lastChecked = Date() }
let previous = report?.overall
func refreshAll() async {
await withTaskGroup(of: Void.self) { group in
for server in servers where server.enabled {
group.addTask { await self.refresh(id: server.id) }
}
}
}
func refreshSelected() async {
if let id = selectedID { await refresh(id: id) }
}
func refresh(id: UUID) async {
guard let server = servers.first(where: { $0.id == id }), server.enabled else { return }
if results[id]?.refreshing == true { return }
var state = results[id] ?? ServerResult()
state.refreshing = true
results[id] = state
let previous = state.status
do {
let data = try await SSHRunner.run(host: settings.host, user: settings.user, port: settings.port, keyFile: settings.keyFile, remoteCommand: "sudo -n /usr/local/bin/pegasus-health-json")
let value = try JSONDecoder().decode(HealthReport.self, from: data)
guard value.schema == 1, value.groups.count == 14 else { throw SSHError.invalidResponse }
report = value
status = value.overall
lastError = nil
if settings.notifications && everLoaded && previous == .ok && value.overall != .ok {
notify(title: "Pegasus \(value.overall.title)", message: "\(value.counts.fail) failures, \(value.counts.warn) warnings")
let data = try await SSHRunner.run(host: server.host, user: server.user, port: server.port,
keyFile: server.keyFile, remoteCommand: server.remoteCommand)
let report = try JSONDecoder().decode(HealthReport.self, from: data)
guard report.schema == 1 else { throw SSHError.invalidResponse }
state.report = report
state.error = nil
if notifications && visited.contains(id) && previous == .ok && report.overall != .ok {
notify(title: "\(server.name): \(report.overall.title)", message: "\(report.counts.fail) failures, \(report.counts.warn) warnings")
}
everLoaded = true
} catch {
lastError = error.localizedDescription
status = .fail
if settings.notifications && everLoaded && previous != .fail {
notify(title: "Pegasus Unreachable", message: error.localizedDescription)
state.error = error.localizedDescription
if notifications && visited.contains(id) && previous != .fail {
notify(title: "\(server.name) unreachable", message: error.localizedDescription)
}
}
state.lastChecked = Date()
state.refreshing = false
results[id] = state
visited.insert(id)
}
func showSystemSettings() {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}
private func notify(title: String, message: String) {
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound]) { allowed, _ in
@@ -140,12 +182,11 @@ final class ServerMonitor: ObservableObject {
}
enum SSHError: LocalizedError {
case invalidSettings, timeout, invalidResponse, process(String)
case invalidSettings, invalidResponse, process(String)
var errorDescription: String? {
switch self {
case .invalidSettings: "Invalid SSH settings. Check host, user and port."
case .timeout: "SSH connection timed out."
case .invalidResponse: "Server returned invalid health JSON. Install the server adapter."
case .invalidSettings: "Invalid SSH settings (host, user, port or command)."
case .invalidResponse: "Invalid health JSON. Install the remote health adapter."
case .process(let text): text
}
}
@@ -154,23 +195,21 @@ enum SSHError: LocalizedError {
enum SSHRunner {
static func run(host: String, user: String, port: String, keyFile: String, remoteCommand: String) async throws -> Data {
guard !host.isEmpty, !user.isEmpty, let number = Int(port), (1...65535).contains(number),
host.range(of: #"^[A-Za-z0-9.:-]+$"#, options: .regularExpression) != nil,
user.range(of: #"^[A-Za-z0-9_-]+$"#, options: .regularExpression) != nil else {
throw SSHError.invalidSettings
}
host.range(of: #"^[A-Za-z0-9._:-]+$"#, options: .regularExpression) != nil,
user.range(of: #"^[A-Za-z0-9_-]+$"#, options: .regularExpression) != nil,
!remoteCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw SSHError.invalidSettings }
return try await Task.detached(priority: .utility) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
var args = ["-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=8", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2", "-p", String(number)]
if !keyFile.isEmpty { args += ["-i", (keyFile as NSString).expandingTildeInPath] }
args += ["--", "\(user)@\(host)", remoteCommand]
args += ["\(user)@\(host)", remoteCommand]
process.arguments = args
let output = Pipe()
let errors = Pipe()
process.standardOutput = output
process.standardError = errors
try process.run()
// Drain both pipes concurrently so verbose output cannot block the child.
let stdout = Task.detached { output.fileHandleForReading.readDataToEndOfFile() }
let stderr = Task.detached { errors.fileHandleForReading.readDataToEndOfFile() }
let timeout = Task.detached {
@@ -181,10 +220,9 @@ enum SSHRunner {
let errorBytes = await stderr.value
process.waitUntilExit()
timeout.cancel()
// Health status exits 1/2 intentionally; JSON remains authoritative.
if bytes.isEmpty {
let errorMessage = String(data: errorBytes, encoding: .utf8) ?? "SSH returned no data"
throw SSHError.process(errorMessage.isEmpty ? "SSH returned no data" : errorMessage)
let message = String(data: errorBytes, encoding: .utf8) ?? "SSH returned no data"
throw SSHError.process(message.isEmpty ? "SSH returned no data" : message)
}
return bytes
}.value