From df32af064d292e595d44a16b07252d002679321a Mon Sep 17 00:00:00 2001 From: Micha Date: Sun, 16 Nov 2025 12:59:05 +0100 Subject: [PATCH] Use versioned API client for server summary --- CHANGELOG.md | 1 + Model/API/ApiFactory.swift | 5 + Model/API/BaseAPI.swift | 1 + Model/API/PingService.swift | 34 ++++++ Model/API/Versions/APIv2_12.swift | 168 ++++++++++++++++++++++++++++++ Views/MainView.swift | 31 ++++-- Views/ServerFormView.swift | 6 +- 7 files changed, 233 insertions(+), 13 deletions(-) create mode 100644 Model/API/PingService.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index cbd0826..87eb985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,3 +5,4 @@ - Fixed build settings (entitlements, preview assets) and placeholder previews to work with the new layout. - Migrated the updated API layer and unified `ServerInfo` model from the previous branch. - Added verbose logging in `MainView` to trace server loading, selection, and fetch/ping activity when the list appears empty. +- Switched `MainView` and `ServerFormView` to the version-aware API client (`APIFactory`/`APIv2_12`) for server summaries and introduced a shared `PingService`. diff --git a/Model/API/ApiFactory.swift b/Model/API/ApiFactory.swift index 04ebd87..decd0ca 100644 --- a/Model/API/ApiFactory.swift +++ b/Model/API/ApiFactory.swift @@ -33,6 +33,7 @@ protocol AnyServerAPI { func fetchLoadData() async throws -> Any func fetchMemoryData() async throws -> Any func fetchUtilizationData() async throws -> Any + func fetchServerSummary(apiKey: String) async throws -> ServerInfo } private struct AnyServerAPIWrapper: AnyServerAPI { @@ -57,6 +58,10 @@ private struct AnyServerAPIWrapper: AnyServerAPI { func fetchUtilizationData() async throws -> Any { return try await wrapped.fetchUtilization() } + + func fetchServerSummary(apiKey: String) async throws -> ServerInfo { + return try await wrapped.fetchServerSummary(apiKey: apiKey) + } } class APIFactory { diff --git a/Model/API/BaseAPI.swift b/Model/API/BaseAPI.swift index 1273a6d..5f49243 100644 --- a/Model/API/BaseAPI.swift +++ b/Model/API/BaseAPI.swift @@ -17,6 +17,7 @@ protocol ServerAPIProtocol { func fetchLoad() async throws -> LoadType func fetchMemory() async throws -> MemoryType func fetchUtilization() async throws -> UtilizationType + func fetchServerSummary(apiKey: String) async throws -> ServerInfo } struct SystemInfo: Codable { diff --git a/Model/API/PingService.swift b/Model/API/PingService.swift new file mode 100644 index 0000000..fa476d3 --- /dev/null +++ b/Model/API/PingService.swift @@ -0,0 +1,34 @@ +import Foundation + +enum PingService { + static func ping(hostname: String, apiKey: String) async -> Bool { + guard let url = URL(string: "https://\(hostname)/api/v2/ping") else { + print("❌ [PingService] Invalid URL for \(hostname)") + return false + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(apiKey, forHTTPHeaderField: "X-API-KEY") + request.timeoutInterval = 10 + + 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)") + } + return false + } + + if let result = try? JSONDecoder().decode([String: String].self, from: data), result["response"] == "pong" { + return true + } else { + return false + } + } catch { + print("❌ [PingService] Error pinging \(hostname): \(error)") + return false + } + } +} diff --git a/Model/API/Versions/APIv2_12.swift b/Model/API/Versions/APIv2_12.swift index aef492a..82bcd76 100644 --- a/Model/API/Versions/APIv2_12.swift +++ b/Model/API/Versions/APIv2_12.swift @@ -167,4 +167,172 @@ class APIv2_12: BaseAPIClient, ServerAPIProtocol { request.setValue("application/json", forHTTPHeaderField: "Accept") return try await performRequest(request, responseType: UtilizationType.self) } + + func fetchServerSummary(apiKey: String) async throws -> ServerInfo { + let summaryURL = baseURL.appendingPathComponent("api/v2/server") + var request = URLRequest(url: summaryURL) + request.httpMethod = "GET" + request.setValue(apiKey, forHTTPHeaderField: "X-API-KEY") + request.timeoutInterval = 30 + + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + throw APIError.httpError(httpResponse.statusCode) + } + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let envelope = try decoder.decode(ServerSummaryEnvelope.self, from: data) + return envelope.toDomain() + } +} + +// MARK: - Server Summary Mapping + +private extension APIv2_12 { + struct ServerSummaryEnvelope: Decodable { + let meta: Meta + let utilization: Utilization + let components: Components + let ports: [Port]? + let additionalPhpInterpreters: [AdditionalInterpreter]? + + struct Meta: Decodable { + struct Uptime: Decodable { + let days: Int + let hours: Int + let minutes: Int + let seconds: Int + + var formatted: String { + "\(days) Days / \(hours) Hours / \(minutes) Minutes / \(seconds) Seconds" + } + } + + let hostname: String + let ipAddresses: [String] + let serverTime: String + let uptime: Uptime + let panelVersion: String + let panelBuild: Int + let apiVersion: String + } + + struct Utilization: Decodable { + struct Load: Decodable { + let minute1: Double + let minute5: Double + let minute15: Double + let cpuCount: Int + let percent: Double + let level: String + } + + struct Memory: Decodable { + let free: Int + let used: Int + let total: Int + let percent: Double + } + + struct Disk: Decodable { + let free: Int + let used: Int + let total: Int + let percent: Double + } + + let processCount: Int + let load: Load + let diskSpace: Disk + let memory: Memory + let swap: Memory + } + + struct Components: Decodable { + let apache: String + let php: String + let mysql: String? + let mariadb: String? + } + + struct Port: Decodable { + let service: String + let status: String + let port: Int + let proto: String + } + + struct AdditionalInterpreter: Decodable { + let version: String + let path: String? + let configFile: String? + } + + func toDomain() -> ServerInfo { + let load = utilization.load + let disk = utilization.diskSpace + let memory = utilization.memory + let swapMemory = utilization.swap + + return ServerInfo( + hostname: meta.hostname, + ipAddresses: meta.ipAddresses, + cpuCores: load.cpuCount, + serverTime: meta.serverTime, + uptime: meta.uptime.formatted, + processCount: utilization.processCount, + apacheVersion: components.apache, + phpVersion: components.php, + mysqlVersion: components.mysql, + mariadbVersion: components.mariadb, + ports: ports?.map { + ServerInfo.ServicePort(service: $0.service, status: $0.status, port: $0.port, proto: $0.proto) + }, + load: ServerInfo.Load( + minute1: load.minute1, + minute5: load.minute5, + minute15: load.minute15, + percent: load.percent, + cpuCount: load.cpuCount, + level: load.level + ), + memory: ServerInfo.Memory( + free: memory.free, + used: memory.used, + total: memory.total, + percent: memory.percent + ), + swap: ServerInfo.Memory( + free: swapMemory.free, + used: swapMemory.used, + total: swapMemory.total, + percent: swapMemory.percent + ), + diskSpace: ServerInfo.DiskSpace( + free: disk.free, + used: disk.used, + total: disk.total, + percent: disk.percent + ), + panelVersion: meta.panelVersion, + panelBuild: String(meta.panelBuild), + apiVersion: meta.apiVersion, + additionalPHPInterpreters: additionalPhpInterpreters?.map { + ServerInfo.PHPInterpreter( + version: $0.version, + path: $0.path, + configFile: $0.configFile, + extensions: [], + memoryLimit: nil, + maxExecutionTime: nil + ) + } + ) + } + } } diff --git a/Views/MainView.swift b/Views/MainView.swift index d5f83e3..fbe9c81 100644 --- a/Views/MainView.swift +++ b/Views/MainView.swift @@ -132,8 +132,13 @@ struct MainView: View { print("❌ [MainView] fetchServerInfo: server not found for id \(id)") return } - guard let api = ServerAPI(server: server) else { - print("❌ [MainView] fetchServerInfo: could not create API for \(server.hostname)") + 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 } @@ -142,12 +147,15 @@ struct MainView: View { Task { defer { isFetchingInfo = false } do { - let info = try await api.fetchServerInfo() - if let index = servers.firstIndex(where: { $0.id == id }) { - var updated = servers[index] - updated.info = info - servers[index] = updated - print("✅ [MainView] Updated server info for \(updated.hostname)") + let api = try await APIFactory.detectAndCreateAPI(baseURL: baseURL) + 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 + print("✅ [MainView] Updated server info for \(updated.hostname)") + } } } catch { print("❌ Failed to fetch server data: \(error)") @@ -212,9 +220,10 @@ struct MainView: View { for (index, server) in servers.enumerated() { Task { let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let api = ServerAPI(hostname: server.hostname, apiKey: apiKey) - let pingable = await api.ping() - servers[index].pingable = pingable + let pingable = await PingService.ping(hostname: server.hostname, apiKey: apiKey) + await MainActor.run { + servers[index].pingable = pingable + } print("📶 [MainView] Ping \(server.hostname): \(pingable ? "online" : "offline")") } } diff --git a/Views/ServerFormView.swift b/Views/ServerFormView.swift index 4b5a01f..299ffbc 100644 --- a/Views/ServerFormView.swift +++ b/Views/ServerFormView.swift @@ -102,8 +102,10 @@ struct ServerFormView: View { let host = hostname.trimmingCharacters(in: .whitespacesAndNewlines) let key = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) - let pinger = ServerAPI(hostname: host, apiKey: key) - connectionOK = await pinger.ping() + let reachable = await PingService.ping(hostname: host, apiKey: key) + await MainActor.run { + connectionOK = reachable + } // // guard let url = URL(string: "https://\(host)/api/v2/ping") else { // print("❌ Invalid URL")