Iniital release
This commit is contained in:
Generated
+8
@@ -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
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="EMPTY_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/PegasusMonitor.iml" filepath="$PROJECT_DIR$/.idea/PegasusMonitor.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
@@ -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 = "<group>"; };
|
||||
BB0000000000000000000002 /* ServerMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerMonitor.swift; sourceTree = "<group>"; };
|
||||
BB0000000000000000000003 /* MonitorMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonitorMenuView.swift; sourceTree = "<group>"; };
|
||||
BB0000000000000000000004 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>";
|
||||
};
|
||||
EE0000000000000000000002 /* PegasusMonitor */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BB0000000000000000000001 /* PegasusMonitorApp.swift */,
|
||||
BB0000000000000000000002 /* ServerMonitor.swift */,
|
||||
BB0000000000000000000003 /* MonitorMenuView.swift */,
|
||||
BB0000000000000000000004 /* SettingsView.swift */,
|
||||
);
|
||||
path = PegasusMonitor;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EE0000000000000000000003 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CC0000000000000000000001 /* PegasusMonitor.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
BIN
Binary file not shown.
+14
@@ -0,0 +1,14 @@
|
||||
<?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>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>PegasusMonitor.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user