first public commit
This commit is contained in:
parent
5cf094da16
commit
6bb7db1fa4
Screenshots
iKeyMon
BIN
Screenshots/edit_server.png
Normal file
BIN
Screenshots/edit_server.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 163 KiB |
BIN
Screenshots/general_view.png
Normal file
BIN
Screenshots/general_view.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 184 KiB |
BIN
Screenshots/resources_view.png
Normal file
BIN
Screenshots/resources_view.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 174 KiB |
BIN
Screenshots/services_view.png
Normal file
BIN
Screenshots/services_view.png
Normal file
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
|
// runtime-only, skip for Codable / Hashable / Equatable
|
||||||
var info: ServerInfo? = nil
|
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.id = id
|
||||||
self.hostname = hostname
|
self.hostname = hostname
|
||||||
self.info = info
|
self.info = info
|
||||||
|
self.pingable = pingable
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Manual conformance
|
// MARK: - Manual conformance
|
||||||
|
|
||||||
static func == (lhs: Server, rhs: Server) -> Bool {
|
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) {
|
func hash(into hasher: inout Hasher) {
|
||||||
|
@ -5,7 +5,13 @@
|
|||||||
// Created by tracer on 30.03.25.
|
// 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 hostname: String
|
||||||
var ipAddresses: [String]
|
var ipAddresses: [String]
|
||||||
// var processor: String
|
// var processor: String
|
||||||
@ -24,6 +30,12 @@ struct ServerInfo: Decodable {
|
|||||||
var memory: Memory
|
var memory: Memory
|
||||||
var swap: Memory
|
var swap: Memory
|
||||||
var diskSpace: DiskSpace
|
var diskSpace: DiskSpace
|
||||||
|
var panelVersion: String
|
||||||
|
var panelBuild: String
|
||||||
|
var apiVersion: String
|
||||||
|
var additionalPHPInterpreters: [PHPInterpreter]?
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ServerInfo {
|
extension ServerInfo {
|
||||||
@ -50,12 +62,33 @@ extension ServerInfo {
|
|||||||
),
|
),
|
||||||
memory: Memory(free: 1234, used: 4567, total: 123456, percent: 23.45),
|
memory: Memory(free: 1234, used: 4567, total: 123456, percent: 23.45),
|
||||||
swap: 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 {
|
var cpuLoadDetail: String {
|
||||||
"1min: \(load.minute1), 5min: \(load.minute5), 15min: \(load.minute15) (\(load.level))"
|
"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 {
|
extension ServerInfo {
|
||||||
@ -82,5 +115,10 @@ extension ServerInfo {
|
|||||||
self.memory = response.utilization.memory
|
self.memory = response.utilization.memory
|
||||||
self.swap = response.utilization.swap
|
self.swap = response.utilization.swap
|
||||||
self.diskSpace = response.utilization.diskSpace
|
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
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: {})
|
|
||||||
}
|
|
47
iKeyMon/Views/Cells/InfoCell.swift
Normal file
47
iKeyMon/Views/Cells/InfoCell.swift
Normal 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"])
|
||||||
|
}
|
||||||
|
|
@ -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
|
import SwiftUI
|
||||||
|
|
||||||
struct MainView: View {
|
struct MainView: View {
|
||||||
|
|
||||||
@State var showAddServerSheet: Bool = false
|
@State var showAddServerSheet: Bool = false
|
||||||
@State private var serverBeingEdited: Server?
|
@State private var serverBeingEdited: Server?
|
||||||
@State private var serverToDelete: Server?
|
@State private var serverToDelete: Server?
|
||||||
@State private var showDeleteConfirmation = false
|
@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] = {
|
@State private var servers: [Server] = {
|
||||||
if let data = UserDefaults.standard.data(forKey: "storedServers"),
|
if let data = UserDefaults.standard.data(forKey: "storedServers"),
|
||||||
let saved = try? JSONDecoder().decode([Server].self, from: data) {
|
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 saved
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
@ -25,25 +44,29 @@ struct MainView: View {
|
|||||||
@State private var selectedServerID: UUID?
|
@State private var selectedServerID: UUID?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
List(servers, selection: $selectedServerID) { server in
|
List(selection: $selectedServerID) {
|
||||||
HStack {
|
ForEach(servers) { server in
|
||||||
Image(systemName: "dot.circle.fill")
|
HStack {
|
||||||
.foregroundColor(.green) // ← later update based on ping
|
Image(systemName: "dot.circle.fill")
|
||||||
Text(server.hostname)
|
.foregroundColor(server.pingable ? .green : .red)
|
||||||
}
|
Text(server.hostname)
|
||||||
.tag(server)
|
|
||||||
.contextMenu {
|
|
||||||
Button("Edit") {
|
|
||||||
print("Editing:", server.hostname)
|
|
||||||
serverBeingEdited = server
|
|
||||||
}
|
}
|
||||||
Divider()
|
.tag(server)
|
||||||
Button("Delete", role: .destructive) {
|
.contextMenu {
|
||||||
serverToDelete = server
|
Button("Edit") {
|
||||||
showDeleteConfirmation = true
|
print("Editing:", server.hostname)
|
||||||
|
serverBeingEdited = server
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
serverToDelete = server
|
||||||
|
showDeleteConfirmation = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onMove(perform: moveServer)
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
@ -56,16 +79,14 @@ struct MainView: View {
|
|||||||
.navigationTitle("Servers")
|
.navigationTitle("Servers")
|
||||||
.onChange(of: selectedServerID) {
|
.onChange(of: selectedServerID) {
|
||||||
if let selectedServerID {
|
if let selectedServerID {
|
||||||
|
UserDefaults.standard.set(selectedServerID.uuidString, forKey: "selectedServerID")
|
||||||
fetchServerInfo(for: selectedServerID)
|
fetchServerInfo(for: selectedServerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} detail: {
|
} detail: {
|
||||||
let _ = print("selectedServerID: \(selectedServerID ?? UUID())")
|
|
||||||
// if let id = selectedServerID,
|
|
||||||
if let selectedServerID,
|
if let selectedServerID,
|
||||||
let index = servers.firstIndex(where: { selectedServerID == $0.id }) {
|
let index = servers.firstIndex(where: { selectedServerID == $0.id }) {
|
||||||
let _ = print( "index: %d\n", index)
|
ServerDetailView(server: $servers[index], isFetching: isFetchingInfo)
|
||||||
ServerDetailView(server: $servers[index])
|
|
||||||
} else {
|
} else {
|
||||||
ContentUnavailableView("No Server Selected", systemImage: "server.rack")
|
ContentUnavailableView("No Server Selected", systemImage: "server.rack")
|
||||||
}
|
}
|
||||||
@ -78,7 +99,6 @@ struct MainView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.sheet(item: $serverBeingEdited) { server in
|
.sheet(item: $serverBeingEdited) { server in
|
||||||
let _ = print("serverBeingEdited: \(server)")
|
|
||||||
ServerFormView(
|
ServerFormView(
|
||||||
mode: .edit(server),
|
mode: .edit(server),
|
||||||
servers: $servers,
|
servers: $servers,
|
||||||
@ -91,39 +111,44 @@ struct MainView: View {
|
|||||||
}
|
}
|
||||||
Button("Cancel", role: .cancel) {}
|
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) {
|
private func fetchServerInfo(for id: UUID) {
|
||||||
guard let server = servers.first(where: { $0.id == id }) else {
|
guard let server = servers.first(where: { $0.id == id }),
|
||||||
print ("server not found: \(id)")
|
let api = ServerAPI(server: server) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
print("after guard")
|
|
||||||
|
|
||||||
print("fetching server: \(server.hostname)")
|
isFetchingInfo = true
|
||||||
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 {
|
Task {
|
||||||
|
defer { isFetchingInfo = false }
|
||||||
do {
|
do {
|
||||||
print("inside task")
|
let info = try await api.fetchServerInfo()
|
||||||
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 }) {
|
if let index = servers.firstIndex(where: { $0.id == id }) {
|
||||||
print("index found: \(index)")
|
|
||||||
var updated = servers[index]
|
var updated = servers[index]
|
||||||
updated.info = ServerInfo(from: decoded)
|
updated.info = ServerInfo(from: info)
|
||||||
dump(updated.info)
|
|
||||||
servers[index] = updated
|
servers[index] = updated
|
||||||
}
|
}
|
||||||
} catch {
|
} 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 {
|
#Preview {
|
||||||
MainView()
|
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
|
import SwiftUI
|
||||||
|
|
||||||
struct TableRowView<Label: View, Value: View>: View {
|
struct TableRowView<Label: View, Value: View>: View {
|
||||||
|
var showDivider: Bool = true
|
||||||
@ViewBuilder let label: () -> Label
|
@ViewBuilder let label: () -> Label
|
||||||
@ViewBuilder let value: () -> Value
|
@ViewBuilder let value: () -> Value
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top) {
|
VStack(spacing: 0) {
|
||||||
label()
|
HStack(alignment: .top) {
|
||||||
.frame(width: 100, alignment: .leading)
|
label()
|
||||||
|
.frame(width: 180, alignment: .leading)
|
||||||
value()
|
|
||||||
.frame(maxWidth: .infinity, 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 {
|
struct ServerDetailView: View {
|
||||||
@Binding var server: Server
|
@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 {
|
var body: some View {
|
||||||
if server.info == nil {
|
VStack(spacing: 0) {
|
||||||
ProgressView("Fetching server info...")
|
ProgressView(value: progress)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.progressViewStyle(LinearProgressViewStyle())
|
||||||
} else {
|
.padding(.horizontal)
|
||||||
TabView {
|
.frame(height: 2)
|
||||||
GeneralView(server: $server)
|
|
||||||
.tabItem {
|
if server.info == nil {
|
||||||
Text("General")
|
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 {
|
if isFetching {
|
||||||
Text("Resources")
|
ProgressView()
|
||||||
}
|
.scaleEffect(0.5)
|
||||||
ServicesView(server: $server)
|
.padding()
|
||||||
.tabItem {
|
|
||||||
Text("Services")
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.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()
|
Spacer()
|
||||||
Button("Test connection") {
|
Button("Test connection") {
|
||||||
testConnection()
|
Task {
|
||||||
|
await testConnection()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Button("Save") {
|
Button("Save") {
|
||||||
saveServer()
|
saveServer()
|
||||||
@ -69,7 +71,7 @@ struct ServerFormView: View {
|
|||||||
saveServers()
|
saveServers()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
// .disabled(hostname.isEmpty || apiKey.isEmpty || !con nectionOK)
|
.disabled(hostname.isEmpty || apiKey.isEmpty || !connectionOK)
|
||||||
}
|
}
|
||||||
.padding(.top)
|
.padding(.top)
|
||||||
}
|
}
|
||||||
@ -96,43 +98,45 @@ struct ServerFormView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func testConnection() {
|
private func testConnection() async {
|
||||||
// @State is no reliable source
|
|
||||||
let host = hostname.trimmingCharacters(in: .whitespacesAndNewlines)
|
let host = hostname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let key = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
let key = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
guard let url = URL(string: "https://\(host)/api/v2/ping") else {
|
let pinger = ServerAPI(hostname: host, apiKey: key)
|
||||||
print("❌ Invalid URL")
|
connectionOK = await pinger.ping()
|
||||||
return
|
//
|
||||||
}
|
// guard let url = URL(string: "https://\(host)/api/v2/ping") else {
|
||||||
|
// print("❌ Invalid URL")
|
||||||
var request = URLRequest(url: url)
|
// return
|
||||||
request.httpMethod = "GET"
|
// }
|
||||||
request.setValue(key, forHTTPHeaderField: "X-API-KEY")
|
//
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
// var request = URLRequest(url: url)
|
||||||
|
// request.httpMethod = "GET"
|
||||||
print("🔍 Headers: \(request.allHTTPHeaderFields ?? [:])")
|
// request.setValue(key, forHTTPHeaderField: "X-API-KEY")
|
||||||
|
// request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
Task {
|
//
|
||||||
do {
|
// print("🔍 Headers: \(request.allHTTPHeaderFields ?? [:])")
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
//
|
||||||
if let httpResponse = response as? HTTPURLResponse {
|
// Task {
|
||||||
print("🔄 Status: \(httpResponse.statusCode)")
|
// do {
|
||||||
}
|
// let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
// if let httpResponse = response as? HTTPURLResponse {
|
||||||
if let result = try? JSONDecoder().decode([String: String].self, from: data),
|
// print("🔄 Status: \(httpResponse.statusCode)")
|
||||||
result["response"] == "pong" {
|
// }
|
||||||
print("✅ Pong received")
|
//
|
||||||
connectionOK = true
|
// if let result = try? JSONDecoder().decode([String: String].self, from: data),
|
||||||
} else {
|
// result["response"] == "pong" {
|
||||||
print("⚠️ Unexpected response")
|
// print("✅ Pong received")
|
||||||
connectionOK = false
|
// connectionOK = true
|
||||||
}
|
// } else {
|
||||||
} catch {
|
// print("⚠️ Unexpected response")
|
||||||
print("❌ Error: \(error)")
|
// connectionOK = false
|
||||||
connectionOK = false
|
// }
|
||||||
}
|
// } catch {
|
||||||
}
|
// print("❌ Error: \(error)")
|
||||||
|
// connectionOK = false
|
||||||
|
// }
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
// func testKeyHelpConnection() {
|
// func testKeyHelpConnection() {
|
||||||
|
@ -11,26 +11,60 @@ struct GeneralView: View {
|
|||||||
@Binding var server: Server
|
@Binding var server: Server
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
GeometryReader { geometry in
|
||||||
Form {
|
ScrollView {
|
||||||
Section {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
InfoRow(label: "Hostname", value: server.info?.hostname ?? "—")
|
TableRowView {
|
||||||
InfoRow(label: "IP addresses", value: server.info?.ipAddresses ?? [])
|
Text("Hostname")
|
||||||
// InfoRow(label: "Processor", value: info.processor)
|
} value: {
|
||||||
// InfoRow(label: "CPU cores", value: "\(info.cpuCores)")
|
InfoCell(value: [server.hostname])
|
||||||
// InfoRow(label: "System virtualization", value: info.virtualization)
|
}
|
||||||
// InfoRow(label: "Server time", value: info.serverTime)
|
|
||||||
// InfoRow(label: "Uptime", value: info.uptime)
|
TableRowView {
|
||||||
// InfoRow(label: "SSH fingerprint", value: info.sshFingerprint)
|
Text("IP addresses")
|
||||||
// InfoRow(label: "DKIM DNS record", value: "Show", color: .yellow)
|
} value: {
|
||||||
// } header: {
|
InfoCell(value: server.info?.ipAddresses ?? [], monospaced: true)
|
||||||
Text("General").font(.headline)
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
.padding(0)
|
.scrollDisabled(true)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -62,7 +62,6 @@ struct ResourcesView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
||||||
.padding()
|
.padding()
|
||||||
.frame(minHeight: geometry.size.height, alignment: .top)
|
.frame(minHeight: geometry.size.height, alignment: .top)
|
||||||
}
|
}
|
||||||
|
@ -16,5 +16,6 @@ struct iKeyMonApp: App {
|
|||||||
NSApp.terminate(nil)
|
NSApp.terminate(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.windowResizability(.contentMinSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user