813 lines
28 KiB
Swift
813 lines
28 KiB
Swift
//
|
||
// MainView.swift
|
||
// iKeyMon
|
||
//
|
||
// Created by tracer on 30.03.25.
|
||
//
|
||
|
||
import SwiftUI
|
||
import Combine
|
||
import UserNotifications
|
||
import SwiftData
|
||
import AppKit
|
||
import UniformTypeIdentifiers
|
||
|
||
struct MainView: View {
|
||
|
||
private static let serverOrderKeyStatic = "serverOrder"
|
||
private static let storedServersKeyStatic = "storedServers"
|
||
private static let storedGroupsKeyStatic = "storedGroups"
|
||
|
||
@State var showAddServerSheet: Bool = false
|
||
@State private var showAddGroupSheet: Bool = false
|
||
@State private var serverBeingEdited: Server?
|
||
@State private var groupBeingEdited: ServerGroup?
|
||
@State private var serverToDelete: Server?
|
||
@State private var groupToDelete: ServerGroup?
|
||
@State private var showDeleteConfirmation = false
|
||
@State private var showDeleteGroupConfirmation = false
|
||
@State private var isFetchingInfo: Bool = false
|
||
@AppStorage("pingInterval") private var pingInterval: Int = 10
|
||
@AppStorage("refreshInterval") private var refreshInterval: Int = 60
|
||
@AppStorage("enableStatusNotifications") private var enableStatusNotifications: Bool = true
|
||
@AppStorage("enableAlertNotifications") private var enableAlertNotifications: Bool = true
|
||
@State private var refreshTimer: Timer.TimerPublisher?
|
||
@State private var refreshSubscription: AnyCancellable?
|
||
@State private var pingTimer: Timer?
|
||
@State private var restartingServerID: UUID?
|
||
@State private var draggedGroupID: UUID?
|
||
@State private var groupDropIndicator: GroupDropIndicator?
|
||
@State private var lastRefreshInterval: Int?
|
||
@State private var lastMetricPrune: Date?
|
||
@State private var previousServiceStates: [String: String] = [:]
|
||
private let serverOrderKey = MainView.serverOrderKeyStatic
|
||
private let storedGroupsKey = MainView.storedGroupsKeyStatic
|
||
@Environment(\.modelContext) private var modelContext
|
||
|
||
@State private var servers: [Server] = MainView.loadStoredServers()
|
||
@State private var groups: [ServerGroup] = MainView.loadStoredGroups()
|
||
|
||
// @State private var selectedServer: Server?
|
||
@State private var selectedServerID: UUID?
|
||
|
||
var body: some View {
|
||
var mainContent: some View {
|
||
NavigationSplitView {
|
||
ZStack {
|
||
SidebarMaterialView()
|
||
|
||
List(selection: $selectedServerID) {
|
||
sidebarContent
|
||
}
|
||
.listStyle(.sidebar)
|
||
.scrollContentBackground(.hidden)
|
||
.background(Color.clear)
|
||
}
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 0, style: .continuous)
|
||
.fill(.ultraThinMaterial)
|
||
)
|
||
.overlay(alignment: .trailing) {
|
||
Rectangle()
|
||
.fill(Color.white.opacity(0.08))
|
||
.frame(width: 1)
|
||
}
|
||
.toolbar {
|
||
ToolbarItem(placement: .primaryAction) {
|
||
Menu {
|
||
Button("Add Host") {
|
||
showAddServerSheet = true
|
||
}
|
||
Button("Add Group") {
|
||
showAddGroupSheet = true
|
||
}
|
||
} label: {
|
||
Image(systemName: "plus")
|
||
}
|
||
.help("Add Host or Group")
|
||
}
|
||
}
|
||
.navigationTitle("Servers")
|
||
.onChange(of: selectedServerID) {
|
||
if let selectedServerID {
|
||
UserDefaults.standard.set(selectedServerID.uuidString, forKey: "selectedServerID")
|
||
fetchServerInfo(for: selectedServerID)
|
||
}
|
||
}
|
||
} detail: {
|
||
if let selectedServerID,
|
||
let index = servers.firstIndex(where: { selectedServerID == $0.id }) {
|
||
let serverID = servers[index].id
|
||
ServerDetailView(
|
||
server: $servers[index],
|
||
isFetching: isFetchingInfo,
|
||
canRestart: servers[index].info?.supportsRestartCommand == true,
|
||
isRestarting: restartingServerID == serverID
|
||
) {
|
||
await restartServer(for: serverID)
|
||
}
|
||
} else {
|
||
ContentUnavailableView("No Server Selected", systemImage: "server.rack")
|
||
}
|
||
}
|
||
}
|
||
return mainContent
|
||
.sheet(isPresented: $showAddServerSheet) {
|
||
ServerFormView(
|
||
mode: .add,
|
||
servers: $servers,
|
||
groups: $groups,
|
||
dismiss: { showAddServerSheet = false }
|
||
)
|
||
}
|
||
.sheet(isPresented: $showAddGroupSheet) {
|
||
GroupFormView(mode: .add, groups: $groups) {
|
||
saveGroups()
|
||
}
|
||
}
|
||
.sheet(item: $serverBeingEdited) { server in
|
||
ServerFormView(
|
||
mode: .edit(server),
|
||
servers: $servers,
|
||
groups: $groups,
|
||
dismiss: { serverBeingEdited = nil }
|
||
)
|
||
}
|
||
.sheet(item: $groupBeingEdited) { group in
|
||
GroupFormView(mode: .edit(group), groups: $groups) {
|
||
saveGroups()
|
||
}
|
||
}
|
||
.alert("Are you sure you want to delete this server?", isPresented: $showDeleteConfirmation, presenting: serverToDelete) { server in
|
||
Button("Delete", role: .destructive) {
|
||
ServerFormView.delete(server: server, from: &servers)
|
||
saveServers()
|
||
}
|
||
Button("Cancel", role: .cancel) {}
|
||
}
|
||
.alert("Are you sure you want to delete this group?", isPresented: $showDeleteGroupConfirmation, presenting: groupToDelete) { group in
|
||
Button("Delete", role: .destructive) {
|
||
deleteGroup(group)
|
||
}
|
||
Button("Cancel", role: .cancel) {}
|
||
} message: { group in
|
||
Text("Servers in \(group.name) will remain available and become ungrouped.")
|
||
}
|
||
.onAppear {
|
||
requestNotificationPermissions()
|
||
|
||
let initialID: UUID?
|
||
if let storedID = UserDefaults.standard.string(forKey: "selectedServerID"),
|
||
let uuid = UUID(uuidString: storedID),
|
||
servers.contains(where: { $0.id == uuid }) {
|
||
print("✅ [MainView] Restored selected server \(uuid)")
|
||
initialID = uuid
|
||
} else if let first = servers.first {
|
||
print("✅ [MainView] Selecting first server \(first.hostname)")
|
||
initialID = first.id
|
||
} else {
|
||
print("ℹ️ [MainView] No stored selection")
|
||
initialID = nil
|
||
}
|
||
|
||
selectedServerID = initialID
|
||
|
||
if let initialID {
|
||
Task {
|
||
await prefetchOtherServers(activeID: initialID)
|
||
}
|
||
}
|
||
setupTimers()
|
||
}
|
||
.onChange(of: pingInterval) { _, _ in
|
||
setupPingTimer()
|
||
}
|
||
.onChange(of: refreshInterval) { oldValue, newValue in
|
||
if oldValue != newValue {
|
||
setupRefreshTimer()
|
||
}
|
||
}
|
||
.onChange(of: groups) { _, _ in
|
||
saveGroups()
|
||
}
|
||
.frame(minWidth: 800, minHeight: 450)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var sidebarContent: some View {
|
||
if groups.isEmpty {
|
||
ForEach(servers) { server in
|
||
sidebarRow(for: server)
|
||
}
|
||
.onMove(perform: moveServer)
|
||
} else {
|
||
ForEach(groups) { group in
|
||
Section {
|
||
ForEach(servers(in: group)) { server in
|
||
sidebarRow(for: server)
|
||
}
|
||
.onMove { source, destination in
|
||
moveServers(in: group.id, from: source, to: destination)
|
||
}
|
||
} header: {
|
||
groupHeader(for: group)
|
||
}
|
||
}
|
||
|
||
if !ungroupedServers.isEmpty {
|
||
Section {
|
||
ForEach(ungroupedServers) { server in
|
||
sidebarRow(for: server)
|
||
}
|
||
.onMove { source, destination in
|
||
moveServers(in: nil, from: source, to: destination)
|
||
}
|
||
} header: {
|
||
sidebarSectionHeader("Ungrouped")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func sidebarSectionHeader(_ title: String) -> some View {
|
||
HStack {
|
||
Text(title)
|
||
.font(.system(size: NSFont.systemFontSize + 1, weight: .bold))
|
||
.foregroundStyle(Color.accentColor)
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(.vertical, 4)
|
||
}
|
||
|
||
private func sidebarRow(for server: Server) -> some View {
|
||
HStack {
|
||
Image(systemName: "dot.circle.fill")
|
||
.foregroundColor(server.pingable ? .green : .red)
|
||
Text(server.hostname)
|
||
}
|
||
.tag(server.id)
|
||
.contextMenu {
|
||
Button("Edit") {
|
||
print("Editing:", server.hostname)
|
||
serverBeingEdited = server
|
||
}
|
||
Divider()
|
||
Button("Delete", role: .destructive) {
|
||
serverToDelete = server
|
||
showDeleteConfirmation = true
|
||
}
|
||
}
|
||
}
|
||
|
||
private func groupHeader(for group: ServerGroup) -> some View {
|
||
let activePlacement = groupDropIndicator?.groupID == group.id ? groupDropIndicator?.placement : nil
|
||
|
||
return VStack(spacing: 0) {
|
||
if activePlacement == .before {
|
||
dropIndicator
|
||
}
|
||
|
||
sidebarSectionHeader(group.name)
|
||
.contentShape(Rectangle())
|
||
.background {
|
||
if activePlacement != nil {
|
||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||
.fill(Color.accentColor.opacity(0.12))
|
||
}
|
||
}
|
||
|
||
if activePlacement == .after {
|
||
dropIndicator
|
||
}
|
||
}
|
||
.onDrag {
|
||
draggedGroupID = group.id
|
||
return NSItemProvider(object: group.id.uuidString as NSString)
|
||
}
|
||
.onDrop(
|
||
of: [UTType.text],
|
||
delegate: GroupDropDelegate(
|
||
targetGroup: group,
|
||
groups: $groups,
|
||
draggedGroupID: $draggedGroupID,
|
||
indicator: $groupDropIndicator
|
||
)
|
||
)
|
||
.contextMenu {
|
||
Button("Edit Group") {
|
||
groupBeingEdited = group
|
||
}
|
||
Button("Delete Group", role: .destructive) {
|
||
groupToDelete = group
|
||
showDeleteGroupConfirmation = true
|
||
}
|
||
}
|
||
}
|
||
|
||
private var dropIndicator: some View {
|
||
VStack(spacing: 4) {
|
||
Capsule()
|
||
.fill(Color.accentColor)
|
||
.frame(height: 3)
|
||
.shadow(color: Color.accentColor.opacity(0.25), radius: 1, y: 0)
|
||
Color.clear
|
||
.frame(height: 4)
|
||
}
|
||
.padding(.vertical, 2)
|
||
}
|
||
|
||
private var ungroupedServers: [Server] {
|
||
servers.filter { server in
|
||
guard let groupID = server.groupID else { return true }
|
||
return groups.contains(where: { $0.id == groupID }) == false
|
||
}
|
||
}
|
||
|
||
private func servers(in group: ServerGroup) -> [Server] {
|
||
servers.filter { $0.groupID == group.id }
|
||
}
|
||
|
||
private func fetchServerInfo(for id: UUID) {
|
||
guard let server = servers.first(where: { $0.id == id }) else {
|
||
print("❌ [MainView] fetchServerInfo: server not found for id \(id)")
|
||
return
|
||
}
|
||
guard let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||
!apiKey.isEmpty else {
|
||
print("❌ [MainView] fetchServerInfo: missing API key for \(server.hostname)")
|
||
return
|
||
}
|
||
guard let baseURL = URL(string: "https://\(server.hostname)") else {
|
||
print("❌ [MainView] Invalid base URL for \(server.hostname)")
|
||
return
|
||
}
|
||
|
||
isFetchingInfo = true
|
||
|
||
Task {
|
||
defer { isFetchingInfo = false }
|
||
do {
|
||
let api = try await APIFactory.detectAndCreateAPI(baseURL: baseURL, apiKey: apiKey)
|
||
let info = try await api.fetchServerSummary(apiKey: apiKey)
|
||
await MainActor.run {
|
||
if let index = servers.firstIndex(where: { $0.id == id }) {
|
||
var updated = servers[index]
|
||
updated.info = info
|
||
servers[index] = updated
|
||
recordMetricSample(for: id, info: info)
|
||
checkServiceStatusChanges(for: server.hostname, newInfo: info)
|
||
}
|
||
}
|
||
} catch {
|
||
print("❌ Failed to fetch server data: \(error)")
|
||
}
|
||
}
|
||
}
|
||
|
||
private func prefetchOtherServers(activeID: UUID) async {
|
||
let others = servers.filter { $0.id != activeID }
|
||
await withTaskGroup(of: Void.self) { group in
|
||
for server in others {
|
||
group.addTask {
|
||
await fetchServerInfoAsync(for: server.id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func fetchServerInfoAsync(for id: UUID) async {
|
||
guard let server = servers.first(where: { $0.id == id }) else { return }
|
||
guard let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||
!apiKey.isEmpty,
|
||
let baseURL = URL(string: "https://\(server.hostname)")
|
||
else { return }
|
||
|
||
do {
|
||
let api = try await APIFactory.detectAndCreateAPI(baseURL: baseURL, apiKey: apiKey)
|
||
let info = try await api.fetchServerSummary(apiKey: apiKey)
|
||
await MainActor.run {
|
||
if let index = servers.firstIndex(where: { $0.id == id }) {
|
||
var updated = servers[index]
|
||
updated.info = info
|
||
servers[index] = updated
|
||
recordMetricSample(for: id, info: info)
|
||
}
|
||
}
|
||
} catch {
|
||
print("❌ Prefetch failed for \(server.hostname): \(error)")
|
||
}
|
||
}
|
||
|
||
private func moveServer(from source: IndexSet, to destination: Int) {
|
||
servers.move(fromOffsets: source, toOffset: destination)
|
||
saveServers()
|
||
saveServerOrder()
|
||
}
|
||
|
||
private func moveServers(in groupID: UUID?, from source: IndexSet, to destination: Int) {
|
||
let matchingServers = servers.filter { server in
|
||
if let groupID {
|
||
return server.groupID == groupID
|
||
}
|
||
return server.groupID == nil || groups.contains(where: { $0.id == server.groupID }) == false
|
||
}
|
||
|
||
var reorderedServers = matchingServers
|
||
reorderedServers.move(fromOffsets: source, toOffset: destination)
|
||
|
||
let replacements = Dictionary(uniqueKeysWithValues: reorderedServers.map { ($0.id, $0) })
|
||
var reorderedIDs = reorderedServers.map(\.id)
|
||
|
||
servers = servers.map { server in
|
||
let belongsInSection: Bool
|
||
if let groupID {
|
||
belongsInSection = server.groupID == groupID
|
||
} else {
|
||
belongsInSection = server.groupID == nil || groups.contains(where: { $0.id == server.groupID }) == false
|
||
}
|
||
|
||
guard belongsInSection, let nextID = reorderedIDs.first else {
|
||
return server
|
||
}
|
||
|
||
reorderedIDs.removeFirst()
|
||
return replacements[nextID] ?? server
|
||
}
|
||
|
||
saveServers()
|
||
saveServerOrder()
|
||
}
|
||
|
||
private func saveServerOrder() {
|
||
let ids = servers.map { $0.id.uuidString }
|
||
UserDefaults.standard.set(ids, forKey: serverOrderKey)
|
||
print("💾 [MainView] Saved server order with \(ids.count) entries")
|
||
}
|
||
|
||
private func saveServers() {
|
||
if let data = try? JSONEncoder().encode(servers) {
|
||
UserDefaults.standard.set(data, forKey: MainView.storedServersKeyStatic)
|
||
}
|
||
}
|
||
|
||
private func saveGroups() {
|
||
if let data = try? JSONEncoder().encode(groups) {
|
||
UserDefaults.standard.set(data, forKey: storedGroupsKey)
|
||
}
|
||
}
|
||
|
||
private func recordMetricSample(for serverID: UUID, info: ServerInfo) {
|
||
let sample = MetricSample(
|
||
serverID: serverID,
|
||
cpuPercent: info.load.percent,
|
||
memoryPercent: info.memory.percent,
|
||
swapPercent: info.swap.percent,
|
||
diskPercent: info.diskSpace.percent
|
||
)
|
||
modelContext.insert(sample)
|
||
|
||
do {
|
||
try modelContext.save()
|
||
} catch {
|
||
print("❌ [MainView] Failed to save metric sample: \(error)")
|
||
}
|
||
|
||
pruneOldMetricSamplesIfNeeded()
|
||
}
|
||
|
||
private func pruneOldMetricSamplesIfNeeded() {
|
||
let now = Date()
|
||
|
||
if let lastMetricPrune, now.timeIntervalSince(lastMetricPrune) < 3600 {
|
||
return
|
||
}
|
||
|
||
let cutoff = now.addingTimeInterval(-30 * 24 * 60 * 60)
|
||
let descriptor = FetchDescriptor<MetricSample>(
|
||
predicate: #Predicate { sample in
|
||
sample.timestamp < cutoff
|
||
}
|
||
)
|
||
|
||
do {
|
||
let expiredSamples = try modelContext.fetch(descriptor)
|
||
for sample in expiredSamples {
|
||
modelContext.delete(sample)
|
||
}
|
||
if !expiredSamples.isEmpty {
|
||
try modelContext.save()
|
||
}
|
||
lastMetricPrune = now
|
||
} catch {
|
||
print("❌ [MainView] Failed to prune metric samples: \(error)")
|
||
}
|
||
}
|
||
|
||
private func deleteGroup(_ group: ServerGroup) {
|
||
groups.removeAll { $0.id == group.id }
|
||
for index in servers.indices {
|
||
if servers[index].groupID == group.id {
|
||
servers[index].groupID = nil
|
||
}
|
||
}
|
||
saveGroups()
|
||
saveServers()
|
||
}
|
||
|
||
private struct PingResponse: Codable {
|
||
let response: String
|
||
}
|
||
|
||
func pingAllServers() {
|
||
let pingTargets = servers.map { ($0.id, $0.hostname) }
|
||
|
||
for (serverID, hostname) in pingTargets {
|
||
Task {
|
||
let apiKey = KeychainHelper.loadApiKey(for: hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
let pingable = await PingService.ping(hostname: hostname, apiKey: apiKey, notificationsEnabled: enableStatusNotifications)
|
||
await MainActor.run {
|
||
guard let index = servers.firstIndex(where: { $0.id == serverID }) else {
|
||
return
|
||
}
|
||
servers[index].pingable = pingable
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func setupTimers() {
|
||
setupPingTimer()
|
||
setupRefreshTimer()
|
||
}
|
||
|
||
private func setupPingTimer() {
|
||
pingTimer?.invalidate()
|
||
pingAllServers()
|
||
pingTimer = Timer.scheduledTimer(withTimeInterval: Double(pingInterval), repeats: true) { _ in
|
||
pingAllServers()
|
||
}
|
||
}
|
||
|
||
private func setupRefreshTimer() {
|
||
refreshSubscription?.cancel()
|
||
refreshSubscription = nil
|
||
refreshTimer = Timer.publish(every: Double(refreshInterval), on: .main, in: .common)
|
||
refreshSubscription = refreshTimer?.autoconnect().sink { _ in
|
||
for server in servers {
|
||
fetchServerInfo(for: server.id)
|
||
}
|
||
}
|
||
}
|
||
|
||
private static func loadStoredServers() -> [Server] {
|
||
let defaults = UserDefaults.standard
|
||
guard let data = defaults.data(forKey: storedServersKeyStatic) else {
|
||
print("ℹ️ [MainView] No storedServers data found")
|
||
return []
|
||
}
|
||
do {
|
||
let saved = try JSONDecoder().decode([Server].self, from: data)
|
||
print("📦 [MainView] Loaded \(saved.count) servers from UserDefaults")
|
||
if let order = defaults.stringArray(forKey: serverOrderKeyStatic) {
|
||
let idMap = order.compactMap(UUID.init)
|
||
let sorted = saved.sorted { a, b in
|
||
guard
|
||
let i1 = idMap.firstIndex(of: a.id),
|
||
let i2 = idMap.firstIndex(of: b.id)
|
||
else { return false }
|
||
return i1 < i2
|
||
}
|
||
return sorted
|
||
}
|
||
return saved
|
||
} catch {
|
||
print("❌ [MainView] Failed to decode stored servers: \(error)")
|
||
return []
|
||
}
|
||
}
|
||
|
||
private static func loadStoredGroups() -> [ServerGroup] {
|
||
let defaults = UserDefaults.standard
|
||
guard let data = defaults.data(forKey: storedGroupsKeyStatic) else {
|
||
return []
|
||
}
|
||
|
||
do {
|
||
return try JSONDecoder().decode([ServerGroup].self, from: data)
|
||
} catch {
|
||
print("❌ [MainView] Failed to decode stored groups: \(error)")
|
||
return []
|
||
}
|
||
}
|
||
|
||
private func requestNotificationPermissions() {
|
||
Task {
|
||
do {
|
||
try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])
|
||
} catch {
|
||
print("❌ [MainView] Failed to request notification permissions: \(error)")
|
||
}
|
||
}
|
||
}
|
||
|
||
private func checkServiceStatusChanges(for hostname: String, newInfo: ServerInfo) {
|
||
guard let ports = newInfo.ports else { return }
|
||
|
||
for port in ports {
|
||
let key = "\(hostname)-\(port.id)"
|
||
let previousStatus = previousServiceStates[key]
|
||
let currentStatus = port.status
|
||
|
||
previousServiceStates[key] = currentStatus
|
||
|
||
if let previousStatus, previousStatus != currentStatus {
|
||
if currentStatus == "offline" && enableStatusNotifications {
|
||
sendServiceNotification(service: port.service, hostname: hostname, status: "offline")
|
||
} else if currentStatus == "online" && previousStatus == "offline" && enableStatusNotifications {
|
||
sendServiceNotification(service: port.service, hostname: hostname, status: "online")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func sendServiceNotification(service: String, hostname: String, status: String) {
|
||
let content = UNMutableNotificationContent()
|
||
content.title = "\(service) \(status.uppercased())"
|
||
content.body = "\(service) on \(hostname) is \(status)"
|
||
content.sound = .default
|
||
|
||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||
UNUserNotificationCenter.current().add(request)
|
||
}
|
||
|
||
private func restartServer(for id: UUID) async -> ServerActionFeedback {
|
||
guard let server = servers.first(where: { $0.id == id }) else {
|
||
return ServerActionFeedback(
|
||
title: "Reboot Failed",
|
||
message: "The selected server could not be found."
|
||
)
|
||
}
|
||
|
||
guard server.info?.supportsRestartCommand == true else {
|
||
return ServerActionFeedback(
|
||
title: "Reboot Unavailable",
|
||
message: "\(server.hostname) does not support remote reboot via the API."
|
||
)
|
||
}
|
||
|
||
guard let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||
!apiKey.isEmpty else {
|
||
return ServerActionFeedback(
|
||
title: "Reboot Failed",
|
||
message: "No API key is configured for \(server.hostname)."
|
||
)
|
||
}
|
||
|
||
guard let baseURL = URL(string: "https://\(server.hostname)") else {
|
||
return ServerActionFeedback(
|
||
title: "Reboot Failed",
|
||
message: "The server URL for \(server.hostname) is invalid."
|
||
)
|
||
}
|
||
|
||
restartingServerID = id
|
||
defer { restartingServerID = nil }
|
||
|
||
do {
|
||
let api: AnyServerAPI
|
||
if let versionString = server.info?.apiVersion,
|
||
let versionedAPI = APIFactory.createAPI(baseURL: baseURL, versionString: versionString) {
|
||
api = versionedAPI
|
||
} else {
|
||
api = try await APIFactory.detectAndCreateAPI(baseURL: baseURL, apiKey: apiKey)
|
||
}
|
||
try await api.restartServer(apiKey: apiKey)
|
||
await PingService.suppressChecks(for: server.hostname, duration: 90)
|
||
|
||
return ServerActionFeedback(
|
||
title: "Reboot Requested",
|
||
message: "The reboot command was sent to \(server.hostname). The host may become unavailable briefly while it restarts."
|
||
)
|
||
} catch let error as URLError where Self.isExpectedRestartDisconnect(error) {
|
||
await PingService.suppressChecks(for: server.hostname, duration: 90)
|
||
return ServerActionFeedback(
|
||
title: "Reboot Requested",
|
||
message: "The reboot command appears to have been accepted by \(server.hostname). The connection dropped while the host was going away, which is expected during a reboot."
|
||
)
|
||
} catch APIError.httpError(404, let message) {
|
||
return ServerActionFeedback(
|
||
title: "Reboot Unavailable",
|
||
message: message ?? "\(server.hostname) returned 404 for /api/v2/server/reboot."
|
||
)
|
||
} catch {
|
||
return ServerActionFeedback(
|
||
title: "Reboot Failed",
|
||
message: error.localizedDescription
|
||
)
|
||
}
|
||
}
|
||
|
||
private static func isExpectedRestartDisconnect(_ error: URLError) -> Bool {
|
||
switch error.code {
|
||
case .timedOut,
|
||
.cannotConnectToHost,
|
||
.networkConnectionLost,
|
||
.notConnectedToInternet,
|
||
.cannotFindHost,
|
||
.dnsLookupFailed:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct SidebarMaterialView: NSViewRepresentable {
|
||
func makeNSView(context: Context) -> NSVisualEffectView {
|
||
let view = NSVisualEffectView()
|
||
view.blendingMode = .behindWindow
|
||
view.material = .sidebar
|
||
view.state = .active
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
|
||
nsView.state = .active
|
||
}
|
||
}
|
||
|
||
private struct GroupDropDelegate: DropDelegate {
|
||
enum Placement {
|
||
case before
|
||
case after
|
||
}
|
||
|
||
let targetGroup: ServerGroup
|
||
@Binding var groups: [ServerGroup]
|
||
@Binding var draggedGroupID: UUID?
|
||
@Binding var indicator: GroupDropIndicator?
|
||
|
||
func dropEntered(info: DropInfo) {
|
||
updateIndicator(with: info)
|
||
}
|
||
|
||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||
updateIndicator(with: info)
|
||
return DropProposal(operation: .move)
|
||
}
|
||
|
||
func dropExited(info: DropInfo) {
|
||
indicator = nil
|
||
}
|
||
|
||
func performDrop(info: DropInfo) -> Bool {
|
||
defer {
|
||
draggedGroupID = nil
|
||
indicator = nil
|
||
}
|
||
|
||
guard
|
||
let draggedGroupID,
|
||
draggedGroupID != targetGroup.id,
|
||
let fromIndex = groups.firstIndex(where: { $0.id == draggedGroupID }),
|
||
let toIndex = groups.firstIndex(where: { $0.id == targetGroup.id })
|
||
else {
|
||
return false
|
||
}
|
||
|
||
let placement = placement(for: info)
|
||
let proposedIndex = placement == .after ? toIndex + 1 : toIndex
|
||
groups.move(
|
||
fromOffsets: IndexSet(integer: fromIndex),
|
||
toOffset: proposedIndex > fromIndex ? proposedIndex + 1 : proposedIndex
|
||
)
|
||
return true
|
||
}
|
||
|
||
private func updateIndicator(with info: DropInfo) {
|
||
guard let draggedGroupID, draggedGroupID != targetGroup.id else {
|
||
indicator = nil
|
||
return
|
||
}
|
||
|
||
indicator = GroupDropIndicator(
|
||
groupID: targetGroup.id,
|
||
placement: placement(for: info)
|
||
)
|
||
}
|
||
|
||
private func placement(for info: DropInfo) -> Placement {
|
||
info.location.y > 12 ? .after : .before
|
||
}
|
||
}
|
||
|
||
private struct GroupDropIndicator: Equatable {
|
||
let groupID: UUID
|
||
let placement: GroupDropDelegate.Placement
|
||
}
|
||
|
||
#Preview {
|
||
MainView()
|
||
.environmentObject(SparkleUpdater())
|
||
}
|