193 lines
6.9 KiB
Swift
193 lines
6.9 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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?
|
|
let generatedAt: String?
|
|
let overall: HealthStatus
|
|
let counts: HealthCounts
|
|
let groups: [HealthGroup]
|
|
let checks: [HealthCheck]
|
|
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
|
|
}
|
|
|
|
@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()
|
|
private var timer: Timer?
|
|
private var everLoaded = false
|
|
|
|
init() {
|
|
configureTimer()
|
|
Task { await refresh() }
|
|
}
|
|
|
|
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() }
|
|
}
|
|
}
|
|
|
|
func refresh() async {
|
|
guard !isRefreshing else { return }
|
|
isRefreshing = true
|
|
defer { isRefreshing = false; lastChecked = Date() }
|
|
let previous = report?.overall
|
|
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")
|
|
}
|
|
everLoaded = true
|
|
} catch {
|
|
lastError = error.localizedDescription
|
|
status = .fail
|
|
if settings.notifications && everLoaded && previous != .fail {
|
|
notify(title: "Pegasus Unreachable", message: error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
guard allowed else { return }
|
|
let content = UNMutableNotificationContent()
|
|
content.title = title
|
|
content.body = message
|
|
center.add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil))
|
|
}
|
|
}
|
|
}
|
|
|
|
enum SSHError: LocalizedError {
|
|
case invalidSettings, timeout, 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 .process(let text): text
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
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]
|
|
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 {
|
|
try? await Task.sleep(for: .seconds(90))
|
|
if process.isRunning { process.terminate() }
|
|
}
|
|
let bytes = await stdout.value
|
|
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)
|
|
}
|
|
return bytes
|
|
}.value
|
|
}
|
|
}
|