172 lines
8.1 KiB
Swift
172 lines
8.1 KiB
Swift
import SwiftUI
|
|
|
|
struct SettingsView: View {
|
|
@ObservedObject var monitor: ServerMonitor
|
|
@State private var draft = ServerProfile()
|
|
@State private var editingID: UUID?
|
|
@State private var validation = ""
|
|
@State private var interval = 5
|
|
@State private var notifications = true
|
|
@State private var newRequiredService = ""
|
|
@State private var showUninstalled = false
|
|
|
|
var body: some View {
|
|
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) }
|
|
}
|
|
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)
|
|
Toggle("Automatically discover services", isOn: $draft.discoverServices)
|
|
}
|
|
serviceConfiguration
|
|
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)
|
|
}
|
|
}
|
|
.padding(20)
|
|
.frame(minWidth: 460)
|
|
}
|
|
.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 = ""
|
|
newRequiredService = ""
|
|
}
|
|
.onChange(of: interval) { _, value in monitor.intervalMinutes = value }
|
|
.onChange(of: notifications) { _, value in monitor.notifications = value }
|
|
}
|
|
|
|
private var serviceConfiguration: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
Text("Service monitoring").font(.headline)
|
|
Spacer()
|
|
Button("Refresh services") {
|
|
if let id = editingID { Task { await monitor.refresh(id: id) } }
|
|
}.disabled(editingID == nil)
|
|
}
|
|
Text("Mark critical services as Required. Missing or non-running required services count as failures. Optional services not installed do not affect health.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
Toggle("Show not installed services", isOn: $showUninstalled)
|
|
.font(.caption)
|
|
let discovered = editingID.map { monitor.discoveredServices(for: $0) } ?? []
|
|
let display = discovered.filter { showUninstalled || $0.isInstalled || draft.requiredServices.contains($0.name) }
|
|
if display.isEmpty {
|
|
Text("No service discovery data yet. Refresh the server or add a required service below.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(spacing: 6) {
|
|
ForEach(display) { service in
|
|
HStack(spacing: 8) {
|
|
Image(systemName: service.symbol).foregroundStyle(service.color).frame(width: 20)
|
|
Text(service.name).frame(maxWidth: .infinity, alignment: .leading)
|
|
Text(service.title).font(.caption).foregroundStyle(.secondary)
|
|
Toggle("Required", isOn: Binding(
|
|
get: { draft.requiredServices.contains(service.name) },
|
|
set: { required in setRequired(service.name, required: required) }
|
|
)).labelsHidden().help("Require \(service.name) to be running")
|
|
}
|
|
.font(.system(size: 12))
|
|
}
|
|
}
|
|
}.frame(maxHeight: 190)
|
|
}
|
|
HStack {
|
|
TextField("Add required service (e.g. mysql)", text: $newRequiredService)
|
|
.onSubmit { addRequiredService() }
|
|
Button("Add Required") { addRequiredService() }
|
|
.disabled(newRequiredService.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
}
|
|
if !draft.requiredServices.isEmpty {
|
|
Text("Required: \(draft.requiredServices.sorted().joined(separator: ", "))")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setRequired(_ name: String, required: Bool) {
|
|
if required && !draft.requiredServices.contains(name) { draft.requiredServices.append(name) }
|
|
if !required { draft.requiredServices.removeAll { $0 == name } }
|
|
}
|
|
private func addRequiredService() {
|
|
let name = newRequiredService.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !name.isEmpty, name.range(of: #"^[a-zA-Z0-9@_.-]+$"#, options: .regularExpression) != nil else {
|
|
validation = "Use a valid systemd service name (letters, digits, @, _, -, or .)."
|
|
return
|
|
}
|
|
setRequired(name, required: true)
|
|
newRequiredService = ""
|
|
validation = ""
|
|
}
|
|
private func newProfile() {
|
|
editingID = nil
|
|
draft = ServerProfile()
|
|
validation = ""
|
|
newRequiredService = ""
|
|
}
|
|
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 = ""
|
|
}
|
|
}
|