diff --git a/CHANGELOG.md b/CHANGELOG.md index 0648729..0c282d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog ## Unreleased (2026-04-21) +- Added a new `Summary` tab with a denser dashboard layout inspired by infrastructure monitoring tools. +- Added persisted metric history with SwiftData for CPU, memory, disk, and swap charts. +- Added `Hour`, `Day`, `Week`, and `Month` ranges to summary charts. +- Added CPU, memory, disk, and swap history widgets that expand across the available summary width. +- Reworked `General` to remain the more traditional detailed information tab while `Summary` focuses on quick status and trends. +- Isolated metric history into an app-specific SwiftData store and recover cleanly from incompatible history stores. +- Fixed the summary CPU chart to use the summary payload's reported CPU percentage and allow values above `100%` with a dynamic Y axis. +- Fixed excessive summary redraws by moving the interval indicator timer out of the main detail view so charts no longer refresh every second. - Added optional sidebar groups for hosts, including group creation, editing, deletion, and host assignment. - Added grouped host ordering, group reordering via drag and drop, and clearer visual feedback while moving groups. - Improved group header styling to better distinguish groups and ungrouped hosts in the sidebar. diff --git a/Sources/Model/API/ApiFactory.swift b/Sources/Model/API/ApiFactory.swift index 50fae75..fe190a2 100644 --- a/Sources/Model/API/ApiFactory.swift +++ b/Sources/Model/API/ApiFactory.swift @@ -37,6 +37,7 @@ protocol AnyServerAPI { func fetchLoadData() async throws -> Any func fetchMemoryData() async throws -> Any func fetchUtilizationData() async throws -> Any + func fetchCPUUtilizationPercent(apiKey: String) async throws -> Double func fetchServerSummary(apiKey: String) async throws -> ServerInfo func restartServer(apiKey: String) async throws } @@ -64,6 +65,10 @@ private struct AnyServerAPIWrapper: AnyServerAPI { return try await wrapped.fetchUtilization() } + func fetchCPUUtilizationPercent(apiKey: String) async throws -> Double { + return try await wrapped.fetchCPUUtilizationPercent(apiKey: apiKey) + } + func fetchServerSummary(apiKey: String) async throws -> ServerInfo { return try await wrapped.fetchServerSummary(apiKey: apiKey) } diff --git a/Sources/Model/API/BaseAPI.swift b/Sources/Model/API/BaseAPI.swift index 0552ff7..bab3e4d 100644 --- a/Sources/Model/API/BaseAPI.swift +++ b/Sources/Model/API/BaseAPI.swift @@ -17,6 +17,7 @@ protocol ServerAPIProtocol { func fetchLoad() async throws -> LoadType func fetchMemory() async throws -> MemoryType func fetchUtilization() async throws -> UtilizationType + func fetchCPUUtilizationPercent(apiKey: String) async throws -> Double func fetchServerSummary(apiKey: String) async throws -> ServerInfo func restartServer(apiKey: String) async throws } diff --git a/Sources/Model/API/ServerInfo.swift b/Sources/Model/API/ServerInfo.swift index e06017f..71b7299 100644 --- a/Sources/Model/API/ServerInfo.swift +++ b/Sources/Model/API/ServerInfo.swift @@ -19,6 +19,16 @@ struct ServerInfo: Codable, Hashable, Equatable { self.cpuCount = cpuCount self.level = level } + + var displayPercent: Double { + let clampedPercent = min(max(percent, 0), 100) + guard clampedPercent != percent else { + return clampedPercent + } + + let normalized = (minute1 / Double(max(cpuCount, 1))) * 100 + return min(max(normalized, 0), 100) + } } struct Memory: Codable, Hashable, Equatable { @@ -155,6 +165,7 @@ struct ServerInfo: Codable, Hashable, Equatable { var memory: Memory var swap: Memory var diskSpace: DiskSpace + var cpuUtilizationPercent: Double? var panelVersion: String var panelBuild: String var apiVersion: String @@ -189,6 +200,13 @@ struct ServerInfo: Codable, Hashable, Equatable { var supportsRestartCommand: Bool { ServerInfo.version(apiVersion, isAtLeast: "2.14") } + + var summaryCPUPercent: Double { + if let cpuUtilizationPercent { + return min(max(cpuUtilizationPercent, 0), 100) + } + return load.displayPercent + } } // MARK: - Helpers & Sample Data @@ -284,6 +302,7 @@ extension ServerInfo { 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), + cpuUtilizationPercent: 12.5, panelVersion: "25.0", panelBuild: "3394", apiVersion: "2", diff --git a/Sources/Model/API/Versions/APIv2_12.swift b/Sources/Model/API/Versions/APIv2_12.swift index d0af9d1..95291c1 100644 --- a/Sources/Model/API/Versions/APIv2_12.swift +++ b/Sources/Model/API/Versions/APIv2_12.swift @@ -168,6 +168,17 @@ class APIv2_12: BaseAPIClient, ServerAPIProtocol { return try await performRequest(request, responseType: UtilizationType.self) } + func fetchCPUUtilizationPercent(apiKey: String) async throws -> Double { + let url = Endpoint.utilization.url(baseURL: baseURL) + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(apiKey, forHTTPHeaderField: "X-API-KEY") + request.timeoutInterval = 30 + + let utilization = try await performRequest(request, responseType: UtilizationType.self) + return utilization.cpu.overall + } + func fetchServerSummary(apiKey: String) async throws -> ServerInfo { let summaryURL = baseURL.appendingPathComponent("api/v2/server") var request = URLRequest(url: summaryURL) @@ -397,6 +408,7 @@ private extension APIv2_12 { total: disk.total, percent: disk.percent ), + cpuUtilizationPercent: nil, panelVersion: meta.panelVersion, panelBuild: String(meta.panelBuild), apiVersion: meta.apiVersion, diff --git a/Sources/Model/API/Versions/APIv2_13.swift b/Sources/Model/API/Versions/APIv2_13.swift index 2b8e325..e5a5adf 100644 --- a/Sources/Model/API/Versions/APIv2_13.swift +++ b/Sources/Model/API/Versions/APIv2_13.swift @@ -168,6 +168,17 @@ class APIv2_13: BaseAPIClient, ServerAPIProtocol { return try await performRequest(request, responseType: UtilizationType.self) } + func fetchCPUUtilizationPercent(apiKey: String) async throws -> Double { + let url = Endpoint.utilization.url(baseURL: baseURL) + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(apiKey, forHTTPHeaderField: "X-API-KEY") + request.timeoutInterval = 30 + + let utilization = try await performRequest(request, responseType: UtilizationType.self) + return utilization.cpu.overall + } + func fetchServerSummary(apiKey: String) async throws -> ServerInfo { let summaryURL = baseURL.appendingPathComponent("api/v2/server") var request = URLRequest(url: summaryURL) @@ -397,6 +408,7 @@ private extension APIv2_13 { total: disk.total, percent: disk.percent ), + cpuUtilizationPercent: nil, panelVersion: meta.panelVersion, panelBuild: String(meta.panelBuild), apiVersion: meta.apiVersion, diff --git a/Sources/Model/History/MetricSample.swift b/Sources/Model/History/MetricSample.swift new file mode 100644 index 0000000..e3e994a --- /dev/null +++ b/Sources/Model/History/MetricSample.swift @@ -0,0 +1,35 @@ +// +// MetricSample.swift +// iKeyMon +// +// Created by tracer on 21.04.26. +// + +import Foundation +import SwiftData + +@Model +final class MetricSample { + var serverID: UUID + var timestamp: Date + var cpuPercent: Double + var memoryPercent: Double + var swapPercent: Double + var diskPercent: Double + + init( + serverID: UUID, + timestamp: Date = .now, + cpuPercent: Double, + memoryPercent: Double, + swapPercent: Double, + diskPercent: Double + ) { + self.serverID = serverID + self.timestamp = timestamp + self.cpuPercent = cpuPercent + self.memoryPercent = memoryPercent + self.swapPercent = swapPercent + self.diskPercent = diskPercent + } +} diff --git a/Sources/Views/MainView.swift b/Sources/Views/MainView.swift index f113c0f..d874aa3 100644 --- a/Sources/Views/MainView.swift +++ b/Sources/Views/MainView.swift @@ -8,6 +8,7 @@ import SwiftUI import Combine import UserNotifications +import SwiftData import AppKit import UniformTypeIdentifiers @@ -37,9 +38,11 @@ struct MainView: View { @State private var draggedGroupID: UUID? @State private var groupDropIndicator: GroupDropIndicator? @State private var lastRefreshInterval: Int? + @State private var lastMetricPrune: Date? @State private var previousServiceStates: [String: String] = [:] private let serverOrderKey = MainView.serverOrderKeyStatic private let storedGroupsKey = MainView.storedGroupsKeyStatic + @Environment(\.modelContext) private var modelContext @State private var servers: [Server] = MainView.loadStoredServers() @State private var groups: [ServerGroup] = MainView.loadStoredGroups() @@ -351,6 +354,7 @@ struct MainView: View { var updated = servers[index] updated.info = info servers[index] = updated + recordMetricSample(for: id, info: info) checkServiceStatusChanges(for: server.hostname, newInfo: info) } } @@ -386,6 +390,7 @@ struct MainView: View { var updated = servers[index] updated.info = info servers[index] = updated + recordMetricSample(for: id, info: info) } } } catch { @@ -451,6 +456,53 @@ struct MainView: View { } } + private func recordMetricSample(for serverID: UUID, info: ServerInfo) { + let sample = MetricSample( + serverID: serverID, + cpuPercent: info.load.percent, + memoryPercent: info.memory.percent, + swapPercent: info.swap.percent, + diskPercent: info.diskSpace.percent + ) + modelContext.insert(sample) + + do { + try modelContext.save() + } catch { + print("❌ [MainView] Failed to save metric sample: \(error)") + } + + pruneOldMetricSamplesIfNeeded() + } + + private func pruneOldMetricSamplesIfNeeded() { + let now = Date() + + if let lastMetricPrune, now.timeIntervalSince(lastMetricPrune) < 3600 { + return + } + + let cutoff = now.addingTimeInterval(-30 * 24 * 60 * 60) + let descriptor = FetchDescriptor( + predicate: #Predicate { sample in + sample.timestamp < cutoff + } + ) + + do { + let expiredSamples = try modelContext.fetch(descriptor) + for sample in expiredSamples { + modelContext.delete(sample) + } + if !expiredSamples.isEmpty { + try modelContext.save() + } + lastMetricPrune = now + } catch { + print("❌ [MainView] Failed to prune metric samples: \(error)") + } + } + private func deleteGroup(_ group: ServerGroup) { groups.removeAll { $0.id == group.id } for index in servers.indices { diff --git a/Sources/Views/ServerDetailView.swift b/Sources/Views/ServerDetailView.swift index 9726173..462f185 100644 --- a/Sources/Views/ServerDetailView.swift +++ b/Sources/Views/ServerDetailView.swift @@ -20,30 +20,34 @@ struct ServerDetailView: View { var isRestarting: Bool = false var onRestart: (() async -> ServerActionFeedback)? = nil @AppStorage("showIntervalIndicator") private var showIntervalIndicator: Bool = true - @AppStorage("refreshInterval") private var refreshInterval: Int = 60 private var showPlaceholder: Bool { server.info == nil } - @State private var progress: Double = 0 @State private var showRestartSheet = false @State private var restartFeedback: ServerActionFeedback? - private let indicatorTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() var body: some View { VStack(spacing: 0) { if showIntervalIndicator { - ProgressView(value: progress) - .progressViewStyle(LinearProgressViewStyle()) - .padding(.horizontal) - .frame(height: 2) + RefreshIntervalIndicator() } ZStack(alignment: .topTrailing) { VStack(spacing: 0) { Spacer().frame(height: 6) TabView { + SummaryView( + server: resolvedBinding, + canRestart: canRestart, + isRestarting: isRestarting + ) { + showRestartSheet = true + } + .tabItem { + Text("Summary").unredacted() + } GeneralView( server: resolvedBinding, canRestart: canRestart, @@ -85,21 +89,6 @@ struct ServerDetailView: View { .padding() } } - .onReceive(indicatorTimer) { _ in - guard showIntervalIndicator else { return } - withAnimation(.linear(duration: 1)) { - progress += 1.0 / Double(refreshInterval) - if progress >= 1 { progress = 0 } - } - } - .onChange(of: refreshInterval) { _, _ in - progress = 0 - } - .onChange(of: showIntervalIndicator) { _, isVisible in - if !isVisible { - progress = 0 - } - } .sheet(isPresented: $showRestartSheet) { RestartConfirmationSheet( hostname: server.hostname, @@ -133,6 +122,30 @@ struct ServerDetailView: View { } } +private struct RefreshIntervalIndicator: View { + @AppStorage("refreshInterval") private var refreshInterval: Int = 60 + @State private var progress: Double = 0 + private let indicatorTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + var body: some View { + ProgressView(value: progress) + .progressViewStyle(LinearProgressViewStyle()) + .padding(.horizontal) + .frame(height: 2) + .onReceive(indicatorTimer) { _ in + withAnimation(.linear(duration: 1)) { + progress += 1.0 / Double(max(refreshInterval, 1)) + if progress >= 1 { + progress = 0 + } + } + } + .onChange(of: refreshInterval) { _, _ in + progress = 0 + } + } +} + #Preview { ServerDetailView( server: .constant(Server(id: UUID(), hostname: "preview.example.com", info: ServerInfo.placeholder)), diff --git a/Sources/Views/Tabs/GeneralView.swift b/Sources/Views/Tabs/GeneralView.swift index 590ee9f..aadfa0d 100644 --- a/Sources/Views/Tabs/GeneralView.swift +++ b/Sources/Views/Tabs/GeneralView.swift @@ -12,7 +12,7 @@ struct GeneralView: View { var canRestart: Bool = false var isRestarting: Bool = false var onRestart: (() -> Void)? = nil - + var body: some View { GeometryReader { geometry in ScrollView { @@ -22,13 +22,13 @@ struct GeneralView: View { } value: { InfoCell(value: [server.hostname]) } - + TableRowView { Text("IP addresses") } value: { InfoCell(value: server.info?.ipAddresses ?? [], monospaced: true) } - + TableRowView { Text("Server time") } value: { @@ -49,53 +49,76 @@ struct GeneralView: View { TableRowView { Text("Operating system") + } value: { + InfoCell(value: operatingSystemRows, monospaced: true) + } + + TableRowView { + Text("CPU") } 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 && description.range(of: distro, options: [.caseInsensitive]) == nil { - description += " • \(distro)" - } - if !os.architecture.isEmpty && - description.range(of: os.architecture, options: [.caseInsensitive]) == nil { - 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 - }(), + value: [ + "\(server.info?.cpuCores ?? 0) cores", + String(format: "Load %.2f%% (%.2f / %.2f / %.2f)", + server.info?.load.percent ?? 0, + server.info?.load.minute1 ?? 0, + server.info?.load.minute5 ?? 0, + server.info?.load.minute15 ?? 0) + ], monospaced: true ) } TableRowView { - Text("Sytem PHP version") + Text("Memory") + } value: { + InfoCell( + value: [ + "Used \(server.info?.memory.used ?? 0) / Total \(server.info?.memory.total ?? 0)", + String(format: "%.2f %%", server.info?.memory.percent ?? 0) + ].map { line in + line + .replacingOccurrences(of: "\(server.info?.memory.used ?? 0)", with: (server.info?.memory.used ?? 0).toNiceBinaryUnit()) + .replacingOccurrences(of: "\(server.info?.memory.total ?? 0)", with: (server.info?.memory.total ?? 0).toNiceBinaryUnit()) + }, + monospaced: true + ) + } + + TableRowView { + Text("Swap") + } value: { + InfoCell( + value: [ + "Used \(server.info?.swap.used ?? 0) / Total \(server.info?.swap.total ?? 0)", + String(format: "%.2f %%", server.info?.swap.percent ?? 0) + ].map { line in + line + .replacingOccurrences(of: "\(server.info?.swap.used ?? 0)", with: (server.info?.swap.used ?? 0).toNiceBinaryUnit()) + .replacingOccurrences(of: "\(server.info?.swap.total ?? 0)", with: (server.info?.swap.total ?? 0).toNiceBinaryUnit()) + }, + monospaced: true + ) + } + + TableRowView { + Text("Disk space") + } value: { + InfoCell( + value: [ + "Used \(server.info?.diskSpace.used ?? 0) / Total \(server.info?.diskSpace.total ?? 0)", + String(format: "%.2f %%", server.info?.diskSpace.percent ?? 0) + ].map { line in + line + .replacingOccurrences(of: "\(server.info?.diskSpace.used ?? 0)", with: (server.info?.diskSpace.used ?? 0).toNiceBinaryUnit()) + .replacingOccurrences(of: "\(server.info?.diskSpace.total ?? 0)", with: (server.info?.diskSpace.total ?? 0).toNiceBinaryUnit()) + }, + monospaced: true + ) + } + + TableRowView { + Text("System PHP version") } value: { InfoCell(value: [server.info?.phpVersion ?? ""], monospaced: true) } @@ -103,22 +126,7 @@ struct GeneralView: View { TableRowView(showDivider: false) { Text("Additional PHP interpreters") } value: { - InfoCell( - value: { - let interpreters = server.info?.additionalPHPInterpreters ?? [] - if interpreters.isEmpty { - return ["None"] - } - let versions = interpreters - .map { $0.fullVersion } - .filter { !$0.isEmpty } - if versions.isEmpty { - return ["None"] - } - return [versions.joined(separator: " • ")] - }(), - monospaced: true - ) + InfoCell(value: additionalPHPRows, monospaced: true) } if canRestart, let onRestart { @@ -155,6 +163,60 @@ struct GeneralView: View { .scrollDisabled(true) } } + + private var operatingSystemRows: [String] { + 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 && description.range(of: distro, options: [.caseInsensitive]) == nil { + description += " • \(distro)" + } + if !os.architecture.isEmpty && + description.range(of: os.architecture, options: [.caseInsensitive]) == nil { + 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 + } + + private var additionalPHPRows: [String] { + let interpreters = server.info?.additionalPHPInterpreters ?? [] + let versions = interpreters + .map { $0.fullVersion } + .filter { !$0.isEmpty } + + if versions.isEmpty { + return ["None"] + } + + return [versions.joined(separator: " • ")] + } } #Preview { diff --git a/Sources/Views/Tabs/SummaryView.swift b/Sources/Views/Tabs/SummaryView.swift new file mode 100644 index 0000000..531e3ea --- /dev/null +++ b/Sources/Views/Tabs/SummaryView.swift @@ -0,0 +1,434 @@ +// +// SummaryView.swift +// iKeyMon +// +// Created by tracer on 21.04.26. +// + +import SwiftUI +import SwiftData +import Charts + +struct SummaryView: View { + private enum TimeRange: String, CaseIterable, Identifiable { + case hour = "Hour" + case day = "Day" + case week = "Week" + case month = "Month" + + var id: String { rawValue } + + var duration: TimeInterval { + switch self { + case .hour: + return 60 * 60 + case .day: + return 24 * 60 * 60 + case .week: + return 7 * 24 * 60 * 60 + case .month: + return 30 * 24 * 60 * 60 + } + } + + var axisLabelFormat: Date.FormatStyle { + switch self { + case .hour: + return .dateTime.hour().minute() + case .day: + return .dateTime.hour() + case .week: + return .dateTime.month(.abbreviated).day() + case .month: + return .dateTime.month(.abbreviated).day() + } + } + } + + @Binding var server: Server + var canRestart: Bool = false + var isRestarting: Bool = false + var onRestart: (() -> Void)? = nil + @Query(sort: \MetricSample.timestamp, order: .forward) private var metricSamples: [MetricSample] + @State private var selectedRange: TimeRange = .hour + + private let cardSpacing: CGFloat = 16 + private let minCardWidth: CGFloat = 260 + + var body: some View { + GeometryReader { geometry in + let contentWidth = max(geometry.size.width - 36, 0) + let chartColumns = summaryColumns(for: contentWidth) + + ScrollView { + VStack(alignment: .leading, spacing: 18) { + summaryHeader + + HStack { + Spacer() + Picker("Time Range", selection: $selectedRange) { + ForEach(TimeRange.allCases) { range in + Text(range.rawValue) + .tag(range) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 260) + } + .frame(maxWidth: .infinity) + + LazyVGrid(columns: chartColumns, spacing: cardSpacing) { + historyChartCard( + title: "CPU usage", + currentValue: server.info?.load.percent ?? 0, + tint: loadTint, + samples: filteredSamples, + value: \.cpuPercent, + yAxisDomain: cpuChartDomain, + yAxisValues: nil, + clampValuesToDomain: false, + footer: [ + ("1 min", String(format: "%.2f", server.info?.load.minute1 ?? 0)), + ("5 min", String(format: "%.2f", server.info?.load.minute5 ?? 0)), + ("15 min", String(format: "%.2f", server.info?.load.minute15 ?? 0)) + ] + ) + + historyChartCard( + title: "Memory usage", + currentValue: server.info?.memory.percent ?? 0, + tint: .blue, + samples: filteredSamples, + value: \.memoryPercent, + footer: [ + ("Used", (server.info?.memory.used ?? 0).toNiceBinaryUnit()), + ("Free", (server.info?.memory.free ?? 0).toNiceBinaryUnit()), + ("Total", (server.info?.memory.total ?? 0).toNiceBinaryUnit()) + ] + ) + + historyChartCard( + title: "Disk usage", + currentValue: server.info?.diskSpace.percent ?? 0, + tint: .green, + samples: filteredSamples, + value: \.diskPercent, + footer: [ + ("Used", (server.info?.diskSpace.used ?? 0).toNiceBinaryUnit()), + ("Free", (server.info?.diskSpace.free ?? 0).toNiceBinaryUnit()), + ("Total", (server.info?.diskSpace.total ?? 0).toNiceBinaryUnit()) + ] + ) + + historyChartCard( + title: "Swap usage", + currentValue: server.info?.swap.percent ?? 0, + tint: .orange, + samples: filteredSamples, + value: \.swapPercent, + footer: [ + ("Used", (server.info?.swap.used ?? 0).toNiceBinaryUnit()), + ("Free", (server.info?.swap.free ?? 0).toNiceBinaryUnit()), + ("Total", (server.info?.swap.total ?? 0).toNiceBinaryUnit()) + ] + ) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(18) + .frame(width: contentWidth, alignment: .leading) + } + } + } + + private func summaryColumns(for width: CGFloat) -> [GridItem] { + let count = max(1, min(4, Int((width + cardSpacing) / (minCardWidth + cardSpacing)))) + return Array( + repeating: GridItem(.flexible(minimum: minCardWidth), spacing: cardSpacing, alignment: .top), + count: count + ) + } + + private var selectedRangeStart: Date { + Date().addingTimeInterval(-selectedRange.duration) + } + + private var filteredSamples: [MetricSample] { + return metricSamples.filter { sample in + sample.serverID == server.id && sample.timestamp >= selectedRangeStart + } + } + + private var cpuChartDomain: ClosedRange { + let values = filteredSamples.map(\.cpuPercent) + [server.info?.load.percent ?? 0] + let maximum = max(values.max() ?? 0, 100) + let roundedUpperBound = ceil(maximum / 25) * 25 + return 0 ... roundedUpperBound + } + + private var summaryHeader: some View { + dashboardCard { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .center, spacing: 10) { + Circle() + .fill(server.pingable ? Color.green : Color.red) + .frame(width: 10, height: 10) + + Text(server.hostname) + .font(.system(size: 22, weight: .semibold)) + + statusBadge(server.pingable ? "Online" : "Offline", tint: server.pingable ? .green : .red) + } + + HStack(spacing: 8) { + statusBadge(panelBadgeText, tint: .orange) + statusBadge(apiBadgeText, tint: .blue) + if let operatingSystemSummary = server.info?.operatingSystemSummary, !operatingSystemSummary.isEmpty { + statusBadge(operatingSystemSummary, tint: .secondary) + } + } + + Text(summaryLine) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 12) + + if canRestart, let onRestart { + Button(role: .destructive) { + onRestart() + } label: { + if isRestarting { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Rebooting…") + } + } else { + Label("Reboot Server", systemImage: "arrow.clockwise.circle") + } + } + .disabled(isRestarting) + } + } + } + } + + private func historyChartCard( + title: String, + currentValue: Double, + tint: Color, + samples: [MetricSample], + value: KeyPath, + yAxisDomain: ClosedRange = 0...100, + yAxisValues: [Double]? = [0, 25, 50, 75, 100], + clampValuesToDomain: Bool = true, + footer: [(String, String)], + caption: String? = nil + ) -> some View { + dashboardCard { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .firstTextBaseline) { + Text(title) + .font(.headline) + Spacer() + Text(String(format: "%.2f %%", currentValue)) + .font(.title3.weight(.semibold)) + .foregroundStyle(tint) + .monospacedDigit() + } + + if samples.isEmpty { + ContentUnavailableView( + "No chart data yet", + systemImage: "chart.xyaxis.line", + description: Text("History appears after a few refresh cycles.") + ) + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + } else { + Chart(samples) { sample in + let rawValue = sample[keyPath: value] + let sampleValue = clampValuesToDomain + ? min(max(rawValue, yAxisDomain.lowerBound), yAxisDomain.upperBound) + : rawValue + + AreaMark( + x: .value("Time", sample.timestamp), + y: .value(title, sampleValue) + ) + .interpolationMethod(.catmullRom) + .foregroundStyle( + LinearGradient( + colors: [ + tint.opacity(0.55), + tint.opacity(0.12) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + + LineMark( + x: .value("Time", sample.timestamp), + y: .value(title, sampleValue) + ) + .interpolationMethod(.catmullRom) + .foregroundStyle(tint) + .lineStyle(StrokeStyle(lineWidth: 2)) + } + .chartYScale(domain: yAxisDomain) + .chartXScale(domain: selectedRangeStart ... Date()) + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 6)) { value in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(.secondary.opacity(0.25)) + AxisTick() + .foregroundStyle(.secondary.opacity(0.7)) + AxisValueLabel(format: selectedRange.axisLabelFormat) + } + } + .chartYAxis { + if let yAxisValues { + AxisMarks(position: .leading, values: yAxisValues) { value in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(.secondary.opacity(0.2)) + AxisTick() + .foregroundStyle(.secondary.opacity(0.7)) + AxisValueLabel { + if let percent = value.as(Double.self) { + Text("\(Int(percent))%") + } + } + } + } else { + AxisMarks(position: .leading, values: .automatic(desiredCount: 5)) { value in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(.secondary.opacity(0.2)) + AxisTick() + .foregroundStyle(.secondary.opacity(0.7)) + AxisValueLabel { + if let percent = value.as(Double.self) { + Text("\(Int(percent))%") + } + } + } + } + } + .frame(height: 220) + } + + HStack(spacing: 16) { + ForEach(Array(footer.enumerated()), id: \.offset) { _, item in + metric(item.0, value: item.1) + } + } + + if let caption { + Text(caption) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + private func metric(_ title: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title.uppercased()) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + Text(value) + .font(.system(.body, design: .monospaced)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func statusBadge(_ text: String, tint: Color) -> some View { + Text(text) + .font(.caption.weight(.semibold)) + .foregroundStyle(tint == .secondary ? .secondary : tint) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background { + Capsule(style: .continuous) + .fill(tint == .secondary ? Color.secondary.opacity(0.12) : tint.opacity(0.14)) + } + } + + private func dashboardCard(@ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 0) { + content() + } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.white.opacity(0.03)) + ) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.06), lineWidth: 1) + } + } + + private var apiBadgeText: String { + guard let apiVersion = server.info?.apiVersion, !apiVersion.isEmpty else { + return "API unknown" + } + return "API \(apiVersion)" + } + + private var panelBadgeText: String { + guard let panelVersion = server.info?.panelVersion, !panelVersion.isEmpty else { + return "KeyHelp unknown" + } + return "KeyHelp \(panelVersion)" + } + + private var summaryLine: String { + var parts: [String] = [] + + if let uptime = server.info?.uptime, !uptime.isEmpty { + parts.append("Uptime \(uptime)") + } + + if let serverTime = server.info?.formattedServerTime, !serverTime.isEmpty { + parts.append("Server time \(serverTime)") + } + + if parts.isEmpty { + return "Live summary for \(server.hostname)" + } + + return parts.joined(separator: " • ") + } + + private var loadTint: Color { + switch server.info?.load.level.lowercased() { + case "warning": + return .orange + case "critical": + return .red + default: + return .green + } + } +} + +#Preview { + struct PreviewWrapper: View { + @State var previewServer = Server(hostname: "example.com", info: .placeholder) + + var body: some View { + SummaryView(server: $previewServer, canRestart: true) + .padding() + .frame(width: 1100, height: 760) + } + } + + return PreviewWrapper() +} diff --git a/Sources/iKeyMonApp.swift b/Sources/iKeyMonApp.swift index 5195560..fbabd04 100644 --- a/Sources/iKeyMonApp.swift +++ b/Sources/iKeyMonApp.swift @@ -6,6 +6,7 @@ // import SwiftUI +import SwiftData #if os(macOS) import AppKit #endif @@ -13,6 +14,7 @@ import AppKit @main struct iKeyMonApp: App { @StateObject private var sparkleUpdater = SparkleUpdater() + private let modelContainer: ModelContainer init() { #if os(macOS) @@ -20,6 +22,8 @@ struct iKeyMonApp: App { NSApplication.shared.applicationIconImage = customIcon } #endif + + self.modelContainer = Self.makeModelContainer() } var body: some Scene { @@ -30,6 +34,7 @@ struct iKeyMonApp: App { NSApp.terminate(nil) } } + .modelContainer(modelContainer) .windowResizability(.contentMinSize) Settings { @@ -38,4 +43,55 @@ struct iKeyMonApp: App { .environmentObject(sparkleUpdater) } } + + private static func makeModelContainer() -> ModelContainer { + let schema = Schema([MetricSample.self]) + let storeURL = metricStoreURL() + let configuration = ModelConfiguration(url: storeURL) + + do { + return try ModelContainer(for: schema, configurations: [configuration]) + } catch { + print("⚠️ [SwiftData] Failed to open metric store at \(storeURL.path): \(error)") + resetMetricStore(at: storeURL) + + do { + return try ModelContainer(for: schema, configurations: [configuration]) + } catch { + fatalError("Unable to create metric history store: \(error)") + } + } + } + + private static func metricStoreURL() -> URL { + let fileManager = FileManager.default + let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let directory = appSupport + .appendingPathComponent(Bundle.main.bundleIdentifier ?? "net.24unix.iKeyMon", isDirectory: true) + + do { + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + } catch { + fatalError("Unable to create application support directory: \(error)") + } + + return directory.appendingPathComponent("metric-history.store") + } + + private static func resetMetricStore(at url: URL) { + let fileManager = FileManager.default + let sidecarURLs = [ + url, + url.appendingPathExtension("shm"), + url.appendingPathExtension("wal") + ] + + for sidecarURL in sidecarURLs where fileManager.fileExists(atPath: sidecarURL.path) { + do { + try fileManager.removeItem(at: sidecarURL) + } catch { + print("⚠️ [SwiftData] Failed to remove \(sidecarURL.path): \(error)") + } + } + } }