Use versioned API client for server summary
This commit is contained in:
@@ -5,3 +5,4 @@
|
|||||||
- Fixed build settings (entitlements, preview assets) and placeholder previews to work with the new layout.
|
- 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.
|
- 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.
|
- 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`.
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ protocol AnyServerAPI {
|
|||||||
func fetchLoadData() async throws -> Any
|
func fetchLoadData() async throws -> Any
|
||||||
func fetchMemoryData() async throws -> Any
|
func fetchMemoryData() async throws -> Any
|
||||||
func fetchUtilizationData() async throws -> Any
|
func fetchUtilizationData() async throws -> Any
|
||||||
|
func fetchServerSummary(apiKey: String) async throws -> ServerInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct AnyServerAPIWrapper<T: ServerAPIProtocol>: AnyServerAPI {
|
private struct AnyServerAPIWrapper<T: ServerAPIProtocol>: AnyServerAPI {
|
||||||
@@ -57,6 +58,10 @@ private struct AnyServerAPIWrapper<T: ServerAPIProtocol>: AnyServerAPI {
|
|||||||
func fetchUtilizationData() async throws -> Any {
|
func fetchUtilizationData() async throws -> Any {
|
||||||
return try await wrapped.fetchUtilization()
|
return try await wrapped.fetchUtilization()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fetchServerSummary(apiKey: String) async throws -> ServerInfo {
|
||||||
|
return try await wrapped.fetchServerSummary(apiKey: apiKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class APIFactory {
|
class APIFactory {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ protocol ServerAPIProtocol {
|
|||||||
func fetchLoad() async throws -> LoadType
|
func fetchLoad() async throws -> LoadType
|
||||||
func fetchMemory() async throws -> MemoryType
|
func fetchMemory() async throws -> MemoryType
|
||||||
func fetchUtilization() async throws -> UtilizationType
|
func fetchUtilization() async throws -> UtilizationType
|
||||||
|
func fetchServerSummary(apiKey: String) async throws -> ServerInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SystemInfo: Codable {
|
struct SystemInfo: Codable {
|
||||||
|
|||||||
34
Model/API/PingService.swift
Normal file
34
Model/API/PingService.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -167,4 +167,172 @@ class APIv2_12: BaseAPIClient, ServerAPIProtocol {
|
|||||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
return try await performRequest(request, responseType: UtilizationType.self)
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,8 +132,13 @@ struct MainView: View {
|
|||||||
print("❌ [MainView] fetchServerInfo: server not found for id \(id)")
|
print("❌ [MainView] fetchServerInfo: server not found for id \(id)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let api = ServerAPI(server: server) else {
|
guard let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
print("❌ [MainView] fetchServerInfo: could not create API for \(server.hostname)")
|
!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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,13 +147,16 @@ struct MainView: View {
|
|||||||
Task {
|
Task {
|
||||||
defer { isFetchingInfo = false }
|
defer { isFetchingInfo = false }
|
||||||
do {
|
do {
|
||||||
let info = try await api.fetchServerInfo()
|
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 }) {
|
if let index = servers.firstIndex(where: { $0.id == id }) {
|
||||||
var updated = servers[index]
|
var updated = servers[index]
|
||||||
updated.info = info
|
updated.info = info
|
||||||
servers[index] = updated
|
servers[index] = updated
|
||||||
print("✅ [MainView] Updated server info for \(updated.hostname)")
|
print("✅ [MainView] Updated server info for \(updated.hostname)")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("❌ Failed to fetch server data: \(error)")
|
print("❌ Failed to fetch server data: \(error)")
|
||||||
}
|
}
|
||||||
@@ -212,9 +220,10 @@ struct MainView: View {
|
|||||||
for (index, server) in servers.enumerated() {
|
for (index, server) in servers.enumerated() {
|
||||||
Task {
|
Task {
|
||||||
let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
let api = ServerAPI(hostname: server.hostname, apiKey: apiKey)
|
let pingable = await PingService.ping(hostname: server.hostname, apiKey: apiKey)
|
||||||
let pingable = await api.ping()
|
await MainActor.run {
|
||||||
servers[index].pingable = pingable
|
servers[index].pingable = pingable
|
||||||
|
}
|
||||||
print("📶 [MainView] Ping \(server.hostname): \(pingable ? "online" : "offline")")
|
print("📶 [MainView] Ping \(server.hostname): \(pingable ? "online" : "offline")")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,8 +102,10 @@ struct ServerFormView: View {
|
|||||||
let host = hostname.trimmingCharacters(in: .whitespacesAndNewlines)
|
let host = hostname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let key = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
let key = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
let pinger = ServerAPI(hostname: host, apiKey: key)
|
let reachable = await PingService.ping(hostname: host, apiKey: key)
|
||||||
connectionOK = await pinger.ping()
|
await MainActor.run {
|
||||||
|
connectionOK = reachable
|
||||||
|
}
|
||||||
//
|
//
|
||||||
// guard let url = URL(string: "https://\(host)/api/v2/ping") else {
|
// guard let url = URL(string: "https://\(host)/api/v2/ping") else {
|
||||||
// print("❌ Invalid URL")
|
// print("❌ Invalid URL")
|
||||||
|
|||||||
Reference in New Issue
Block a user