updates
This commit is contained in:
@@ -1,153 +1,102 @@
|
||||
import SwiftUI
|
||||
|
||||
|
||||
|
||||
struct MonitorMenuView: View {
|
||||
@ObservedObject var monitor: ServerMonitor
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
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)
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Label("PEGASUS MONITOR", systemImage: "server.rack").font(.headline)
|
||||
Label(monitor.status.title, systemImage: monitor.status.symbol)
|
||||
.foregroundStyle(monitor.status.color)
|
||||
}
|
||||
Spacer()
|
||||
if monitor.offlineCount > 0 {
|
||||
Text("\(monitor.offlineCount) offline").foregroundStyle(.red).font(.caption.bold())
|
||||
}
|
||||
}.padding(14)
|
||||
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)
|
||||
Text("SERVERS").font(.caption.bold()).foregroundStyle(.secondary).padding(.horizontal, 14).padding(.top, 12)
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 2) {
|
||||
ForEach(monitor.servers) { server in
|
||||
Button {
|
||||
monitor.select(server.id)
|
||||
} label: {
|
||||
HStack {
|
||||
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) {
|
||||
Text(server.name).fontWeight(server.id == monitor.selectedID ? .bold : .regular)
|
||||
Text(server.host).font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if monitor.results[server.id]?.refreshing == true { ProgressView().controlSize(.mini) }
|
||||
else if let report = monitor.results[server.id]?.report {
|
||||
Text("\(report.counts.ok) OK").font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
if server.id == monitor.selectedID { Image(systemName: "checkmark") }
|
||||
}.padding(.horizontal, 14).padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
}.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 420)
|
||||
|
||||
}.frame(maxHeight: 175)
|
||||
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 selected = monitor.selected {
|
||||
HStack {
|
||||
Text(selected.name.uppercased()).font(.caption.bold())
|
||||
Spacer()
|
||||
Button { Task { await monitor.refreshSelected() } } label: { Image(systemName: "arrow.clockwise") }
|
||||
.buttonStyle(.plain)
|
||||
}.padding(.horizontal, 14).padding(.vertical, 10)
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
if let report = monitor.report {
|
||||
ForEach(report.groups) { group in
|
||||
HStack {
|
||||
Text(group.name).font(.system(size: 12, weight: .medium))
|
||||
Spacer()
|
||||
Label(group.status.title, systemImage: group.status.symbol)
|
||||
.foregroundStyle(group.status.color).font(.caption)
|
||||
}.padding(.horizontal, 14).padding(.vertical, 8)
|
||||
Divider().padding(.leading, 14)
|
||||
}
|
||||
} 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()
|
||||
|
||||
// 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)
|
||||
}
|
||||
let totals = monitor.aggregate
|
||||
Text("ALL SERVERS: \(totals.ok) OK · \(totals.warn) WARN · \(totals.fail) FAIL")
|
||||
.font(.caption.bold()).padding(14)
|
||||
if let error = monitor.selectedResult?.error {
|
||||
Text(error).foregroundStyle(.red).font(.caption).textSelection(.enabled)
|
||||
.padding(.horizontal, 14).padding(.bottom, 8)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
Divider()
|
||||
HStack {
|
||||
Button("Refresh All") { Task { await monitor.refreshAll() } }
|
||||
Spacer()
|
||||
Button("Diagnostics") { openWindow(id: "monitor-diagnostics") }
|
||||
Button("Settings") { openWindow(id: "monitor-settings") }
|
||||
Button("Quit") { NSApp.terminate(nil) }
|
||||
}.buttonStyle(.borderless).padding(14)
|
||||
}.frame(width: 410)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
Text("\(monitor.selected?.name ?? "Server") — Diagnostics").font(.title2.bold())
|
||||
Text("Select a server in the menu bar to change this report.").font(.caption).foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(monitor.report?.checks ?? []) { check in
|
||||
@@ -161,10 +110,7 @@ struct DiagnosticsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 650, height: 560)
|
||||
}.textSelection(.enabled)
|
||||
}.padding(20).frame(minWidth: 650, minHeight: 450)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
struct PegasusMonitorApp: App {
|
||||
@@ -8,7 +7,6 @@ struct PegasusMonitorApp: App {
|
||||
var body: some Scene {
|
||||
MenuBarExtra {
|
||||
MonitorMenuView(monitor: monitor)
|
||||
.frame(width: 380)
|
||||
} label: {
|
||||
Image(systemName: monitor.status.symbol)
|
||||
.symbolRenderingMode(.palette)
|
||||
@@ -17,9 +15,16 @@ struct PegasusMonitorApp: App {
|
||||
}
|
||||
.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)
|
||||
.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
@@ -4,53 +4,13 @@ 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
|
||||
}
|
||||
}
|
||||
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 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?
|
||||
@@ -62,71 +22,153 @@ struct HealthReport: Codable {
|
||||
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
|
||||
struct ServerProfile: Identifiable, Codable, Equatable {
|
||||
var id: UUID = UUID()
|
||||
var name: String = "New Server"
|
||||
var host: String = ""
|
||||
var user: String = ""
|
||||
var port: String = "22"
|
||||
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
|
||||
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()
|
||||
@Published private(set) var servers: [ServerProfile] = []
|
||||
@Published private(set) var results: [UUID: ServerResult] = [:]
|
||||
@Published var selectedID: UUID?
|
||||
@Published var intervalMinutes: Int { didSet { UserDefaults.standard.set(intervalMinutes, forKey: "monitor.intervalMinutes"); configureTimer() } }
|
||||
@Published var notifications: Bool { didSet { UserDefaults.standard.set(notifications, forKey: "monitor.notifications") } }
|
||||
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() {
|
||||
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()
|
||||
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() {
|
||||
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() }
|
||||
timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(max(1, intervalMinutes) * 60), repeats: true) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in await self?.refreshAll() }
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
guard !isRefreshing else { return }
|
||||
isRefreshing = true
|
||||
defer { isRefreshing = false; lastChecked = Date() }
|
||||
let previous = report?.overall
|
||||
func refreshAll() async {
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for server in servers where server.enabled {
|
||||
group.addTask { await self.refresh(id: server.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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")
|
||||
let data = try await SSHRunner.run(host: server.host, user: server.user, port: server.port,
|
||||
keyFile: server.keyFile, remoteCommand: server.remoteCommand)
|
||||
let report = try JSONDecoder().decode(HealthReport.self, from: data)
|
||||
guard report.schema == 1 else { throw SSHError.invalidResponse }
|
||||
state.report = report
|
||||
state.error = nil
|
||||
if notifications && visited.contains(id) && previous == .ok && report.overall != .ok {
|
||||
notify(title: "\(server.name): \(report.overall.title)", message: "\(report.counts.fail) failures, \(report.counts.warn) warnings")
|
||||
}
|
||||
everLoaded = true
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
status = .fail
|
||||
if settings.notifications && everLoaded && previous != .fail {
|
||||
notify(title: "Pegasus Unreachable", message: error.localizedDescription)
|
||||
state.error = error.localizedDescription
|
||||
if notifications && visited.contains(id) && previous != .fail {
|
||||
notify(title: "\(server.name) 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) {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.requestAuthorization(options: [.alert, .sound]) { allowed, _ in
|
||||
@@ -140,12 +182,11 @@ final class ServerMonitor: ObservableObject {
|
||||
}
|
||||
|
||||
enum SSHError: LocalizedError {
|
||||
case invalidSettings, timeout, invalidResponse, process(String)
|
||||
case invalidSettings, 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 .invalidSettings: "Invalid SSH settings (host, user, port or command)."
|
||||
case .invalidResponse: "Invalid health JSON. Install the remote health adapter."
|
||||
case .process(let text): text
|
||||
}
|
||||
}
|
||||
@@ -154,23 +195,21 @@ enum SSHError: LocalizedError {
|
||||
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
|
||||
}
|
||||
host.range(of: #"^[A-Za-z0-9._:-]+$"#, options: .regularExpression) != nil,
|
||||
user.range(of: #"^[A-Za-z0-9_-]+$"#, options: .regularExpression) != nil,
|
||||
!remoteCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 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]
|
||||
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 {
|
||||
@@ -181,10 +220,9 @@ enum SSHRunner {
|
||||
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)
|
||||
let message = String(data: errorBytes, encoding: .utf8) ?? "SSH returned no data"
|
||||
throw SSHError.process(message.isEmpty ? "SSH returned no data" : message)
|
||||
}
|
||||
return bytes
|
||||
}.value
|
||||
|
||||
@@ -2,43 +2,100 @@ 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
|
||||
@State private var draft = ServerProfile()
|
||||
@State private var editingID: UUID?
|
||||
@State private var validation = ""
|
||||
@State private var interval = 5
|
||||
@State 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()
|
||||
HStack(spacing: 0) {
|
||||
VStack(spacing: 8) {
|
||||
Text("Servers").font(.headline).frame(maxWidth: .infinity, alignment: .leading)
|
||||
List(selection: $editingID) {
|
||||
ForEach(monitor.servers) { server in
|
||||
HStack {
|
||||
Image(systemName: "server.rack")
|
||||
Text(server.name)
|
||||
if !server.enabled { Image(systemName: "pause.circle") }
|
||||
}.tag(server.id)
|
||||
}
|
||||
.onMove { monitor.move(from: $0, to: $1) }
|
||||
}
|
||||
Spacer()
|
||||
Button("Apply and Refresh") {
|
||||
monitor.configureTimer()
|
||||
Task { await monitor.refresh() }
|
||||
HStack {
|
||||
Button { newProfile() } label: { Image(systemName: "plus") }
|
||||
Button { if let id = editingID { monitor.remove(id); editingID = monitor.servers.first?.id } } label: { Image(systemName: "minus") }
|
||||
.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.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(20)
|
||||
}
|
||||
.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 = ""
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user