352 lines
17 KiB
Swift
352 lines
17 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 uptimeSeconds: Int?
|
|
var uptimeLabel: String? {
|
|
guard let uptimeSeconds, uptimeSeconds >= 0 else { return nil }
|
|
let days = uptimeSeconds / 86400
|
|
let hours = (uptimeSeconds % 86400) / 3600
|
|
let minutes = (uptimeSeconds % 3600) / 60
|
|
if days > 0 { return "\(days)d \(hours)h \(minutes)m" }
|
|
if hours > 0 { return "\(hours)h \(minutes)m" }
|
|
return "\(minutes)m"
|
|
}
|
|
let overall: HealthStatus
|
|
let counts: HealthCounts
|
|
let groups: [HealthGroup]
|
|
let checks: [HealthCheck]
|
|
let diagnostics: String?
|
|
let services: [DiscoveredService]?
|
|
}
|
|
|
|
|
|
// Service discovery is optional so legacy Pegasus health JSON stays compatible.
|
|
struct DiscoveredService: Codable, Identifiable, Equatable {
|
|
let name: String
|
|
let state: String
|
|
let detail: String
|
|
var id: String { name }
|
|
var isInstalled: Bool { state != "not_installed" }
|
|
var isHealthy: Bool { state == "running" }
|
|
var symbol: String {
|
|
switch state {
|
|
case "running": "checkmark.circle.fill"
|
|
case "failed", "required_missing": "xmark.circle.fill"
|
|
case "stopped": "exclamationmark.triangle.fill"
|
|
case "disabled": "pause.circle"
|
|
case "not_installed": "minus.circle"
|
|
default: "questionmark.circle"
|
|
}
|
|
}
|
|
var color: Color {
|
|
switch state {
|
|
case "running": .green
|
|
case "failed", "required_missing": .red
|
|
case "stopped": .orange
|
|
default: .secondary
|
|
}
|
|
}
|
|
var title: String {
|
|
switch state {
|
|
case "running": "Running"
|
|
case "failed": "Failed"
|
|
case "stopped": "Stopped"
|
|
case "disabled": "Disabled"
|
|
case "not_installed": "Not installed"
|
|
case "required_missing": "Required — missing"
|
|
default: "Unknown"
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
var requiredServices: [String] = []
|
|
var discoverServices: Bool = true
|
|
|
|
// Preserve profiles saved by 1.1, which did not include service preferences.
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, name, host, user, port, keyFile, remoteCommand, enabled, requiredServices, discoverServices
|
|
}
|
|
init(id: UUID = UUID(), name: String = "New Server", host: String = "", user: String = "", port: String = "22",
|
|
keyFile: String = "", remoteCommand: String = "/usr/local/bin/pegasus-monitor-health-json",
|
|
enabled: Bool = true, requiredServices: [String] = [], discoverServices: Bool = true) {
|
|
self.id = id; self.name = name; self.host = host; self.user = user; self.port = port
|
|
self.keyFile = keyFile; self.remoteCommand = remoteCommand; self.enabled = enabled
|
|
self.requiredServices = requiredServices; self.discoverServices = discoverServices
|
|
}
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
id = try c.decode(UUID.self, forKey: .id)
|
|
name = try c.decode(String.self, forKey: .name)
|
|
host = try c.decode(String.self, forKey: .host)
|
|
user = try c.decode(String.self, forKey: .user)
|
|
port = try c.decode(String.self, forKey: .port)
|
|
keyFile = try c.decode(String.self, forKey: .keyFile)
|
|
remoteCommand = try c.decode(String.self, forKey: .remoteCommand)
|
|
enabled = try c.decode(Bool.self, forKey: .enabled)
|
|
requiredServices = try c.decodeIfPresent([String].self, forKey: .requiredServices) ?? []
|
|
discoverServices = try c.decodeIfPresent(Bool.self, forKey: .discoverServices) ?? 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 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 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 refreshAll() }
|
|
}
|
|
|
|
var selected: ServerProfile? { servers.first(where: { $0.id == selectedID }) }
|
|
var selectedResult: ServerResult? { selectedID.flatMap { results[$0] } }
|
|
var report: HealthReport? { selectedResult?.report }
|
|
var selectedServices: [DiscoveredService] {
|
|
guard let profile = selected else { return [] }
|
|
return discoveredServices(for: profile.id).filter { profile.requiredServices.contains($0.name) || (profile.discoverServices && $0.state != "not_installed") }
|
|
}
|
|
func discoveredServices(for id: UUID) -> [DiscoveredService] {
|
|
guard let profile = servers.first(where: { $0.id == id }) else { return [] }
|
|
let discovered = results[id]?.report?.services ?? []
|
|
var byName = Dictionary(uniqueKeysWithValues: discovered.map { ($0.name, $0) })
|
|
for name in profile.requiredServices where byName[name] == nil {
|
|
byName[name] = DiscoveredService(name: name, state: "not_installed", detail: "Not reported by agent")
|
|
}
|
|
return byName.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
|
}
|
|
func serviceStatus(_ item: DiscoveredService, required: Bool) -> HealthStatus? {
|
|
if required && !item.isHealthy { return .fail }
|
|
if item.state == "failed" { return .fail }
|
|
if item.state == "stopped" { return .warn }
|
|
return nil
|
|
}
|
|
func applyServicePolicy(_ report: HealthReport, for profile: ServerProfile) -> HealthReport {
|
|
let services = report.services ?? []
|
|
// If an older agent has no service discovery, do not invent a healthy state.
|
|
// Explicit required services with no reported data are a failure.
|
|
let known = Dictionary(uniqueKeysWithValues: services.map { ($0.name, $0) })
|
|
var extra: [HealthCheck] = []
|
|
for required in Set(profile.requiredServices) {
|
|
let item = known[required]
|
|
if item?.isHealthy != true {
|
|
let description = item?.title ?? "Not reported by monitoring agent"
|
|
extra.append(HealthCheck(name: "Required: \(required)", status: .fail, detail: description, section: "Required services"))
|
|
}
|
|
}
|
|
let existing = Set(report.checks.map { $0.id })
|
|
extra.removeAll { existing.contains($0.id) }
|
|
guard !extra.isEmpty else { return report }
|
|
let checks = report.checks + extra
|
|
let counts = HealthCounts(ok: report.counts.ok, warn: report.counts.warn, fail: report.counts.fail + extra.count)
|
|
let groups = report.groups + [HealthGroup(name: "Required services", status: .fail, missing: extra.map(\.name))]
|
|
return HealthReport(schema: report.schema, host: report.host, generatedAt: report.generatedAt, uptimeSeconds: report.uptimeSeconds, overall: .fail,
|
|
counts: counts, groups: groups, checks: checks, diagnostics: report.diagnostics, services: report.services)
|
|
}
|
|
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) }
|
|
results.removeValue(forKey: profile.id)
|
|
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()
|
|
timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(max(1, intervalMinutes) * 60), repeats: true) { [weak self] _ in
|
|
Task { @MainActor [weak self] in await self?.refreshAll() }
|
|
}
|
|
}
|
|
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: server.host, user: server.user, port: server.port,
|
|
keyFile: server.keyFile, remoteCommand: server.remoteCommand)
|
|
let decoded = try JSONDecoder().decode(HealthReport.self, from: data)
|
|
let report = applyServicePolicy(decoded, for: server)
|
|
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")
|
|
}
|
|
} catch {
|
|
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)
|
|
}
|
|
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, invalidResponse, process(String)
|
|
var errorDescription: String? {
|
|
switch self {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
!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]
|
|
process.arguments = args
|
|
let output = Pipe()
|
|
let errors = Pipe()
|
|
process.standardOutput = output
|
|
process.standardError = errors
|
|
try process.run()
|
|
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()
|
|
if bytes.isEmpty {
|
|
let message = String(data: errorBytes, encoding: .utf8) ?? "SSH returned no data"
|
|
throw SSHError.process(message.isEmpty ? "SSH returned no data" : message)
|
|
}
|
|
return bytes
|
|
}.value
|
|
}
|
|
}
|