first public commit

This commit is contained in:
Micha 2025-04-06 13:18:39 +02:00
parent 5cf094da16
commit 6bb7db1fa4
20 changed files with 469 additions and 314 deletions

BIN
Screenshots/edit_server.png Normal file

Binary file not shown.

After

(image error) Size: 163 KiB

Binary file not shown.

After

(image error) Size: 184 KiB

Binary file not shown.

After

(image error) Size: 174 KiB

Binary file not shown.

After

(image error) Size: 185 KiB

@ -13,17 +13,19 @@ struct Server: Identifiable, Codable, Hashable, Equatable {
// runtime-only, skip for Codable / Hashable / Equatable
var info: ServerInfo? = nil
var pingable: Bool = false
init(id: UUID = UUID(), hostname: String, info: ServerInfo? = nil) {
init(id: UUID = UUID(), hostname: String, info: ServerInfo? = nil, pingable: Bool = false) {
self.id = id
self.hostname = hostname
self.info = info
self.pingable = pingable
}
// MARK: - Manual conformance
static func == (lhs: Server, rhs: Server) -> Bool {
lhs.id == rhs.id && lhs.hostname == rhs.hostname
lhs.id == rhs.id && lhs.hostname == rhs.hostname && lhs.info == rhs.info && lhs.pingable == rhs.pingable
}
func hash(into hasher: inout Hasher) {

@ -5,7 +5,13 @@
// Created by tracer on 30.03.25.
//
struct ServerInfo: Decodable {
import Foundation
struct ServerInfo: Decodable, Equatable {
static func == (lhs: ServerInfo, rhs: ServerInfo) -> Bool {
lhs.hostname == rhs.hostname && lhs.serverTime == rhs.serverTime
}
var hostname: String
var ipAddresses: [String]
// var processor: String
@ -24,6 +30,12 @@ struct ServerInfo: Decodable {
var memory: Memory
var swap: Memory
var diskSpace: DiskSpace
var panelVersion: String
var panelBuild: String
var apiVersion: String
var additionalPHPInterpreters: [PHPInterpreter]?
}
extension ServerInfo {
@ -50,12 +62,33 @@ extension ServerInfo {
),
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)
diskSpace: DiskSpace(free: 1234, used: 4567, total: 123456, percent: 23.45),
panelVersion: "25.0",
panelBuild: "3394",
apiVersion: "2.0"
)
var cpuLoadDetail: String {
"1min: \(load.minute1), 5min: \(load.minute5), 15min: \(load.minute15) (\(load.level))"
}
var formattedServerTime: String {
let isoFormatter = ISO8601DateFormatter()
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "de_DE")
formatter.dateStyle = .medium
formatter.timeStyle = .short
if let date = isoFormatter.date(from: serverTime) {
return formatter.string(from: date)
} else {
return serverTime // fallback to raw string if parsing fails
}
}
var formattedVersion: String {
"\(panelVersion) (Build \(panelBuild))"
}
}
extension ServerInfo {
@ -82,5 +115,10 @@ extension ServerInfo {
self.memory = response.utilization.memory
self.swap = response.utilization.swap
self.diskSpace = response.utilization.diskSpace
self.panelVersion = response.meta.panelVersion
self.panelBuild = response.meta.panelBuild
self.apiVersion = response.meta.apiVersion
self.additionalPHPInterpreters = response.additionalPHPInterpreters
}
}

67
iKeyMon/ServerAPI.swift Normal file

@ -0,0 +1,67 @@
//
// ServerAPI.swift
// iKeyMon
//
// Created by tracer on 06.04.25.
//
import Foundation
final class ServerAPI {
private let hostname: String
private let apiKey: String
init(hostname: String, apiKey: String) {
self.hostname = hostname
self.apiKey = apiKey
}
init?(server: Server) {
guard let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) else {
return nil
}
self.hostname = server.hostname
self.apiKey = apiKey
}
@discardableResult
func ping() async -> Bool {
guard let url = URL(string: "https://\(hostname)/api/v2/ping") else {
return false
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue(apiKey, forHTTPHeaderField: "X-API-KEY")
do {
let (data, _ /*response */) = try await URLSession.shared.data(for: request)
// if let httpResponse = response as? HTTPURLResponse {
// print("data: \(String(data: data, encoding: .utf8))")
// }
if let result = try? JSONDecoder().decode([String: String].self, from: data), result["response"] == "pong" {
return true
}
} catch {
print("❌ Ping error: \(error)")
}
return false
}
func fetchServerInfo() async throws -> ServerResponse {
guard let url = URL(string: "https://\(hostname)/api/v2/server") else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue(apiKey, forHTTPHeaderField: "X-API-KEY")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, _) = try await URLSession.shared.data(for: request)
let decoded = try JSONDecoder().decode(ServerResponse.self, from: data)
return decoded
}
}

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

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

@ -8,14 +8,33 @@
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 []
@ -25,25 +44,29 @@ struct MainView: View {
@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
List(selection: $selectedServerID) {
ForEach(servers) { server in
HStack {
Image(systemName: "dot.circle.fill")
.foregroundColor(server.pingable ? .green : .red)
Text(server.hostname)
}
Divider()
Button("Delete", role: .destructive) {
serverToDelete = server
showDeleteConfirmation = true
.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) {
@ -56,16 +79,14 @@ struct MainView: View {
.navigationTitle("Servers")
.onChange(of: selectedServerID) {
if let selectedServerID {
UserDefaults.standard.set(selectedServerID.uuidString, forKey: "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])
ServerDetailView(server: $servers[index], isFetching: isFetchingInfo)
} else {
ContentUnavailableView("No Server Selected", systemImage: "server.rack")
}
@ -78,7 +99,6 @@ struct MainView: View {
)
}
.sheet(item: $serverBeingEdited) { server in
let _ = print("serverBeingEdited: \(server)")
ServerFormView(
mode: .edit(server),
servers: $servers,
@ -91,39 +111,44 @@ struct MainView: View {
}
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 }) else {
print ("server not found: \(id)")
guard let server = servers.first(where: { $0.id == id }),
let api = ServerAPI(server: server) else {
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")
isFetchingInfo = true
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 {
defer { isFetchingInfo = false }
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")
let info = try await api.fetchServerInfo()
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)
updated.info = ServerInfo(from: info)
servers[index] = updated
}
} catch {
@ -132,7 +157,72 @@ struct MainView: View {
}
}
}
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()

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

@ -8,18 +8,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 {
HStack(alignment: .top) {
label()
.frame(width: 100, alignment: .leading)
value()
.frame(maxWidth: .infinity, alignment: .leading)
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)
}
.padding(.vertical, 2)
}
}

@ -9,27 +9,81 @@ 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 {
if server.info == nil {
ProgressView("Fetching server info...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
TabView {
GeneralView(server: $server)
.tabItem {
Text("General")
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")
}
}
}
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 }
}
.padding(0)
}
}
}
#Preview {
ServerDetailView(
server: .constant(Server(id: UUID(), hostname: "preview.example.com", info: ServerInfo(
hostname: "preview.example.com",
ipAddresses: ["192.168.1.1", "fe80::1"],
cpuCores: 4,
serverTime: "2025-04-04T18:00:00+0200",
uptime: "3 Days / 12 Hours / 30 Minutes",
processCount: 123,
apacheVersion: "2.4.58",
phpVersion: "8.2.12",
mysqlVersion: "8.0.33",
mariadbVersion: nil,
ports: nil,
load: Load(minute1: 0.5, minute5: 0.3, minute15: 0.2, percent: 10.0, cpuCount: 4, level: "low"),
memory: Memory(free: 8_000_000_000, used: 4_000_000_000, total: 12_000_000_000, percent: 33.3),
swap: Memory(free: 4_000_000_000, used: 1_000_000_000, total: 5_000_000_000, percent: 20.0),
diskSpace: DiskSpace(free: 100_000_000_000, used: 50_000_000_000, total: 150_000_000_000, percent: 33.3),
panelVersion: "25.0",
panelBuild: "3394",
apiVersion: "2"
))),
isFetching: false
)
}

@ -61,7 +61,9 @@ struct ServerFormView: View {
}
Spacer()
Button("Test connection") {
testConnection()
Task {
await testConnection()
}
}
Button("Save") {
saveServer()
@ -69,7 +71,7 @@ struct ServerFormView: View {
saveServers()
dismiss()
}
// .disabled(hostname.isEmpty || apiKey.isEmpty || !con nectionOK)
.disabled(hostname.isEmpty || apiKey.isEmpty || !connectionOK)
}
.padding(.top)
}
@ -96,43 +98,45 @@ struct ServerFormView: View {
}
}
private func testConnection() {
// @State is no reliable source
private func testConnection() async {
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
}
}
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() {

@ -11,26 +11,60 @@ 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)
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)
}
.formStyle(.grouped)
.padding(0)
.padding()
.scrollDisabled(true)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}

@ -62,7 +62,6 @@ struct ResourcesView: View {
)
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding()
.frame(minHeight: geometry.size.height, alignment: .top)
}

@ -16,5 +16,6 @@ struct iKeyMonApp: App {
NSApp.terminate(nil)
}
}
.windowResizability(.contentMinSize)
}
}