Iniital release

This commit is contained in:
Mark Gregory
2026-10-21 14:07:45 +01:00
commit 5c56299e46
19 changed files with 1555 additions and 0 deletions
+170
View File
@@ -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)
}
}
+25
View File
@@ -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)
}
}
}
+192
View File
@@ -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
}
}
+44
View File
@@ -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)
}
}