// // 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( 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()) }