From f045a5d339a796f40a9e958436ebc6f5314bdab8 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 23 Apr 2026 22:15:20 -0500 Subject: feat(v3.4.0): add backup, restore, and data portability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • introduce versioned DomainDig backup format • add full backup export/import with merge and replace modes • implement data validation and conflict handling • add partial exports for tracked domains, workflows, and history • add Data Portability settings section • support Files/iCloud Drive import and export • harden migrations for evolving local models • extend CLI with backup export and validation --- DomainDig/MonitoringView.swift | 198 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 DomainDig/MonitoringView.swift (limited to 'DomainDig/MonitoringView.swift') diff --git a/DomainDig/MonitoringView.swift b/DomainDig/MonitoringView.swift new file mode 100644 index 0000000..0b7a916 --- /dev/null +++ b/DomainDig/MonitoringView.swift @@ -0,0 +1,198 @@ +import SwiftUI + +struct MonitoringView: View { + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel + + private var monitoredDomainsCount: Int { + MonitoringStorage.monitoredDomains( + settings: viewModel.monitoringSettings, + trackedDomains: viewModel.trackedDomains + ).count + } + + var body: some View { + List { + Section("Overview") { + VStack(alignment: .leading, spacing: 8) { + LabeledContent("Status", value: viewModel.monitoringSettings.isEnabled ? "Scheduled" : "Manual only") + LabeledContent("Domains", value: "\(monitoredDomainsCount)") + LabeledContent("Frequency", value: viewModel.monitoringSettings.frequency.title) + LabeledContent("Alerts", value: viewModel.monitoringSettings.alertsEnabled ? viewModel.monitoringSettings.alertFilter.title : "Off") + + if let monitoringStatusMessage = viewModel.monitoringStatusMessage, + !monitoringStatusMessage.isEmpty { + Text(monitoringStatusMessage) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + } + + Button(viewModel.monitoringRunInProgress ? "Monitoring…" : "Run Now") { + viewModel.runMonitoringNow() + } + .disabled(viewModel.monitoringRunInProgress) + } + .padding(.vertical, 4) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + if viewModel.monitoringLogs.isEmpty { + Section { + EmptyStateCardView( + title: "No Monitoring Runs Yet", + message: "Monitoring history appears here after manual or background runs finish.", + suggestion: "Enable monitoring in Settings or run a manual monitoring sweep.", + systemImage: "waveform.path.ecg" + ) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } else { + Section("Recent Runs") { + ForEach(viewModel.monitoringLogs) { log in + NavigationLink { + MonitoringLogDetailView(viewModel: viewModel, log: log) + } label: { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(log.trigger.title) + .font(appDensity.font(.callout)) + .foregroundStyle(.primary) + Spacer() + Text(log.timestamp.formatted(date: .abbreviated, time: .shortened)) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + + Text(log.summary) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + metricBadge(title: "\(log.domainsChecked) checked") + if log.changesFound > 0 { + metricBadge(title: "\(log.changesFound) changed", tint: .orange) + } + if log.alertsTriggered > 0 { + metricBadge(title: "\(log.alertsTriggered) alerts", tint: .red) + } + } + } + .padding(.vertical, 4) + } + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle("Monitoring") + .preferredColorScheme(.dark) + .onAppear { + viewModel.refreshMonitoringState() + } + } + + private func metricBadge(title: String, tint: Color = .cyan) -> some View { + Text(title) + .font(appDensity.font(.caption2)) + .foregroundStyle(tint) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(tint.opacity(0.16)) + .clipShape(Capsule()) + } +} + +struct MonitoringLogDetailView: View { + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel + let log: MonitoringLog + + var body: some View { + List { + Section("Summary") { + LabeledContent("Trigger", value: log.trigger.title) + LabeledContent("Checked", value: "\(log.domainsChecked)") + LabeledContent("Changes", value: "\(log.changesFound)") + LabeledContent("Alerts", value: "\(log.alertsTriggered)") + LabeledContent("Timestamp", value: log.timestamp.formatted(date: .abbreviated, time: .shortened)) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + Section("Domains") { + ForEach(log.checkedDomains) { result in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(result.domain) + .font(appDensity.font(.callout)) + .foregroundStyle(.primary) + Spacer() + if let alertSeverity = result.alertSeverity { + Text(alertSeverity.title.uppercased()) + .font(appDensity.font(.caption2)) + .foregroundStyle(color(for: alertSeverity)) + } + } + + Text(result.summaryMessage) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + + HStack(spacing: 10) { + Text(result.resultSource.label) + Text(result.didChange ? "Changed" : "No change") + if result.certificateWarningLevel != .none { + Text(result.certificateWarningLevel.title) + } + } + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + + if let errorMessage = result.errorMessage { + Text(errorMessage) + .font(appDensity.font(.caption2)) + .foregroundStyle(.yellow) + } + + if let historyEntryID = result.historyEntryID, + let entry = viewModel.history.first(where: { $0.id == historyEntryID }) { + NavigationLink("Open Snapshot") { + HistoryDetailView(viewModel: viewModel, entry: entry) + } + .font(appDensity.font(.caption)) + } + } + .padding(.vertical, 4) + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + if !log.errors.isEmpty { + Section("Errors") { + ForEach(log.errors, id: \.self) { error in + Text(error) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle("Run Details") + .preferredColorScheme(.dark) + } + + private func color(for severity: MonitoringAlertSeverity) -> Color { + switch severity { + case .info: + return .secondary + case .warning: + return .yellow + case .critical: + return .red + } + } +} -- cgit v1.2.3