From 5cf094da1615ad9046821ec7c48a6f4ca077e6fe Mon Sep 17 00:00:00 2001
From: Micha <espey@smart-q.de>
Date: Thu, 3 Apr 2025 19:49:03 +0200
Subject: [PATCH] add/edit/delete servers done

---
 iKeyMon/ContentView.swift               |  24 ---
 iKeyMon/Extensions/ByteFormatting.swift |  23 +++
 iKeyMon/KeychainHelper.swift            |  51 +++++
 iKeyMon/Model/API/ServerResponse.swift  | 179 ++++++++++++++++++
 iKeyMon/Model/Server.swift              |  37 ++++
 iKeyMon/Model/ServerInfo.swift          |  86 +++++++++
 iKeyMon/Views/AddServerView.swift       |  54 ++++++
 iKeyMon/Views/ContentView.swift         |  96 ++++++++++
 iKeyMon/Views/MainView.swift            | 139 ++++++++++++++
 iKeyMon/Views/Rows/InfoRow.swift        |  42 ++++
 iKeyMon/Views/Rows/LoadBarCell.swift    |  37 ++++
 iKeyMon/Views/Rows/ResouceRow.swift     |  38 ++++
 iKeyMon/Views/Rows/TableRowView.swift   |  41 ++++
 iKeyMon/Views/Rows/UsageBarCell.swift   |  50 +++++
 iKeyMon/Views/ServerDetailView.swift    |  35 ++++
 iKeyMon/Views/ServerFormView.swift      | 242 ++++++++++++++++++++++++
 iKeyMon/Views/Tabs/GeneralView.swift    |  47 +++++
 iKeyMon/Views/Tabs/ResourcesView.swift  | 117 ++++++++++++
 iKeyMon/Views/Tabs/ServicesView.swift   |  62 ++++++
 iKeyMon/iKeyMon.entitlements            |   2 +
 iKeyMon/iKeyMonApp.swift                |   5 +-
 21 files changed, 1382 insertions(+), 25 deletions(-)
 delete mode 100644 iKeyMon/ContentView.swift
 create mode 100644 iKeyMon/Extensions/ByteFormatting.swift
 create mode 100644 iKeyMon/KeychainHelper.swift
 create mode 100644 iKeyMon/Model/API/ServerResponse.swift
 create mode 100644 iKeyMon/Model/Server.swift
 create mode 100644 iKeyMon/Model/ServerInfo.swift
 create mode 100644 iKeyMon/Views/AddServerView.swift
 create mode 100644 iKeyMon/Views/ContentView.swift
 create mode 100644 iKeyMon/Views/MainView.swift
 create mode 100644 iKeyMon/Views/Rows/InfoRow.swift
 create mode 100644 iKeyMon/Views/Rows/LoadBarCell.swift
 create mode 100644 iKeyMon/Views/Rows/ResouceRow.swift
 create mode 100644 iKeyMon/Views/Rows/TableRowView.swift
 create mode 100644 iKeyMon/Views/Rows/UsageBarCell.swift
 create mode 100644 iKeyMon/Views/ServerDetailView.swift
 create mode 100644 iKeyMon/Views/ServerFormView.swift
 create mode 100644 iKeyMon/Views/Tabs/GeneralView.swift
 create mode 100644 iKeyMon/Views/Tabs/ResourcesView.swift
 create mode 100644 iKeyMon/Views/Tabs/ServicesView.swift

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)
+                }
         }
     }
 }