updates
This commit is contained in:
@@ -55,6 +55,27 @@ struct MonitorMenuView: View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
if let report = monitor.report {
|
||||
if !monitor.selectedServices.isEmpty {
|
||||
Text("SERVICES")
|
||||
.font(.caption.bold()).foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 14).padding(.vertical, 8)
|
||||
ForEach(monitor.selectedServices) { service in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: service.symbol).foregroundStyle(service.color)
|
||||
Text(service.name).font(.system(size: 12))
|
||||
Spacer()
|
||||
if selected.requiredServices.contains(service.name) {
|
||||
Text("REQUIRED").font(.system(size: 9, weight: .bold)).foregroundStyle(.orange)
|
||||
}
|
||||
Text(service.title).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 5)
|
||||
}
|
||||
Divider()
|
||||
Text("HEALTH GROUPS")
|
||||
.font(.caption.bold()).foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 14).padding(.vertical, 8)
|
||||
}
|
||||
ForEach(report.groups) { group in
|
||||
HStack {
|
||||
Text(group.name).font(.system(size: 12, weight: .medium))
|
||||
|
||||
@@ -18,9 +18,9 @@ struct PegasusMonitorApp: App {
|
||||
// Real independent windows: editing cannot be dismissed by the menu-bar popover.
|
||||
Window("Pegasus Monitor Settings", id: "monitor-settings") {
|
||||
SettingsView(monitor: monitor)
|
||||
.frame(minWidth: 630, minHeight: 470)
|
||||
.frame(minWidth: 780, minHeight: 640)
|
||||
}
|
||||
.defaultSize(width: 750, height: 570)
|
||||
.defaultSize(width: 880, height: 750)
|
||||
|
||||
Window("Server Diagnostics", id: "monitor-diagnostics") {
|
||||
DiagnosticsView(monitor: monitor)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,8 @@ struct SettingsView: View {
|
||||
@State private var validation = ""
|
||||
@State private var interval = 5
|
||||
@State private var notifications = true
|
||||
@State private var newRequiredService = ""
|
||||
@State private var showUninstalled = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
@@ -44,7 +46,9 @@ struct SettingsView: View {
|
||||
TextField("SSH key path (optional)", text: $draft.keyFile)
|
||||
TextField("Remote health command", text: $draft.remoteCommand)
|
||||
Toggle("Monitoring enabled", isOn: $draft.enabled)
|
||||
Toggle("Automatically discover services", isOn: $draft.discoverServices)
|
||||
}
|
||||
serviceConfiguration
|
||||
Text("Use the Pegasus adapter for Pegasus, or install server/remote-health-json on other Linux servers. SSH must work without interactive prompts. SSH known_hosts is enforced.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if !validation.isEmpty { Text(validation).foregroundStyle(.red).font(.caption) }
|
||||
@@ -65,6 +69,7 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(minWidth: 460)
|
||||
}
|
||||
.onAppear {
|
||||
interval = monitor.intervalMinutes
|
||||
@@ -75,15 +80,80 @@ struct SettingsView: View {
|
||||
.onChange(of: editingID) { _, newValue in
|
||||
if let id = newValue, let server = monitor.servers.first(where: { $0.id == id }) { draft = server }
|
||||
validation = ""
|
||||
newRequiredService = ""
|
||||
}
|
||||
.onChange(of: interval) { _, value in monitor.intervalMinutes = value }
|
||||
.onChange(of: notifications) { _, value in monitor.notifications = value }
|
||||
}
|
||||
|
||||
private var serviceConfiguration: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Service monitoring").font(.headline)
|
||||
Spacer()
|
||||
Button("Refresh services") {
|
||||
if let id = editingID { Task { await monitor.refresh(id: id) } }
|
||||
}.disabled(editingID == nil)
|
||||
}
|
||||
Text("Mark critical services as Required. Missing or non-running required services count as failures. Optional services not installed do not affect health.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Toggle("Show not installed services", isOn: $showUninstalled)
|
||||
.font(.caption)
|
||||
let discovered = editingID.map { monitor.discoveredServices(for: $0) } ?? []
|
||||
let display = discovered.filter { showUninstalled || $0.isInstalled || draft.requiredServices.contains($0.name) }
|
||||
if display.isEmpty {
|
||||
Text("No service discovery data yet. Refresh the server or add a required service below.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 6) {
|
||||
ForEach(display) { service in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: service.symbol).foregroundStyle(service.color).frame(width: 20)
|
||||
Text(service.name).frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(service.title).font(.caption).foregroundStyle(.secondary)
|
||||
Toggle("Required", isOn: Binding(
|
||||
get: { draft.requiredServices.contains(service.name) },
|
||||
set: { required in setRequired(service.name, required: required) }
|
||||
)).labelsHidden().help("Require \(service.name) to be running")
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
}
|
||||
}.frame(maxHeight: 190)
|
||||
}
|
||||
HStack {
|
||||
TextField("Add required service (e.g. mysql)", text: $newRequiredService)
|
||||
.onSubmit { addRequiredService() }
|
||||
Button("Add Required") { addRequiredService() }
|
||||
.disabled(newRequiredService.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
if !draft.requiredServices.isEmpty {
|
||||
Text("Required: \(draft.requiredServices.sorted().joined(separator: ", "))")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setRequired(_ name: String, required: Bool) {
|
||||
if required && !draft.requiredServices.contains(name) { draft.requiredServices.append(name) }
|
||||
if !required { draft.requiredServices.removeAll { $0 == name } }
|
||||
}
|
||||
private func addRequiredService() {
|
||||
let name = newRequiredService.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty, name.range(of: #"^[a-zA-Z0-9@_.-]+$"#, options: .regularExpression) != nil else {
|
||||
validation = "Use a valid systemd service name (letters, digits, @, _, -, or .)."
|
||||
return
|
||||
}
|
||||
setRequired(name, required: true)
|
||||
newRequiredService = ""
|
||||
validation = ""
|
||||
}
|
||||
private func newProfile() {
|
||||
editingID = nil
|
||||
draft = ServerProfile()
|
||||
validation = ""
|
||||
newRequiredService = ""
|
||||
}
|
||||
private func save() {
|
||||
guard !draft.name.trimmingCharacters(in: .whitespaces).isEmpty,
|
||||
|
||||
Reference in New Issue
Block a user