refactored code structure

This commit is contained in:
Micha
2025-11-17 15:42:55 +01:00
parent 22b2c632a9
commit 4efe1a2324
26 changed files with 417 additions and 29 deletions

View File

@@ -0,0 +1,23 @@
//
// ByteFormatting.swift
// iKeyMon
//
// Created by tracer on 01.04.25.
//
import Foundation
extension Int {
func toNiceBinaryUnit() -> String {
let units = ["B", "KB", "MB", "GB", "TB", "PB"]
var value = Double(self)
var index = 0
while value >= 1024 && index < units.count - 1 {
value /= 1024
index += 1
}
return String(format: "%.2f %@", value, units[index])
}
}

View File

@@ -0,0 +1,51 @@
//
// KeychainHelper.swift
// iKeyMon
//
// Created by tracer on 30.03.25.
//
import Foundation
import Security
enum KeychainHelper {
static func save(apiKey: String, for hostname: String) {
let data = Data(apiKey.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: hostname,
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary) // Overwrite existing
SecItemAdd(query as CFDictionary, nil)
}
static func loadApiKey(for hostname: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: hostname,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess,
let data = result as? Data,
let string = String(data: data, encoding: .utf8) {
return string
}
return nil
}
static func deleteApiKey(for hostname: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: hostname
]
SecItemDelete(query as CFDictionary)
}
}

View File

@@ -0,0 +1,113 @@
//
// ApiFactory.swift
// iKeyMon
//
// Created by tracer on 13.11.25.
//
import Foundation
enum APIVersion: String, CaseIterable {
case v2_12 = "2.12"
static func from(versionString: String) -> APIVersion? {
if let version = APIVersion(rawValue: versionString) {
return version
}
let components = versionString.split(separator: ".").compactMap { Int($0) }
guard components.count >= 2 else { return nil }
let major = components[0]
let minor = components[1]
switch (major, minor) {
case (2, 12...): return .v2_12
default: return nil
}
}
}
protocol AnyServerAPI {
func fetchSystemInfo() async throws -> SystemInfo
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 {
private let wrapped: T
init(_ wrapped: T) {
self.wrapped = wrapped
}
func fetchSystemInfo() async throws -> SystemInfo {
return try await wrapped.fetchSystemInfo()
}
func fetchLoadData() async throws -> Any {
return try await wrapped.fetchLoad()
}
func fetchMemoryData() async throws -> Any {
return try await wrapped.fetchMemory()
}
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 {
static func createAPI(baseURL: URL, version: APIVersion) -> AnyServerAPI {
switch version {
case .v2_12:
return AnyServerAPIWrapper(APIv2_12(baseURL: baseURL))
}
}
static func createAPI(baseURL: URL, versionString: String) -> AnyServerAPI? {
guard let version = APIVersion.from(versionString: versionString) else { return nil }
return createAPI(baseURL: baseURL, version: version)
}
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
}

View File

@@ -0,0 +1,64 @@
//
// ApiManager.swift
// iKeyMon
//
// Created by tracer on 13.11.25.
//
import Foundation
import Combine
@MainActor
class APIManager: ObservableObject {
@Published var isConnected = false
@Published var currentVersion: String = ""
@Published var lastError: Error?
private var api: AnyServerAPI?
private let baseURL: URL
init(baseURL: URL) {
self.baseURL = baseURL
}
func connect() async {
do {
self.api = try await APIFactory.detectAndCreateAPI(baseURL: baseURL)
let systemInfo = try await api!.fetchSystemInfo()
self.currentVersion = systemInfo.version
self.isConnected = true
self.lastError = nil
} catch {
self.isConnected = false
self.lastError = error
self.api = nil
}
}
func disconnect() {
self.api = nil
self.isConnected = false
self.currentVersion = ""
self.lastError = nil
}
func fetchSystemInfo() async throws -> SystemInfo {
guard let api = api else { throw APIError.invalidResponse }
return try await api.fetchSystemInfo()
}
func fetchLoad() async throws -> Any {
guard let api = api else { throw APIError.invalidResponse }
return try await api.fetchLoadData()
}
func fetchMemory() async throws -> Any {
guard let api = api else { throw APIError.invalidResponse }
return try await api.fetchMemoryData()
}
func fetchUtilization() async throws -> Any {
guard let api = api else { throw APIError.invalidResponse }
return try await api.fetchUtilizationData()
}
}

View File

@@ -0,0 +1,67 @@
//
// BaseAPI.swift
// iKeyMon
//
// Created by tracer on 13.11.25.
//
import Foundation
protocol ServerAPIProtocol {
associatedtype LoadType: Codable
associatedtype MemoryType: Codable
associatedtype UtilizationType: Codable
func fetchSystemInfo() async throws -> SystemInfo
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 {
let version: String
let timestamp: Date
let hostname: String
}
class BaseAPIClient {
let baseURL: URL
let session: URLSession
init(baseURL: URL, session: URLSession = .shared) {
self.baseURL = baseURL
self.session = session
}
func performRequest<T: Codable>(_ request: URLRequest, responseType: T.Type) async throws -> T {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard 200...299 ~= httpResponse.statusCode else {
throw APIError.httpError(httpResponse.statusCode)
}
return try JSONDecoder().decode(T.self, from: data)
}
}
enum APIError: Error, LocalizedError {
case invalidURL
case invalidResponse
case httpError(Int)
case decodingError(Error)
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid URL"
case .invalidResponse: return "Invalid response"
case .httpError(let code): return "HTTP Error: \(code)"
case .decodingError(let error): return "Decoding error: \(error.localizedDescription)"
}
}
}

View 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
}
}
}

View File

@@ -0,0 +1,55 @@
//
// Server.swift
// iKeyMon
//
// Created by tracer on 30.03.25.
//
import Foundation
struct Server: Identifiable, Codable, Hashable, Equatable {
let id: UUID
var hostname: String
var info: ServerInfo?
var pingable: Bool
init(id: UUID = UUID(), hostname: String, info: ServerInfo? = nil, pingable: Bool = false) {
self.id = id
self.hostname = hostname
self.info = info
self.pingable = pingable
}
// MARK: - Manual conformance
static func == (lhs: Server, rhs: Server) -> Bool {
lhs.id == rhs.id && lhs.hostname == rhs.hostname && lhs.info == rhs.info && lhs.pingable == rhs.pingable
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(hostname)
hasher.combine(info)
hasher.combine(pingable)
}
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)
}
}

View File

@@ -0,0 +1,271 @@
//
// ServerAPI.swift
// iKeyMon
//
// Created by tracer on 06.04.25.
//
import Foundation
final class ServerAPI {
private let hostname: String
private let apiKey: String
init(hostname: String, apiKey: String) {
self.hostname = hostname
self.apiKey = apiKey
}
init?(server: Server) {
guard let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) else {
return nil
}
self.hostname = server.hostname
self.apiKey = apiKey
}
@discardableResult
func ping() async -> Bool {
guard let url = URL(string: "https://\(hostname)/api/v2/ping") else {
print("❌ [ServerAPI] Invalid ping URL for hostname: \(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)
// Add debug info for comparison with server info request
if let httpResponse = response as? HTTPURLResponse {
if httpResponse.statusCode != 200 {
print("❌ [ServerAPI] Ping HTTP Status: \(httpResponse.statusCode) for \(hostname)")
if let responseString = String(data: data, encoding: .utf8) {
print("❌ [ServerAPI] Ping error response: \(responseString)")
}
}
}
if let result = try? JSONDecoder().decode([String: String].self, from: data), result["response"] == "pong" {
return true
} else {
print("❌ [ServerAPI] Ping failed - invalid response for \(hostname)")
if let responseString = String(data: data, encoding: .utf8) {
print("❌ [ServerAPI] Ping response: \(responseString)")
}
return false
}
} catch {
print("❌ [ServerAPI] Ping error for \(hostname): \(error)")
return false
}
}
func fetchServerInfo() async throws -> ServerInfo {
print("🔍 [ServerAPI] Starting fetchServerInfo for hostname: \(hostname)")
guard let url = URL(string: "https://\(hostname)/api/v2/server") else {
print("❌ [ServerAPI] Invalid URL for hostname: \(hostname)")
throw URLError(.badURL)
}
print("🌐 [ServerAPI] URL created: \(url.absoluteString)")
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue(apiKey, forHTTPHeaderField: "X-API-KEY")
// Temporarily remove Content-Type to match ping request exactly
// request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 30
print("📤 [ServerAPI] Making request with timeout: \(request.timeoutInterval)s")
print("📤 [ServerAPI] API Key (first 10 chars): \(String(apiKey.prefix(10)))...")
print("📤 [ServerAPI] Request headers: \(request.allHTTPHeaderFields ?? [:])")
do {
let startTime = Date()
let (data, response) = try await URLSession.shared.data(for: request)
let endTime = Date()
let duration = endTime.timeIntervalSince(startTime)
print("📥 [ServerAPI] Request completed in \(String(format: "%.2f", duration))s")
if let httpResponse = response as? HTTPURLResponse {
print("📥 [ServerAPI] HTTP Status: \(httpResponse.statusCode)")
// Handle 401 specifically
if httpResponse.statusCode == 401 {
print("❌ [ServerAPI] 401 Unauthorized - API key issue!")
if let responseString = String(data: data, encoding: .utf8) {
print("❌ [ServerAPI] 401 Response body: \(responseString)")
}
throw URLError(.userAuthenticationRequired)
}
// Handle other non-200 status codes
if httpResponse.statusCode != 200 {
print("❌ [ServerAPI] Non-200 status code: \(httpResponse.statusCode)")
if let responseString = String(data: data, encoding: .utf8) {
print("❌ [ServerAPI] Error response body: \(responseString)")
}
throw URLError(.badServerResponse)
}
}
print("📥 [ServerAPI] Data size: \(data.count) bytes")
print("🔄 [ServerAPI] Decoding response payload...")
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let decodedResponse = try decoder.decode(ServerResponseEnvelope.self, from: data)
let decoded = decodedResponse.toDomain()
print("✅ [ServerAPI] JSON decoding successful")
return decoded
} catch let urlError as URLError {
print("❌ [ServerAPI] URLError: \(urlError.localizedDescription)")
print("❌ [ServerAPI] URLError code: \(urlError.code.rawValue)")
throw urlError
} catch let decodingError as DecodingError {
print("❌ [ServerAPI] Decoding error: \(decodingError)")
throw decodingError
} catch {
print("❌ [ServerAPI] Unexpected error: \(error)")
throw error
}
}
}
// MARK: - Response Helpers
private extension ServerAPI {
struct ServerResponseEnvelope: Decodable {
let data: ServerData
let meta: Meta
struct Meta: Decodable {
let apiVersion: String
}
}
struct ServerData: Decodable {
struct Port: Decodable {
let service: String
let status: String
let port: Int
let proto: String
}
struct Load: Decodable {
let minute1: Double
let minute5: Double
let minute15: Double
let percent: Double
let cpuCount: Int
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
}
struct PHPInterpreter: Decodable {
let version: String
let path: String?
let configFile: String?
let extensions: [String]
let memoryLimit: String?
let maxExecutionTime: String?
}
let hostname: String
let ipAddresses: [String]
let cpuCores: Int
let serverTime: String
let uptime: String
let processCount: Int
let apacheVersion: String
let phpVersion: String
let mysqlVersion: String?
let mariadbVersion: String?
let ports: [Port]?
let load: Load
let memory: Memory
let swap: Memory
let diskSpace: Disk
let panelVersion: String
let panelBuild: String
let additionalPhpInterpreters: [PHPInterpreter]?
}
}
private extension ServerAPI.ServerResponseEnvelope {
func toDomain() -> ServerInfo {
ServerInfo(
hostname: data.hostname,
ipAddresses: data.ipAddresses,
cpuCores: data.cpuCores,
serverTime: data.serverTime,
uptime: data.uptime,
processCount: data.processCount,
apacheVersion: data.apacheVersion,
phpVersion: data.phpVersion,
mysqlVersion: data.mysqlVersion,
mariadbVersion: data.mariadbVersion,
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,
minute5: data.load.minute5,
minute15: data.load.minute15,
percent: data.load.percent,
cpuCount: data.load.cpuCount,
level: data.load.level
),
memory: ServerInfo.Memory(
free: data.memory.free,
used: data.memory.used,
total: data.memory.total,
percent: data.memory.percent
),
swap: ServerInfo.Memory(
free: data.swap.free,
used: data.swap.used,
total: data.swap.total,
percent: data.swap.percent
),
diskSpace: ServerInfo.DiskSpace(
free: data.diskSpace.free,
used: data.diskSpace.used,
total: data.diskSpace.total,
percent: data.diskSpace.percent
),
panelVersion: data.panelVersion,
panelBuild: data.panelBuild,
apiVersion: meta.apiVersion,
additionalPHPInterpreters: data.additionalPhpInterpreters?.map {
ServerInfo.PHPInterpreter(
version: $0.version,
path: $0.path,
configFile: $0.configFile,
extensions: $0.extensions,
memoryLimit: $0.memoryLimit,
maxExecutionTime: $0.maxExecutionTime
)
}
)
}
}

View File

@@ -0,0 +1,236 @@
import Foundation
// MARK: - Server Info Domain Model
struct ServerInfo: Codable, Hashable, Equatable {
struct Load: Codable, Hashable, Equatable {
let minute1: Double
let minute5: Double
let minute15: Double
let percent: Double
let cpuCount: Int
let level: String
init(minute1: Double, minute5: Double, minute15: Double, percent: Double, cpuCount: Int, level: String) {
self.minute1 = minute1
self.minute5 = minute5
self.minute15 = minute15
self.percent = percent
self.cpuCount = cpuCount
self.level = level
}
}
struct Memory: Codable, Hashable, Equatable {
let free: Int
let used: Int
let total: Int
let percent: Double
init(free: Int, used: Int, total: Int, percent: Double) {
self.free = free
self.used = used
self.total = total
self.percent = percent
}
}
struct DiskSpace: Codable, Hashable, Equatable {
let free: Int
let used: Int
let total: Int
let percent: Double
init(free: Int, used: Int, total: Int, percent: Double) {
self.free = free
self.used = used
self.total = total
self.percent = percent
}
}
struct ServicePort: Codable, Hashable, Identifiable, Equatable {
var id: String { "\(service)-\(port)-\(proto)" }
let service: String
let status: String
let port: Int
let proto: String
init(service: String, status: String, port: Int, proto: String) {
self.service = service
self.status = status
self.port = port
self.proto = proto
}
}
struct PHPInterpreter: Codable, Hashable, Identifiable, Equatable {
var id: String { versionFull }
let version: String
let path: String?
let configFile: String?
let extensions: [String]
let memoryLimit: String?
let maxExecutionTime: String?
init(
version: String,
path: String? = nil,
configFile: String? = nil,
extensions: [String] = [],
memoryLimit: String? = nil,
maxExecutionTime: String? = nil
) {
self.version = version
self.path = path
self.configFile = configFile
self.extensions = extensions
self.memoryLimit = memoryLimit
self.maxExecutionTime = maxExecutionTime
}
var versionFull: String {
var components = [version]
if let path, !path.isEmpty {
components.append(path)
}
return components.joined(separator: " ")
}
}
struct OperatingSystem: Codable, Hashable, Equatable {
struct UpdateStatus: Codable, Hashable, Equatable {
let updateCount: Int
let securityUpdateCount: Int
let rebootRequired: Bool
init(updateCount: Int, securityUpdateCount: Int, rebootRequired: Bool) {
self.updateCount = updateCount
self.securityUpdateCount = securityUpdateCount
self.rebootRequired = rebootRequired
}
}
let label: String
let distribution: String
let version: String
let architecture: String
let endOfLife: Bool
let updates: UpdateStatus?
init(
label: String,
distribution: String,
version: String,
architecture: String,
endOfLife: Bool,
updates: UpdateStatus?
) {
self.label = label
self.distribution = distribution
self.version = version
self.architecture = architecture
self.endOfLife = endOfLife
self.updates = updates
}
}
var hostname: String
var ipAddresses: [String]
var cpuCores: Int
var serverTime: String
var uptime: String
var processCount: Int
var apacheVersion: String
var phpVersion: String
var mysqlVersion: String?
var mariadbVersion: String?
var operatingSystem: OperatingSystem?
var ports: [ServicePort]?
var load: Load
var memory: Memory
var swap: Memory
var diskSpace: DiskSpace
var panelVersion: String
var panelBuild: String
var apiVersion: String
var additionalPHPInterpreters: [PHPInterpreter]?
var formattedVersion: String {
"KeyHelp \(panelVersion) • Build \(panelBuild) • API \(apiVersion)"
}
var formattedServerTime: String {
guard let date = ServerInfo.isoFormatter.date(from: serverTime) else {
return serverTime
}
return ServerInfo.displayFormatter.string(from: date)
}
var operatingSystemSummary: String? {
guard let operatingSystem else { return nil }
let components = [
operatingSystem.label,
operatingSystem.architecture
].filter { !$0.isEmpty }
return components.isEmpty ? nil : components.joined(separator: "")
}
}
// MARK: - Helpers & Sample Data
extension ServerInfo {
private static let isoFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds, .withColonSeparatorInTimeZone]
return formatter
}()
private static let displayFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .medium
return formatter
}()
static let placeholder = ServerInfo(
hostname: "preview.example.com",
ipAddresses: ["192.168.1.1", "fe80::1"],
cpuCores: 4,
serverTime: "2025-04-04T18:00:00+0200",
uptime: "3 Days / 12 Hours / 30 Minutes",
processCount: 123,
apacheVersion: "2.4.58",
phpVersion: "8.2.12",
mysqlVersion: "8.0.33",
mariadbVersion: nil,
operatingSystem: OperatingSystem(
label: "Debian 12.12 (64-bit)",
distribution: "Debian",
version: "12.12",
architecture: "x86_64",
endOfLife: false,
updates: OperatingSystem.UpdateStatus(
updateCount: 12,
securityUpdateCount: 8,
rebootRequired: true
)
),
ports: [
ServicePort(service: "HTTP", status: "online", port: 80, proto: "tcp"),
ServicePort(service: "HTTPS", status: "online", port: 443, proto: "tcp"),
ServicePort(service: "SSH", status: "offline", port: 22, proto: "tcp")
],
load: Load(minute1: 0.5, minute5: 0.3, minute15: 0.2, percent: 10.0, cpuCount: 4, level: "low"),
memory: Memory(free: 8_000_000_000, used: 4_000_000_000, total: 12_000_000_000, percent: 33.3),
swap: Memory(free: 4_000_000_000, used: 1_000_000_000, total: 5_000_000_000, percent: 20.0),
diskSpace: DiskSpace(free: 100_000_000_000, used: 50_000_000_000, total: 150_000_000_000, percent: 33.3),
panelVersion: "25.0",
panelBuild: "3394",
apiVersion: "2",
additionalPHPInterpreters: [
PHPInterpreter(version: "8.3", path: "/usr/bin/php8.3"),
PHPInterpreter(version: "8.2", path: "/usr/bin/php8.2")
]
)
}

View File

@@ -0,0 +1,73 @@
//
// ServerTypes_Fixed.swift
// iKeyMon
//
// Fixed server types matching your existing code structure
//
import Foundation
// MARK: - Server API Response Types
struct ServerAPIResponse<T: Codable>: Codable {
let data: T
let status: String
let timestamp: Date
let version: String
enum CodingKeys: String, CodingKey {
case data
case status
case timestamp
case version
}
}
// MARK: - Server API Version
struct ServerAPIVersion: Codable {
let major: Int
let minor: Int
let patch: Int
let string: String
init(major: Int, minor: Int, patch: Int = 0) {
self.major = major
self.minor = minor
self.patch = patch
self.string = "\(major).\(minor).\(patch)"
}
init(from versionString: String) throws {
let components = versionString.split(separator: ".").compactMap { Int($0) }
guard components.count >= 2 else {
throw DecodingError.dataCorrupted(
DecodingError.Context(codingPath: [], debugDescription: "Invalid version string format")
)
}
self.major = components[0]
self.minor = components[1]
self.patch = components.count > 2 ? components[2] : 0
self.string = versionString
}
}
// MARK: - Server API Response Factory
class ServerAPIResponseFactory {
static func createResponse<T: Codable>(data: T, status: String = "success") -> ServerAPIResponse<T> {
return ServerAPIResponse(
data: data,
status: status,
timestamp: Date(),
version: "1.0"
)
}
static func createErrorResponse(error: String) -> ServerAPIResponse<String> {
return ServerAPIResponse(
data: error,
status: "error",
timestamp: Date(),
version: "1.0"
)
}
}

View File

@@ -0,0 +1,392 @@
//
// 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 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"
}
}
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
}
}
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,
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
)
}
)
}
}
}

View File

@@ -0,0 +1,47 @@
//
// InfoBarCell.swift
// iKeyMon
//
// Created by tracer on 03.04.25.
//
//
// ResourcesBarRow.swift
// iKeyMon
//
// Created by tracer on 31.03.25.
//
import SwiftUI
struct InfoCell: View {
var value: [String]
var monospaced: Bool = false
var color: Color = .primary
init(value: [String], monospaced: Bool = false) {
self.value = value
self.monospaced = monospaced
}
var body: some View {
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 2) {
ForEach(value, id: \.self) { item in
Text(item)
.font(monospaced ? .system(.body, design: .monospaced) : .body)
}
}
// if let subtext {
// Text(subtext)
// .font(.caption)
// .foregroundColor(.secondary)
// }
}
}
}
#Preview {
InfoCell(value: ["Some Text", "Another Text"])
}

View File

@@ -0,0 +1,37 @@
//
// ResourcesBarRow.swift
// iKeyMon
//
// Created by tracer on 31.03.25.
//
import SwiftUI
struct LoadBarCell: View {
let percent: Double
let load1: Double
let load5: Double
let load15: Double
var subtext: String? = nil
var body: some View {
VStack(alignment: .leading) {
HStack {
Text(String(format: "%.2f%%", percent))
.foregroundColor(.green)
Text(String(format: "(%.2f / %.2f / %.2f)", load1, load5, load15))
}
if let subtext {
Text(subtext)
.font(.caption)
.foregroundColor(.secondary)
}
ProgressView(value: percent / 100)
.progressViewStyle(.linear)
}
}
}
#Preview {
LoadBarCell(percent: 2.34, load1: 1.23, load5: 0.08, load15: 0.06)
}

View File

@@ -0,0 +1,50 @@
//
// UsageBarCell.swift
// iKeyMon
//
// Created by tracer on 01.04.25.
//
import SwiftUI
struct UsageBarCell: View {
let free: Int
let used: Int
let total: Int
let percent: Double
var subtext: String? = nil
var body: some View {
VStack(alignment: .leading) {
HStack {
Text("Free:")
.fontWeight(.bold)
Text(free.toNiceBinaryUnit())
Text("Used:")
.fontWeight(.bold)
Text(used.toNiceBinaryUnit())
Text("Total:")
.fontWeight(.bold)
Text(total.toNiceBinaryUnit())
}
if let subtext {
Text(subtext)
.font(.caption)
.foregroundColor(.secondary)
}
HStack {
Spacer()
Text(String(format: "%.2f %%", percent))
}
ProgressView(value: percent / 100)
.progressViewStyle(.linear)
}
}
}
#Preview {
UsageBarCell(free: 1024, used: 16238, total: 1232312323123, percent: 33.33)
}

View File

@@ -0,0 +1,221 @@
//
// MainView.swift
// iKeyMon
//
// Created by tracer on 30.03.25.
//
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?
@State private var showDeleteConfirmation = false
@State private var isFetchingInfo: Bool = false
@State private var refreshTimer = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
@State private var progress: Double = 0
@State private var lastRefresh = Date()
@State private var pingTimer: Timer?
private let serverOrderKey = MainView.serverOrderKeyStatic
private let storedServersKey = MainView.storedServersKeyStatic
@State private var servers: [Server] = MainView.loadStoredServers()
// @State private var selectedServer: Server?
@State private var selectedServerID: UUID?
var body: some View {
NavigationSplitView {
List(selection: $selectedServerID) {
ForEach(servers) { server in
HStack {
Image(systemName: "dot.circle.fill")
.foregroundColor(server.pingable ? .green : .red)
Text(server.hostname)
}
.tag(server)
.contextMenu {
Button("Edit") {
print("Editing:", server.hostname)
serverBeingEdited = server
}
Divider()
Button("Delete", role: .destructive) {
serverToDelete = server
showDeleteConfirmation = true
}
}
}
.onMove(perform: moveServer)
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: { showAddServerSheet = true }) {
Image(systemName: "plus")
}
.help("Add Host")
}
}
.navigationTitle("Servers")
.onChange(of: selectedServerID) {
if let selectedServerID {
UserDefaults.standard.set(selectedServerID.uuidString, forKey: "selectedServerID")
fetchServerInfo(for: selectedServerID)
}
}
} detail: {
if let selectedServerID,
let index = servers.firstIndex(where: { selectedServerID == $0.id }) {
ServerDetailView(server: $servers[index], isFetching: isFetchingInfo)
} else {
ContentUnavailableView("No Server Selected", systemImage: "server.rack")
}
}
.sheet(isPresented: $showAddServerSheet) {
ServerFormView(
mode: .add,
servers: $servers,
dismiss: { showAddServerSheet = false }
)
}
.sheet(item: $serverBeingEdited) { server in
ServerFormView(
mode: .edit(server),
servers: $servers,
dismiss: { serverBeingEdited = nil }
)
}
.alert("Are you sure you want to delete this server?", isPresented: $showDeleteConfirmation, presenting: serverToDelete) { server in
Button("Delete", role: .destructive) {
ServerFormView.delete(server: server, from: &servers)
}
Button("Cancel", role: .cancel) {}
}
.onReceive(refreshTimer) { _ in
for server in servers {
fetchServerInfo(for: server.id)
}
}
.onAppear {
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
pingAllServers()
}
}
.frame(minWidth: 800, minHeight: 450)
}
private func fetchServerInfo(for id: UUID) {
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
}
isFetchingInfo = true
Task {
defer { isFetchingInfo = false }
do {
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)")
}
}
}
private func moveServer(from source: IndexSet, to destination: Int) {
servers.move(fromOffsets: source, toOffset: destination)
saveServerOrder()
}
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 pingAllServers() {
for (index, server) in servers.enumerated() {
Task {
let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
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()
}

View File

@@ -0,0 +1,242 @@
import SwiftUI
struct PreferencesView: View {
private enum Tab: CaseIterable {
case monitor, notifications, alerts
var title: String {
switch self {
case .monitor: return "Monitor"
case .notifications: return "Notifications"
case .alerts: return "Alerts"
}
}
var icon: String {
switch self {
case .monitor: return "waveform.path.ecg"
case .notifications: return "bell.badge"
case .alerts: return "exclamationmark.triangle"
}
}
}
@AppStorage("pingInterval") private var storedPingInterval: Int = 10
@AppStorage("refreshInterval") private var storedRefreshInterval: Int = 60
@AppStorage("showIntervalIndicator") private var showIntervalIndicator: Bool = true
@State private var pingIntervalSlider: Double = 10
@State private var refreshIntervalSlider: Double = 60
@State private var selection: Tab = .monitor
private let minimumInterval: Double = 10
private let maximumPingInterval: Double = 600
private let maximumRefreshInterval: Double = 600
var body: some View {
HStack(spacing: 0) {
sidebar
Divider()
ScrollView {
detailContent(for: selection)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.frame(minWidth: 360, maxWidth: .infinity, maxHeight: .infinity)
}
.frame(minWidth: 560, minHeight: 360)
.onAppear {
pingIntervalSlider = Double(storedPingInterval)
refreshIntervalSlider = Double(storedRefreshInterval)
}
.onChange(of: pingIntervalSlider) { _, newValue in
storedPingInterval = Int(newValue)
}
.onChange(of: refreshIntervalSlider) { _, newValue in
storedRefreshInterval = Int(newValue)
}
}
private var sidebar: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Preferences")
.font(.headline)
.padding(.bottom, 8)
ForEach(Tab.allCases, id: \.self) { tab in
Button {
selection = tab
} label: {
HStack(spacing: 10) {
Image(systemName: tab.icon)
.frame(width: 20)
Text(tab.title)
Spacer()
}
.padding(.vertical, 8)
.padding(.horizontal, 6)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(selection == tab ? Color.accentColor.opacity(0.25) : Color.clear)
)
}
.buttonStyle(.plain)
}
Spacer()
}
.padding()
.frame(width: 180, alignment: .top)
}
@ViewBuilder
private func detailContent(for tab: Tab) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(tab.title)
.font(.title2)
.padding(.bottom, 12)
switch tab {
case .monitor:
MonitorPreferencesView(
pingIntervalSlider: $pingIntervalSlider,
refreshIntervalSlider: $refreshIntervalSlider,
showIntervalIndicator: $showIntervalIndicator,
minimumInterval: minimumInterval,
maximumPingInterval: maximumPingInterval,
maximumRefreshInterval: maximumRefreshInterval,
pingChanged: handlePingSliderEditing(_:),
refreshChanged: handleRefreshSliderEditing(_:)
)
case .notifications:
NotificationsPreferencesView()
case .alerts:
AlertsPreferencesView()
}
}
}
private func handlePingSliderEditing(_ editing: Bool) {
if !editing {
storedPingInterval = Int(pingIntervalSlider)
}
}
private func handleRefreshSliderEditing(_ editing: Bool) {
if !editing {
storedRefreshInterval = Int(refreshIntervalSlider)
}
}
}
private struct MonitorPreferencesView: View {
@Binding var pingIntervalSlider: Double
@Binding var refreshIntervalSlider: Double
@Binding var showIntervalIndicator: Bool
let minimumInterval: Double
let maximumPingInterval: Double
let maximumRefreshInterval: Double
let pingChanged: (Bool) -> Void
let refreshChanged: (Bool) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 18) {
Group {
Text("Ping interval")
.font(.headline)
Slider(
value: $pingIntervalSlider,
in: minimumInterval...maximumPingInterval,
step: 5
) {
Text("Ping interval")
} minimumValueLabel: {
Text("\(Int(minimumInterval))s")
.font(.caption)
.foregroundColor(.secondary)
} maximumValueLabel: {
Text("\(Int(maximumPingInterval))s")
.font(.caption)
.foregroundColor(.secondary)
} onEditingChanged: { editing in
pingChanged(editing)
}
Text("Current: \(Int(pingIntervalSlider)) seconds")
.font(.caption)
.foregroundColor(.secondary)
}
Group {
Text("Refresh interval")
.font(.headline)
Slider(
value: $refreshIntervalSlider,
in: minimumInterval...maximumRefreshInterval,
step: 5
) {
Text("Refresh interval")
} minimumValueLabel: {
Text("\(Int(minimumInterval))s")
.font(.caption)
.foregroundColor(.secondary)
} maximumValueLabel: {
Text("\(Int(maximumRefreshInterval))s")
.font(.caption)
.foregroundColor(.secondary)
} onEditingChanged: { editing in
refreshChanged(editing)
}
Text("Current: \(Int(refreshIntervalSlider)) seconds")
.font(.caption)
.foregroundColor(.secondary)
}
Divider()
Toggle("Show interval indicator", isOn: $showIntervalIndicator)
.toggleStyle(.switch)
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct NotificationsPreferencesView: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Notifications")
.font(.headline)
.padding(.bottom)
Text("Configure notification behavior here.")
.foregroundColor(.secondary)
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
}
private struct AlertsPreferencesView: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Alerts")
.font(.headline)
.padding(.bottom)
Text("Configure alert thresholds and behavior.")
.foregroundColor(.secondary)
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
}
#Preview {
PreferencesView()
}

View File

@@ -0,0 +1,42 @@
//
// InfoView.swift
// iKeyMon
//
// Created by tracer on 30.03.25.
//
import SwiftUI
struct InfoRow: View {
var label: String
var value: [String]
var color: Color = .primary
init(label: String, value: String) {
self.label = label
self.value = [value]
}
init(label: String, value: [String]) {
self.label = label
self.value = value
}
var body: some View {
HStack(alignment: .top) {
Text(label)
.foregroundStyle(.secondary)
.frame(width: 160, alignment: .leading)
VStack(alignment: .leading, spacing: 2) {
ForEach(value, id: \.self) { item in
Text(item)
}
}
}
.padding(.vertical, 4)
}
}
#Preview {
InfoRow(label: "Hostname", value: "keyhelp.lab.24unix.net")
}

View File

@@ -0,0 +1,48 @@
//
// TableRowView.swift
// iKeyMon
//
// Created by tracer on 01.04.25.
//
import SwiftUI
struct TableRowView<Label: View, Value: View>: View {
var showDivider: Bool = true
@ViewBuilder let label: () -> Label
@ViewBuilder let value: () -> Value
var body: some View {
VStack(spacing: 0) {
HStack(alignment: .top) {
label()
.frame(width: 180, alignment: .leading)
value()
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 2)
}
if showDivider {
Divider()
.opacity(0.6)
}
}
}
#Preview {
TableRowView {
Text("Label")
.fontWeight(.semibold)
} value: {
HStack(spacing: 4) {
Text("42%")
.foregroundColor(.green)
.fontWeight(.semibold)
Text("(extra info)")
.foregroundColor(.secondary)
.font(.callout)
}
}
.padding()
}

View File

@@ -0,0 +1,70 @@
//
// ServerDetailView.swift
// iKeyMon
//
// Created by tracer on 30.03.25.
//
import SwiftUI
struct ServerDetailView: View {
@Binding var server: Server
var isFetching: Bool
@State private var progress: Double = 0
let timer = Timer.publish(every: 1.0 / 60.0, on: .main, in: .common).autoconnect()
var body: some View {
VStack(spacing: 0) {
ProgressView(value: progress)
.progressViewStyle(LinearProgressViewStyle())
.padding(.horizontal)
.frame(height: 2)
if server.info == nil {
ProgressView("Fetching server info...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ZStack(alignment: .topTrailing) {
VStack(spacing: 0) {
Spacer().frame(height: 6)
TabView {
GeneralView(server: $server)
.tabItem {
Text("General")
}
ResourcesView(server: $server)
.tabItem {
Text("Resources")
}
ServicesView(server: $server)
.tabItem {
Text("Services")
}
}
}
if isFetching {
ProgressView()
.scaleEffect(0.5)
.padding()
}
}
.padding(0)
}
}
.onReceive(timer) { _ in
withAnimation(.linear(duration: 1.0 / 60.0)) {
progress += 1.0 / (60.0 * 60.0)
if progress >= 1 { progress = 0 }
}
}
}
}
#Preview {
ServerDetailView(
server: .constant(Server(id: UUID(), hostname: "preview.example.com", info: ServerInfo.placeholder)),
isFetching: false
)
}

View File

@@ -0,0 +1,248 @@
//
// EditServerView.swift
// iKeyMon
//
// Created by tracer on 30.03.25.
//
import SwiftUI
struct ServerFormView: View {
enum Mode {
case add
case edit(Server)
}
var mode: Mode
@Binding var servers: [Server]
@State private var hostname: String
@State private var apiKey: String
@State private var connectionOK: Bool = false
@Environment(\.dismiss) private var dismiss
init(
mode: Mode,
servers: Binding<[Server]>,
dismiss: @escaping () -> Void
) {
self.mode = mode
self._servers = servers
switch mode {
case .add:
self._hostname = State(initialValue: "")
self._apiKey = State(initialValue: "")
case .edit(let server):
self._hostname = State(initialValue: server.hostname)
self._apiKey = State(initialValue: KeychainHelper.loadApiKey(for: server.hostname) ?? "")
}
}
var body: some View {
VStack {
Text("Edit Server")
.font(.headline)
TextField("Hostname", text: $hostname)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding(.top)
SecureField("API Key", text: $apiKey)
.textFieldStyle(RoundedBorderTextFieldStyle())
HStack {
Button("Cancel") {
dismiss()
}
Spacer()
Button("Test connection") {
Task {
await testConnection()
}
}
Button("Save") {
saveServer()
updateServer()
saveServers()
dismiss()
}
.disabled(hostname.isEmpty || apiKey.isEmpty || !connectionOK)
}
.padding(.top)
}
.padding()
.frame(width: 300)
.onAppear {
print("on appear")
if case let .edit(server) = mode {
print("serve \(server)")
hostname = server.hostname
apiKey = KeychainHelper.loadApiKey(for: server.hostname) ?? ""
print("💡 Loaded server: \(hostname)")
}
}
}
private var modeTitle: String {
switch mode {
case .add:
return "Add Server"
case .edit(let server):
return "Edit \(server.hostname)"
}
}
private func testConnection() async {
let host = hostname.trimmingCharacters(in: .whitespacesAndNewlines)
let key = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
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")
// return
// }
//
// var request = URLRequest(url: url)
// request.httpMethod = "GET"
// request.setValue(key, forHTTPHeaderField: "X-API-KEY")
// request.setValue("application/json", forHTTPHeaderField: "Content-Type")
//
// print("🔍 Headers: \(request.allHTTPHeaderFields ?? [:])")
//
// Task {
// do {
// let (data, response) = try await URLSession.shared.data(for: request)
// if let httpResponse = response as? HTTPURLResponse {
// print("🔄 Status: \(httpResponse.statusCode)")
// }
//
// if let result = try? JSONDecoder().decode([String: String].self, from: data),
// result["response"] == "pong" {
// print(" Pong received")
// connectionOK = true
// } else {
// print(" Unexpected response")
// connectionOK = false
// }
// } catch {
// print(" Error: \(error)")
// connectionOK = false
// }
// }
}
// func testKeyHelpConnection() {
// let url = URL(string: "https://keyhelp.lab.24unix.net/api/v2/ping")!
// var request = URLRequest(url: url)
// request.httpMethod = "GET"
//
// // @State is no reliable source
// let key = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
// request.setValue(key, forHTTPHeaderField: "X-API-KEY")
//
// request.setValue("application/json", forHTTPHeaderField: "Content-Type")
//
// print("🔍 Headers: \(request.allHTTPHeaderFields ?? [:])")
//
// let task = URLSession.shared.dataTask(with: request) { data, response, error in
// if let error = error {
// print(" Error: \(error.localizedDescription)")
// return
// }
//
// if let httpResponse = response as? HTTPURLResponse {
// print("🔄 Status Code: \(httpResponse.statusCode)")
// }
//
// if let data = data,
// let json = try? JSONSerialization.jsonObject(with: data) {
// print(" Response: \(json)")
// } else {
// print(" No JSON response")
// }
// }
//
// task.resume()
// }
private func saveServer() {
print("in save server")
let trimmedHost = hostname.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
switch mode {
case .add:
print("adding server")
let newServer = Server(hostname: trimmedHost)
servers.append(newServer)
KeychainHelper.save(apiKey: trimmedKey, for: trimmedHost)
saveServers()
case .edit(let oldServer):
if let index = servers.firstIndex(where: { $0.id == oldServer.id }) {
let oldHostname = servers[index].hostname
servers[index].hostname = trimmedHost
if oldHostname != trimmedHost {
KeychainHelper.deleteApiKey(for: oldHostname)
}
KeychainHelper.save(apiKey: trimmedKey, for: trimmedHost)
}
}
}
private func updateServer() {
print ("in edit server")
guard case let .edit(server) = mode else {
return
}
if let index = servers.firstIndex(where: { $0.id == server.id }) {
// Only replace hostname if changed
let oldHostname = servers[index].hostname
servers[index].hostname = hostname
// Update Keychain
if oldHostname != hostname {
KeychainHelper.deleteApiKey(for: oldHostname)
}
KeychainHelper.save(apiKey: apiKey, for: hostname)
saveServers()
}
}
private func saveServers() {
if let data = try? JSONEncoder().encode(servers) {
UserDefaults.standard.set(data, forKey: "storedServers")
}
}
static func delete(server: Server, from servers: inout [Server]) {
if let index = servers.firstIndex(where: { $0.id == server.id }) {
servers.remove(at: index)
if let data = try? JSONEncoder().encode(servers) {
UserDefaults.standard.set(data, forKey: "storedServers")
}
}
}
}
#Preview {
ServerFormView(
mode: .edit(Server(hostname: "example.com")),
servers: .constant([
Server(hostname: "example.com")
]),
dismiss: {}
)
}

View File

@@ -0,0 +1,127 @@
//
// GeneralTab.swift
// iKeyMon
//
// Created by tracer on 30.03.25.
//
import SwiftUI
struct GeneralView: View {
@Binding var server: Server
var body: some View {
GeometryReader { geometry in
ScrollView {
VStack(alignment: .leading, spacing: 6) {
TableRowView {
Text("Hostname")
} value: {
InfoCell(value: [server.hostname])
}
TableRowView {
Text("IP addresses")
} value: {
InfoCell(value: server.info?.ipAddresses ?? [], monospaced: true)
}
TableRowView {
Text("Server time")
} value: {
InfoCell(value: [server.info?.formattedServerTime ?? ""], monospaced: true)
}
TableRowView {
Text("Uptime")
} value: {
InfoCell(value: [server.info?.uptime ?? ""])
}
TableRowView {
Text("KeyHelp version")
} value: {
InfoCell(value: [server.info?.formattedVersion ?? ""], monospaced: true)
}
TableRowView {
Text("Operating system")
} value: {
InfoCell(
value: {
guard let os = server.info?.operatingSystem else { return [] }
var rows: [String] = []
let distro = [os.distribution, os.version]
.filter { !$0.isEmpty }
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
var description = os.label.trimmingCharacters(in: .whitespacesAndNewlines)
if description.isEmpty {
description = distro
} else if !distro.isEmpty && distro.caseInsensitiveCompare(description) != .orderedSame {
description += "\(distro)"
}
if !os.architecture.isEmpty {
description += " (\(os.architecture))"
}
if !description.isEmpty {
rows.append(description)
}
if let updates = os.updates {
var updateDescription = "Updates: \(updates.updateCount)"
if updates.securityUpdateCount > 0 {
updateDescription += "\(updates.securityUpdateCount) security"
}
rows.append(updateDescription)
if updates.rebootRequired {
rows.append("Reboot required")
}
}
if os.endOfLife {
rows.append("End-of-life release")
}
return rows
}(),
monospaced: true
)
}
TableRowView {
Text("Sytem PHP version")
} value: {
InfoCell(value: [server.info?.phpVersion ?? ""], monospaced: true)
}
TableRowView(showDivider: false) {
Text("Additional PHP interpreters")
} value: {
InfoCell(
value: server.info?.additionalPHPInterpreters?.map { $0.versionFull } ?? [],
monospaced: true
)
}
}
.padding()
.frame(minHeight: geometry.size.height, alignment: .top)
}
.padding()
.scrollDisabled(true)
}
}
}
#Preview {
struct PreviewWrapper: View {
@State var previewServer = Server(hostname: "example.com", info: .placeholder)
var body: some View {
GeneralView(server: $previewServer)
}
}
return PreviewWrapper()
}

View File

@@ -0,0 +1,116 @@
//
// RecourcesView.swift
// iKeyMon
//
// Created by tracer on 31.03.25.
//
//
// GeneralTab.swift
// iKeyMon
//
// Created by tracer on 30.03.25.
//
import SwiftUI
struct ResourcesView: View {
@Binding var server: Server
var body: some View {
GeometryReader { geometry in
ScrollView {
VStack(alignment: .leading, spacing: 6) {
TableRowView {
Text("CPU Load")
} value: {
LoadBarCell(
percent: (server.info?.load.percent)!,
load1: (server.info?.load.minute1)!,
load5: (server.info?.load.minute5)!,
load15: (server.info?.load.minute15)!
)
}
TableRowView {
Text("Memory")
} value: {
UsageBarCell(
free: (server.info?.memory.free)!,
used: (server.info?.memory.used)!,
total: (server.info?.memory.total)!,
percent: (server.info?.memory.percent)!
)
}
TableRowView {
Text("Swap")
} value: {
UsageBarCell(
free: (server.info?.swap.free)!,
used: (server.info?.swap.used)!,
total: (server.info?.swap.total)!,
percent: (server.info?.swap.percent)!
)
}
TableRowView {
Text("SSD")
} value: {
UsageBarCell(
free: (server.info?.diskSpace.free)!,
used: (server.info?.diskSpace.used)!,
total: (server.info?.diskSpace.total)!,
percent: (server.info?.diskSpace.percent)!
)
}
}
.padding()
.frame(minHeight: geometry.size.height, alignment: .top)
}
}
// VStack(alignment: .leading, spacing: 16) {
// if let info = server.info {
// Text("Server Utilization")
// .font(.headline)
// .padding(.bottom, 4)
//
// ResourceRow(label: "CPU Load", value: "\(info.load.percent)", subtext: info.cpuLoadDetail)
// ResourceRow(label: "Process Count", value: "\(info.processCount)")
//// ResourceRow(label: "Emails in Queue", value: "\(info.emailsInQueue)")
//
// ResourceBarRow(
// label: "Memory",
// free: info.memory.free,
// used: info.memory.used,
// total: info.memory.total,
// percent: info.memory.percent
// )
//
// ResourceBarRow(
// label: "Swap",
// free: info.memory.free,
// used: info.memory.used,
// total: info.memory.total,
// percent: info.memory.percent
// )
//
// Spacer()
// } else {
// Text("No data")
// }
// }
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
}
#Preview {
struct PreviewWrapper: View {
@State var previewServer = Server(hostname: "example.com", info: .placeholder)
var body: some View {
ResourcesView(server: $previewServer)
}
}
return PreviewWrapper()
}

View File

@@ -0,0 +1,62 @@
//
// ServicesView.swift
// iKeyMon
//
// Created by tracer on 31.03.25.
//
import SwiftUI
struct ServicesView: View {
@Binding var server: Server
var body: some View {
VStack(alignment: .leading) {
if let ports = server.info?.ports {
Table(ports) {
TableColumn("Service") { port in
Text(port.service)
}
TableColumn("Status") { port in
Text(port.status)
.foregroundColor(
port.status.lowercased() == "online" ? .green : .red
)
}
TableColumn("Port") { port in
Text("\(port.port) \(port.proto.uppercased())")
}
TableColumn("Protocol") { port in
Text("\(port.proto.uppercased())")
.monospacedDigit()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(0)
} else {
Text("No service information available")
.foregroundColor(.secondary)
.padding()
}
}
.navigationTitle("Service/port monitoring")
}
}
#Preview {
struct PreviewWrapper: View {
@State var previewServer = Server(hostname: "example.com", info: .placeholder)
var body: some View {
ServicesView(server: $previewServer)
}
}
return PreviewWrapper()
}

26
Sources/iKeyMonApp.swift Normal file
View File

@@ -0,0 +1,26 @@
//
// iKeyMonApp.swift
// iKeyMon
//
// Created by tracer on 30.03.25.
//
import SwiftUI
@main
struct iKeyMonApp: App {
var body: some Scene {
WindowGroup {
MainView()
.onDisappear {
NSApp.terminate(nil)
}
}
.windowResizability(.contentMinSize)
Settings {
PreferencesView()
.padding()
}
}
}