diff --git a/iKeyMon/ContentView.swift b/iKeyMon/ContentView.swift deleted file mode 100644 index 24b7fe0..0000000 --- a/iKeyMon/ContentView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ContentView.swift -// iKeyMon -// -// Created by tracer on 30.03.25. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() - } -} - -#Preview { - ContentView() -} diff --git a/iKeyMon/Extensions/ByteFormatting.swift b/iKeyMon/Extensions/ByteFormatting.swift new file mode 100644 index 0000000..041fbab --- /dev/null +++ b/iKeyMon/Extensions/ByteFormatting.swift @@ -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]) + } +} diff --git a/iKeyMon/KeychainHelper.swift b/iKeyMon/KeychainHelper.swift new file mode 100644 index 0000000..2eb4db4 --- /dev/null +++ b/iKeyMon/KeychainHelper.swift @@ -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) + } +} diff --git a/iKeyMon/Model/API/ServerResponse.swift b/iKeyMon/Model/API/ServerResponse.swift new file mode 100644 index 0000000..d11170e --- /dev/null +++ b/iKeyMon/Model/API/ServerResponse.swift @@ -0,0 +1,179 @@ +// +// ServerResponse.swift +// iKeyMon +// +// Created by tracer on 30.03.25. +// + +import Foundation +import Foundation + +// MARK: - Root Response +struct ServerResponse: Decodable { + let meta: Meta + let operatingSystem: OperatingSystem + let utilization: Utilization + let resources: Resources + let components: Components + let additionalPHPInterpreters: [PHPInterpreter]? + let ports: [ServicePort]? + + enum CodingKeys: String, CodingKey { + case meta + case operatingSystem = "operating_system" + case utilization, resources, components + case additionalPHPInterpreters = "additional_php_interpreters" + case ports + } +} + +// MARK: - Meta +struct Meta: Decodable { + let hostname: String + let ipAddresses: [String] + let serverTime: String + let uptime: Uptime + let panelVersion: String + let panelBuild: String + let apiVersion: String + let apiDocs: String + let keyhelpPro: Bool + + enum CodingKeys: String, CodingKey { + case hostname + case ipAddresses = "ip_addresses" + case serverTime = "server_time" + case uptime + case panelVersion = "panel_version" + case panelBuild = "panel_build" + case apiVersion = "api_version" + case apiDocs = "api_docs" + case keyhelpPro = "keyhelp_pro" + } +} + +struct Uptime: Decodable { + let days, hours, minutes, seconds: Int +} + +// MARK: - OperatingSystem +struct OperatingSystem: Decodable { + let label, distribution, version, architecture: String + let endOfLife: Bool + let updates: Updates + + enum CodingKeys: String, CodingKey { + case label, distribution, version, architecture + case endOfLife = "end_of_life" + case updates + } +} + +struct Updates: Decodable { + let updateCount, securityUpdateCount: Int + let rebootRequired: Bool + + enum CodingKeys: String, CodingKey { + case updateCount = "update_count" + case securityUpdateCount = "security_update_count" + case rebootRequired = "reboot_required" + } +} + +// MARK: - Utilization +struct Utilization: Decodable { + let processCount, emailsInQueue: Int + let load: Load + let diskSpace: DiskSpace + let inodes: Inodes + let memory, swap: Memory + + enum CodingKeys: String, CodingKey { + case processCount = "process_count" + case emailsInQueue = "emails_in_queue" + case load + case diskSpace = "disk_space" + case inodes, memory, swap + } +} + +struct Load: Decodable { + let minute1, minute5, minute15, percent: Double + let cpuCount: Int + let level: String + + enum CodingKeys: String, CodingKey { + case minute1 = "minute_1" + case minute5 = "minute_5" + case minute15 = "minute_15" + case cpuCount = "cpu_count" + case percent, level + } +} + +struct DiskSpace: Decodable { + let free, used, total: Int + let percent: Double +} + +struct Inodes: Decodable { + let free, used, total: Int + let percent: Double +} + +struct Memory: Decodable { + let free, used, total: Int + let percent: Double +} + +// MARK: - Resources +struct Resources: Decodable { + let adminAccounts, clientAccounts, domains, subdomains: Int + let emailAccounts, emailAddresses, emailForwardings, databases: Int + let ftpUsers, scheduledTasks: Int + let consumedDiskSpace, traffic: Int + + enum CodingKeys: String, CodingKey { + case adminAccounts = "admin_accounts" + case clientAccounts = "client_accounts" + case domains, subdomains + case emailAccounts = "email_accounts" + case emailAddresses = "email_addresses" + case emailForwardings = "email_forwardings" + case databases + case ftpUsers = "ftp_users" + case scheduledTasks = "scheduled_tasks" + case consumedDiskSpace = "consumed_disk_space" + case traffic + } +} + +// MARK: - Components +struct Components: Decodable { + let kernel, apache, php, proftpd, dovecot, postfix: String + let mariadb, mysql: String? +} + +struct PHPInterpreter: Decodable { + let version, versionFull: String + + enum CodingKeys: String, CodingKey { + case version + case versionFull = "version_full" + } +} + +struct ServicePort: Decodable, Identifiable { + let service: String + let port: Int + let proto: String + let status: String + var id: String { service } + + enum CodingKeys: String, CodingKey { + case service + case port + case proto = "protocol" + case status + } +} diff --git a/iKeyMon/Model/Server.swift b/iKeyMon/Model/Server.swift new file mode 100644 index 0000000..a7a4caa --- /dev/null +++ b/iKeyMon/Model/Server.swift @@ -0,0 +1,37 @@ +// +// Server.swift +// iKeyMon +// +// Created by tracer on 30.03.25. +// + +import Foundation + +struct Server: Identifiable, Codable, Hashable, Equatable { + let id: UUID + var hostname: String + + // runtime-only, skip for Codable / Hashable / Equatable + var info: ServerInfo? = nil + + init(id: UUID = UUID(), hostname: String, info: ServerInfo? = nil) { + self.id = id + self.hostname = hostname + self.info = info + } + + // MARK: - Manual conformance + + static func == (lhs: Server, rhs: Server) -> Bool { + lhs.id == rhs.id && lhs.hostname == rhs.hostname + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(hostname) + } + + enum CodingKeys: String, CodingKey { + case id, hostname + } +} diff --git a/iKeyMon/Model/ServerInfo.swift b/iKeyMon/Model/ServerInfo.swift new file mode 100644 index 0000000..fca7c49 --- /dev/null +++ b/iKeyMon/Model/ServerInfo.swift @@ -0,0 +1,86 @@ +// +// ServerInfo.swift +// iKeyMon +// +// Created by tracer on 30.03.25. +// + +struct ServerInfo: Decodable { + var hostname: String + var ipAddresses: [String] +// var processor: String + var cpuCores: Int +// var virtualization: String + var serverTime: String + var uptime: String +// var sshFingerprint: String + var processCount: Int + var apacheVersion: String + var phpVersion: String + var mysqlVersion: String? + var mariadbVersion: String? + var ports: [ServicePort]? + var load: Load + var memory: Memory + var swap: Memory + var diskSpace: DiskSpace +} + +extension ServerInfo { + static let placeholder = ServerInfo( + hostname: "keyhelp.lab.24unix.net", + ipAddresses: ["192.168.99.44", "2a03:..."], +// processor: "Common processor (arm64)", + cpuCores: 8, +// virtualization: "QEMU", + serverTime: "Sunday, March 30, 2025 at 08:01 PM (Europe/Berlin)", + uptime: "6 Days / 7 Hours / 16 Minutes", +// sshFingerprint: "Ed25519 / ECDSA / RSA", + processCount: 123, + apacheVersion: "2.4", + phpVersion: "8.4", + ports: [], + load: Load( + minute1: 0.42, + minute5: 0.31, + minute15: 0.29, + percent: 10.5, + cpuCount: 4, + level: "low" + ), + memory: Memory(free: 1234, used: 4567, total: 123456, percent: 23.45), + swap: Memory(free: 1234, used: 4567, total: 123456, percent: 23.45), + diskSpace: DiskSpace(free: 1234, used: 4567, total: 123456, percent: 23.45) + ) + + var cpuLoadDetail: String { + "1min: \(load.minute1), 5min: \(load.minute5), 15min: \(load.minute15) (\(load.level))" + } +} + +extension ServerInfo { + init(from response: ServerResponse) { + self.hostname = response.meta.hostname + self.ipAddresses = response.meta.ipAddresses + self.serverTime = response.meta.serverTime + + let u = response.meta.uptime + self.uptime = "\(u.days) Days / \(u.hours) Hours / \(u.minutes) Minutes" + + self.cpuCores = response.utilization.load.cpuCount + self.processCount = response.utilization.processCount + + self.apacheVersion = response.components.apache + self.phpVersion = response.components.php + + self.mysqlVersion = response.components.mysql ?? "" + self.mariadbVersion = response.components.mariadb ?? "" + + self.ports = response.ports + + self.load = response.utilization.load + self.memory = response.utilization.memory + self.swap = response.utilization.swap + self.diskSpace = response.utilization.diskSpace + } +} diff --git a/iKeyMon/Views/AddServerView.swift b/iKeyMon/Views/AddServerView.swift new file mode 100644 index 0000000..40ebb1f --- /dev/null +++ b/iKeyMon/Views/AddServerView.swift @@ -0,0 +1,54 @@ +// +// addServerView.swift +// iKeyMon +// +// Created by tracer on 30.03.25. +// + +import SwiftUI + +struct AddServerView: View { + @Environment(\.dismiss) private var dismiss + + @Binding var servers: [Server] + @State private var hostname: String = "" + @State private var apiKey: String = "" + + var saveAction: () -> Void + + var body: some View { + VStack { + Text("Add New Server") + .font(.headline) + + TextField("Hostname", text: $hostname) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .padding(.top) + + SecureField("API Key", text: $apiKey) + .textFieldStyle(RoundedBorderTextFieldStyle()) + + HStack { + Button("Cancel") { + dismiss() + } + Spacer() + Button("Add") { + let newServer = Server(hostname: hostname) + servers.append(newServer) + saveAction() + KeychainHelper.save(apiKey: apiKey, for: hostname) + dismiss() + } + .disabled(hostname.isEmpty || apiKey.isEmpty) + } + .padding(.top) + } + .padding() + .frame(width: 300) + } +} + +#Preview { + AddServerView(servers: .constant([]), saveAction: {}) +} diff --git a/iKeyMon/Views/ContentView.swift b/iKeyMon/Views/ContentView.swift new file mode 100644 index 0000000..d05c2dc --- /dev/null +++ b/iKeyMon/Views/ContentView.swift @@ -0,0 +1,96 @@ +// +// ContentView.swift +// iKeyMon +// +// Created by tracer on 30.03.25. +// + +import SwiftUI + + +//struct ContentView: View { +// @State private var showingAddSheet = false +// @State private var showingEditSheet = false +// @State private var editingServer: Server? +// +// let serversKey = "storedServers" +// +// @State private var servers: [Server] = { +// if let data = UserDefaults.standard.data(forKey: "storedServers"), +// let saved = try? JSONDecoder().decode([Server].self, from: data) { +// return saved +// } +// return [] +// }() +// +// var body: some View { +// VStack(alignment: .leading) { +// Text("Monitored Servers") +// .font(.largeTitle) +// .padding(.top) +// +// List { +// ForEach(servers) { server in +// HStack { +// VStack(alignment: .leading) { +// Text(server.hostname) +// .font(.headline) +// if let apiKey = KeychainHelper.loadApiKey(for: server.hostname) { +// Text("API Key: \(apiKey.prefix(8))") +// .font(.caption) +// .foregroundColor(.secondary) +// } else { +// Text("No API key found") +// .font(.caption) +// .foregroundColor(.red) +// } +// } +// Spacer() +// Button("Edit") { +// editingServer = server +// showingEditSheet = true +// } +// .buttonStyle(BorderlessButtonStyle()) +// } +// } +// .onDelete(perform: deleteServer) +// } +// +// HStack { +// Spacer() +// Button("Add Server") { +// showingAddSheet = true +// } +// .padding(.bottom) +// } +// } +// .padding() +// .frame(minWidth: 400, minHeight: 300) +// .sheet(isPresented: $showingAddSheet) { +// AddServerView(servers: $servers, saveAction: saveServers) +// } +// .sheet(item: $editingServer) { server in +// let _ = print(server.hostname) +// EditServerView( +// server: server, +// servers: $servers, +// saveAction: saveServers, +// dismiss: { self.showingEditSheet = false } +// ) +// } +// } +// +// +// private func deleteServer(at offsets: IndexSet) { +// for index in offsets { +// let hostname = servers[index].hostname +// KeychainHelper.deleteApiKey(for: hostname) +// } +// servers.remove(atOffsets: offsets) +// saveServers() +// } +//} +// +//#Preview { +// ContentView() +//} diff --git a/iKeyMon/Views/MainView.swift b/iKeyMon/Views/MainView.swift new file mode 100644 index 0000000..766acf4 --- /dev/null +++ b/iKeyMon/Views/MainView.swift @@ -0,0 +1,139 @@ +// +// MainView.swift +// iKeyMon +// +// Created by tracer on 30.03.25. +// + +import SwiftUI + +struct MainView: View { + @State var showAddServerSheet: Bool = false + @State private var serverBeingEdited: Server? + @State private var serverToDelete: Server? + @State private var showDeleteConfirmation = false + + @State private var servers: [Server] = { + if let data = UserDefaults.standard.data(forKey: "storedServers"), + let saved = try? JSONDecoder().decode([Server].self, from: data) { + return saved + } + return [] + }() + +// @State private var selectedServer: Server? + @State private var selectedServerID: UUID? + + var body: some View { + NavigationSplitView { + List(servers, selection: $selectedServerID) { server in + HStack { + Image(systemName: "dot.circle.fill") + .foregroundColor(.green) // ← later update based on ping + Text(server.hostname) + } + .tag(server) + .contextMenu { + Button("Edit") { + print("Editing:", server.hostname) + serverBeingEdited = server + } + Divider() + Button("Delete", role: .destructive) { + serverToDelete = server + showDeleteConfirmation = true + } + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(action: { showAddServerSheet = true }) { + Image(systemName: "plus") + } + .help("Add Host") + } + } + .navigationTitle("Servers") + .onChange(of: selectedServerID) { + if let selectedServerID { + fetchServerInfo(for: selectedServerID) + } + } + } detail: { + let _ = print("selectedServerID: \(selectedServerID ?? UUID())") +// if let id = selectedServerID, + if let selectedServerID, + let index = servers.firstIndex(where: { selectedServerID == $0.id }) { + let _ = print( "index: %d\n", index) + ServerDetailView(server: $servers[index]) + } else { + ContentUnavailableView("No Server Selected", systemImage: "server.rack") + } + } + .sheet(isPresented: $showAddServerSheet) { + ServerFormView( + mode: .add, + servers: $servers, + dismiss: { showAddServerSheet = false } + ) + } + .sheet(item: $serverBeingEdited) { server in + let _ = print("serverBeingEdited: \(server)") + 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) {} + } + } + + private func fetchServerInfo(for id: UUID) { + guard let server = servers.first(where: { $0.id == id }) else { + print ("server not found: \(id)") + return + } + print("after guard") + + print("fetching server: \(server.hostname)") + let apiKey = (KeychainHelper.loadApiKey(for: server.hostname) ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: "https://\(server.hostname)/api/v2/server") else { return } + print("after url") + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(apiKey, forHTTPHeaderField: "X-API-KEY") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + print("after request") + Task { + do { + print("inside task") + let (data, _) = try await URLSession.shared.data(for: request) + let decoded = try JSONDecoder().decode(ServerResponse.self, from: data) + print("after decode") + + if let index = servers.firstIndex(where: { $0.id == id }) { + print("index found: \(index)") + var updated = servers[index] + updated.info = ServerInfo(from: decoded) + dump(updated.info) + servers[index] = updated + } + } catch { + print("❌ Failed to fetch server data: \(error)") + } + } + } + +} + +#Preview { + MainView() +} diff --git a/iKeyMon/Views/Rows/InfoRow.swift b/iKeyMon/Views/Rows/InfoRow.swift new file mode 100644 index 0000000..c9d1a75 --- /dev/null +++ b/iKeyMon/Views/Rows/InfoRow.swift @@ -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") +} diff --git a/iKeyMon/Views/Rows/LoadBarCell.swift b/iKeyMon/Views/Rows/LoadBarCell.swift new file mode 100644 index 0000000..7afa837 --- /dev/null +++ b/iKeyMon/Views/Rows/LoadBarCell.swift @@ -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) +} diff --git a/iKeyMon/Views/Rows/ResouceRow.swift b/iKeyMon/Views/Rows/ResouceRow.swift new file mode 100644 index 0000000..5c8038d --- /dev/null +++ b/iKeyMon/Views/Rows/ResouceRow.swift @@ -0,0 +1,38 @@ +// +// ResouceRow.swift +// iKeyMon +// +// Created by tracer on 31.03.25. +// + +//import SwiftUI +// +//struct ResourceRow: View { +// let label: String +// let value: String +// var subtext: String? = nil +// +// var body: some View { +// HStack(spacing: 6) { +// Text(label) +// .fontWeight(.semibold) +// +// Text(value) +// .foregroundColor(.green) +// .fontWeight(.semibold) +// +// if let subtext { +// Text("(\(subtext))") +// .foregroundColor(.secondary) +// .font(.callout) +// } +// +// Spacer() +// } +// .padding(.vertical, 2) +// } +//} +// +//#Preview { +// ResourceRow(label: "Test", value: "value", subtext: "some additional info") +//} diff --git a/iKeyMon/Views/Rows/TableRowView.swift b/iKeyMon/Views/Rows/TableRowView.swift new file mode 100644 index 0000000..9dce0a7 --- /dev/null +++ b/iKeyMon/Views/Rows/TableRowView.swift @@ -0,0 +1,41 @@ +// +// TableRowView.swift +// iKeyMon +// +// Created by tracer on 01.04.25. +// + +import SwiftUI + +struct TableRowView<Label: View, Value: View>: View { + @ViewBuilder let label: () -> Label + @ViewBuilder let value: () -> Value + + var body: some View { + HStack(alignment: .top) { + label() + .frame(width: 100, alignment: .leading) + + value() + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 2) + } +} + +#Preview { + TableRowView { + Text("Label") + .fontWeight(.semibold) + } value: { + HStack(spacing: 4) { + Text("42%") + .foregroundColor(.green) + .fontWeight(.semibold) + Text("(extra info)") + .foregroundColor(.secondary) + .font(.callout) + } + } + .padding() +} diff --git a/iKeyMon/Views/Rows/UsageBarCell.swift b/iKeyMon/Views/Rows/UsageBarCell.swift new file mode 100644 index 0000000..aa55f17 --- /dev/null +++ b/iKeyMon/Views/Rows/UsageBarCell.swift @@ -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) +} diff --git a/iKeyMon/Views/ServerDetailView.swift b/iKeyMon/Views/ServerDetailView.swift new file mode 100644 index 0000000..9824384 --- /dev/null +++ b/iKeyMon/Views/ServerDetailView.swift @@ -0,0 +1,35 @@ +// +// ServerDetailView.swift +// iKeyMon +// +// Created by tracer on 30.03.25. +// + +import SwiftUI + +struct ServerDetailView: View { + @Binding var server: Server + + var body: some View { + if server.info == nil { + ProgressView("Fetching server info...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + TabView { + GeneralView(server: $server) + .tabItem { + Text("General") + } + ResourcesView(server: $server) + .tabItem { + Text("Resources") + } + ServicesView(server: $server) + .tabItem { + Text("Services") + } + } + .padding(0) + } + } +} diff --git a/iKeyMon/Views/ServerFormView.swift b/iKeyMon/Views/ServerFormView.swift new file mode 100644 index 0000000..11ceb9b --- /dev/null +++ b/iKeyMon/Views/ServerFormView.swift @@ -0,0 +1,242 @@ +// +// 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") { + testConnection() + } + Button("Save") { + saveServer() + updateServer() + saveServers() + dismiss() + } +// .disabled(hostname.isEmpty || apiKey.isEmpty || !con nectionOK) + } + .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() { + // @State is no reliable source + let host = hostname.trimmingCharacters(in: .whitespacesAndNewlines) + let key = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + + 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: {} + ) +} diff --git a/iKeyMon/Views/Tabs/GeneralView.swift b/iKeyMon/Views/Tabs/GeneralView.swift new file mode 100644 index 0000000..41ac0b1 --- /dev/null +++ b/iKeyMon/Views/Tabs/GeneralView.swift @@ -0,0 +1,47 @@ +// +// GeneralTab.swift +// iKeyMon +// +// Created by tracer on 30.03.25. +// + +import SwiftUI + +struct GeneralView: View { + @Binding var server: Server + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Form { + Section { + InfoRow(label: "Hostname", value: server.info?.hostname ?? "—") + InfoRow(label: "IP addresses", value: server.info?.ipAddresses ?? []) + // InfoRow(label: "Processor", value: info.processor) + // InfoRow(label: "CPU cores", value: "\(info.cpuCores)") + // InfoRow(label: "System virtualization", value: info.virtualization) + // InfoRow(label: "Server time", value: info.serverTime) + // InfoRow(label: "Uptime", value: info.uptime) + // InfoRow(label: "SSH fingerprint", value: info.sshFingerprint) + // InfoRow(label: "DKIM DNS record", value: "Show", color: .yellow) + // } header: { + Text("General").font(.headline) + } + } + .formStyle(.grouped) + .padding(0) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +#Preview { + struct PreviewWrapper: View { + @State var previewServer = Server(hostname: "example.com", info: .placeholder) + + var body: some View { + GeneralView(server: $previewServer) + } + } + + return PreviewWrapper() +} diff --git a/iKeyMon/Views/Tabs/ResourcesView.swift b/iKeyMon/Views/Tabs/ResourcesView.swift new file mode 100644 index 0000000..caed2c5 --- /dev/null +++ b/iKeyMon/Views/Tabs/ResourcesView.swift @@ -0,0 +1,117 @@ +// +// 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)! + ) + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .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() +} diff --git a/iKeyMon/Views/Tabs/ServicesView.swift b/iKeyMon/Views/Tabs/ServicesView.swift new file mode 100644 index 0000000..2841f6f --- /dev/null +++ b/iKeyMon/Views/Tabs/ServicesView.swift @@ -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() +} diff --git a/iKeyMon/iKeyMon.entitlements b/iKeyMon/iKeyMon.entitlements index 18aff0c..625af03 100644 --- a/iKeyMon/iKeyMon.entitlements +++ b/iKeyMon/iKeyMon.entitlements @@ -6,5 +6,7 @@ <true/> <key>com.apple.security.files.user-selected.read-only</key> <true/> + <key>com.apple.security.network.client</key> + <true/> </dict> </plist> diff --git a/iKeyMon/iKeyMonApp.swift b/iKeyMon/iKeyMonApp.swift index 1ed5cd6..63f2c49 100644 --- a/iKeyMon/iKeyMonApp.swift +++ b/iKeyMon/iKeyMonApp.swift @@ -11,7 +11,10 @@ import SwiftUI struct iKeyMonApp: App { var body: some Scene { WindowGroup { - ContentView() + MainView() + .onDisappear { + NSApp.terminate(nil) + } } } }