add/edit/delete servers done

This commit is contained in:
Micha 2025-04-03 19:49:03 +02:00
parent 57ad42480f
commit 5cf094da16
21 changed files with 1382 additions and 25 deletions

@ -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()
}

@ -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])
}
}

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

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

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

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

@ -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: {})
}

@ -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()
//}

@ -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()
}

@ -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")
}

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

@ -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")
//}

@ -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()
}

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

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

@ -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: {}
)
}

@ -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()
}

@ -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()
}

@ -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()
}

@ -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>

@ -11,7 +11,10 @@ import SwiftUI
struct iKeyMonApp: App {
var body: some Scene {
WindowGroup {
ContentView()
MainView()
.onDisappear {
NSApp.terminate(nil)
}
}
}
}