krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v5.0.0: DomainDig/MonitoringView.swift · raw

  1import SwiftUI
  2
  3struct MonitoringView: View {
  4    @Environment(\.appDensity) private var appDensity
  5    @Bindable var viewModel: DomainViewModel
  6
  7    private var monitoredDomainsCount: Int {
  8        MonitoringStorage.monitoredDomains(
  9            settings: viewModel.monitoringSettings,
 10            trackedDomains: viewModel.trackedDomains
 11        ).count
 12    }
 13
 14    var body: some View {
 15        List {
 16            Section("Overview") {
 17                VStack(alignment: .leading, spacing: 8) {
 18                    LabeledContent("Status", value: viewModel.monitoringSettings.isEnabled ? "Scheduled" : "Manual only")
 19                    LabeledContent("Domains", value: "\(monitoredDomainsCount)")
 20                    LabeledContent("Base Interval", value: MonitoringBaseInterval.nearest(to: viewModel.monitoringSettings.baseInterval).title)
 21                    LabeledContent("Adaptive", value: viewModel.monitoringSettings.adaptiveEnabled ? viewModel.monitoringSettings.sensitivity.title : "Off")
 22                    LabeledContent(
 23                        "Quiet Hours",
 24                        value: viewModel.monitoringSettings.quietHours.map {
 25                            "\(Self.hourLabel($0.startHour)) to \(Self.hourLabel($0.endHour))"
 26                        } ?? "Off"
 27                    )
 28                    LabeledContent("Alerts", value: viewModel.monitoringSettings.alertsEnabled ? viewModel.monitoringSettings.alertFilter.title : "Off")
 29
 30                    if let monitoringStatusMessage = viewModel.monitoringStatusMessage,
 31                       !monitoringStatusMessage.isEmpty {
 32                        Text(monitoringStatusMessage)
 33                            .font(appDensity.font(.caption))
 34                            .foregroundStyle(Color(.appTextSecondary))
 35                    }
 36
 37                    Button(viewModel.monitoringRunInProgress ? "Monitoring…" : "Run Now") {
 38                        viewModel.runMonitoringNow()
 39                    }
 40                    .disabled(viewModel.monitoringRunInProgress)
 41                }
 42                .padding(.vertical, 4)
 43            }
 44            .listRowBackground(Color(.appSurface))
 45
 46            if viewModel.monitoringLogs.isEmpty {
 47                Section {
 48                    EmptyStateCardView(
 49                        title: "No Monitoring Runs Yet",
 50                        message: "Monitoring history appears here after manual or background runs finish.",
 51                        suggestion: "Enable monitoring in Settings or run a manual monitoring sweep.",
 52                        systemImage: "waveform.path.ecg",
 53                        showsCardBackground: false
 54                    )
 55                }
 56                .listRowBackground(Color(.appSurface))
 57            } else {
 58                Section("Recent Runs") {
 59                    ForEach(viewModel.monitoringLogs) { log in
 60                        NavigationLink {
 61                            MonitoringLogDetailView(viewModel: viewModel, log: log)
 62                        } label: {
 63                            VStack(alignment: .leading, spacing: 6) {
 64                                HStack {
 65                                    Text(log.trigger.title)
 66                                        .font(appDensity.font(.callout))
 67                                        .foregroundStyle(.primary)
 68                                    Spacer()
 69                                    Text(log.timestamp.formatted(date: .abbreviated, time: .shortened))
 70                                        .font(appDensity.font(.caption2))
 71                                        .foregroundStyle(Color(.appTextSecondary))
 72                                }
 73
 74                                Text(log.summary)
 75                                    .font(appDensity.font(.caption))
 76                                    .foregroundStyle(Color(.appTextSecondary))
 77
 78                                HStack(spacing: 8) {
 79                                    metricBadge(title: "\(log.domainsChecked) checked")
 80                                    if log.changesFound > 0 {
 81                                        metricBadge(title: "\(log.changesFound) changed", tone: .warning)
 82                                    }
 83                                    if log.alertsTriggered > 0 {
 84                                        metricBadge(title: "\(log.alertsTriggered) alerts", tone: .critical)
 85                                    }
 86                                }
 87                            }
 88                            .padding(.vertical, 4)
 89                        }
 90                    }
 91                }
 92                .listRowBackground(Color(.appSurface))
 93            }
 94        }
 95        .scrollContentBackground(.hidden)
 96        .background(Color(.appBackground))
 97        .navigationTitle("Monitoring")
 98        .onAppear {
 99            viewModel.refreshMonitoringState()
100        }
101    }
102
103    private func metricBadge(title: String, tone: AppStatusTone = .info) -> some View {
104        Text(title)
105            .font(appDensity.font(.caption2))
106            .foregroundStyle(tone.foreground)
107            .padding(.horizontal, 8)
108            .padding(.vertical, 4)
109            .background(tone.surface)
110            .clipShape(Capsule())
111    }
112
113    private static func hourLabel(_ hour: Int) -> String {
114        let formatter = DateFormatter()
115        formatter.dateFormat = "h a"
116        let components = DateComponents(calendar: .current, hour: hour)
117        return components.date.map(formatter.string(from:)) ?? "\(hour):00"
118    }
119}
120
121struct MonitoringLogDetailView: View {
122    @Environment(\.appDensity) private var appDensity
123    @Bindable var viewModel: DomainViewModel
124    let log: MonitoringLog
125
126    var body: some View {
127        List {
128            Section("Summary") {
129                LabeledContent("Trigger", value: log.trigger.title)
130                LabeledContent("Checked", value: "\(log.domainsChecked)")
131                LabeledContent("Changes", value: "\(log.changesFound)")
132                LabeledContent("Alerts", value: "\(log.alertsTriggered)")
133                LabeledContent("Timestamp", value: log.timestamp.formatted(date: .abbreviated, time: .shortened))
134            }
135            .listRowBackground(Color(.appSurface))
136
137            Section("Domains") {
138                ForEach(log.checkedDomains) { result in
139                    VStack(alignment: .leading, spacing: 6) {
140                        HStack {
141                            Text(result.domain)
142                                .font(appDensity.font(.callout))
143                                .foregroundStyle(.primary)
144                            Spacer()
145                            if let alertSeverity = result.alertSeverity {
146                                Text(alertSeverity.title.uppercased())
147                                    .font(appDensity.font(.caption2))
148                                    .foregroundStyle(color(for: alertSeverity))
149                            }
150                        }
151
152                        Text(result.summaryMessage)
153                            .font(appDensity.font(.caption))
154                            .foregroundStyle(Color(.appTextSecondary))
155
156                        HStack(spacing: 10) {
157                            Text(result.resultSource.label)
158                            Text(result.didChange ? "Changed" : "No change")
159                            if result.certificateWarningLevel != .none {
160                                Text(result.certificateWarningLevel.title)
161                            }
162                        }
163                        .font(appDensity.font(.caption2))
164                        .foregroundStyle(Color(.appTextSecondary))
165
166                        if let errorMessage = result.errorMessage {
167                            Text(errorMessage)
168                                .font(appDensity.font(.caption2))
169                                .foregroundStyle(Color(.statusWarning))
170                        }
171
172                        if let historyEntryID = result.historyEntryID,
173                           let entry = viewModel.history.first(where: { $0.id == historyEntryID }) {
174                            NavigationLink("Open Snapshot") {
175                                HistoryDetailView(viewModel: viewModel, entry: entry)
176                            }
177                            .font(appDensity.font(.caption))
178                        }
179                    }
180                    .padding(.vertical, 4)
181                }
182            }
183            .listRowBackground(Color(.appSurface))
184
185            if !log.errors.isEmpty {
186                Section("Errors") {
187                    ForEach(log.errors, id: \.self) { error in
188                        Text(error)
189                            .font(appDensity.font(.caption))
190                            .foregroundStyle(Color(.appTextSecondary))
191                    }
192                }
193                .listRowBackground(Color(.appSurface))
194            }
195        }
196        .scrollContentBackground(.hidden)
197        .background(Color(.appBackground))
198        .navigationTitle("Run Details")
199    }
200
201    private func color(for severity: MonitoringAlertSeverity) -> Color {
202        switch severity {
203        case .info:
204            return .secondary
205        case .warning:
206            return Color(.statusWarning)
207        case .critical:
208            return Color(.statusCritical)
209        }
210    }
211}