Files
iKeyMon/Sources/Views/MainView.swift

761 lines
27 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// MainView.swift
// iKeyMon
//
// Created by tracer on 30.03.25.
//
import SwiftUI
import Combine
import UserNotifications
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 previousServiceStates: [String: String] = [:]
private let serverOrderKey = MainView.serverOrderKeyStatic
private let storedGroupsKey = MainView.storedGroupsKeyStatic
@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
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
}
}
} 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 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())
}