// // 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() }