This commit is contained in:
Mark Gregory
2026-10-21 14:50:05 +01:00
parent 90987bd898
commit 9e501ca176
44 changed files with 461 additions and 222 deletions
+112 -1
View File
@@ -20,6 +20,47 @@ struct HealthReport: Codable {
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 {
@@ -31,6 +72,33 @@ struct ServerProfile: Identifiable, Codable, Equatable {
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
@@ -84,6 +152,47 @@ final class ServerMonitor: ObservableObject {
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, 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 }
@@ -106,6 +215,7 @@ final class ServerMonitor: ObservableObject {
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) }
@@ -151,7 +261,8 @@ final class ServerMonitor: ObservableObject {
do {
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)
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