This commit is contained in:
Mark Gregory
2026-10-21 14:39:25 +01:00
parent f76eec0b70
commit 7692274602
63 changed files with 582 additions and 722 deletions
@@ -4,66 +4,50 @@
"Localizable": [ "Localizable": [
{ {
"comment": "", "comment": "",
"key": "192.168.1.2", "key": "SERVERS",
"location": { "location": {
"startingColumn": 22, "startingColumn": 18,
"startingLine": 23 "startingLine": 21
}
},
{
"comment": "",
"key": "ALL SERVERS: %lld OK · %lld WARN · %lld FAIL",
"location": {
"startingColumn": 18,
"startingLine": 76
}
},
{
"comment": "",
"key": "%lld offline",
"location": {
"startingColumn": 26,
"startingLine": 17
} }
}, },
{ {
"comment": "", "comment": "",
"key": "PEGASUS MONITOR", "key": "PEGASUS MONITOR",
"location": { "location": {
"startingColumn": 23, "startingColumn": 27,
"startingLine": 13 "startingLine": 11
} }
}, },
{ {
"comment": "", "comment": "",
"key": "Waiting for Pegasus health data…", "key": "%lld OK",
"location": { "location": {
"startingColumn": 30, "startingColumn": 42,
"startingLine": 62 "startingLine": 38
} }
}, },
{ {
"comment": "", "comment": "",
"key": "No system groups returned by Pegasus.", "key": "Refresh All",
"location": {
"startingColumn": 34,
"startingLine": 36
}
},
{
"comment": "",
"key": "Not checked",
"location": {
"startingColumn": 25,
"startingLine": 83
}
},
{
"comment": "",
"key": "Last checked: %@",
"location": {
"startingColumn": 29,
"startingLine": 84
}
},
{
"comment": "",
"key": "Not checked",
"location": {
"startingColumn": 30,
"startingLine": 85
}
},
{
"comment": "",
"key": "Refresh",
"location": { "location": {
"startingColumn": 24, "startingColumn": 24,
"startingLine": 106 "startingLine": 84
} }
}, },
{ {
@@ -71,7 +55,7 @@
"key": "Diagnostics", "key": "Diagnostics",
"location": { "location": {
"startingColumn": 24, "startingColumn": 24,
"startingLine": 111 "startingLine": 86
} }
}, },
{ {
@@ -79,7 +63,7 @@
"key": "Settings", "key": "Settings",
"location": { "location": {
"startingColumn": 24, "startingColumn": 24,
"startingLine": 115 "startingLine": 87
} }
}, },
{ {
@@ -87,31 +71,23 @@
"key": "Quit", "key": "Quit",
"location": { "location": {
"startingColumn": 24, "startingColumn": 24,
"startingLine": 119 "startingLine": 88
} }
}, },
{ {
"comment": "", "comment": "",
"key": "Latest remote report — refresh from the menu to update", "key": "%@ — Diagnostics",
"location": { "location": {
"startingColumn": 18, "startingColumn": 18,
"startingLine": 149 "startingLine": 98
} }
}, },
{ {
"comment": "", "comment": "",
"key": "Pegasus Diagnostics", "key": "Select a server in the menu bar to change this report.",
"location": { "location": {
"startingColumn": 22, "startingColumn": 18,
"startingLine": 145 "startingLine": 99
}
},
{
"comment": "",
"key": "Close",
"location": {
"startingColumn": 24,
"startingLine": 147
} }
} }
] ]
@@ -2,12 +2,28 @@
"source": "/Volumes/Developer/Applications/PegasusMonitor/PegasusMonitor/PegasusMonitorApp.swift", "source": "/Volumes/Developer/Applications/PegasusMonitor/PegasusMonitor/PegasusMonitorApp.swift",
"tables": { "tables": {
"Localizable": [ "Localizable": [
{
"comment": "",
"key": "Pegasus Monitor Settings",
"location": {
"startingColumn": 16,
"startingLine": 19
}
},
{
"comment": "",
"key": "Server Diagnostics",
"location": {
"startingColumn": 16,
"startingLine": 25
}
},
{ {
"comment": "", "comment": "",
"key": "Pegasus Monitor — %@", "key": "Pegasus Monitor — %@",
"location": { "location": {
"startingColumn": 23, "startingColumn": 23,
"startingLine": 16 "startingLine": 14
} }
} }
] ]
@@ -4,122 +4,130 @@
"Localizable": [ "Localizable": [
{ {
"comment": "", "comment": "",
"key": "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.", "key": "Servers",
"location": { "location": {
"startingColumn": 18, "startingColumn": 22,
"startingLine": 38 "startingLine": 14
} }
}, },
{ {
"comment": "", "comment": "",
"key": "Host", "key": "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.",
"location": { "location": {
"startingColumn": 23, "startingColumn": 22,
"startingLine": 15 "startingLine": 48
} }
}, },
{ {
"comment": "", "comment": "",
"key": "SSH user", "key": "Add a server",
"location": { "location": {
"startingColumn": 23, "startingColumn": 41,
"startingLine": 16 "startingLine": 37
}
},
{
"comment": "",
"key": "Connection details",
"location": {
"startingColumn": 58,
"startingLine": 37
}
},
{
"comment": "",
"key": "Display name",
"location": {
"startingColumn": 31,
"startingLine": 40
}
},
{
"comment": "",
"key": "Host / SSH alias",
"location": {
"startingColumn": 31,
"startingLine": 41
}
},
{
"comment": "",
"key": "SSH username",
"location": {
"startingColumn": 31,
"startingLine": 42
} }
}, },
{ {
"comment": "", "comment": "",
"key": "SSH port", "key": "SSH port",
"location": { "location": {
"startingColumn": 23, "startingColumn": 31,
"startingLine": 17 "startingLine": 43
} }
}, },
{ {
"comment": "", "comment": "",
"key": "SSH key path (optional)", "key": "SSH key path (optional)",
"location": { "location": {
"startingColumn": 23, "startingColumn": 31,
"startingLine": 18 "startingLine": 44
} }
}, },
{ {
"comment": "", "comment": "",
"key": "Leave blank to use your SSH agent or ~/.ssh/config", "key": "Remote health command",
"location": { "location": {
"startingColumn": 23, "startingColumn": 31,
"startingLine": 19 "startingLine": 45
} }
}, },
{ {
"comment": "", "comment": "",
"key": "Refresh interval", "key": "Monitoring enabled",
"location": { "location": {
"startingColumn": 20, "startingColumn": 28,
"startingLine": 20 "startingLine": 46
} }
}, },
{ {
"comment": "", "comment": "",
"key": "Notify when server becomes unhealthy", "key": "New Server",
"location": { "location": {
"startingColumn": 20, "startingColumn": 28,
"startingLine": 27 "startingLine": 52
} }
}, },
{ {
"comment": "", "comment": "",
"key": "1 minute", "key": "Save and Refresh",
"location": { "location": {
"startingColumn": 22, "startingColumn": 28,
"startingLine": 21 "startingLine": 54
} }
}, },
{ {
"comment": "", "comment": "",
"key": "5 minutes", "key": "Refresh every",
"location": { "location": {
"startingColumn": 22, "startingColumn": 28,
"startingLine": 22 "startingLine": 59
} }
}, },
{ {
"comment": "", "comment": "",
"key": "10 minutes", "key": "Notifications",
"location": { "location": {
"startingColumn": 22, "startingColumn": 28,
"startingLine": 23 "startingLine": 64
} }
}, },
{ {
"comment": "", "comment": "",
"key": "15 minutes", "key": "%lld min",
"location": { "location": {
"startingColumn": 22, "startingColumn": 34,
"startingLine": 24 "startingLine": 61
}
},
{
"comment": "",
"key": "30 minutes",
"location": {
"startingColumn": 22,
"startingLine": 25
}
},
{
"comment": "",
"key": "Close",
"location": {
"startingColumn": 24,
"startingLine": 29
}
},
{
"comment": "",
"key": "Apply and Refresh",
"location": {
"startingColumn": 24,
"startingLine": 33
} }
} }
] ]
@@ -1,49 +0,0 @@
{
"" : {
"diagnostics" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/PegasusMonitor-primary.dia",
"emit-module-dependencies" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/PegasusMonitor-primary-emit-module.d",
"emit-module-diagnostics" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/PegasusMonitor-primary-emit-module.dia",
"pch" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/PegasusMonitor-primary-Bridging-header.pch",
"swift-dependencies" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/PegasusMonitor-primary.swiftdeps"
},
"/Volumes/Developer/Applications/PegasusMonitor/PegasusMonitor/MonitorMenuView.swift" : {
"const-values" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/MonitorMenuView.swiftconstvalues",
"dependencies" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/MonitorMenuView.d",
"diagnostics" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/MonitorMenuView.dia",
"index-unit-output-path" : "/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/MonitorMenuView.o",
"llvm-bc" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/MonitorMenuView.bc",
"object" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/MonitorMenuView.o",
"swift-dependencies" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/MonitorMenuView.swiftdeps",
"swiftmodule" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/MonitorMenuView~partial.swiftmodule"
},
"/Volumes/Developer/Applications/PegasusMonitor/PegasusMonitor/PegasusMonitorApp.swift" : {
"const-values" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/PegasusMonitorApp.swiftconstvalues",
"dependencies" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/PegasusMonitorApp.d",
"diagnostics" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/PegasusMonitorApp.dia",
"index-unit-output-path" : "/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/PegasusMonitorApp.o",
"llvm-bc" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/PegasusMonitorApp.bc",
"object" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/PegasusMonitorApp.o",
"swift-dependencies" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/PegasusMonitorApp.swiftdeps",
"swiftmodule" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/PegasusMonitorApp~partial.swiftmodule"
},
"/Volumes/Developer/Applications/PegasusMonitor/PegasusMonitor/ServerMonitor.swift" : {
"const-values" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/ServerMonitor.swiftconstvalues",
"dependencies" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/ServerMonitor.d",
"diagnostics" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/ServerMonitor.dia",
"index-unit-output-path" : "/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/ServerMonitor.o",
"llvm-bc" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/ServerMonitor.bc",
"object" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/ServerMonitor.o",
"swift-dependencies" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/ServerMonitor.swiftdeps",
"swiftmodule" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/ServerMonitor~partial.swiftmodule"
},
"/Volumes/Developer/Applications/PegasusMonitor/PegasusMonitor/SettingsView.swift" : {
"const-values" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/SettingsView.swiftconstvalues",
"dependencies" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/SettingsView.d",
"diagnostics" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/SettingsView.dia",
"index-unit-output-path" : "/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/SettingsView.o",
"llvm-bc" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/SettingsView.bc",
"object" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/SettingsView.o",
"swift-dependencies" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/SettingsView.swiftdeps",
"swiftmodule" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/SettingsView~partial.swiftmodule"
}
}
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
@@ -1,4 +0,0 @@
/Volumes/Developer/Applications/PegasusMonitor/PegasusMonitor/PegasusMonitorApp.swift
/Volumes/Developer/Applications/PegasusMonitor/PegasusMonitor/ServerMonitor.swift
/Volumes/Developer/Applications/PegasusMonitor/PegasusMonitor/MonitorMenuView.swift
/Volumes/Developer/Applications/PegasusMonitor/PegasusMonitor/SettingsView.swift
@@ -1,4 +0,0 @@
/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/PegasusMonitorApp.o
/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/ServerMonitor.o
/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/MonitorMenuView.o
/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/SettingsView.o
@@ -1 +0,0 @@
["AnyResolverProviding","AppEntity","AppEnum","AppExtension","AppIntent","AppIntentsPackage","AppShortcutProviding","AppShortcutsProvider","AppUnionValue","AppUnionValueCasesProviding","DynamicOptionsProvider","EntityQuery","ExtensionPointDefining","IntentValueQuery","Resolver","TransientEntity","_AssistantIntentsProvider","_GenerativeFunctionExtractable","_IntentValueRepresentable"]
@@ -1,4 +0,0 @@
/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/PegasusMonitorApp.swiftconstvalues
/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/ServerMonitor.swiftconstvalues
/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/MonitorMenuView.swiftconstvalues
/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PegasusMonitor.build/Release/PegasusMonitor.build/Objects-normal/arm64/SettingsView.swiftconstvalues
@@ -1,66 +0,0 @@
{
"buildCommand" : {
"command" : "build",
"skipDependencies" : false,
"style" : "buildOnly"
},
"configuredTargets" : [
{
"guid" : "651e0ee2bbff102a3d1ac16211f573867d28c6b0d11cbd2d9a7f0a38de01f763"
}
],
"containerPath" : "/Volumes/Developer/Applications/PegasusMonitor/PegasusMonitor.xcodeproj",
"continueBuildingAfterErrors" : false,
"dependencyScope" : "workspace",
"enableIndexBuildArena" : false,
"hideShellScriptEnvironment" : false,
"parameters" : {
"action" : "build",
"activeArchitecture" : "arm64",
"activeRunDestination" : {
"disableOnlyActiveArch" : false,
"hostTargetedPlatform" : null,
"platform" : "macosx",
"sdk" : "macosx27.0",
"sdkVariant" : "macos",
"supportedArchitectures" : [
"arm64e",
"arm64",
"x86_64"
],
"targetArchitecture" : "arm64"
},
"arenaInfo" : {
"buildIntermediatesPath" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex",
"buildProductsPath" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Products",
"derivedDataPath" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData",
"indexDataStoreFolderPath" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Index.noindex/DataStore",
"indexEnableDataStore" : true,
"indexPCHPath" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Index.noindex/PrecompiledHeaders",
"pchPath" : "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Intermediates.noindex/PrecompiledHeaders"
},
"configurationName" : "Release",
"overrides" : {
"commandLine" : {
"table" : {
"CODE_SIGNING_ALLOWED" : "NO"
}
},
"synthesized" : {
"table" : {
"ACTION" : "build",
"ENABLE_PREVIEWS" : "NO",
"ENABLE_XOJIT_PREVIEWS" : "YES",
"ONLY_ACTIVE_ARCH" : "YES",
"TOOLCHAINS" : "com.apple.dt.toolchain.Metal.32023.921.5 $(inherited)"
}
}
}
},
"schemeCommand" : "launch",
"showNonLoggedProgress" : true,
"useDryRun" : false,
"useImplicitDependencies" : true,
"useLegacyBuildLocations" : false,
"useParallelTargets" : true
}
@@ -1,2 +0,0 @@
Target dependency graph (1 target)
Target 'PegasusMonitor' in project 'PegasusMonitor' (no dependencies)
@@ -4,3 +4,4 @@ f917cf82bd848fc97291bd609bd414d6
4366908824f51bff885c44198535c243 4366908824f51bff885c44198535c243
ae0397526b5e3c6e8848cdfd6a483288 ae0397526b5e3c6e8848cdfd6a483288
4366908824f51bff885c44198535c243 4366908824f51bff885c44198535c243
4f303a1f6ed436c288335f2c2511f2f6
@@ -1,2 +1,3 @@
/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Products/Release/PegasusMonitor.app /Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Products/Release/PegasusMonitor.app
/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Products/Release/PegasusMonitor.app /Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Products/Release/PegasusMonitor.app
/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/Build/Products/Release/PegasusMonitor.app
@@ -23,13 +23,13 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.0</string> <string>1.1.0</string>
<key>CFBundleSupportedPlatforms</key> <key>CFBundleSupportedPlatforms</key>
<array> <array>
<string>MacOSX</string> <string>MacOSX</string>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>2</string>
<key>DTCompiler</key> <key>DTCompiler</key>
<string>com.apple.compilers.llvm.clang.1_0</string> <string>com.apple.compilers.llvm.clang.1_0</string>
<key>DTPlatformBuild</key> <key>DTPlatformBuild</key>
@@ -6,11 +6,11 @@
<dict> <dict>
<key>Resources/AppIcon.icns</key> <key>Resources/AppIcon.icns</key>
<data> <data>
S8C6t1Z/4EVT9S8MtxX+dWOTDDI= vKs1dGjxnr8qvszRig1NygUnEvQ=
</data> </data>
<key>Resources/Assets.car</key> <key>Resources/Assets.car</key>
<data> <data>
nXwOZPAX5QoAWKwBNXu/+rgmZas= 0vWT7uDgnvGjO8uys5wHeLklVEA=
</data> </data>
</dict> </dict>
<key>files2</key> <key>files2</key>
@@ -19,14 +19,14 @@
<dict> <dict>
<key>hash2</key> <key>hash2</key>
<data> <data>
969/63CS71MfGGi22Ly12tj0fLLBfSujCjuZm/Pzg3A= 1Im+MWM/WyHdmIHqPK/QYzToAHalwBwkHPbSyKKKpKI=
</data> </data>
</dict> </dict>
<key>Resources/Assets.car</key> <key>Resources/Assets.car</key>
<dict> <dict>
<key>hash2</key> <key>hash2</key>
<data> <data>
XL2oX1sbJ0IMB/Y+o7sObFkNbTn/DWGDRiC/RrqhQb0= Zu8DqsY66ElekR5oriOrFU+DwTtetDvo/TZkYknqheM=
</data> </data>
</dict> </dict>
</dict> </dict>
@@ -6,7 +6,7 @@
<integer>13</integer> <integer>13</integer>
<key>logs</key> <key>logs</key>
<dict> <dict>
<key>0CF3E8FE-5D7C-4BFF-95CD-9A3DB378E862</key> <key>CC8DC4F3-DD53-4B42-8573-B118D88EBAB2</key>
<dict> <dict>
<key>className</key> <key>className</key>
<string>IDECommandLineBuildLog</string> <string>IDECommandLineBuildLog</string>
@@ -15,175 +15,7 @@
<key>domainType</key> <key>domainType</key>
<string>Xcode.IDEActivityLogDomainType.BuildLog</string> <string>Xcode.IDEActivityLogDomainType.BuildLog</string>
<key>fileName</key> <key>fileName</key>
<string>0CF3E8FE-5D7C-4BFF-95CD-9A3DB378E862.xcactivitylog</string> <string>CC8DC4F3-DD53-4B42-8573-B118D88EBAB2.xcactivitylog</string>
<key>hasPrimaryLog</key>
<true/>
<key>primaryObservable</key>
<dict>
<key>highLevelStatus</key>
<string>S</string>
<key>totalNumberOfAnalyzerIssues</key>
<integer>0</integer>
<key>totalNumberOfErrors</key>
<integer>0</integer>
<key>totalNumberOfTestFailures</key>
<integer>0</integer>
<key>totalNumberOfWarnings</key>
<integer>0</integer>
</dict>
<key>schemeIdentifier-containerName</key>
<string>PegasusMonitor project</string>
<key>schemeIdentifier-schemeName</key>
<string>PegasusMonitor</string>
<key>schemeIdentifier-sharedScheme</key>
<integer>1</integer>
<key>signature</key>
<string>Building project PegasusMonitor with scheme PegasusMonitor and configuration Release</string>
<key>timeStartedRecording</key>
<real>814281795.00424099</real>
<key>timeStoppedRecording</key>
<real>814281795.84052396</real>
<key>title</key>
<string>Building project PegasusMonitor with scheme PegasusMonitor and configuration Release</string>
<key>uniqueIdentifier</key>
<string>0CF3E8FE-5D7C-4BFF-95CD-9A3DB378E862</string>
</dict>
<key>128B3D00-57CF-468F-AFAF-A168A96DF72F</key>
<dict>
<key>className</key>
<string>IDECommandLineBuildLog</string>
<key>documentTypeString</key>
<string>&lt;nil&gt;</string>
<key>domainType</key>
<string>Xcode.IDEActivityLogDomainType.BuildLog</string>
<key>fileName</key>
<string>128B3D00-57CF-468F-AFAF-A168A96DF72F.xcactivitylog</string>
<key>hasPrimaryLog</key>
<true/>
<key>primaryObservable</key>
<dict>
<key>highLevelStatus</key>
<string>S</string>
<key>totalNumberOfAnalyzerIssues</key>
<integer>0</integer>
<key>totalNumberOfErrors</key>
<integer>0</integer>
<key>totalNumberOfTestFailures</key>
<integer>0</integer>
<key>totalNumberOfWarnings</key>
<integer>0</integer>
</dict>
<key>schemeIdentifier-containerName</key>
<string>PegasusMonitor project</string>
<key>schemeIdentifier-schemeName</key>
<string>PegasusMonitor</string>
<key>schemeIdentifier-sharedScheme</key>
<integer>1</integer>
<key>signature</key>
<string>Building project PegasusMonitor with scheme PegasusMonitor and configuration Release</string>
<key>timeStartedRecording</key>
<real>814281713.47929299</real>
<key>timeStoppedRecording</key>
<real>814281714.33905697</real>
<key>title</key>
<string>Building project PegasusMonitor with scheme PegasusMonitor and configuration Release</string>
<key>uniqueIdentifier</key>
<string>128B3D00-57CF-468F-AFAF-A168A96DF72F</string>
</dict>
<key>24D00FB5-EA46-4D8C-9DFD-013A873B2575</key>
<dict>
<key>className</key>
<string>IDECommandLineBuildLog</string>
<key>documentTypeString</key>
<string>&lt;nil&gt;</string>
<key>domainType</key>
<string>Xcode.IDEActivityLogDomainType.BuildLog</string>
<key>fileName</key>
<string>24D00FB5-EA46-4D8C-9DFD-013A873B2575.xcactivitylog</string>
<key>hasPrimaryLog</key>
<true/>
<key>primaryObservable</key>
<dict>
<key>highLevelStatus</key>
<string>S</string>
<key>totalNumberOfAnalyzerIssues</key>
<integer>0</integer>
<key>totalNumberOfErrors</key>
<integer>0</integer>
<key>totalNumberOfTestFailures</key>
<integer>0</integer>
<key>totalNumberOfWarnings</key>
<integer>0</integer>
</dict>
<key>schemeIdentifier-containerName</key>
<string>PegasusMonitor project</string>
<key>schemeIdentifier-schemeName</key>
<string>PegasusMonitor</string>
<key>schemeIdentifier-sharedScheme</key>
<integer>1</integer>
<key>signature</key>
<string>Building project PegasusMonitor with scheme PegasusMonitor and configuration Release</string>
<key>timeStartedRecording</key>
<real>814281613.32994998</real>
<key>timeStoppedRecording</key>
<real>814281614.79972601</real>
<key>title</key>
<string>Building project PegasusMonitor with scheme PegasusMonitor and configuration Release</string>
<key>uniqueIdentifier</key>
<string>24D00FB5-EA46-4D8C-9DFD-013A873B2575</string>
</dict>
<key>4F97B3EC-928A-4943-A881-C363BC254670</key>
<dict>
<key>className</key>
<string>IDECommandLineBuildLog</string>
<key>documentTypeString</key>
<string>&lt;nil&gt;</string>
<key>domainType</key>
<string>Xcode.IDEActivityLogDomainType.BuildLog</string>
<key>fileName</key>
<string>4F97B3EC-928A-4943-A881-C363BC254670.xcactivitylog</string>
<key>hasPrimaryLog</key>
<true/>
<key>primaryObservable</key>
<dict>
<key>highLevelStatus</key>
<string>S</string>
<key>totalNumberOfAnalyzerIssues</key>
<integer>0</integer>
<key>totalNumberOfErrors</key>
<integer>0</integer>
<key>totalNumberOfTestFailures</key>
<integer>0</integer>
<key>totalNumberOfWarnings</key>
<integer>0</integer>
</dict>
<key>schemeIdentifier-containerName</key>
<string>PegasusMonitor project</string>
<key>schemeIdentifier-schemeName</key>
<string>PegasusMonitor</string>
<key>schemeIdentifier-sharedScheme</key>
<integer>1</integer>
<key>signature</key>
<string>Building project PegasusMonitor with scheme PegasusMonitor and configuration Release</string>
<key>timeStartedRecording</key>
<real>814281826.88459897</real>
<key>timeStoppedRecording</key>
<real>814281828.74992299</real>
<key>title</key>
<string>Building project PegasusMonitor with scheme PegasusMonitor and configuration Release</string>
<key>uniqueIdentifier</key>
<string>4F97B3EC-928A-4943-A881-C363BC254670</string>
</dict>
<key>D32A8397-030B-41E2-A72D-ADAD409E6B98</key>
<dict>
<key>className</key>
<string>IDECommandLineBuildLog</string>
<key>documentTypeString</key>
<string>&lt;nil&gt;</string>
<key>domainType</key>
<string>Xcode.IDEActivityLogDomainType.BuildLog</string>
<key>fileName</key>
<string>D32A8397-030B-41E2-A72D-ADAD409E6B98.xcactivitylog</string>
<key>hasPrimaryLog</key> <key>hasPrimaryLog</key>
<true/> <true/>
<key>primaryObservable</key> <key>primaryObservable</key>
@@ -197,7 +29,7 @@
<key>totalNumberOfTestFailures</key> <key>totalNumberOfTestFailures</key>
<integer>0</integer> <integer>0</integer>
<key>totalNumberOfWarnings</key> <key>totalNumberOfWarnings</key>
<integer>10</integer> <integer>2</integer>
</dict> </dict>
<key>schemeIdentifier-containerName</key> <key>schemeIdentifier-containerName</key>
<string>PegasusMonitor project</string> <string>PegasusMonitor project</string>
@@ -208,13 +40,13 @@
<key>signature</key> <key>signature</key>
<string>Building project PegasusMonitor with scheme PegasusMonitor and configuration Release</string> <string>Building project PegasusMonitor with scheme PegasusMonitor and configuration Release</string>
<key>timeStartedRecording</key> <key>timeStartedRecording</key>
<real>814281516.31568694</real> <real>814282733.91194606</real>
<key>timeStoppedRecording</key> <key>timeStoppedRecording</key>
<real>814281518.00763202</real> <real>814282739.63976002</real>
<key>title</key> <key>title</key>
<string>Building project PegasusMonitor with scheme PegasusMonitor and configuration Release</string> <string>Building project PegasusMonitor with scheme PegasusMonitor and configuration Release</string>
<key>uniqueIdentifier</key> <key>uniqueIdentifier</key>
<string>D32A8397-030B-41E2-A72D-ADAD409E6B98</string> <string>CC8DC4F3-DD53-4B42-8573-B118D88EBAB2</string>
</dict> </dict>
<key>E1DE6BCB-D5CB-4252-BF49-D92793CE0E02</key> <key>E1DE6BCB-D5CB-4252-BF49-D92793CE0E02</key>
<dict> <dict>
@@ -1 +1 @@
1792589066.837541: Module build session file for module cache at Path(str: "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/ModuleCache.noindex") 1792589935.5735621: Module build session file for module cache at Path(str: "/Volumes/Developer/Applications/PegasusMonitor/.build/ReleaseDerivedData/ModuleCache.noindex")
+1 -1
View File
@@ -3,7 +3,7 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>LastAccessedDate</key> <key>LastAccessedDate</key>
<date>2026-10-21T13:24:26Z</date> <date>2026-10-21T13:38:54Z</date>
<key>WorkspacePath</key> <key>WorkspacePath</key>
<string>/Volumes/Developer/Applications/PegasusMonitor/PegasusMonitor.xcodeproj</string> <string>/Volumes/Developer/Applications/PegasusMonitor/PegasusMonitor.xcodeproj</string>
</dict> </dict>
+4 -4
View File
@@ -166,7 +166,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = 39R6U3437D; DEVELOPMENT_TEAM = 39R6U3437D;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -174,7 +174,7 @@
INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSPrincipalClass = NSApplication; INFOPLIST_KEY_NSPrincipalClass = NSApplication;
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.1.0;
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.PegasusMonitor; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.PegasusMonitor;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx; SDKROOT = macosx;
@@ -189,7 +189,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = 39R6U3437D; DEVELOPMENT_TEAM = 39R6U3437D;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -197,7 +197,7 @@
INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSPrincipalClass = NSApplication; INFOPLIST_KEY_NSPrincipalClass = NSApplication;
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.1.0;
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.PegasusMonitor; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.PegasusMonitor;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx; SDKROOT = macosx;
+80 -134
View File
@@ -1,153 +1,102 @@
import SwiftUI import SwiftUI
struct MonitorMenuView: View { struct MonitorMenuView: View {
@ObservedObject var monitor: ServerMonitor @ObservedObject var monitor: ServerMonitor
@Environment(\.openWindow) private var openWindow
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
HStack {
// Header VStack(alignment: .leading, spacing: 5) {
VStack(alignment: .leading, spacing: 6) { Label("PEGASUS MONITOR", systemImage: "server.rack").font(.headline)
Label("PEGASUS MONITOR", systemImage: "server.rack") Label(monitor.status.title, systemImage: monitor.status.symbol)
.font(.headline) .foregroundStyle(monitor.status.color)
}
Label( Spacer()
monitor.status.title, if monitor.offlineCount > 0 {
systemImage: monitor.status.symbol Text("\(monitor.offlineCount) offline").foregroundStyle(.red).font(.caption.bold())
) }
.foregroundStyle(monitor.status.color) }.padding(14)
.font(.subheadline.bold())
Text("192.168.1.2")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(16)
Divider() Divider()
Text("SERVERS").font(.caption.bold()).foregroundStyle(.secondary).padding(.horizontal, 14).padding(.top, 12)
// Systems: vertical stack ScrollView {
ScrollView(.vertical) { LazyVStack(spacing: 2) {
LazyVStack(alignment: .leading, spacing: 0) { ForEach(monitor.servers) { server in
if let report = monitor.report { Button {
if report.groups.isEmpty { monitor.select(server.id)
Text("No system groups returned by Pegasus.") } label: {
.foregroundStyle(.secondary) HStack {
.padding(16) Image(systemName: monitor.results[server.id]?.status.symbol ?? "circle.dotted")
} .foregroundStyle(server.enabled ? (monitor.results[server.id]?.status.color ?? .secondary) : .gray)
VStack(alignment: .leading, spacing: 2) {
ForEach(report.groups) { group in Text(server.name).fontWeight(server.id == monitor.selectedID ? .bold : .regular)
VStack(alignment: .leading, spacing: 5) { Text(server.host).font(.caption2).foregroundStyle(.secondary)
Text(group.name) }
.font(.system(size: 13, weight: .semibold)) Spacer()
.fixedSize(horizontal: false, vertical: true) if monitor.results[server.id]?.refreshing == true { ProgressView().controlSize(.mini) }
else if let report = monitor.results[server.id]?.report {
Label( Text("\(report.counts.ok) OK").font(.caption2).foregroundStyle(.secondary)
group.status.title, }
systemImage: group.status.symbol if server.id == monitor.selectedID { Image(systemName: "checkmark") }
) }.padding(.horizontal, 14).padding(.vertical, 8)
.font(.system(size: 12, weight: .medium)) .contentShape(Rectangle())
.foregroundStyle(group.status.color) }.buttonStyle(.plain)
}
.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(maxHeight: 175)
.frame(height: 420)
Divider() Divider()
if let selected = monitor.selected {
// Summary HStack {
if let report = monitor.report { Text(selected.name.uppercased()).font(.caption.bold())
VStack(alignment: .leading, spacing: 6) { Spacer()
Text( Button { Task { await monitor.refreshSelected() } } label: { Image(systemName: "arrow.clockwise") }
"\(report.counts.ok) OK · " + .buttonStyle(.plain)
"\(report.counts.warn) WARN · " + }.padding(.horizontal, 14).padding(.vertical, 10)
"\(report.counts.fail) FAIL" ScrollView {
) LazyVStack(alignment: .leading, spacing: 0) {
.font(.system(size: 12, weight: .semibold)) if let report = monitor.report {
ForEach(report.groups) { group in
Text( HStack {
monitor.lastChecked.map { Text(group.name).font(.system(size: 12, weight: .medium))
"Last checked: \($0.formatted(date: .omitted, time: .shortened))" Spacer()
} ?? "Not checked" Label(group.status.title, systemImage: group.status.symbol)
) .foregroundStyle(group.status.color).font(.caption)
.font(.caption) }.padding(.horizontal, 14).padding(.vertical, 8)
.foregroundStyle(.secondary) Divider().padding(.leading, 14)
} }
.padding(16) } else {
Text(monitor.selectedResult?.error ?? "Waiting for health data…")
.font(.caption).foregroundStyle(.secondary).padding(14)
}
}
}.frame(height: 300)
} }
if let error = monitor.lastError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.textSelection(.enabled)
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
Divider() Divider()
let totals = monitor.aggregate
// Vertical actions Text("ALL SERVERS: \(totals.ok) OK · \(totals.warn) WARN · \(totals.fail) FAIL")
VStack(alignment: .leading, spacing: 8) { .font(.caption.bold()).padding(14)
Button("Refresh") { if let error = monitor.selectedResult?.error {
Task { await monitor.refresh() } Text(error).foregroundStyle(.red).font(.caption).textSelection(.enabled)
} .padding(.horizontal, 14).padding(.bottom, 8)
.disabled(monitor.isRefreshing)
Button("Diagnostics") {
monitor.showDiagnostics = true
}
Button("Settings") {
monitor.showSettings = true
}
Button("Quit") {
NSApplication.shared.terminate(nil)
}
} }
.buttonStyle(.plain) Divider()
.frame(maxWidth: .infinity, alignment: .leading) HStack {
.padding(16) Button("Refresh All") { Task { await monitor.refreshAll() } }
} Spacer()
.frame(width: 320) Button("Diagnostics") { openWindow(id: "monitor-diagnostics") }
.sheet(isPresented: $monitor.showDiagnostics) { Button("Settings") { openWindow(id: "monitor-settings") }
DiagnosticsView(monitor: monitor) Button("Quit") { NSApp.terminate(nil) }
} }.buttonStyle(.borderless).padding(14)
.sheet(isPresented: $monitor.showSettings) { }.frame(width: 410)
SettingsView(monitor: monitor)
.frame(width: 480)
}
} }
} }
struct DiagnosticsView: View { struct DiagnosticsView: View {
@ObservedObject var monitor: ServerMonitor @ObservedObject var monitor: ServerMonitor
@Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack { Text("\(monitor.selected?.name ?? "Server") — Diagnostics").font(.title2.bold())
Text("Pegasus Diagnostics").font(.title2.bold()) Text("Select a server in the menu bar to change this report.").font(.caption).foregroundStyle(.secondary)
Spacer()
Button("Close") { dismiss() }
}
Text("Latest remote report — refresh from the menu to update")
.foregroundStyle(.secondary)
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 10) { LazyVStack(alignment: .leading, spacing: 10) {
ForEach(monitor.report?.checks ?? []) { check in ForEach(monitor.report?.checks ?? []) { check in
@@ -161,10 +110,7 @@ struct DiagnosticsView: View {
} }
} }
} }
} }.textSelection(.enabled)
.textSelection(.enabled) }.padding(20).frame(minWidth: 650, minHeight: 450)
}
.padding(20)
.frame(width: 650, height: 560)
} }
} }
+9 -4
View File
@@ -1,5 +1,4 @@
import SwiftUI import SwiftUI
import UserNotifications
@main @main
struct PegasusMonitorApp: App { struct PegasusMonitorApp: App {
@@ -8,7 +7,6 @@ struct PegasusMonitorApp: App {
var body: some Scene { var body: some Scene {
MenuBarExtra { MenuBarExtra {
MonitorMenuView(monitor: monitor) MonitorMenuView(monitor: monitor)
.frame(width: 380)
} label: { } label: {
Image(systemName: monitor.status.symbol) Image(systemName: monitor.status.symbol)
.symbolRenderingMode(.palette) .symbolRenderingMode(.palette)
@@ -17,9 +15,16 @@ struct PegasusMonitorApp: App {
} }
.menuBarExtraStyle(.window) .menuBarExtraStyle(.window)
Settings { // Real independent windows: editing cannot be dismissed by the menu-bar popover.
Window("Pegasus Monitor Settings", id: "monitor-settings") {
SettingsView(monitor: monitor) SettingsView(monitor: monitor)
.frame(width: 480) .frame(minWidth: 630, minHeight: 470)
} }
.defaultSize(width: 750, height: 570)
Window("Server Diagnostics", id: "monitor-diagnostics") {
DiagnosticsView(monitor: monitor)
}
.defaultSize(width: 760, height: 600)
} }
} }
+142 -104
View File
@@ -4,53 +4,13 @@ import UserNotifications
enum HealthStatus: String, Codable { enum HealthStatus: String, Codable {
case ok, warn, fail case ok, warn, fail
var title: String { switch self { case .ok: "Healthy"; case .warn: "Warning"; case .fail: "Critical" } }
var title: String { var symbol: String { switch self { case .ok: "checkmark.circle.fill"; case .warn: "exclamationmark.triangle.fill"; case .fail: "xmark.circle.fill" } }
switch self { var color: Color { switch self { case .ok: .green; case .warn: .orange; case .fail: .red } }
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 HealthCounts: Codable { struct HealthGroup: Codable, Identifiable { let name: String; let status: HealthStatus; let missing: [String]; var id: String { name } }
let ok: Int struct HealthCheck: Codable, Identifiable { let name: String; let status: HealthStatus; let detail: String; let section: String; var id: String { "\(section)/\(name)" } }
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 { struct HealthReport: Codable {
let schema: Int let schema: Int
let host: String? let host: String?
@@ -62,71 +22,153 @@ struct HealthReport: Codable {
let diagnostics: String? let diagnostics: String?
} }
struct SSHSettings { struct ServerProfile: Identifiable, Codable, Equatable {
@AppStorage("server.host") var host = "192.168.1.2" var id: UUID = UUID()
@AppStorage("server.user") var user = "mag" var name: String = "New Server"
@AppStorage("server.port") var port = "22" var host: String = ""
@AppStorage("server.keyFile") var keyFile = "" var user: String = ""
@AppStorage("monitor.intervalMinutes") var intervalMinutes = 5 var port: String = "22"
@AppStorage("monitor.notifications") var notifications = true var keyFile: String = ""
var remoteCommand: String = "/usr/local/bin/pegasus-monitor-health-json"
var enabled: Bool = 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 @MainActor
final class ServerMonitor: ObservableObject { final class ServerMonitor: ObservableObject {
@Published private(set) var report: HealthReport? @Published private(set) var servers: [ServerProfile] = []
@Published private(set) var status: HealthStatus = .warn @Published private(set) var results: [UUID: ServerResult] = [:]
@Published private(set) var isRefreshing = false @Published var selectedID: UUID?
@Published private(set) var lastChecked: Date? @Published var intervalMinutes: Int { didSet { UserDefaults.standard.set(intervalMinutes, forKey: "monitor.intervalMinutes"); configureTimer() } }
@Published private(set) var lastError: String? @Published var notifications: Bool { didSet { UserDefaults.standard.set(notifications, forKey: "monitor.notifications") } }
@Published var showDiagnostics = false
@Published var showSettings = false
private var settings = SSHSettings()
private var timer: Timer? private var timer: Timer?
private var everLoaded = false private var visited = Set<UUID>()
private let profilesKey = "monitor.serverProfiles.v1"
private let selectedKey = "monitor.selectedServer.v1"
init() { 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() configureTimer()
Task { await refresh() } 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 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) }
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() { func configureTimer() {
timer?.invalidate() timer?.invalidate()
let minutes = max(1, settings.intervalMinutes) timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(max(1, intervalMinutes) * 60), repeats: true) { [weak self] _ in
timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(minutes * 60), repeats: true) { [weak self] _ in Task { @MainActor [weak self] in await self?.refreshAll() }
Task { @MainActor in await self?.refresh() }
} }
} }
func refreshAll() async {
func refresh() async { await withTaskGroup(of: Void.self) { group in
guard !isRefreshing else { return } for server in servers where server.enabled {
isRefreshing = true group.addTask { await self.refresh(id: server.id) }
defer { isRefreshing = false; lastChecked = Date() } }
let previous = report?.overall }
}
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 { 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 data = try await SSHRunner.run(host: server.host, user: server.user, port: server.port,
let value = try JSONDecoder().decode(HealthReport.self, from: data) keyFile: server.keyFile, remoteCommand: server.remoteCommand)
guard value.schema == 1, value.groups.count == 14 else { throw SSHError.invalidResponse } let report = try JSONDecoder().decode(HealthReport.self, from: data)
report = value guard report.schema == 1 else { throw SSHError.invalidResponse }
status = value.overall state.report = report
lastError = nil state.error = nil
if settings.notifications && everLoaded && previous == .ok && value.overall != .ok { if notifications && visited.contains(id) && previous == .ok && report.overall != .ok {
notify(title: "Pegasus \(value.overall.title)", message: "\(value.counts.fail) failures, \(value.counts.warn) warnings") notify(title: "\(server.name): \(report.overall.title)", message: "\(report.counts.fail) failures, \(report.counts.warn) warnings")
} }
everLoaded = true
} catch { } catch {
lastError = error.localizedDescription state.error = error.localizedDescription
status = .fail if notifications && visited.contains(id) && previous != .fail {
if settings.notifications && everLoaded && previous != .fail { notify(title: "\(server.name) unreachable", message: error.localizedDescription)
notify(title: "Pegasus Unreachable", message: error.localizedDescription)
} }
} }
state.lastChecked = Date()
state.refreshing = false
results[id] = state
visited.insert(id)
} }
func showSystemSettings() {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}
private func notify(title: String, message: String) { private func notify(title: String, message: String) {
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound]) { allowed, _ in center.requestAuthorization(options: [.alert, .sound]) { allowed, _ in
@@ -140,12 +182,11 @@ final class ServerMonitor: ObservableObject {
} }
enum SSHError: LocalizedError { enum SSHError: LocalizedError {
case invalidSettings, timeout, invalidResponse, process(String) case invalidSettings, invalidResponse, process(String)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .invalidSettings: "Invalid SSH settings. Check host, user and port." case .invalidSettings: "Invalid SSH settings (host, user, port or command)."
case .timeout: "SSH connection timed out." case .invalidResponse: "Invalid health JSON. Install the remote health adapter."
case .invalidResponse: "Server returned invalid health JSON. Install the server adapter."
case .process(let text): text case .process(let text): text
} }
} }
@@ -154,23 +195,21 @@ enum SSHError: LocalizedError {
enum SSHRunner { enum SSHRunner {
static func run(host: String, user: String, port: String, keyFile: String, remoteCommand: String) async throws -> Data { 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), guard !host.isEmpty, !user.isEmpty, let number = Int(port), (1...65535).contains(number),
host.range(of: #"^[A-Za-z0-9.:-]+$"#, options: .regularExpression) != nil, host.range(of: #"^[A-Za-z0-9._:-]+$"#, options: .regularExpression) != nil,
user.range(of: #"^[A-Za-z0-9_-]+$"#, options: .regularExpression) != nil else { user.range(of: #"^[A-Za-z0-9_-]+$"#, options: .regularExpression) != nil,
throw SSHError.invalidSettings !remoteCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw SSHError.invalidSettings }
}
return try await Task.detached(priority: .utility) { return try await Task.detached(priority: .utility) {
let process = Process() let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") 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)] 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] } if !keyFile.isEmpty { args += ["-i", (keyFile as NSString).expandingTildeInPath] }
args += ["--", "\(user)@\(host)", remoteCommand] args += ["\(user)@\(host)", remoteCommand]
process.arguments = args process.arguments = args
let output = Pipe() let output = Pipe()
let errors = Pipe() let errors = Pipe()
process.standardOutput = output process.standardOutput = output
process.standardError = errors process.standardError = errors
try process.run() try process.run()
// Drain both pipes concurrently so verbose output cannot block the child.
let stdout = Task.detached { output.fileHandleForReading.readDataToEndOfFile() } let stdout = Task.detached { output.fileHandleForReading.readDataToEndOfFile() }
let stderr = Task.detached { errors.fileHandleForReading.readDataToEndOfFile() } let stderr = Task.detached { errors.fileHandleForReading.readDataToEndOfFile() }
let timeout = Task.detached { let timeout = Task.detached {
@@ -181,10 +220,9 @@ enum SSHRunner {
let errorBytes = await stderr.value let errorBytes = await stderr.value
process.waitUntilExit() process.waitUntilExit()
timeout.cancel() timeout.cancel()
// Health status exits 1/2 intentionally; JSON remains authoritative.
if bytes.isEmpty { if bytes.isEmpty {
let errorMessage = String(data: errorBytes, encoding: .utf8) ?? "SSH returned no data" let message = String(data: errorBytes, encoding: .utf8) ?? "SSH returned no data"
throw SSHError.process(errorMessage.isEmpty ? "SSH returned no data" : errorMessage) throw SSHError.process(message.isEmpty ? "SSH returned no data" : message)
} }
return bytes return bytes
}.value }.value
+89 -32
View File
@@ -2,43 +2,100 @@ import SwiftUI
struct SettingsView: View { struct SettingsView: View {
@ObservedObject var monitor: ServerMonitor @ObservedObject var monitor: ServerMonitor
@Environment(\.dismiss) private var dismiss @State private var draft = ServerProfile()
@AppStorage("server.host") private var host = "192.168.1.2" @State private var editingID: UUID?
@AppStorage("server.user") private var user = "mag" @State private var validation = ""
@AppStorage("server.port") private var port = "22" @State private var interval = 5
@AppStorage("server.keyFile") private var keyFile = "" @State private var notifications = true
@AppStorage("monitor.intervalMinutes") private var intervalMinutes = 5
@AppStorage("monitor.notifications") private var notifications = true
var body: some View { var body: some View {
Form { HStack(spacing: 0) {
TextField("Host", text: $host) VStack(spacing: 8) {
TextField("SSH user", text: $user) Text("Servers").font(.headline).frame(maxWidth: .infinity, alignment: .leading)
TextField("SSH port", text: $port) List(selection: $editingID) {
TextField("SSH key path (optional)", text: $keyFile) ForEach(monitor.servers) { server in
.help("Leave blank to use your SSH agent or ~/.ssh/config") HStack {
Picker("Refresh interval", selection: $intervalMinutes) { Image(systemName: "server.rack")
Text("1 minute").tag(1) Text(server.name)
Text("5 minutes").tag(5) if !server.enabled { Image(systemName: "pause.circle") }
Text("10 minutes").tag(10) }.tag(server.id)
Text("15 minutes").tag(15) }
Text("30 minutes").tag(30) .onMove { monitor.move(from: $0, to: $1) }
}
Toggle("Notify when server becomes unhealthy", isOn: $notifications)
HStack {
Button("Close") {
dismiss()
} }
Spacer() HStack {
Button("Apply and Refresh") { Button { newProfile() } label: { Image(systemName: "plus") }
monitor.configureTimer() Button { if let id = editingID { monitor.remove(id); editingID = monitor.servers.first?.id } } label: { Image(systemName: "minus") }
Task { await monitor.refresh() } .disabled(editingID == nil)
Spacer()
}
.buttonStyle(.borderless)
}
.frame(width: 205)
.padding()
Divider()
VStack(alignment: .leading, spacing: 14) {
Text(editingID == nil ? "Add a server" : "Connection details")
.font(.title3.bold())
Form {
TextField("Display name", text: $draft.name)
TextField("Host / SSH alias", text: $draft.host)
TextField("SSH username", text: $draft.user)
TextField("SSH port", text: $draft.port)
TextField("SSH key path (optional)", text: $draft.keyFile)
TextField("Remote health command", text: $draft.remoteCommand)
Toggle("Monitoring enabled", isOn: $draft.enabled)
}
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) }
HStack {
Button("New Server") { newProfile() }
Spacer()
Button("Save and Refresh") { save() }
.buttonStyle(.borderedProminent)
}
Divider()
HStack {
Picker("Refresh every", selection: $interval) {
ForEach([1, 5, 10, 15, 30], id: \.self) { value in
Text("\(value) min").tag(value)
}
}
Toggle("Notifications", isOn: $notifications)
} }
} }
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.") .padding(20)
.font(.caption)
.foregroundStyle(.secondary)
} }
.padding(20) .onAppear {
interval = monitor.intervalMinutes
notifications = monitor.notifications
editingID = monitor.selectedID
if let selected = monitor.selected { draft = selected }
}
.onChange(of: editingID) { _, newValue in
if let id = newValue, let server = monitor.servers.first(where: { $0.id == id }) { draft = server }
validation = ""
}
.onChange(of: interval) { _, value in monitor.intervalMinutes = value }
.onChange(of: notifications) { _, value in monitor.notifications = value }
}
private func newProfile() {
editingID = nil
draft = ServerProfile()
validation = ""
}
private func save() {
guard !draft.name.trimmingCharacters(in: .whitespaces).isEmpty,
!draft.host.trimmingCharacters(in: .whitespaces).isEmpty,
!draft.user.trimmingCharacters(in: .whitespaces).isEmpty,
let port = Int(draft.port), (1...65535).contains(port),
!draft.remoteCommand.trimmingCharacters(in: .whitespaces).isEmpty else {
validation = "Enter a name, host, SSH username, valid port and remote command."
return
}
monitor.save(draft)
editingID = draft.id
validation = ""
} }
} }
+34
View File
@@ -0,0 +1,34 @@
# Pegasus Monitor 1.1.0 (build 2)
## What's new
- Monitor multiple Ubuntu/Debian servers via independent SSH profiles.
- Add, edit, remove and reorder servers; persist profiles locally in macOS UserDefaults.
- Select a server from the menu bar; view its original health groups and diagnostics.
- Consolidated health totals and offline detection; parallel refresh across enabled servers.
- Remote profiles support SSH host aliases, custom key files and individual remote JSON commands.
- New generic read-only Linux health adapter (`server/remote-health-json`).
- Settings and Diagnostics are now **independent windows**, rather than sheets tied to the menu bar window. Editing a field will not dismiss Settings when the menu bar loses focus.
- Retains Pegasus's existing specialised health adapter, application icon, code signing and notarization workflow.
## Install the adapter on another Linux server
Copy `server/remote-health-json` to the remote host, e.g.:
```sh
scp server/remote-health-json USER@HOST:/tmp/pegasus-monitor-health-json
ssh USER@HOST 'sudo install -m 755 /tmp/pegasus-monitor-health-json /usr/local/bin/pegasus-monitor-health-json'
ssh USER@HOST /usr/local/bin/pegasus-monitor-health-json
```
The remote adapter requires Python 3 and built-in utilities; it runs without sudo. Docker checks require appropriate Docker group access; if unavailable they report warning rather than modify permissions.
In Settings → New Server, set the server name, SSH host, username, port and remote command `/usr/local/bin/pegasus-monitor-health-json`. Select Save and Refresh. Configure SSH keys and known_hosts **before** adding the server; interactive password prompts are intentionally disabled.
For Pegasus, the migrated connection continues using `sudo -n /usr/local/bin/pegasus-health-json` and retains its existing 14-group report and 72 checks.
## Notes
- Existing single-server settings migrate automatically on first launch.
- The old preferences are left intact for rollback.
- Profiles do not store passwords or private key contents.
- No remote installations or privileged actions are performed by the Mac app.
- Build and notarize on macOS with Xcode using the existing Scripts directory. The ZIP contains source, not a prebuilt signed application.
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Generic read-only Linux health adapter for Pegasus Monitor 1.1.
Install as /usr/local/bin/pegasus-monitor-health-json and run as an ordinary user.
No third-party Python packages required.
"""
import datetime
import json
import os
import shutil
import socket
import subprocess
checks = []
def run(args, timeout=8):
try:
return subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False)
except (OSError, subprocess.TimeoutExpired):
return None
def check(section, name, status, detail):
checks.append(dict(section=section, name=name, status=status, detail=str(detail)[:500]))
host = socket.gethostname()
try:
load = os.getloadavg()
cpus = os.cpu_count() or 1
check('System', 'CPU load', 'warn' if load[0] > cpus * 1.5 else 'ok',
f'1m load {load[0]:.2f}; {cpus} CPUs')
except OSError as exc:
check('System', 'CPU load', 'warn', str(exc))
try:
with open('/proc/meminfo', encoding='utf-8') as f:
values = {parts[0].rstrip(':'): int(parts[1]) for line in f if (parts := line.split()) and len(parts) > 1}
total = values['MemTotal']
available = values.get('MemAvailable', values.get('MemFree', 0))
pct = round(100 * (total - available) / total)
check('System', 'Memory', 'fail' if pct >= 95 else 'warn' if pct >= 85 else 'ok', f'{pct}% used')
except (OSError, KeyError, ZeroDivisionError, ValueError) as exc:
check('System', 'Memory', 'warn', str(exc))
for directory in ('/', '/backup'):
if directory != '/' and not os.path.ismount(directory):
continue
try:
usage = shutil.disk_usage(directory)
pct = round(100 * usage.used / usage.total)
check('Storage', f'Disk {directory}', 'fail' if pct >= 95 else 'warn' if pct >= 85 else 'ok', f'{pct}% used')
except (OSError, ZeroDivisionError) as exc:
check('Storage', f'Disk {directory}', 'warn', str(exc))
systemctl = run(['systemctl', '--failed', '--no-legend', '--plain'])
if systemctl is not None:
failed = [line for line in systemctl.stdout.splitlines() if line.strip()]
check('Services', 'Failed systemd units', 'fail' if failed else 'ok',
', '.join(failed[:5]) if failed else 'None')
if shutil.which('docker'):
docker = run(['docker', 'ps', '--format', '{{.Names}}'], timeout=12)
if docker and docker.returncode == 0:
names = [name for name in docker.stdout.splitlines() if name]
check('Docker', 'Docker containers', 'ok', f'{len(names)} running: {", ".join(names[:8])}')
else:
check('Docker', 'Docker access', 'warn', 'Docker is installed but unavailable to this SSH user')
for service in ('nginx', 'mysql', 'postgresql', 'redis-server', 'gitea'):
result = run(['systemctl', 'is-enabled', service])
if not result or result.returncode != 0:
continue
active = run(['systemctl', 'is-active', service])
working = active is not None and active.stdout.strip() == 'active'
check('Services', service, 'ok' if working else 'fail', 'active' if working else 'inactive')
groups = []
for section in dict.fromkeys(c['section'] for c in checks):
relevant = [c for c in checks if c['section'] == section]
status = 'fail' if any(c['status'] == 'fail' for c in relevant) else 'warn' if any(c['status'] == 'warn' for c in relevant) else 'ok'
groups.append(dict(name=section, status=status, missing=[]))
counts = {s: sum(c['status'] == s for c in checks) for s in ('ok', 'warn', 'fail')}
overall = 'fail' if counts['fail'] else 'warn' if counts['warn'] else 'ok'
print(json.dumps(dict(schema=1, host=host, generatedAt=datetime.datetime.now(datetime.timezone.utc).isoformat(),
overall=overall, counts=counts, groups=groups, checks=checks, diagnostics=None)))