460 lines
20 KiB
Swift
460 lines
20 KiB
Swift
import SwiftUI
|
|
|
|
struct MonitorMenuView: View {
|
|
@ObservedObject var monitor: ServerMonitor
|
|
@Environment(\.openWindow) private var openWindow
|
|
@State private var detailItem: MonitorDetailItem?
|
|
|
|
private func showDetails(
|
|
title: String,
|
|
status: String,
|
|
details: String
|
|
) {
|
|
detailItem = MonitorDetailItem(
|
|
title: title,
|
|
status: status,
|
|
details: details
|
|
)
|
|
}
|
|
|
|
private func copyDetails(_ details: String) {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(
|
|
details,
|
|
forType: .string
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
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()
|
|
HStack {
|
|
Text("SERVERS")
|
|
.font(.caption.bold())
|
|
.foregroundStyle(.secondary)
|
|
|
|
Spacer()
|
|
|
|
Menu {
|
|
ForEach(monitor.servers) { server in
|
|
Button {
|
|
monitor.select(server.id)
|
|
} label: {
|
|
if server.id == monitor.selectedID {
|
|
Label(server.name, systemImage: "checkmark")
|
|
} else {
|
|
Text(server.name)
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
Label("Switch Server", systemImage: "chevron.down.circle")
|
|
.font(.caption)
|
|
}
|
|
}
|
|
.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 {
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text("\(report.counts.ok) OK").font(.caption2)
|
|
if let uptime = report.uptimeLabel {
|
|
Text("Up \(uptime)").font(.caption2)
|
|
}
|
|
}.foregroundStyle(.secondary)
|
|
}
|
|
if server.id == monitor.selectedID { Image(systemName: "checkmark") }
|
|
}.padding(.horizontal, 14).padding(.vertical, 8)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.contextMenu {
|
|
Button("Show Details") {
|
|
let result = monitor.results[server.id]
|
|
let report = result?.report
|
|
|
|
let failures = report?.checks.filter {
|
|
$0.status != .ok
|
|
} ?? []
|
|
|
|
let checkDetails = failures.map {
|
|
"\($0.section) / \($0.name) [\($0.status.title)]\\n\($0.detail)"
|
|
}.joined(separator: "\\n\\n")
|
|
|
|
let missingGroups = report?.groups
|
|
.filter { $0.status != .ok }
|
|
.map {
|
|
"\($0.name): \($0.missing.joined(separator: ", "))"
|
|
}
|
|
.joined(separator: "\\n") ?? ""
|
|
|
|
let details = [
|
|
"Server: \(server.name)",
|
|
"Host: \(server.host)",
|
|
"Status: \(result?.status.title ?? "Unknown")",
|
|
result?.error ?? "",
|
|
missingGroups,
|
|
checkDetails,
|
|
report?.diagnostics ?? ""
|
|
]
|
|
.filter { !$0.isEmpty }
|
|
.joined(separator: "\\n\\n")
|
|
|
|
showDetails(
|
|
title: server.name,
|
|
status: result?.status.title ?? "Unknown",
|
|
details: details
|
|
)
|
|
}
|
|
|
|
Button("Copy Error Details") {
|
|
let result = monitor.results[server.id]
|
|
let failures = result?.report?.checks
|
|
.filter { $0.status != .ok }
|
|
.map {
|
|
"\($0.name): \($0.detail)"
|
|
}
|
|
.joined(separator: "\\n") ?? ""
|
|
|
|
copyDetails(
|
|
result?.error
|
|
?? (failures.isEmpty
|
|
? "No errors reported."
|
|
: failures)
|
|
)
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("Refresh Server") {
|
|
Task {
|
|
await monitor.refresh(id: server.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(height: min(CGFloat(max(monitor.servers.count, 1)) * 58, 175))
|
|
Divider()
|
|
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 {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "clock.arrow.circlepath")
|
|
.foregroundStyle(.secondary)
|
|
Text("Uptime").font(.system(size: 12, weight: .medium))
|
|
Spacer()
|
|
Text(report.uptimeLabel ?? "Not available")
|
|
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.horizontal, 14).padding(.vertical, 10)
|
|
Divider()
|
|
if !monitor.selectedServices.isEmpty {
|
|
Text("SERVICES")
|
|
.font(.caption.bold()).foregroundStyle(.secondary)
|
|
.padding(.horizontal, 14).padding(.vertical, 8)
|
|
ForEach(monitor.selectedServices) { service in
|
|
HStack(spacing: 8) {
|
|
Image(systemName: service.symbol).foregroundStyle(service.color)
|
|
Text(service.name).font(.system(size: 12))
|
|
Spacer()
|
|
if selected.requiredServices.contains(service.name) {
|
|
Text("REQUIRED").font(.system(size: 9, weight: .bold)).foregroundStyle(.orange)
|
|
}
|
|
Text(service.title).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
.padding(.horizontal, 14).padding(.vertical, 5)
|
|
.contentShape(Rectangle())
|
|
.contextMenu {
|
|
Button("Show Details") {
|
|
showDetails(
|
|
title: service.name,
|
|
status: service.title,
|
|
details: service.detail
|
|
)
|
|
}
|
|
|
|
Button("Copy Details") {
|
|
copyDetails(service.detail)
|
|
}
|
|
}
|
|
}
|
|
Divider()
|
|
Text("HEALTH GROUPS")
|
|
.font(.caption.bold()).foregroundStyle(.secondary)
|
|
.padding(.horizontal, 14).padding(.vertical, 8)
|
|
}
|
|
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)
|
|
.contentShape(Rectangle())
|
|
.contextMenu {
|
|
Button("Show Details") {
|
|
let related = report.checks.filter {
|
|
group.missing.contains($0.name)
|
|
|| (
|
|
$0.status != .ok
|
|
&& $0.section == group.name
|
|
)
|
|
}
|
|
|
|
let messages = related.map {
|
|
"\($0.name): \($0.detail)"
|
|
}.joined(separator: "\\n\\n")
|
|
|
|
let missing = group.missing.isEmpty
|
|
? ""
|
|
: "Missing: "
|
|
+ group.missing.joined(separator: ", ")
|
|
|
|
let details = [
|
|
"Group: \(group.name)",
|
|
"Status: \(group.status.title)",
|
|
missing,
|
|
messages.isEmpty
|
|
? (group.status == .ok
|
|
? "All required checks passed."
|
|
: "No matching individual check details were returned.")
|
|
: messages
|
|
]
|
|
.filter { !$0.isEmpty }
|
|
.joined(separator: "\\n\\n")
|
|
|
|
showDetails(
|
|
title: group.name,
|
|
status: group.status.title,
|
|
details: details
|
|
)
|
|
}
|
|
|
|
Button("Copy Details") {
|
|
copyDetails(
|
|
"\(group.name): \(group.status.title)\\n"
|
|
+ group.missing.joined(separator: "\\n")
|
|
)
|
|
}
|
|
}
|
|
|
|
Divider().padding(.leading, 14)
|
|
}
|
|
} else {
|
|
Text(monitor.selectedResult?.error ?? "Waiting for health data…")
|
|
.font(.caption).foregroundStyle(.secondary).padding(14)
|
|
}
|
|
}
|
|
}.frame(height: 300)
|
|
}
|
|
Divider()
|
|
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)
|
|
}
|
|
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)
|
|
.sheet(item: $detailItem) { item in
|
|
MonitorDetailSheet(item: item)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct DiagnosticsView: View {
|
|
@ObservedObject var monitor: ServerMonitor
|
|
@State private var detailItem: MonitorDetailItem?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
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
|
|
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()
|
|
}
|
|
.contentShape(Rectangle())
|
|
.contextMenu {
|
|
Button("Show Details") {
|
|
detailItem = MonitorDetailItem(
|
|
title: check.name,
|
|
status: check.status.title,
|
|
details: """
|
|
Section: \(check.section)
|
|
|
|
\(check.detail)
|
|
"""
|
|
)
|
|
}
|
|
|
|
Button("Copy Details") {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(
|
|
check.detail,
|
|
forType: .string
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}.textSelection(.enabled)
|
|
}
|
|
.padding(20)
|
|
.frame(minWidth: 650, minHeight: 450)
|
|
.sheet(item: $detailItem) { item in
|
|
MonitorDetailSheet(item: item)
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// MARK: - Monitoring Details
|
|
|
|
struct MonitorDetailItem: Identifiable {
|
|
let id = UUID()
|
|
let title: String
|
|
let status: String
|
|
let details: String
|
|
}
|
|
|
|
struct MonitorDetailSheet: View {
|
|
let item: MonitorDetailItem
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
HStack {
|
|
Image(systemName: icon)
|
|
.font(.title2)
|
|
.foregroundStyle(statusColor)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(item.title)
|
|
.font(.title2.bold())
|
|
|
|
Text(item.status)
|
|
.foregroundStyle(statusColor)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
|
|
Divider()
|
|
|
|
ScrollView {
|
|
Text(item.details.isEmpty
|
|
? "No additional diagnostic information available."
|
|
: item.details)
|
|
.font(.system(
|
|
size: 12,
|
|
design: .monospaced
|
|
))
|
|
.frame(
|
|
maxWidth: .infinity,
|
|
alignment: .leading
|
|
)
|
|
.textSelection(.enabled)
|
|
.padding(12)
|
|
}
|
|
.background(.quaternary.opacity(0.3))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
|
|
HStack {
|
|
Button("Copy Details") {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(
|
|
item.details,
|
|
forType: .string
|
|
)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button("Close") {
|
|
dismiss()
|
|
}
|
|
.keyboardShortcut(.defaultAction)
|
|
}
|
|
}
|
|
.padding(20)
|
|
.frame(minWidth: 540, minHeight: 350)
|
|
}
|
|
|
|
private var icon: String {
|
|
switch item.status.lowercased() {
|
|
case "critical", "failed":
|
|
return "xmark.circle.fill"
|
|
case "warning", "stopped":
|
|
return "exclamationmark.triangle.fill"
|
|
default:
|
|
return "checkmark.circle.fill"
|
|
}
|
|
}
|
|
|
|
private var statusColor: Color {
|
|
switch item.status.lowercased() {
|
|
case "critical", "failed":
|
|
return .red
|
|
case "warning", "stopped":
|
|
return .orange
|
|
default:
|
|
return .green
|
|
}
|
|
}
|
|
}
|