commit 5c56299e4661f8e95ef2bc82dc824c9aa66f74b7 Author: Mark Gregory Date: Wed Oct 21 14:07:45 2026 +0100 Iniital release diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..ea0faf1 Binary files /dev/null and b/.DS_Store differ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..9a897b6 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/PegasusMonitor.iml b/.idea/PegasusMonitor.iml new file mode 100644 index 0000000..6102194 --- /dev/null +++ b/.idea/PegasusMonitor.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..74db1b1 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/PegasusMonitor.xcodeproj/project.pbxproj b/PegasusMonitor.xcodeproj/project.pbxproj new file mode 100644 index 0000000..2bf328d --- /dev/null +++ b/PegasusMonitor.xcodeproj/project.pbxproj @@ -0,0 +1,229 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + AA0000000000000000000001 /* PegasusMonitorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000001 /* PegasusMonitorApp.swift */; }; + AA0000000000000000000002 /* ServerMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000002 /* ServerMonitor.swift */; }; + AA0000000000000000000003 /* MonitorMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000003 /* MonitorMenuView.swift */; }; + AA0000000000000000000004 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000004 /* SettingsView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + BB0000000000000000000001 /* PegasusMonitorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PegasusMonitorApp.swift; sourceTree = ""; }; + BB0000000000000000000002 /* ServerMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerMonitor.swift; sourceTree = ""; }; + BB0000000000000000000003 /* MonitorMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonitorMenuView.swift; sourceTree = ""; }; + BB0000000000000000000004 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + CC0000000000000000000001 /* PegasusMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PegasusMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + DD0000000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + EE0000000000000000000001 = { + isa = PBXGroup; + children = ( + EE0000000000000000000002 /* PegasusMonitor */, + EE0000000000000000000003 /* Products */, + ); + sourceTree = ""; + }; + EE0000000000000000000002 /* PegasusMonitor */ = { + isa = PBXGroup; + children = ( + BB0000000000000000000001 /* PegasusMonitorApp.swift */, + BB0000000000000000000002 /* ServerMonitor.swift */, + BB0000000000000000000003 /* MonitorMenuView.swift */, + BB0000000000000000000004 /* SettingsView.swift */, + ); + path = PegasusMonitor; + sourceTree = ""; + }; + EE0000000000000000000003 /* Products */ = { + isa = PBXGroup; + children = ( + CC0000000000000000000001 /* PegasusMonitor.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + FF0000000000000000000001 /* PegasusMonitor */ = { + isa = PBXNativeTarget; + buildConfigurationList = 110000000000000000000001 /* Build configuration list for PBXNativeTarget "PegasusMonitor" */; + buildPhases = ( + 220000000000000000000001 /* Sources */, + DD0000000000000000000001 /* Frameworks */, + 330000000000000000000001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = PegasusMonitor; + productName = PegasusMonitor; + productReference = CC0000000000000000000001 /* PegasusMonitor.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 440000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1600; + TargetAttributes = { + FF0000000000000000000001 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = 550000000000000000000001 /* Build configuration list for PBXProject "PegasusMonitor" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = EE0000000000000000000001; + productRefGroup = EE0000000000000000000003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + FF0000000000000000000001 /* PegasusMonitor */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 330000000000000000000001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 220000000000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AA0000000000000000000001 /* PegasusMonitorApp.swift in Sources */, + AA0000000000000000000002 /* ServerMonitor.swift in Sources */, + AA0000000000000000000003 /* MonitorMenuView.swift in Sources */, + AA0000000000000000000004 /* SettingsView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 660000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + SDKROOT = macosx; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 660000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + SDKROOT = macosx; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 770000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 39R6U3437D; + ENABLE_APP_SANDBOX = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Pegasus Monitor"; + INFOPLIST_KEY_LSUIElement = YES; + INFOPLIST_KEY_NSPrincipalClass = NSApplication; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = net.absdev.PegasusMonitor; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 770000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 39R6U3437D; + ENABLE_APP_SANDBOX = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Pegasus Monitor"; + INFOPLIST_KEY_LSUIElement = YES; + INFOPLIST_KEY_NSPrincipalClass = NSApplication; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = net.absdev.PegasusMonitor; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 110000000000000000000001 /* Build configuration list for PBXNativeTarget "PegasusMonitor" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 770000000000000000000001 /* Debug */, + 770000000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 550000000000000000000001 /* Build configuration list for PBXProject "PegasusMonitor" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 660000000000000000000001 /* Debug */, + 660000000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 440000000000000000000001 /* Project object */; +} diff --git a/PegasusMonitor.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/PegasusMonitor.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/PegasusMonitor.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/PegasusMonitor.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate b/PegasusMonitor.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..e4be4ec Binary files /dev/null and b/PegasusMonitor.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/PegasusMonitor.xcodeproj/xcuserdata/markgregory.xcuserdatad/xcschemes/xcschememanagement.plist b/PegasusMonitor.xcodeproj/xcuserdata/markgregory.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..b1a7db0 --- /dev/null +++ b/PegasusMonitor.xcodeproj/xcuserdata/markgregory.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + PegasusMonitor.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/PegasusMonitor/MonitorMenuView.swift b/PegasusMonitor/MonitorMenuView.swift new file mode 100644 index 0000000..343eb44 --- /dev/null +++ b/PegasusMonitor/MonitorMenuView.swift @@ -0,0 +1,170 @@ +import SwiftUI + + + +struct MonitorMenuView: View { + @ObservedObject var monitor: ServerMonitor + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + + // Header + VStack(alignment: .leading, spacing: 6) { + Label("PEGASUS MONITOR", systemImage: "server.rack") + .font(.headline) + + Label( + monitor.status.title, + systemImage: monitor.status.symbol + ) + .foregroundStyle(monitor.status.color) + .font(.subheadline.bold()) + + Text("192.168.1.2") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(16) + + Divider() + + // Systems: vertical stack + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + if let report = monitor.report { + if report.groups.isEmpty { + Text("No system groups returned by Pegasus.") + .foregroundStyle(.secondary) + .padding(16) + } + + ForEach(report.groups) { group in + VStack(alignment: .leading, spacing: 5) { + Text(group.name) + .font(.system(size: 13, weight: .semibold)) + .fixedSize(horizontal: false, vertical: true) + + Label( + group.status.title, + systemImage: group.status.symbol + ) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(group.status.color) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 11) + + Divider() + .padding(.leading, 16) + } + } else { + Text("Waiting for Pegasus health data…") + .foregroundStyle(.secondary) + .padding(16) + } + } + } + .frame(height: 420) + + Divider() + + // Summary + if let report = monitor.report { + VStack(alignment: .leading, spacing: 6) { + Text( + "\(report.counts.ok) OK · " + + "\(report.counts.warn) WARN · " + + "\(report.counts.fail) FAIL" + ) + .font(.system(size: 12, weight: .semibold)) + + Text( + monitor.lastChecked.map { + "Last checked: \($0.formatted(date: .omitted, time: .shortened))" + } ?? "Not checked" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(16) + } + + if let error = monitor.lastError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .textSelection(.enabled) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + + Divider() + + // Vertical actions + VStack(alignment: .leading, spacing: 8) { + Button("Refresh") { + Task { await monitor.refresh() } + } + .disabled(monitor.isRefreshing) + + Button("Diagnostics") { + monitor.showDiagnostics = true + } + + Button("Settings") { + monitor.showSettings = true + } + + Button("Quit") { + NSApplication.shared.terminate(nil) + } + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + } + .frame(width: 320) + .sheet(isPresented: $monitor.showDiagnostics) { + DiagnosticsView(monitor: monitor) + } + .sheet(isPresented: $monitor.showSettings) { + SettingsView(monitor: monitor) + .frame(width: 480) + } + } +} + +struct DiagnosticsView: View { + @ObservedObject var monitor: ServerMonitor + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Pegasus Diagnostics").font(.title2.bold()) + Spacer() + Button("Close") { dismiss() } + } + Text("Latest remote report — refresh from the menu to update") + .foregroundStyle(.secondary) + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(monitor.report?.checks ?? []) { check in + HStack(alignment: .top) { + Image(systemName: check.status.symbol).foregroundStyle(check.status.color) + VStack(alignment: .leading, spacing: 2) { + Text(check.name).fontWeight(.medium) + Text(check.detail).font(.caption).foregroundStyle(.secondary) + } + Spacer() + } + } + } + } + .textSelection(.enabled) + } + .padding(20) + .frame(width: 650, height: 560) + } +} diff --git a/PegasusMonitor/MonitorMenuView.swift.20261021-140032.bak b/PegasusMonitor/MonitorMenuView.swift.20261021-140032.bak new file mode 100644 index 0000000..27ec07e --- /dev/null +++ b/PegasusMonitor/MonitorMenuView.swift.20261021-140032.bak @@ -0,0 +1,132 @@ +import SwiftUI + +struct MonitorMenuView: View { + @ObservedObject var monitor: ServerMonitor + @Environment(\.openSettings) private var openSettings + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 12) { + Image(systemName: "server.rack") + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(.tint) + VStack(alignment: .leading, spacing: 3) { + Text("PEGASUS MONITOR") + .font(.headline) + Text("192.168.1.2 · Remote server health") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if monitor.isRefreshing { ProgressView().controlSize(.small) } + } + .padding(18) + Divider() + + if let report = monitor.report { + ScrollView { + VStack(alignment: .leading, spacing: 10) { + ForEach(report.groups) { group in + HStack(spacing: 8) { + Image(systemName: group.status.symbol) + .foregroundStyle(group.status.color) + .frame(width: 22) + Text(group.name) + .font(.system(size: 13)) + Spacer() + } + .help(group.missing.isEmpty ? group.status.title : "Missing checks: \(group.missing.joined(separator: ", "))") + } + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + } + .frame(maxHeight: 375) + Divider() + HStack { + Image(systemName: monitor.status.symbol) + .foregroundStyle(monitor.status.color) + Text("OVERALL: \(monitor.status.title.uppercased())") + .font(.system(size: 13, weight: .bold)) + Spacer() + Text("\(report.counts.ok) OK · \(report.counts.warn) WARN · \(report.counts.fail) FAIL") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 18) + .padding(.vertical, 12) + } else { + ContentUnavailableView("No health report", systemImage: "network", description: Text("Check SSH configuration and refresh.")) + .frame(height: 220) + } + + if let error = monitor.lastError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .textSelection(.enabled) + .padding(.horizontal, 18) + .padding(.bottom, 10) + } + Divider() + HStack { + Text(monitor.lastChecked.map { "Checked \($0.formatted(date: .omitted, time: .shortened))" } ?? "Not checked yet") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button { Task { await monitor.refresh() } } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(monitor.isRefreshing) + } + .padding(.horizontal, 18) + .padding(.vertical, 8) + + HStack { + Button("Full Diagnostics") { monitor.showDiagnostics = true } + Spacer() + Button("Settings") { openSettings() } + Button("Quit") { NSApplication.shared.terminate(nil) } + } + .padding(.horizontal, 18) + .padding(.bottom, 12) + } + .sheet(isPresented: $monitor.showDiagnostics) { + DiagnosticsView(monitor: monitor) + } + } +} + +struct DiagnosticsView: View { + @ObservedObject var monitor: ServerMonitor + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Pegasus Diagnostics").font(.title2.bold()) + Spacer() + Button("Close") { dismiss() } + } + Text("Latest remote report — refresh from the menu to update") + .foregroundStyle(.secondary) + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(monitor.report?.checks ?? []) { check in + HStack(alignment: .top) { + Image(systemName: check.status.symbol).foregroundStyle(check.status.color) + VStack(alignment: .leading, spacing: 2) { + Text(check.name).fontWeight(.medium) + Text(check.detail).font(.caption).foregroundStyle(.secondary) + } + Spacer() + } + } + } + } + .textSelection(.enabled) + } + .padding(20) + .frame(width: 650, height: 560) + } +} diff --git a/PegasusMonitor/MonitorMenuView.swift.20261021-140144.bak b/PegasusMonitor/MonitorMenuView.swift.20261021-140144.bak new file mode 100644 index 0000000..2d48753 --- /dev/null +++ b/PegasusMonitor/MonitorMenuView.swift.20261021-140144.bak @@ -0,0 +1,136 @@ +import SwiftUI + +struct MonitorMenuView: View { + @ObservedObject var monitor: ServerMonitor + @Environment(\.openSettings) private var openSettings + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 12) { + Image(systemName: "server.rack") + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(.tint) + VStack(alignment: .leading, spacing: 3) { + Text("PEGASUS MONITOR") + .font(.headline) + Text("192.168.1.2 · Remote server health") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if monitor.isRefreshing { ProgressView().controlSize(.small) } + } + .padding(18) + Divider() + + if let report = monitor.report { + ScrollView { + VStack(alignment: .leading, spacing: 10) { + ForEach(report.groups) { group in + HStack(spacing: 8) { + Image(systemName: group.status.symbol) + .foregroundStyle(group.status.color) + .frame(width: 22) + Text(group.name) + .font(.system(size: 13)) + Spacer() + } + .help(group.missing.isEmpty ? group.status.title : "Missing checks: \(group.missing.joined(separator: ", "))") + } + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + } + .frame(maxHeight: 375) + Divider() + HStack { + Image(systemName: monitor.status.symbol) + .foregroundStyle(monitor.status.color) + Text("OVERALL: \(monitor.status.title.uppercased())") + .font(.system(size: 13, weight: .bold)) + Spacer() + Text("\(report.counts.ok) OK · \(report.counts.warn) WARN · \(report.counts.fail) FAIL") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 18) + .padding(.vertical, 12) + } else { + ContentUnavailableView("No health report", systemImage: "network", description: Text("Check SSH configuration and refresh.")) + .frame(height: 220) + } + + if let error = monitor.lastError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .textSelection(.enabled) + .padding(.horizontal, 18) + .padding(.bottom, 10) + } + Divider() + HStack { + Text(monitor.lastChecked.map { "Checked \($0.formatted(date: .omitted, time: .shortened))" } ?? "Not checked yet") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button { Task { await monitor.refresh() } } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(monitor.isRefreshing) + } + .padding(.horizontal, 18) + .padding(.vertical, 8) + + HStack { + Button("Full Diagnostics") { monitor.showDiagnostics = true } + Spacer() + Button("Settings") { monitor.showSettings = true } + Button("Quit") { NSApplication.shared.terminate(nil) } + } + .padding(.horizontal, 18) + .padding(.bottom, 12) + } + .sheet(isPresented: $monitor.showDiagnostics) { + DiagnosticsView(monitor: monitor) + } + .sheet(isPresented: $monitor.showSettings) { + SettingsView(monitor: monitor) + .frame(width: 480) + } + } +} + +struct DiagnosticsView: View { + @ObservedObject var monitor: ServerMonitor + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Pegasus Diagnostics").font(.title2.bold()) + Spacer() + Button("Close") { dismiss() } + } + Text("Latest remote report — refresh from the menu to update") + .foregroundStyle(.secondary) + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(monitor.report?.checks ?? []) { check in + HStack(alignment: .top) { + Image(systemName: check.status.symbol).foregroundStyle(check.status.color) + VStack(alignment: .leading, spacing: 2) { + Text(check.name).fontWeight(.medium) + Text(check.detail).font(.caption).foregroundStyle(.secondary) + } + Spacer() + } + } + } + } + .textSelection(.enabled) + } + .padding(20) + .frame(width: 650, height: 560) + } +} diff --git a/PegasusMonitor/MonitorMenuView.swift.20261021-140334.bak b/PegasusMonitor/MonitorMenuView.swift.20261021-140334.bak new file mode 100644 index 0000000..526267c --- /dev/null +++ b/PegasusMonitor/MonitorMenuView.swift.20261021-140334.bak @@ -0,0 +1,171 @@ +import SwiftUI + +struct MonitorMenuView: View { + @ObservedObject var monitor: ServerMonitor + @Environment(\.openSettings) private var openSettings + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 12) { + Image(systemName: "server.rack") + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(.tint) + VStack(alignment: .leading, spacing: 3) { + Text("PEGASUS MONITOR") + .font(.headline) + HStack(spacing: 6) { + Text("192.168.1.2") + .foregroundStyle(.secondary) + + Image(systemName: monitor.status.symbol) + .foregroundStyle(monitor.status.color) + + Text(monitor.status.title.uppercased()) + .foregroundStyle(monitor.status.color) + .fontWeight(.semibold) + } + .font(.caption) + } + Spacer() + if monitor.isRefreshing { ProgressView().controlSize(.small) } + } + .padding(18) + Divider() + + if let report = monitor.report { + ScrollView { + VStack(alignment: .leading, spacing: 10) { + // Main system status table + HStack { + Text("SYSTEM") + Spacer() + Text("STATUS") + } + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + .padding(.bottom, 5) + + ForEach(report.groups) { group in + HStack(spacing: 10) { + + Text(group.name) + .font(.system(size: 12)) + .lineLimit(1) + .minimumScaleFactor(0.85) + + Spacer(minLength: 8) + + HStack(spacing: 5) { + Image(systemName: group.status.symbol) + .font(.system(size: 11)) + + Text(group.status.title) + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(group.status.color) + .frame(width: 86, alignment: .trailing) + } + .padding(.vertical, 3) + .help( + group.missing.isEmpty + ? group.status.title + : "Missing checks: \(group.missing.joined(separator: ", "))" + ) + } + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + } + .frame(maxHeight: 375) + Divider() + HStack { + Image(systemName: monitor.status.symbol) + .foregroundStyle(monitor.status.color) + Text("OVERALL: \(monitor.status.title.uppercased())") + .font(.system(size: 13, weight: .bold)) + Spacer() + Text("\(report.counts.ok) OK · \(report.counts.warn) WARN · \(report.counts.fail) FAIL") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 18) + .padding(.vertical, 12) + } else { + ContentUnavailableView("No health report", systemImage: "network", description: Text("Check SSH configuration and refresh.")) + .frame(height: 220) + } + + if let error = monitor.lastError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .textSelection(.enabled) + .padding(.horizontal, 18) + .padding(.bottom, 10) + } + Divider() + HStack { + Text(monitor.lastChecked.map { "Checked \($0.formatted(date: .omitted, time: .shortened))" } ?? "Not checked yet") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button { Task { await monitor.refresh() } } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(monitor.isRefreshing) + } + .padding(.horizontal, 18) + .padding(.vertical, 8) + + HStack { + Button("Full Diagnostics") { monitor.showDiagnostics = true } + Spacer() + Button("Settings") { monitor.showSettings = true } + Button("Quit") { NSApplication.shared.terminate(nil) } + } + .padding(.horizontal, 18) + .padding(.bottom, 12) + } + .sheet(isPresented: $monitor.showDiagnostics) { + DiagnosticsView(monitor: monitor) + } + .sheet(isPresented: $monitor.showSettings) { + SettingsView(monitor: monitor) + .frame(width: 480) + } + } +} + +struct DiagnosticsView: View { + @ObservedObject var monitor: ServerMonitor + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Pegasus Diagnostics").font(.title2.bold()) + Spacer() + Button("Close") { dismiss() } + } + Text("Latest remote report — refresh from the menu to update") + .foregroundStyle(.secondary) + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(monitor.report?.checks ?? []) { check in + HStack(alignment: .top) { + Image(systemName: check.status.symbol).foregroundStyle(check.status.color) + VStack(alignment: .leading, spacing: 2) { + Text(check.name).fontWeight(.medium) + Text(check.detail).font(.caption).foregroundStyle(.secondary) + } + Spacer() + } + } + } + } + .textSelection(.enabled) + } + .padding(20) + .frame(width: 650, height: 560) + } +} diff --git a/PegasusMonitor/MonitorMenuView.swift.20261021-140459.bak b/PegasusMonitor/MonitorMenuView.swift.20261021-140459.bak new file mode 100644 index 0000000..3921406 --- /dev/null +++ b/PegasusMonitor/MonitorMenuView.swift.20261021-140459.bak @@ -0,0 +1,256 @@ +import SwiftUI + + +struct MonitorMenuView: View { + + @ObservedObject var monitor: ServerMonitor + + var body: some View { + + VStack(alignment: .leading, spacing: 0) { + + // MARK: Header + + HStack(spacing: 12) { + + Image(systemName: "server.rack") + .font(.system(size: 26)) + .foregroundStyle(monitor.status.color) + + VStack(alignment: .leading, spacing: 4) { + + Text("PEGASUS MONITOR") + .font(.headline) + + Text("192.168.1.2") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + + Image(systemName: monitor.status.symbol) + .foregroundStyle(monitor.status.color) + + Text(monitor.status.title) + .font(.caption.bold()) + .foregroundStyle(monitor.status.color) + } + } + .padding(18) + + Divider() + + // MARK: System Status + + if let report = monitor.report { + + HStack { + Text("SYSTEM") + Spacer() + Text("STATUS") + } + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 18) + .padding(.top, 14) + .padding(.bottom, 8) + + ScrollView { + + VStack(spacing: 0) { + + ForEach(report.groups) { group in + + HStack(spacing: 10) { + + Text(group.name) + .font(.system(size: 12)) + .lineLimit(1) + + Spacer(minLength: 8) + + HStack(spacing: 5) { + + Image(systemName: group.status.symbol) + .font(.system(size: 12)) + + Text(group.status.title) + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(group.status.color) + .frame(width: 90, alignment: .trailing) + } + .padding(.vertical, 7) + + Divider() + .opacity(0.35) + } + } + .padding(.horizontal, 18) + } + .frame(maxHeight: 460) + + Divider() + + // MARK: Overall Status + + HStack { + + Text("OVERALL STATUS") + .font(.system(size: 12, weight: .bold)) + + Spacer() + + Image(systemName: monitor.status.symbol) + .foregroundStyle(monitor.status.color) + + Text(monitor.status.title.uppercased()) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(monitor.status.color) + } + .padding(.horizontal, 18) + .padding(.vertical, 12) + + HStack(spacing: 16) { + + Label( + "\(report.counts.ok) OK", + systemImage: "checkmark.circle.fill" + ) + .foregroundStyle(.green) + + Label( + "\(report.counts.warn) WARN", + systemImage: "exclamationmark.triangle.fill" + ) + .foregroundStyle(.orange) + + Label( + "\(report.counts.fail) FAIL", + systemImage: "xmark.circle.fill" + ) + .foregroundStyle(.red) + } + .font(.caption) + .padding(.horizontal, 18) + .padding(.bottom, 12) + + } else { + + ContentUnavailableView( + "No Health Report", + systemImage: "network", + description: Text( + "Check your SSH connection and refresh." + ) + ) + .frame(height: 260) + } + + if let error = monitor.lastError { + + Text(error) + .font(.caption) + .foregroundStyle(.red) + .textSelection(.enabled) + .padding(12) + } + + Divider() + + // MARK: Footer + + HStack { + + Text( + monitor.lastChecked.map { + "Checked \($0.formatted(date: .omitted, time: .shortened))" + } ?? "Not checked" + ) + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + if monitor.isRefreshing { + ProgressView() + .controlSize(.small) + } + + Button("Refresh") { + Task { + await monitor.refresh() + } + } + .disabled(monitor.isRefreshing) + } + .padding(14) + + HStack(spacing: 10) { + + Button("Diagnostics") { + monitor.showDiagnostics = true + } + + Button("Settings") { + monitor.showSettings = true + } + + Spacer() + + Button("Quit") { + NSApplication.shared.terminate(nil) + } + } + .padding(.horizontal, 14) + .padding(.bottom, 14) + } + .frame(width: 430) + + .sheet(isPresented: $monitor.showDiagnostics) { + DiagnosticsView(monitor: monitor) + } + + .sheet(isPresented: $monitor.showSettings) { + SettingsView(monitor: monitor) + .frame(width: 480) + } + } +} + +struct DiagnosticsView: View { + @ObservedObject var monitor: ServerMonitor + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Pegasus Diagnostics").font(.title2.bold()) + Spacer() + Button("Close") { dismiss() } + } + Text("Latest remote report — refresh from the menu to update") + .foregroundStyle(.secondary) + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(monitor.report?.checks ?? []) { check in + HStack(alignment: .top) { + Image(systemName: check.status.symbol).foregroundStyle(check.status.color) + VStack(alignment: .leading, spacing: 2) { + Text(check.name).fontWeight(.medium) + Text(check.detail).font(.caption).foregroundStyle(.secondary) + } + Spacer() + } + } + } + } + .textSelection(.enabled) + } + .padding(20) + .frame(width: 650, height: 560) + } +} diff --git a/PegasusMonitor/PegasusMonitorApp.swift b/PegasusMonitor/PegasusMonitorApp.swift new file mode 100644 index 0000000..3676323 --- /dev/null +++ b/PegasusMonitor/PegasusMonitorApp.swift @@ -0,0 +1,25 @@ +import SwiftUI +import UserNotifications + +@main +struct PegasusMonitorApp: App { + @StateObject private var monitor = ServerMonitor() + + var body: some Scene { + MenuBarExtra { + MonitorMenuView(monitor: monitor) + .frame(width: 380) + } label: { + Image(systemName: monitor.status.symbol) + .symbolRenderingMode(.palette) + .foregroundStyle(monitor.status.color) + .help("Pegasus Monitor — \(monitor.status.title)") + } + .menuBarExtraStyle(.window) + + Settings { + SettingsView(monitor: monitor) + .frame(width: 480) + } + } +} diff --git a/PegasusMonitor/ServerMonitor.swift b/PegasusMonitor/ServerMonitor.swift new file mode 100644 index 0000000..1b15318 --- /dev/null +++ b/PegasusMonitor/ServerMonitor.swift @@ -0,0 +1,192 @@ +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 + } +} diff --git a/PegasusMonitor/SettingsView.swift b/PegasusMonitor/SettingsView.swift new file mode 100644 index 0000000..89b7d4c --- /dev/null +++ b/PegasusMonitor/SettingsView.swift @@ -0,0 +1,44 @@ +import SwiftUI + +struct SettingsView: View { + @ObservedObject var monitor: ServerMonitor + @Environment(\.dismiss) private var dismiss + @AppStorage("server.host") private var host = "192.168.1.2" + @AppStorage("server.user") private var user = "mag" + @AppStorage("server.port") private var port = "22" + @AppStorage("server.keyFile") private var keyFile = "" + @AppStorage("monitor.intervalMinutes") private var intervalMinutes = 5 + @AppStorage("monitor.notifications") private var notifications = true + + var body: some View { + Form { + TextField("Host", text: $host) + TextField("SSH user", text: $user) + TextField("SSH port", text: $port) + TextField("SSH key path (optional)", text: $keyFile) + .help("Leave blank to use your SSH agent or ~/.ssh/config") + Picker("Refresh interval", selection: $intervalMinutes) { + Text("1 minute").tag(1) + Text("5 minutes").tag(5) + Text("10 minutes").tag(10) + Text("15 minutes").tag(15) + Text("30 minutes").tag(30) + } + Toggle("Notify when server becomes unhealthy", isOn: $notifications) + HStack { + Button("Close") { + dismiss() + } + Spacer() + Button("Apply and Refresh") { + monitor.configureTimer() + Task { await monitor.refresh() } + } + } + Text("SSH must authenticate without prompts. Server requires /usr/local/bin/pegasus-health-json accessible via sudo -n. Host keys are verified using your existing SSH known_hosts file.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(20) + } +} diff --git a/PegasusMonitor/SettingsView.swift.20261021-140032.bak b/PegasusMonitor/SettingsView.swift.20261021-140032.bak new file mode 100644 index 0000000..bcef95a --- /dev/null +++ b/PegasusMonitor/SettingsView.swift.20261021-140032.bak @@ -0,0 +1,40 @@ +import SwiftUI + +struct SettingsView: View { + @ObservedObject var monitor: ServerMonitor + @AppStorage("server.host") private var host = "192.168.1.2" + @AppStorage("server.user") private var user = "mag" + @AppStorage("server.port") private var port = "22" + @AppStorage("server.keyFile") private var keyFile = "" + @AppStorage("monitor.intervalMinutes") private var intervalMinutes = 5 + @AppStorage("monitor.notifications") private var notifications = true + + var body: some View { + Form { + TextField("Host", text: $host) + TextField("SSH user", text: $user) + TextField("SSH port", text: $port) + TextField("SSH key path (optional)", text: $keyFile) + .help("Leave blank to use your SSH agent or ~/.ssh/config") + Picker("Refresh interval", selection: $intervalMinutes) { + Text("1 minute").tag(1) + Text("5 minutes").tag(5) + Text("10 minutes").tag(10) + Text("15 minutes").tag(15) + Text("30 minutes").tag(30) + } + Toggle("Notify when server becomes unhealthy", isOn: $notifications) + HStack { + Spacer() + Button("Apply and Refresh") { + monitor.configureTimer() + Task { await monitor.refresh() } + } + } + Text("SSH must authenticate without prompts. Server requires /usr/local/bin/pegasus-health-json accessible via sudo -n. Host keys are verified using your existing SSH known_hosts file.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(20) + } +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..7744c06 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,52 @@ +# Pegasus Monitor + +Native macOS SwiftUI menu-bar health monitor for Pegasus. + +## Setup on Pegasus (Ubuntu) + +The existing `/usr/local/bin/pegasus-health` must support `--verbose` and use the updated checks (no Time Machine, Domino at `192.168.1.50`, Cockpit at `192.168.1.2`, and `saturn.absdev.net`). The adapter does **not** replace or modify that existing script. + +Copy `server/pegasus-health-json` to Pegasus then install: + +```bash +sudo install -o root -g root -m 755 pegasus-health-json /usr/local/bin/pegasus-health-json +sudo /usr/local/bin/pegasus-health-json | python3 -m json.tool | head -30 +``` + +For the app to poll automatically, configure **restricted passwordless sudo** for the JSON command only. Run `sudo visudo -f /etc/sudoers.d/pegasus-health-monitor` and enter: + +``` +mag ALL=(root) NOPASSWD: /usr/local/bin/pegasus-health-json +``` + +Both `/usr/local/bin/pegasus-health-json` and `/usr/local/bin/pegasus-health` must be root-owned and not writable by `mag` or any other non-root user. **Do not** grant `NOPASSWD` to arbitrary commands or to `python3` globally. The adapter executes only the fixed health command and accepts no user-controlled command strings. + +From your Mac confirm: + +```bash +ssh -o BatchMode=yes mag@192.168.1.2 'sudo -n /usr/local/bin/pegasus-health-json' | python3 -m json.tool | head -35 +``` + +Use SSH keys and trust the host key from an interactive SSH login before first running the app. The macOS app uses `BatchMode=yes` and `StrictHostKeyChecking=yes`, and will not present password prompts. + +## Build on macOS + +Open `PegasusMonitor.xcodeproj` in Xcode and choose the PegasusMonitor scheme. Set your signing team as appropriate, then Build & Run. The app requires macOS 14.0 or newer. No third-party dependencies, build/release scripts, or packages are required. + +The app appears as a status icon in the macOS menu bar, not the Dock. It polls every five minutes by default (configurable), shows 14 aggregated categories and per-check diagnostics, and sends notifications when a previously healthy server becomes unhealthy. The server JSON adapter is read-only; it never restarts services. + +## Diagnostics + +- `ssh -v mag@192.168.1.2`: confirm SSH key authentication. +- `sudo -n /usr/local/bin/pegasus-health-json`: confirm restricted sudo. +- A non-zero SSH command exit code of 1/2 can indicate warnings/failures; the tool still emits JSON. +- A missing required check is classified as **fail**, not green. +- VM NRPC checks confirm TCP reachability; backup checks do not validate Borg archive integrity. + +## Design + +`PegasusMonitorApp.swift`: `MenuBarExtra` entry and Settings scene. +`ServerMonitor.swift`: async SSH runner, JSON decoding, polling and notifications. +`MonitorMenuView.swift`: summary and drill-down details. +`SettingsView.swift`: SSH configuration and interval. +`server/pegasus-health-json`: read-only JSON adapter for the existing health script. diff --git a/server/pegasus-health-json b/server/pegasus-health-json new file mode 100644 index 0000000..1a42fdf --- /dev/null +++ b/server/pegasus-health-json @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Read-only JSON adapter for an existing pegasus-health --verbose command.""" +import datetime +import json +import re +import subprocess +import sys + +COMMAND = ['/usr/local/bin/pegasus-health', '--verbose'] +GROUPS = [ + ('Ubuntu 26.04', ['Failed systemd units']), + ('Nginx and PHP-FPM', ['nginx','php8.3-fpm','php8.4-fpm','Nginx configuration']), + ('MySQL, PostgreSQL and Redis', ['mysql','postgresql','redis-server','MySQL','PostgreSQL','Redis']), + ('Docker', ['docker','Docker containers discovered']), + ('Gitea and Gitea MCP', ['gitea','gitea-mcp','Gitea HTTP']), + ('Cloudflare Tunnel', ['cloudflared']), + ('Cockpit', ['cockpit.socket','Cockpit']), + ('Domino VM and NRPC', ['domino651 VM','domino651 autostart','Domino NRPC']), + ('OpenVPN and IPsec', ['openvpn','OpenVPN UDP','IKEv2 UDP','IPsec NAT-T UDP']), + ('PPPoE networking', ['Interface ppp0','Default route','DNS resolution']), + ('Backup drive', ['/backup mounted','Backup filesystem UUID']), + ('Backup timer', ['pegasus-backup.timer']), + ('Time Machine removed', ['Time Machine removed']), + ('Public websites', ['absdev.net','git.absdev.net','saturn.absdev.net','cloud.absdev.net']), +] + +def output(): + try: + p = subprocess.run(COMMAND, capture_output=True, text=True, timeout=180, check=False) + except (OSError, subprocess.TimeoutExpired) as exc: + print(json.dumps({'schema': 1, 'error': str(exc), 'overall': 'fail', 'groups': [], 'checks': [], 'counts': {'ok':0,'warn':0,'fail':1}})) + return 2 + checks = [] + section = '' + plain = re.sub(r'\x1b\[[0-9;]*m', '', p.stdout) + for line in plain.splitlines(): + head = re.match(r'^=== (.+) ===$', line) + if head: + section = head.group(1) + match = re.match(r'^\[(OK\s*|WARN|FAIL)\]\s+(.+?)(?:\s+—\s+(.*))?$', line) + if match: + checks.append({'name': match[2].strip(), 'status': match[1].strip().lower(), 'detail': (match[3] or '').strip(), 'section': section}) + lookup = {c['name']: c['status'] for c in checks} + groups = [] + for title, names in GROUPS: + relevant = names + ([c['name'] for c in checks if c['name'].startswith('Container ')] if title == 'Docker' else []) + states = [lookup.get(name, 'fail') for name in relevant] + state = 'fail' if 'fail' in states else 'warn' if 'warn' in states else 'ok' + label = title + if title == 'Docker': + running = sum(c['status'] == 'ok' for c in checks if c['name'].startswith('Container ')) + label = f'Docker — {running} containers' + groups.append({'name':label, 'status':state, 'missing':[n for n in relevant if n not in lookup]}) + counts_match = re.search(r'SUMMARY:\s*(\d+) OK\s*\|\s*(\d+) WARN\s*\|\s*(\d+) FAIL', plain) + counts = dict(zip(('ok','warn','fail'), (int(x) for x in counts_match.groups()))) if counts_match else {'ok':0,'warn':0,'fail':1} + if not checks: + counts['fail'] = max(1, counts['fail']) + overall = 'fail' if counts['fail'] or any(g['status']=='fail' for g in groups) else 'warn' if counts['warn'] or any(g['status']=='warn' for g in groups) else 'ok' + print(json.dumps({'schema':1, 'host':'pegasus', 'generatedAt':datetime.datetime.now(datetime.timezone.utc).isoformat(), 'overall':overall, 'counts':counts,'groups':groups,'checks':checks, 'diagnostics':plain[-24000:], 'commandExitCode':p.returncode}, ensure_ascii=False)) + return {'ok':0,'warn':1,'fail':2}[overall] + +if __name__ == '__main__': + sys.exit(output())