refactored code structure
This commit is contained in:
47
Sources/Views/Cells/InfoCell.swift
Normal file
47
Sources/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"])
|
||||
}
|
||||
|
||||
37
Sources/Views/Cells/LoadBarCell.swift
Normal file
37
Sources/Views/Cells/LoadBarCell.swift
Normal 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)
|
||||
}
|
||||
50
Sources/Views/Cells/UsageBarCell.swift
Normal file
50
Sources/Views/Cells/UsageBarCell.swift
Normal 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)
|
||||
}
|
||||
221
Sources/Views/MainView.swift
Normal file
221
Sources/Views/MainView.swift
Normal file
@@ -0,0 +1,221 @@
|
||||
//
|
||||
// MainView.swift
|
||||
// iKeyMon
|
||||
//
|
||||
// Created by tracer on 30.03.25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MainView: View {
|
||||
|
||||
private static let serverOrderKeyStatic = "serverOrder"
|
||||
private static let storedServersKeyStatic = "storedServers"
|
||||
|
||||
@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 = MainView.serverOrderKeyStatic
|
||||
private let storedServersKey = MainView.storedServersKeyStatic
|
||||
|
||||
@State private var servers: [Server] = MainView.loadStoredServers()
|
||||
|
||||
// @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 {
|
||||
fetchServerInfo(for: server.id)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let storedID = UserDefaults.standard.string(forKey: "selectedServerID"),
|
||||
let uuid = UUID(uuidString: storedID),
|
||||
servers.contains(where: { $0.id == uuid }) {
|
||||
print("✅ [MainView] Restored selected server \(uuid)")
|
||||
selectedServerID = uuid
|
||||
} else if selectedServerID == nil, let first = servers.first {
|
||||
print("✅ [MainView] Selecting first server \(first.hostname)")
|
||||
selectedServerID = first.id
|
||||
} else {
|
||||
print("ℹ️ [MainView] No stored selection")
|
||||
}
|
||||
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("❌ [MainView] fetchServerInfo: server not found for id \(id)")
|
||||
return
|
||||
}
|
||||
guard let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!apiKey.isEmpty else {
|
||||
print("❌ [MainView] fetchServerInfo: missing API key for \(server.hostname)")
|
||||
return
|
||||
}
|
||||
guard let baseURL = URL(string: "https://\(server.hostname)") else {
|
||||
print("❌ [MainView] Invalid base URL for \(server.hostname)")
|
||||
return
|
||||
}
|
||||
|
||||
isFetchingInfo = true
|
||||
|
||||
Task {
|
||||
defer { isFetchingInfo = false }
|
||||
do {
|
||||
let api = try await APIFactory.detectAndCreateAPI(baseURL: baseURL, apiKey: apiKey)
|
||||
let info = try await api.fetchServerSummary(apiKey: apiKey)
|
||||
await MainActor.run {
|
||||
if let index = servers.firstIndex(where: { $0.id == id }) {
|
||||
var updated = servers[index]
|
||||
updated.info = info
|
||||
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)
|
||||
print("💾 [MainView] Saved server order with \(ids.count) entries")
|
||||
}
|
||||
|
||||
private struct PingResponse: Codable {
|
||||
let response: String
|
||||
}
|
||||
|
||||
func pingAllServers() {
|
||||
for (index, server) in servers.enumerated() {
|
||||
Task {
|
||||
let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let pingable = await PingService.ping(hostname: server.hostname, apiKey: apiKey)
|
||||
await MainActor.run {
|
||||
servers[index].pingable = pingable
|
||||
}
|
||||
if !pingable {
|
||||
print("📶 [MainView] Ping \(server.hostname): offline")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadStoredServers() -> [Server] {
|
||||
let defaults = UserDefaults.standard
|
||||
guard let data = defaults.data(forKey: storedServersKeyStatic) else {
|
||||
print("ℹ️ [MainView] No storedServers data found")
|
||||
return []
|
||||
}
|
||||
do {
|
||||
let saved = try JSONDecoder().decode([Server].self, from: data)
|
||||
print("📦 [MainView] Loaded \(saved.count) servers from UserDefaults")
|
||||
if let order = defaults.stringArray(forKey: serverOrderKeyStatic) {
|
||||
let idMap = order.compactMap(UUID.init)
|
||||
let sorted = 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 sorted
|
||||
}
|
||||
return saved
|
||||
} catch {
|
||||
print("❌ [MainView] Failed to decode stored servers: \(error)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MainView()
|
||||
}
|
||||
242
Sources/Views/PreferencesView.swift
Normal file
242
Sources/Views/PreferencesView.swift
Normal file
@@ -0,0 +1,242 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PreferencesView: View {
|
||||
private enum Tab: CaseIterable {
|
||||
case monitor, notifications, alerts
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .monitor: return "Monitor"
|
||||
case .notifications: return "Notifications"
|
||||
case .alerts: return "Alerts"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .monitor: return "waveform.path.ecg"
|
||||
case .notifications: return "bell.badge"
|
||||
case .alerts: return "exclamationmark.triangle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AppStorage("pingInterval") private var storedPingInterval: Int = 10
|
||||
@AppStorage("refreshInterval") private var storedRefreshInterval: Int = 60
|
||||
@AppStorage("showIntervalIndicator") private var showIntervalIndicator: Bool = true
|
||||
|
||||
@State private var pingIntervalSlider: Double = 10
|
||||
@State private var refreshIntervalSlider: Double = 60
|
||||
@State private var selection: Tab = .monitor
|
||||
|
||||
private let minimumInterval: Double = 10
|
||||
private let maximumPingInterval: Double = 600
|
||||
private let maximumRefreshInterval: Double = 600
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
sidebar
|
||||
Divider()
|
||||
ScrollView {
|
||||
detailContent(for: selection)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
}
|
||||
.frame(minWidth: 360, maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.frame(minWidth: 560, minHeight: 360)
|
||||
.onAppear {
|
||||
pingIntervalSlider = Double(storedPingInterval)
|
||||
refreshIntervalSlider = Double(storedRefreshInterval)
|
||||
}
|
||||
.onChange(of: pingIntervalSlider) { _, newValue in
|
||||
storedPingInterval = Int(newValue)
|
||||
}
|
||||
.onChange(of: refreshIntervalSlider) { _, newValue in
|
||||
storedRefreshInterval = Int(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebar: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Preferences")
|
||||
.font(.headline)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
ForEach(Tab.allCases, id: \.self) { tab in
|
||||
Button {
|
||||
selection = tab
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: tab.icon)
|
||||
.frame(width: 20)
|
||||
Text(tab.title)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(selection == tab ? Color.accentColor.opacity(0.25) : Color.clear)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.frame(width: 180, alignment: .top)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func detailContent(for tab: Tab) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(tab.title)
|
||||
.font(.title2)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
switch tab {
|
||||
case .monitor:
|
||||
MonitorPreferencesView(
|
||||
pingIntervalSlider: $pingIntervalSlider,
|
||||
refreshIntervalSlider: $refreshIntervalSlider,
|
||||
showIntervalIndicator: $showIntervalIndicator,
|
||||
minimumInterval: minimumInterval,
|
||||
maximumPingInterval: maximumPingInterval,
|
||||
maximumRefreshInterval: maximumRefreshInterval,
|
||||
pingChanged: handlePingSliderEditing(_:),
|
||||
refreshChanged: handleRefreshSliderEditing(_:)
|
||||
)
|
||||
case .notifications:
|
||||
NotificationsPreferencesView()
|
||||
case .alerts:
|
||||
AlertsPreferencesView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePingSliderEditing(_ editing: Bool) {
|
||||
if !editing {
|
||||
storedPingInterval = Int(pingIntervalSlider)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRefreshSliderEditing(_ editing: Bool) {
|
||||
if !editing {
|
||||
storedRefreshInterval = Int(refreshIntervalSlider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MonitorPreferencesView: View {
|
||||
@Binding var pingIntervalSlider: Double
|
||||
@Binding var refreshIntervalSlider: Double
|
||||
@Binding var showIntervalIndicator: Bool
|
||||
|
||||
let minimumInterval: Double
|
||||
let maximumPingInterval: Double
|
||||
let maximumRefreshInterval: Double
|
||||
let pingChanged: (Bool) -> Void
|
||||
let refreshChanged: (Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Group {
|
||||
Text("Ping interval")
|
||||
.font(.headline)
|
||||
Slider(
|
||||
value: $pingIntervalSlider,
|
||||
in: minimumInterval...maximumPingInterval,
|
||||
step: 5
|
||||
) {
|
||||
Text("Ping interval")
|
||||
} minimumValueLabel: {
|
||||
Text("\(Int(minimumInterval))s")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} maximumValueLabel: {
|
||||
Text("\(Int(maximumPingInterval))s")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} onEditingChanged: { editing in
|
||||
pingChanged(editing)
|
||||
}
|
||||
Text("Current: \(Int(pingIntervalSlider)) seconds")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Group {
|
||||
Text("Refresh interval")
|
||||
.font(.headline)
|
||||
Slider(
|
||||
value: $refreshIntervalSlider,
|
||||
in: minimumInterval...maximumRefreshInterval,
|
||||
step: 5
|
||||
) {
|
||||
Text("Refresh interval")
|
||||
} minimumValueLabel: {
|
||||
Text("\(Int(minimumInterval))s")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} maximumValueLabel: {
|
||||
Text("\(Int(maximumRefreshInterval))s")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} onEditingChanged: { editing in
|
||||
refreshChanged(editing)
|
||||
}
|
||||
Text("Current: \(Int(refreshIntervalSlider)) seconds")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Toggle("Show interval indicator", isOn: $showIntervalIndicator)
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NotificationsPreferencesView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Notifications")
|
||||
.font(.headline)
|
||||
.padding(.bottom)
|
||||
|
||||
Text("Configure notification behavior here.")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private struct AlertsPreferencesView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Alerts")
|
||||
.font(.headline)
|
||||
.padding(.bottom)
|
||||
|
||||
Text("Configure alert thresholds and behavior.")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
PreferencesView()
|
||||
}
|
||||
42
Sources/Views/Rows/InfoRow.swift
Normal file
42
Sources/Views/Rows/InfoRow.swift
Normal 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")
|
||||
}
|
||||
48
Sources/Views/Rows/TableRowView.swift
Normal file
48
Sources/Views/Rows/TableRowView.swift
Normal 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()
|
||||
}
|
||||
70
Sources/Views/ServerDetailView.swift
Normal file
70
Sources/Views/ServerDetailView.swift
Normal 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
|
||||
)
|
||||
}
|
||||
248
Sources/Views/ServerFormView.swift
Normal file
248
Sources/Views/ServerFormView.swift
Normal file
@@ -0,0 +1,248 @@
|
||||
//
|
||||
// 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 reachable = await PingService.ping(hostname: host, apiKey: key)
|
||||
await MainActor.run {
|
||||
connectionOK = reachable
|
||||
}
|
||||
//
|
||||
// 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: {}
|
||||
)
|
||||
}
|
||||
127
Sources/Views/Tabs/GeneralView.swift
Normal file
127
Sources/Views/Tabs/GeneralView.swift
Normal file
@@ -0,0 +1,127 @@
|
||||
//
|
||||
// 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("Operating system")
|
||||
} value: {
|
||||
InfoCell(
|
||||
value: {
|
||||
guard let os = server.info?.operatingSystem else { return [] }
|
||||
var rows: [String] = []
|
||||
|
||||
let distro = [os.distribution, os.version]
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var description = os.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if description.isEmpty {
|
||||
description = distro
|
||||
} else if !distro.isEmpty && distro.caseInsensitiveCompare(description) != .orderedSame {
|
||||
description += " • \(distro)"
|
||||
}
|
||||
if !os.architecture.isEmpty {
|
||||
description += " (\(os.architecture))"
|
||||
}
|
||||
if !description.isEmpty {
|
||||
rows.append(description)
|
||||
}
|
||||
|
||||
if let updates = os.updates {
|
||||
var updateDescription = "Updates: \(updates.updateCount)"
|
||||
if updates.securityUpdateCount > 0 {
|
||||
updateDescription += " • \(updates.securityUpdateCount) security"
|
||||
}
|
||||
rows.append(updateDescription)
|
||||
if updates.rebootRequired {
|
||||
rows.append("Reboot required")
|
||||
}
|
||||
}
|
||||
|
||||
if os.endOfLife {
|
||||
rows.append("End-of-life release")
|
||||
}
|
||||
|
||||
return rows
|
||||
}(),
|
||||
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()
|
||||
}
|
||||
116
Sources/Views/Tabs/ResourcesView.swift
Normal file
116
Sources/Views/Tabs/ResourcesView.swift
Normal 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()
|
||||
}
|
||||
62
Sources/Views/Tabs/ServicesView.swift
Normal file
62
Sources/Views/Tabs/ServicesView.swift
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user