From afbb425e3bce0c76d1ddfa0e9e5bf89ffcafb1f9 Mon Sep 17 00:00:00 2001 From: tracer Date: Sun, 19 Apr 2026 16:53:17 +0200 Subject: [PATCH] feat: add remote reboot support --- CHANGELOG.md | 8 ++ Sources/Model/API/ApiFactory.swift | 13 ++- Sources/Model/API/BaseAPI.swift | 54 +++++++++- Sources/Model/API/PingService.swift | 15 ++- Sources/Model/API/ServerInfo.swift | 25 +++++ Sources/Model/API/Versions/APIv2_12.swift | 6 +- Sources/Model/API/Versions/APIv2_13.swift | 6 +- Sources/Model/API/Versions/APIv2_14.swift | 30 ++++++ Sources/Views/MainView.swift | 95 +++++++++++++++++- Sources/Views/ServerDetailView.swift | 116 +++++++++++++++++++++- Sources/Views/Tabs/GeneralView.swift | 32 +++++- 11 files changed, 381 insertions(+), 19 deletions(-) create mode 100644 Sources/Model/API/Versions/APIv2_14.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d2a76..0d01a69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 26.1.7 +- Added remote reboot support for hosts running KeyHelp API 2.14 or newer. +- Added a dedicated `APIv2_14` client and mapped 2.14+ hosts to it instead of treating them as API 2.13. +- Fixed the reboot request to call `/api/v2/server/reboot` with the required JSON confirmation payload. +- Changed the reboot confirmation and result UI to non-blocking sheets/banner feedback so failures no longer trap the app in modal dialogs. +- Improved API error messages by surfacing the server response body instead of only generic HTTP status codes. +- Reduced expected reboot noise by suppressing ping checks for a short grace period after a reboot request. + ## 26.1.6 - Publish Gitea releases as stable by default instead of pre-releases. - Update the Homebrew tap automatically after each successful release by rewriting the cask version and DMG checksum, then pushing the tap repo. diff --git a/Sources/Model/API/ApiFactory.swift b/Sources/Model/API/ApiFactory.swift index 06a105e..50fae75 100644 --- a/Sources/Model/API/ApiFactory.swift +++ b/Sources/Model/API/ApiFactory.swift @@ -10,6 +10,7 @@ import Foundation enum APIVersion: String, CaseIterable { case v2_12 = "2.12" case v2_13 = "2.13" + case v2_14 = "2.14" static func from(versionString: String) -> APIVersion? { if let version = APIVersion(rawValue: versionString) { @@ -24,7 +25,8 @@ enum APIVersion: String, CaseIterable { switch (major, minor) { case (2, 12): return .v2_12 - case (2, 13...): return .v2_13 + case (2, 13): return .v2_13 + case (2, 14...): return .v2_14 default: return nil } } @@ -36,6 +38,7 @@ protocol AnyServerAPI { func fetchMemoryData() async throws -> Any func fetchUtilizationData() async throws -> Any func fetchServerSummary(apiKey: String) async throws -> ServerInfo + func restartServer(apiKey: String) async throws } private struct AnyServerAPIWrapper: AnyServerAPI { @@ -64,6 +67,10 @@ private struct AnyServerAPIWrapper: AnyServerAPI { func fetchServerSummary(apiKey: String) async throws -> ServerInfo { return try await wrapped.fetchServerSummary(apiKey: apiKey) } + + func restartServer(apiKey: String) async throws { + try await wrapped.restartServer(apiKey: apiKey) + } } class APIFactory { @@ -73,6 +80,8 @@ class APIFactory { return AnyServerAPIWrapper(APIv2_12(baseURL: baseURL)) case .v2_13: return AnyServerAPIWrapper(APIv2_13(baseURL: baseURL)) + case .v2_14: + return AnyServerAPIWrapper(APIv2_14(baseURL: baseURL)) } } @@ -104,7 +113,7 @@ class APIFactory { } } - return AnyServerAPIWrapper(APIv2_13(baseURL: baseURL)) + return AnyServerAPIWrapper(APIv2_14(baseURL: baseURL)) } } diff --git a/Sources/Model/API/BaseAPI.swift b/Sources/Model/API/BaseAPI.swift index 5f49243..0552ff7 100644 --- a/Sources/Model/API/BaseAPI.swift +++ b/Sources/Model/API/BaseAPI.swift @@ -18,6 +18,7 @@ protocol ServerAPIProtocol { func fetchMemory() async throws -> MemoryType func fetchUtilization() async throws -> UtilizationType func fetchServerSummary(apiKey: String) async throws -> ServerInfo + func restartServer(apiKey: String) async throws } struct SystemInfo: Codable { @@ -36,6 +37,15 @@ class BaseAPIClient { } func performRequest(_ request: URLRequest, responseType: T.Type) async throws -> T { + let (data, _) = try await performDataRequest(request) + return try JSONDecoder().decode(T.self, from: data) + } + + func performRequestWithoutBody(_ request: URLRequest) async throws { + _ = try await performDataRequest(request) + } + + private func performDataRequest(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { @@ -43,25 +53,61 @@ class BaseAPIClient { } guard 200...299 ~= httpResponse.statusCode else { - throw APIError.httpError(httpResponse.statusCode) + throw APIError.httpError( + httpResponse.statusCode, + BaseAPIClient.extractErrorMessage(from: data) + ) } - return try JSONDecoder().decode(T.self, from: data) + return (data, httpResponse) + } + + private static func extractErrorMessage(from data: Data) -> String? { + guard !data.isEmpty else { return nil } + + if let envelope = try? JSONDecoder().decode(APIErrorEnvelope.self, from: data) { + let parts = [envelope.code, envelope.message] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + if !parts.isEmpty { + return parts.joined(separator: " ") + } + } + + if let text = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty { + return text + } + + return nil } } enum APIError: Error, LocalizedError { case invalidURL case invalidResponse - case httpError(Int) + case httpError(Int, String?) case decodingError(Error) + case unsupportedFeature(String) var errorDescription: String? { switch self { case .invalidURL: return "Invalid URL" case .invalidResponse: return "Invalid response" - case .httpError(let code): return "HTTP Error: \(code)" + case .httpError(let code, let message): + if let message, !message.isEmpty { + return "HTTP Error: \(code)\n\(message)" + } + return "HTTP Error: \(code)" case .decodingError(let error): return "Decoding error: \(error.localizedDescription)" + case .unsupportedFeature(let feature): return "\(feature) is not supported by this host" } } } + +private struct APIErrorEnvelope: Decodable { + let code: String? + let message: String? +} diff --git a/Sources/Model/API/PingService.swift b/Sources/Model/API/PingService.swift index 66f9a4c..fd8ac0a 100644 --- a/Sources/Model/API/PingService.swift +++ b/Sources/Model/API/PingService.swift @@ -3,8 +3,19 @@ import UserNotifications enum PingService { private static var previousPingStates: [String: Bool] = [:] + private static var suppressedUntil: [String: Date] = [:] + + static func suppressChecks(for hostname: String, duration: TimeInterval) { + suppressedUntil[hostname] = Date().addingTimeInterval(duration) + } static func ping(hostname: String, apiKey: String, notificationsEnabled: Bool = true) async -> Bool { + if let suppressedUntil = suppressedUntil[hostname], suppressedUntil > Date() { + return false + } else { + suppressedUntil.removeValue(forKey: hostname) + } + guard let url = URL(string: "https://\(hostname)/api/v2/ping") else { print("❌ [PingService] Invalid URL for \(hostname)") return false @@ -18,9 +29,6 @@ enum PingService { do { let (data, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { - if let responseString = String(data: data, encoding: .utf8) { - print("❌ [PingService] HTTP \(httpResponse.statusCode): \(responseString)") - } handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled) return false } @@ -33,7 +41,6 @@ enum PingService { return false } } catch { - print("❌ [PingService] Error pinging \(hostname): \(error)") handlePingFailure(for: hostname, notificationsEnabled: notificationsEnabled) return false } diff --git a/Sources/Model/API/ServerInfo.swift b/Sources/Model/API/ServerInfo.swift index d7fc1f7..e06017f 100644 --- a/Sources/Model/API/ServerInfo.swift +++ b/Sources/Model/API/ServerInfo.swift @@ -185,6 +185,10 @@ struct ServerInfo: Codable, Hashable, Equatable { ].filter { !$0.isEmpty } return components.isEmpty ? nil : components.joined(separator: " • ") } + + var supportsRestartCommand: Bool { + ServerInfo.version(apiVersion, isAtLeast: "2.14") + } } // MARK: - Helpers & Sample Data @@ -226,6 +230,27 @@ extension ServerInfo { return normalized } + private static func version(_ value: String, isAtLeast minimum: String) -> Bool { + let lhs = value + .split(separator: ".") + .compactMap { Int($0) } + let rhs = minimum + .split(separator: ".") + .compactMap { Int($0) } + + let count = max(lhs.count, rhs.count) + for index in 0.. right + } + } + + return true + } + static let placeholder = ServerInfo( hostname: "preview.example.com", ipAddresses: ["192.168.1.1", "fe80::1"], diff --git a/Sources/Model/API/Versions/APIv2_12.swift b/Sources/Model/API/Versions/APIv2_12.swift index 4225797..d0af9d1 100644 --- a/Sources/Model/API/Versions/APIv2_12.swift +++ b/Sources/Model/API/Versions/APIv2_12.swift @@ -181,7 +181,7 @@ class APIv2_12: BaseAPIClient, ServerAPIProtocol { } guard httpResponse.statusCode == 200 else { - throw APIError.httpError(httpResponse.statusCode) + throw APIError.httpError(httpResponse.statusCode, nil) } let decoder = JSONDecoder() @@ -189,6 +189,10 @@ class APIv2_12: BaseAPIClient, ServerAPIProtocol { let envelope = try decoder.decode(ServerSummaryEnvelope.self, from: data) return envelope.toDomain() } + + func restartServer(apiKey: String) async throws { + throw APIError.unsupportedFeature("Server reboot") + } } // MARK: - Server Summary Mapping diff --git a/Sources/Model/API/Versions/APIv2_13.swift b/Sources/Model/API/Versions/APIv2_13.swift index 1bb502e..2b8e325 100644 --- a/Sources/Model/API/Versions/APIv2_13.swift +++ b/Sources/Model/API/Versions/APIv2_13.swift @@ -181,7 +181,7 @@ class APIv2_13: BaseAPIClient, ServerAPIProtocol { } guard httpResponse.statusCode == 200 else { - throw APIError.httpError(httpResponse.statusCode) + throw APIError.httpError(httpResponse.statusCode, nil) } let decoder = JSONDecoder() @@ -189,6 +189,10 @@ class APIv2_13: BaseAPIClient, ServerAPIProtocol { let envelope = try decoder.decode(ServerSummaryEnvelope.self, from: data) return envelope.toDomain() } + + func restartServer(apiKey: String) async throws { + throw APIError.unsupportedFeature("Server reboot") + } } // MARK: - Server Summary Mapping diff --git a/Sources/Model/API/Versions/APIv2_14.swift b/Sources/Model/API/Versions/APIv2_14.swift new file mode 100644 index 0000000..84954ec --- /dev/null +++ b/Sources/Model/API/Versions/APIv2_14.swift @@ -0,0 +1,30 @@ +// +// APIv2_14.swift +// iKeyMon +// +// Created by tracer on 19.04.26. +// + +import Foundation + +class APIv2_14: APIv2_13 { + private enum Endpoint: String { + case serverReboot = "/api/v2/server/reboot" + + func url(baseURL: URL) -> URL { + baseURL.appendingPathComponent(rawValue) + } + } + + override func restartServer(apiKey: String) async throws { + var request = URLRequest(url: Endpoint.serverReboot.url(baseURL: baseURL)) + request.httpMethod = "POST" + request.setValue(apiKey, forHTTPHeaderField: "X-API-KEY") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = 30 + request.httpBody = #"{"confirm":true}"#.data(using: .utf8) + + try await performRequestWithoutBody(request) + } +} diff --git a/Sources/Views/MainView.swift b/Sources/Views/MainView.swift index f8d5685..2addaef 100644 --- a/Sources/Views/MainView.swift +++ b/Sources/Views/MainView.swift @@ -26,6 +26,7 @@ struct MainView: View { @State private var refreshTimer: Timer.TimerPublisher? @State private var refreshSubscription: AnyCancellable? @State private var pingTimer: Timer? + @State private var restartingServerID: UUID? @State private var lastRefreshInterval: Int? @State private var previousServiceStates: [String: String] = [:] private let serverOrderKey = MainView.serverOrderKeyStatic @@ -79,7 +80,15 @@ struct MainView: View { } detail: { if let selectedServerID, let index = servers.firstIndex(where: { selectedServerID == $0.id }) { - ServerDetailView(server: $servers[index], isFetching: isFetchingInfo) + 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") } @@ -235,9 +244,6 @@ struct MainView: View { await MainActor.run { servers[index].pingable = pingable } - if !pingable { - print("📶 [MainView] Ping \(server.hostname): offline") - } } } } @@ -332,6 +338,87 @@ struct MainView: View { 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) + 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) { + 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 + } + } } #Preview { diff --git a/Sources/Views/ServerDetailView.swift b/Sources/Views/ServerDetailView.swift index cee72c2..ba28cf0 100644 --- a/Sources/Views/ServerDetailView.swift +++ b/Sources/Views/ServerDetailView.swift @@ -7,9 +7,18 @@ import SwiftUI +struct ServerActionFeedback: Identifiable { + let id = UUID() + let title: String + let message: String +} + struct ServerDetailView: View { @Binding var server: Server var isFetching: Bool + var canRestart: Bool = false + var isRestarting: Bool = false + var onRestart: (() async -> ServerActionFeedback)? = nil @AppStorage("showIntervalIndicator") private var showIntervalIndicator: Bool = true @AppStorage("refreshInterval") private var refreshInterval: Int = 60 @@ -18,6 +27,8 @@ struct ServerDetailView: View { } @State private var progress: Double = 0 + @State private var showRestartSheet = false + @State private var restartFeedback: ServerActionFeedback? let timer = Timer.publish(every: 1.0 / 60.0, on: .main, in: .common).autoconnect() var body: some View { @@ -33,7 +44,13 @@ struct ServerDetailView: View { VStack(spacing: 0) { Spacer().frame(height: 6) TabView { - GeneralView(server: resolvedBinding) + GeneralView( + server: resolvedBinding, + canRestart: canRestart, + isRestarting: isRestarting + ) { + showRestartSheet = true + } .tabItem { Text("General").unredacted() } @@ -57,6 +74,17 @@ struct ServerDetailView: View { } .padding(0) } + .overlay(alignment: .bottomTrailing) { + if let feedback = restartFeedback { + RestartFeedbackBanner( + feedback: feedback, + onDismiss: { + restartFeedback = nil + } + ) + .padding() + } + } .onReceive(timer) { _ in guard showIntervalIndicator else { return } withAnimation(.linear(duration: 1.0 / 60.0)) { @@ -64,6 +92,25 @@ struct ServerDetailView: View { if progress >= 1 { progress = 0 } } } + .sheet(isPresented: $showRestartSheet) { + RestartConfirmationSheet( + hostname: server.hostname, + isRestarting: isRestarting, + onCancel: { + showRestartSheet = false + }, + onConfirm: { + guard let onRestart else { return } + showRestartSheet = false + Task { + let feedback = await onRestart() + await MainActor.run { + restartFeedback = feedback + } + } + } + ) + } } private var resolvedBinding: Binding { @@ -81,7 +128,8 @@ struct ServerDetailView: View { #Preview { ServerDetailView( server: .constant(Server(id: UUID(), hostname: "preview.example.com", info: ServerInfo.placeholder)), - isFetching: false + isFetching: false, + canRestart: true ) } @@ -97,3 +145,67 @@ private struct LoadingBadge: View { .background(.ultraThinMaterial, in: Capsule()) } } + +private struct RestartConfirmationSheet: View { + let hostname: String + let isRestarting: Bool + let onCancel: () -> Void + let onConfirm: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Reboot this server?") + .font(.title3.weight(.semibold)) + + Text("This will send a reboot command to \(hostname).") + .foregroundColor(.secondary) + + HStack { + Spacer() + + Button("Cancel") { + onCancel() + } + .keyboardShortcut(.cancelAction) + .disabled(isRestarting) + + Button("Reboot", role: .destructive) { + onConfirm() + } + .keyboardShortcut(.defaultAction) + .disabled(isRestarting) + } + } + .padding(24) + .frame(width: 420) + } +} + +private struct RestartFeedbackBanner: View { + let feedback: ServerActionFeedback + let onDismiss: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(feedback.title) + .font(.headline) + + Text(feedback.message) + .font(.subheadline) + .foregroundColor(.secondary) + + HStack { + Spacer() + + Button("OK") { + onDismiss() + } + .keyboardShortcut(.defaultAction) + } + } + .frame(maxWidth: 360, alignment: .leading) + .padding(24) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .shadow(radius: 12) + } +} diff --git a/Sources/Views/Tabs/GeneralView.swift b/Sources/Views/Tabs/GeneralView.swift index 22883fc..590ee9f 100644 --- a/Sources/Views/Tabs/GeneralView.swift +++ b/Sources/Views/Tabs/GeneralView.swift @@ -9,6 +9,9 @@ import SwiftUI struct GeneralView: View { @Binding var server: Server + var canRestart: Bool = false + var isRestarting: Bool = false + var onRestart: (() -> Void)? = nil var body: some View { GeometryReader { geometry in @@ -117,6 +120,33 @@ struct GeneralView: View { monospaced: true ) } + + if canRestart, let onRestart { + TableRowView(showDivider: false) { + Text("Actions") + } value: { + VStack(alignment: .leading, spacing: 8) { + Button(role: .destructive) { + onRestart() + } label: { + if isRestarting { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Rebooting…") + } + } else { + Label("Reboot Server", systemImage: "arrow.clockwise.circle") + } + } + .disabled(isRestarting) + + Text("Sends a reboot command to the selected host.") + .font(.caption) + .foregroundColor(.secondary) + } + } + } } .padding() .frame(minHeight: geometry.size.height, alignment: .top) @@ -132,7 +162,7 @@ struct GeneralView: View { @State var previewServer = Server(hostname: "example.com", info: .placeholder) var body: some View { - GeneralView(server: $previewServer) + GeneralView(server: $previewServer, canRestart: true) } }