Refactor project structure and API

This commit is contained in:
Micha
2025-11-15 19:49:28 +01:00
parent 23ffe1268a
commit 7593a781f2
33 changed files with 966 additions and 404 deletions

View File

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

View File

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

View File

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

229
Views/MainView.swift Normal file
View File

@@ -0,0 +1,229 @@
//
// 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 isFetchingInfo: Bool = false
@State private var refreshTimer = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
@State private var progress: Double = 0
@State private var lastRefresh = Date()
@State private var pingTimer: Timer?
private let serverOrderKey = "serverOrder"
@State private var servers: [Server] = {
if let data = UserDefaults.standard.data(forKey: "storedServers"),
let saved = try? JSONDecoder().decode([Server].self, from: data) {
if let idStrings = UserDefaults.standard.stringArray(forKey: "serverOrder") {
let idMap = idStrings.compactMap(UUID.init)
return saved.sorted { a, b in
guard
let i1 = idMap.firstIndex(of: a.id),
let i2 = idMap.firstIndex(of: b.id)
else { return false }
return i1 < i2
}
}
return saved
}
return []
}()
// @State private var selectedServer: Server?
@State private var selectedServerID: UUID?
var body: some View {
NavigationSplitView {
List(selection: $selectedServerID) {
ForEach(servers) { server in
HStack {
Image(systemName: "dot.circle.fill")
.foregroundColor(server.pingable ? .green : .red)
Text(server.hostname)
}
.tag(server)
.contextMenu {
Button("Edit") {
print("Editing:", server.hostname)
serverBeingEdited = server
}
Divider()
Button("Delete", role: .destructive) {
serverToDelete = server
showDeleteConfirmation = true
}
}
}
.onMove(perform: moveServer)
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: { showAddServerSheet = true }) {
Image(systemName: "plus")
}
.help("Add Host")
}
}
.navigationTitle("Servers")
.onChange(of: selectedServerID) {
if let selectedServerID {
UserDefaults.standard.set(selectedServerID.uuidString, forKey: "selectedServerID")
fetchServerInfo(for: selectedServerID)
}
}
} detail: {
if let selectedServerID,
let index = servers.firstIndex(where: { selectedServerID == $0.id }) {
ServerDetailView(server: $servers[index], isFetching: isFetchingInfo)
} else {
ContentUnavailableView("No Server Selected", systemImage: "server.rack")
}
}
.sheet(isPresented: $showAddServerSheet) {
ServerFormView(
mode: .add,
servers: $servers,
dismiss: { showAddServerSheet = false }
)
}
.sheet(item: $serverBeingEdited) { server in
ServerFormView(
mode: .edit(server),
servers: $servers,
dismiss: { serverBeingEdited = nil }
)
}
.alert("Are you sure you want to delete this server?", isPresented: $showDeleteConfirmation, presenting: serverToDelete) { server in
Button("Delete", role: .destructive) {
ServerFormView.delete(server: server, from: &servers)
}
Button("Cancel", role: .cancel) {}
}
.onReceive(refreshTimer) { _ in
for server in servers {
print("fetching server: \(server.hostname)")
fetchServerInfo(for: server.id)
}
}
.onAppear {
if let storedID = UserDefaults.standard.string(forKey: "selectedServerID"),
let uuid = UUID(uuidString: storedID),
servers.contains(where: { $0.id == uuid }) {
selectedServerID = uuid
} else if selectedServerID == nil, let first = servers.first {
selectedServerID = first.id
}
pingAllServers()
pingTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { _ in
pingAllServers()
}
}
.frame(minWidth: 800, minHeight: 450)
}
private func fetchServerInfo(for id: UUID) {
guard let server = servers.first(where: { $0.id == id }),
let api = ServerAPI(server: server) else {
return
}
isFetchingInfo = true
Task {
defer { isFetchingInfo = false }
do {
let info = try await api.fetchServerInfo()
if let index = servers.firstIndex(where: { $0.id == id }) {
var updated = servers[index]
updated.info = try ServerInfo(from: info as! Decoder)
servers[index] = updated
}
} catch {
print("❌ Failed to fetch server data: \(error)")
}
}
}
private func moveServer(from source: IndexSet, to destination: Int) {
servers.move(fromOffsets: source, toOffset: destination)
saveServerOrder()
}
private func saveServerOrder() {
let ids = servers.map { $0.id.uuidString }
UserDefaults.standard.set(ids, forKey: serverOrderKey)
}
private struct PingResponse: Codable {
let response: String
}
// func pingServer(_ server: Server) async -> Bool {
// let hostname = server.hostname
// guard let url = URL(string: "https://\(hostname)/api/v2/ping") else {
// return false
// }
//
// var request = URLRequest(url: url)
// request.httpMethod = "GET"
// request.timeoutInterval = 5
// request.setValue("application/json", forHTTPHeaderField: "Content-Type")
//
// let apiKey = KeychainHelper.loadApiKey(for: hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
// request.setValue(apiKey, forHTTPHeaderField: "X-API-KEY")
//
// do {
// let (data, response) = try await URLSession.shared.data(for: request)
// if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
// do {
// let decoded = try JSONDecoder().decode(PingResponse.self, from: data)
// if decoded.response == "pong" {
// return true
// } else {
// print(" Unexpected response: \(decoded.response)")
// return false
// }
// } catch {
// print(" Failed to decode JSON: \(error)")
// return false
// }
// } else {
// return false
// }
// } catch {
// print("[Ping] \(server.hostname): \(error.localizedDescription)")
// return false
// }
// }
func pingAllServers() {
for (index, server) in servers.enumerated() {
Task {
let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let api = ServerAPI(hostname: server.hostname, apiKey: apiKey)
let pingable = await api.ping()
servers[index].pingable = pingable
}
}
}
}
#Preview {
MainView()
}

42
Views/Rows/InfoRow.swift Normal file
View File

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

View File

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

View File

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

246
Views/ServerFormView.swift Normal file
View File

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

View File

@@ -0,0 +1,81 @@
//
// GeneralTab.swift
// iKeyMon
//
// Created by tracer on 30.03.25.
//
import SwiftUI
struct GeneralView: View {
@Binding var server: Server
var body: some View {
GeometryReader { geometry in
ScrollView {
VStack(alignment: .leading, spacing: 6) {
TableRowView {
Text("Hostname")
} value: {
InfoCell(value: [server.hostname])
}
TableRowView {
Text("IP addresses")
} value: {
InfoCell(value: server.info?.ipAddresses ?? [], monospaced: true)
}
TableRowView {
Text("Server time")
} value: {
InfoCell(value: [server.info?.formattedServerTime ?? ""], monospaced: true)
}
TableRowView {
Text("Uptime")
} value: {
InfoCell(value: [server.info?.uptime ?? ""])
}
TableRowView {
Text("KeyHelp version")
} value: {
InfoCell(value: [server.info?.formattedVersion ?? ""], monospaced: true)
}
TableRowView {
Text("Sytem PHP version")
} value: {
InfoCell(value: [server.info?.phpVersion ?? ""], monospaced: true)
}
TableRowView(showDivider: false) {
Text("Additional PHP interpreters")
} value: {
InfoCell(
value: server.info?.additionalPHPInterpreters?.map { $0.versionFull } ?? [],
monospaced: true
)
}
}
.padding()
.frame(minHeight: geometry.size.height, alignment: .top)
}
.padding()
.scrollDisabled(true)
}
}
}
#Preview {
struct PreviewWrapper: View {
@State var previewServer = Server(hostname: "example.com", info: .placeholder)
var body: some View {
GeneralView(server: $previewServer)
}
}
return PreviewWrapper()
}

View File

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

View File

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