From 01c8da07e08bafcf5527c8fa9fe248c4fd428760 Mon Sep 17 00:00:00 2001 From: Micha Date: Wed, 19 Nov 2025 18:41:28 +0100 Subject: [PATCH] Add API v2.13 client and new server metrics --- Sources/Model/API/ApiFactory.swift | 8 +- Sources/Model/API/ServerAPI.swift | 2 + Sources/Model/API/ServerInfo.swift | 2 + Sources/Model/API/Versions/APIv2_12.swift | 2 + Sources/Model/API/Versions/APIv2_13.swift | 411 ++++++++++++++++++++++ 5 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 Sources/Model/API/Versions/APIv2_13.swift diff --git a/Sources/Model/API/ApiFactory.swift b/Sources/Model/API/ApiFactory.swift index f0fd037..06a105e 100644 --- a/Sources/Model/API/ApiFactory.swift +++ b/Sources/Model/API/ApiFactory.swift @@ -9,6 +9,7 @@ import Foundation enum APIVersion: String, CaseIterable { case v2_12 = "2.12" + case v2_13 = "2.13" static func from(versionString: String) -> APIVersion? { if let version = APIVersion(rawValue: versionString) { @@ -22,7 +23,8 @@ enum APIVersion: String, CaseIterable { let minor = components[1] switch (major, minor) { - case (2, 12...): return .v2_12 + case (2, 12): return .v2_12 + case (2, 13...): return .v2_13 default: return nil } } @@ -69,6 +71,8 @@ class APIFactory { switch version { case .v2_12: return AnyServerAPIWrapper(APIv2_12(baseURL: baseURL)) + case .v2_13: + return AnyServerAPIWrapper(APIv2_13(baseURL: baseURL)) } } @@ -100,7 +104,7 @@ class APIFactory { } } - return AnyServerAPIWrapper(APIv2_12(baseURL: baseURL)) + return AnyServerAPIWrapper(APIv2_13(baseURL: baseURL)) } } diff --git a/Sources/Model/API/ServerAPI.swift b/Sources/Model/API/ServerAPI.swift index 7817169..3e84990 100644 --- a/Sources/Model/API/ServerAPI.swift +++ b/Sources/Model/API/ServerAPI.swift @@ -222,10 +222,12 @@ private extension ServerAPI.ServerResponseEnvelope { serverTime: data.serverTime, uptime: data.uptime, processCount: data.processCount, + emailsInQueue: nil, apacheVersion: data.apacheVersion, phpVersion: data.phpVersion, mysqlVersion: data.mysqlVersion, mariadbVersion: data.mariadbVersion, + operatingSystem: nil, ports: data.ports?.map { ServerInfo.ServicePort(service: $0.service, status: $0.status, port: $0.port, proto: $0.proto) }, load: ServerInfo.Load( minute1: data.load.minute1, diff --git a/Sources/Model/API/ServerInfo.swift b/Sources/Model/API/ServerInfo.swift index ac0620b..cd072eb 100644 --- a/Sources/Model/API/ServerInfo.swift +++ b/Sources/Model/API/ServerInfo.swift @@ -146,6 +146,7 @@ struct ServerInfo: Codable, Hashable, Equatable { var mysqlVersion: String? var mariadbVersion: String? var operatingSystem: OperatingSystem? + var emailsInQueue: Int? var ports: [ServicePort]? var load: Load var memory: Memory @@ -204,6 +205,7 @@ extension ServerInfo { phpVersion: "8.2.12", mysqlVersion: "8.0.33", mariadbVersion: nil, + emailsInQueue: 0, operatingSystem: OperatingSystem( label: "Debian 12.12 (64-bit)", distribution: "Debian", diff --git a/Sources/Model/API/Versions/APIv2_12.swift b/Sources/Model/API/Versions/APIv2_12.swift index 60a9780..ad19d79 100644 --- a/Sources/Model/API/Versions/APIv2_12.swift +++ b/Sources/Model/API/Versions/APIv2_12.swift @@ -248,6 +248,7 @@ private extension APIv2_12 { } let processCount: Int + let emailsInQueue: Int? let load: Load let diskSpace: Disk let memory: Memory @@ -341,6 +342,7 @@ private extension APIv2_12 { serverTime: meta.serverTime, uptime: meta.uptime.formatted, processCount: utilization.processCount, + emailsInQueue: utilization.emailsInQueue, apacheVersion: components.apache, phpVersion: components.php, mysqlVersion: components.mysql, diff --git a/Sources/Model/API/Versions/APIv2_13.swift b/Sources/Model/API/Versions/APIv2_13.swift new file mode 100644 index 0000000..ad19d79 --- /dev/null +++ b/Sources/Model/API/Versions/APIv2_13.swift @@ -0,0 +1,411 @@ +// +// APIv2_12.swift +// iKeyMon +// +// Created by tracer on 13.11.25. +// + + +import Foundation + +extension APIv2_12 { + struct Load: Codable { + let current: LoadMetrics + let historical: [LoadMetrics] + + struct LoadMetrics: Codable { + let oneMinute: Double + let fiveMinute: Double + let fifteenMinute: Double + let timestamp: Date + + enum CodingKeys: String, CodingKey { + case oneMinute = "load_1" + case fiveMinute = "load_5" + case fifteenMinute = "load_15" + case timestamp + } + } + } + + struct Memory: Codable { + let system: SystemMemory + let swap: SwapMemory? + + struct SystemMemory: Codable { + let total: Int64 + let used: Int64 + let free: Int64 + let available: Int64 + let buffers: Int64? + let cached: Int64? + } + + struct SwapMemory: Codable { + let total: Int64 + let used: Int64 + let free: Int64 + } + } + + struct Utilization: Codable { + let cpu: CPUUtilization + let memory: MemoryUtilization + let disk: [DiskUtilization] + let network: [NetworkUtilization]? + + struct CPUUtilization: Codable { + let overall: Double + let cores: [Double] + let processes: [ProcessInfo]? + + struct ProcessInfo: Codable { + let pid: Int + let name: String + let cpuPercent: Double + + enum CodingKeys: String, CodingKey { + case pid + case name + case cpuPercent = "cpu_percent" + } + } + } + + struct MemoryUtilization: Codable { + let percent: Double + let topProcesses: [ProcessMemoryInfo]? + + struct ProcessMemoryInfo: Codable { + let pid: Int + let name: String + let memoryMB: Double + + enum CodingKeys: String, CodingKey { + case pid + case name + case memoryMB = "memory_mb" + } + } + } + + struct DiskUtilization: Codable { + let device: String + let mountpoint: String + let usedPercent: Double + let totalBytes: Int64 + let usedBytes: Int64 + let freeBytes: Int64 + + enum CodingKeys: String, CodingKey { + case device + case mountpoint + case usedPercent = "used_percent" + case totalBytes = "total_bytes" + case usedBytes = "used_bytes" + case freeBytes = "free_bytes" + } + } + + struct NetworkUtilization: Codable { + let interface: String + let bytesIn: Int64 + let bytesOut: Int64 + let packetsIn: Int64 + let packetsOut: Int64 + + enum CodingKeys: String, CodingKey { + case interface + case bytesIn = "bytes_in" + case bytesOut = "bytes_out" + case packetsIn = "packets_in" + case packetsOut = "packets_out" + } + } + } +} + +class APIv2_12: BaseAPIClient, ServerAPIProtocol { + typealias LoadType = APIv2_12.Load + typealias MemoryType = APIv2_12.Memory + typealias UtilizationType = APIv2_12.Utilization + + private enum Endpoint: String { + case systemInfo = "/api/v2.12/system/info" + case load = "/api/v2.12/metrics/load" + case memory = "/api/v2.12/metrics/memory" + case utilization = "/api/v2.12/metrics/utilization" + + func url(baseURL: URL) -> URL { + return baseURL.appendingPathComponent(self.rawValue) + } + } + + func fetchSystemInfo() async throws -> SystemInfo { + let url = Endpoint.systemInfo.url(baseURL: baseURL) + let request = URLRequest(url: url) + return try await performRequest(request, responseType: SystemInfo.self) + } + + func fetchLoad() async throws -> LoadType { + let url = Endpoint.load.url(baseURL: baseURL) + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + return try await performRequest(request, responseType: LoadType.self) + } + + func fetchMemory() async throws -> MemoryType { + let url = Endpoint.memory.url(baseURL: baseURL) + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + return try await performRequest(request, responseType: MemoryType.self) + } + + func fetchUtilization() async throws -> UtilizationType { + let url = Endpoint.utilization.url(baseURL: baseURL) + var request = URLRequest(url: url) + 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 operatingSystem: OperatingSystem? + 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 emailsInQueue: 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 + + enum CodingKeys: String, CodingKey { + case service + case status + case port + case proto = "protocol" + } + } + + struct AdditionalInterpreter: Decodable { + let version: String + let path: String? + let configFile: String? + } + + struct OperatingSystem: Decodable { + struct Updates: Decodable { + let updateCount: Int + let securityUpdateCount: Int + let rebootRequired: Bool + + enum CodingKeys: String, CodingKey { + case updateCount = "update_count" + case securityUpdateCount = "security_update_count" + case rebootRequired = "reboot_required" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + updateCount = try container.decodeIfPresent(Int.self, forKey: .updateCount) ?? 0 + securityUpdateCount = try container.decodeIfPresent(Int.self, forKey: .securityUpdateCount) ?? 0 + rebootRequired = try container.decodeIfPresent(Bool.self, forKey: .rebootRequired) ?? false + } + } + + let label: String + let distribution: String + let version: String + let architecture: String + let endOfLife: Bool + let updates: Updates? + + enum CodingKeys: String, CodingKey { + case label + case distribution + case version + case architecture + case endOfLife = "end_of_life" + case updates + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + label = try container.decodeIfPresent(String.self, forKey: .label) ?? "" + distribution = try container.decodeIfPresent(String.self, forKey: .distribution) ?? "" + version = try container.decodeIfPresent(String.self, forKey: .version) ?? "" + architecture = try container.decodeIfPresent(String.self, forKey: .architecture) ?? "" + endOfLife = try container.decodeIfPresent(Bool.self, forKey: .endOfLife) ?? false + updates = try container.decodeIfPresent(Updates.self, forKey: .updates) + } + } + + 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, + emailsInQueue: utilization.emailsInQueue, + apacheVersion: components.apache, + phpVersion: components.php, + mysqlVersion: components.mysql, + mariadbVersion: components.mariadb, + operatingSystem: operatingSystem.map { + ServerInfo.OperatingSystem( + label: $0.label, + distribution: $0.distribution, + version: $0.version, + architecture: $0.architecture, + endOfLife: $0.endOfLife, + updates: $0.updates.map { + ServerInfo.OperatingSystem.UpdateStatus( + updateCount: $0.updateCount, + securityUpdateCount: $0.securityUpdateCount, + rebootRequired: $0.rebootRequired + ) + } + ) + }, + 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 + ) + } + ) + } + } +}