Compare commits
7 Commits
7593a781f2
...
refactor-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22b2c632a9 | ||
|
|
d759a51ace | ||
|
|
27b8779952 | ||
|
|
3a19246f78 | ||
|
|
df32af064d | ||
|
|
d818af18bb | ||
|
|
ba2b1f32eb |
@@ -4,3 +4,7 @@
|
||||
- Flattened the project structure so sources live at the repository root instead of the nested `iKeyMon/` folder and updated the Xcode project accordingly.
|
||||
- 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`.
|
||||
- Detection now probes `meta.api_version` so future API versions are selected automatically, and the ping loop logs only failures to keep output quiet.
|
||||
- Further reduced MainView console noise by removing redundant refresh/onAppear logs.
|
||||
|
||||
@@ -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<T: ServerAPIProtocol>: AnyServerAPI {
|
||||
@@ -57,6 +58,10 @@ private struct AnyServerAPIWrapper<T: ServerAPIProtocol>: 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 {
|
||||
@@ -72,8 +77,37 @@ class APIFactory {
|
||||
return createAPI(baseURL: baseURL, version: version)
|
||||
}
|
||||
|
||||
static func detectAndCreateAPI(baseURL: URL) async throws -> AnyServerAPI {
|
||||
// For now we only support API v2.12, so return that implementation directly.
|
||||
static func detectAndCreateAPI(baseURL: URL, apiKey: String? = nil) async throws -> AnyServerAPI {
|
||||
if let apiKey, !apiKey.isEmpty {
|
||||
do {
|
||||
let versionURL = baseURL.appendingPathComponent("api/v2/server")
|
||||
var request = URLRequest(url: versionURL)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue(apiKey, forHTTPHeaderField: "X-API-KEY")
|
||||
request.timeoutInterval = 15
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
let probe = try decoder.decode(ServerMetaProbe.self, from: data)
|
||||
if let api = createAPI(baseURL: baseURL, versionString: probe.meta.apiVersion) {
|
||||
return api
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to default version below
|
||||
}
|
||||
}
|
||||
|
||||
return AnyServerAPIWrapper(APIv2_12(baseURL: baseURL))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ServerMetaProbe: Decodable {
|
||||
struct Meta: Decodable {
|
||||
let apiVersion: String
|
||||
}
|
||||
|
||||
let meta: Meta
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,4 +36,20 @@ struct Server: Identifiable, Codable, Hashable, Equatable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, hostname, info, pingable
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
hostname = try container.decode(String.self, forKey: .hostname)
|
||||
info = try container.decodeIfPresent(ServerInfo.self, forKey: .info)
|
||||
pingable = try container.decodeIfPresent(Bool.self, forKey: .pingable) ?? false
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(hostname, forKey: .hostname)
|
||||
try container.encodeIfPresent(info, forKey: .info)
|
||||
try container.encode(pingable, forKey: .pingable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,4 +167,179 @@ 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
|
||||
|
||||
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?
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ import SwiftUI
|
||||
|
||||
struct MainView: View {
|
||||
|
||||
private static let serverOrderKeyStatic = "serverOrder"
|
||||
private static let storedServersKeyStatic = "storedServers"
|
||||
|
||||
@State var showAddServerSheet: Bool = false
|
||||
@State private var serverBeingEdited: Server?
|
||||
@State private var serverToDelete: Server?
|
||||
@@ -18,27 +21,10 @@ struct MainView: View {
|
||||
@State private var progress: Double = 0
|
||||
@State private var lastRefresh = Date()
|
||||
@State private var pingTimer: Timer?
|
||||
private let serverOrderKey = "serverOrder"
|
||||
private let serverOrderKey = MainView.serverOrderKeyStatic
|
||||
private let storedServersKey = MainView.storedServersKeyStatic
|
||||
|
||||
@State private var servers: [Server] = {
|
||||
if let data = UserDefaults.standard.data(forKey: "storedServers"),
|
||||
let saved = try? JSONDecoder().decode([Server].self, from: data) {
|
||||
|
||||
if let idStrings = UserDefaults.standard.stringArray(forKey: "serverOrder") {
|
||||
let idMap = idStrings.compactMap(UUID.init)
|
||||
return 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 saved
|
||||
}
|
||||
return []
|
||||
}()
|
||||
@State private var servers: [Server] = MainView.loadStoredServers()
|
||||
|
||||
// @State private var selectedServer: Server?
|
||||
@State private var selectedServerID: UUID?
|
||||
@@ -113,7 +99,6 @@ struct MainView: View {
|
||||
}
|
||||
.onReceive(refreshTimer) { _ in
|
||||
for server in servers {
|
||||
print("fetching server: \(server.hostname)")
|
||||
fetchServerInfo(for: server.id)
|
||||
}
|
||||
}
|
||||
@@ -121,9 +106,13 @@ struct MainView: View {
|
||||
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)")
|
||||
selectedServerID = uuid
|
||||
} else if selectedServerID == nil, let first = servers.first {
|
||||
print("✅ [MainView] Selecting first server \(first.hostname)")
|
||||
selectedServerID = first.id
|
||||
} else {
|
||||
print("ℹ️ [MainView] No stored selection")
|
||||
}
|
||||
pingAllServers()
|
||||
pingTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { _ in
|
||||
@@ -135,8 +124,17 @@ struct MainView: View {
|
||||
}
|
||||
|
||||
private func fetchServerInfo(for id: UUID) {
|
||||
guard let server = servers.first(where: { $0.id == id }),
|
||||
let api = ServerAPI(server: server) else {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -145,11 +143,14 @@ 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 = try ServerInfo(from: info as! Decoder)
|
||||
servers[index] = updated
|
||||
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("❌ Failed to fetch server data: \(error)")
|
||||
@@ -165,64 +166,55 @@ struct MainView: View {
|
||||
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 struct PingResponse: Codable {
|
||||
let response: String
|
||||
}
|
||||
|
||||
// func pingServer(_ server: Server) async -> Bool {
|
||||
// let hostname = server.hostname
|
||||
// guard let url = URL(string: "https://\(hostname)/api/v2/ping") else {
|
||||
// return false
|
||||
// }
|
||||
//
|
||||
// var request = URLRequest(url: url)
|
||||
// request.httpMethod = "GET"
|
||||
// request.timeoutInterval = 5
|
||||
// request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
//
|
||||
// let apiKey = KeychainHelper.loadApiKey(for: hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
// request.setValue(apiKey, forHTTPHeaderField: "X-API-KEY")
|
||||
//
|
||||
// do {
|
||||
// let (data, response) = try await URLSession.shared.data(for: request)
|
||||
// if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
|
||||
// do {
|
||||
// let decoded = try JSONDecoder().decode(PingResponse.self, from: data)
|
||||
// if decoded.response == "pong" {
|
||||
// return true
|
||||
// } else {
|
||||
// print("❌ Unexpected response: \(decoded.response)")
|
||||
// return false
|
||||
// }
|
||||
// } catch {
|
||||
// print("❌ Failed to decode JSON: \(error)")
|
||||
// return false
|
||||
// }
|
||||
// } else {
|
||||
// return false
|
||||
// }
|
||||
// } catch {
|
||||
// print("[Ping] \(server.hostname): \(error.localizedDescription)")
|
||||
// return false
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
func pingAllServers() {
|
||||
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
|
||||
}
|
||||
if !pingable {
|
||||
print("📶 [MainView] Ping \(server.hostname): offline")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MainView()
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user