krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.3: DomainDig/HistoryView.swift · raw
1import SwiftUI
2
3struct HistoryView: View {
4 @Environment(\.appDensity) private var appDensity
5 @Bindable var viewModel: DomainViewModel
6 @Environment(\.dismiss) private var dismiss
7 @State private var showClearAllConfirmation = false
8 @State private var showWorkflowAddSheet = false
9
10 private var domainSummaries: [(domain: String, latest: SnapshotSummary, count: Int)] {
11 viewModel.timelineDomains.compactMap { domain in
12 let entries = viewModel.timelineEntries(for: domain)
13 guard let latest = entries.first else { return nil }
14 return (domain, latest, entries.count)
15 }
16 }
17
18 var body: some View {
19 List {
20 if viewModel.filteredHistory.isEmpty {
21 EmptyStateCardView(
22 title: "No History Yet",
23 message: "History stores local snapshots of previous inspections so you can revisit and compare them later.",
24 suggestion: "Run a lookup to create your first saved snapshot.",
25 systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
26 showsCardBackground: false
27 )
28 .listRowBackground(Color(.appSurface))
29 } else {
30 Section("Domains") {
31 ForEach(domainSummaries, id: \.domain) { item in
32 NavigationLink {
33 TimelineView(viewModel: viewModel, domain: item.domain)
34 } label: {
35 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
36 HStack(alignment: .center, spacing: 8) {
37 Text(item.domain)
38 .font(appDensity.font(.callout))
39 .foregroundStyle(.primary)
40 Spacer()
41 Text("\(item.count) snapshots")
42 .font(appDensity.font(.caption2))
43 .foregroundStyle(Color(.appTextSecondary))
44 }
45
46 Text(item.latest.changeSummaryMessage ?? "No change summary")
47 .font(appDensity.font(.caption))
48 .foregroundStyle(Color(.appTextSecondary))
49 .lineLimit(2)
50
51 HStack(spacing: 8) {
52 AppStatusBadgeView(model: AppStatusFactory.availability(item.latest.availability))
53 if let severity = item.latest.severitySummary {
54 AppStatusBadgeView(
55 model: .init(
56 title: severity.title,
57 systemImage: "arrow.triangle.2.circlepath",
58 foregroundColor: severity == .high ? Color(.statusCritical) : Color(.statusWarning),
59 backgroundColor: (severity == .high ? AppStatusTone.critical : .warning).surface
60 )
61 )
62 }
63 }
64 }
65 }
66 .listRowBackground(Color(.appSurface))
67 }
68 }
69 }
70 }
71 .scrollContentBackground(.hidden)
72 .background(Color(.appBackground))
73 .navigationTitle("History")
74 .searchable(text: $viewModel.timelineDomainFilter, prompt: "Search domains")
75 .toolbar {
76 if !viewModel.history.isEmpty {
77 ToolbarItemGroup(placement: .topBarTrailing) {
78 Menu {
79 Picker("Date Range", selection: $viewModel.historyDateFilter) {
80 ForEach(HistoryDateFilter.allCases) { option in
81 Text(option.title).tag(option)
82 }
83 }
84
85 Picker("Change Filter", selection: $viewModel.historyChangeFilter) {
86 ForEach(ChangeFilterOption.allCases) { option in
87 Text(option.title).tag(option)
88 }
89 }
90
91 Picker("Sort", selection: $viewModel.historySortOption) {
92 ForEach(HistorySortOption.allCases) { option in
93 Text(option.title).tag(option)
94 }
95 }
96
97 Button("Clear All", role: .destructive) {
98 showClearAllConfirmation = true
99 }
100
101 if !viewModel.filteredHistory.isEmpty {
102 Button("Add to Workflow") {
103 showWorkflowAddSheet = true
104 }
105 }
106 } label: {
107 Image(systemName: "line.3.horizontal.decrease.circle")
108 }
109 .accessibilityLabel("Filter")
110
111 EditButton()
112 }
113 }
114 }
115 .alert("Clear history?", isPresented: $showClearAllConfirmation) {
116 Button("Clear All", role: .destructive) {
117 viewModel.clearHistory()
118 }
119 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
120 } message: {
121 Text("This will delete all saved history entries. This cannot be undone.")
122 }
123 .sheet(isPresented: $showWorkflowAddSheet) {
124 WorkflowBulkAddSheet(
125 viewModel: viewModel,
126 title: "Add History Domains",
127 availableDomains: Array(Set(viewModel.filteredHistory.map(\.domain))).sorted()
128 )
129 }
130 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
131 dismiss()
132 }
133 }
134
135 private func deleteFilteredHistoryEntries(at offsets: IndexSet) {
136 let ids = offsets.map { viewModel.filteredHistory[$0].id }
137 viewModel.removeHistoryEntries(withIDs: ids)
138 }
139}
140
141struct HistoryDetailView: View {
142 @Environment(\.appDensity) private var appDensity
143 @Bindable var viewModel: DomainViewModel
144 let entry: HistoryEntry
145 @Environment(\.dismiss) private var dismiss
146 @State private var noteDraft = ""
147 @State private var isEditingNote = false
148 @State private var showRerunOptions = false
149
150 private let dateFormatter: DateFormatter = {
151 let formatter = DateFormatter()
152 formatter.dateStyle = .medium
153 formatter.timeStyle = .short
154 return formatter
155 }()
156
157 private var snapshot: LookupSnapshot {
158 entry.snapshot
159 }
160
161 private var report: DomainReport {
162 DomainReportBuilder().build(
163 from: entry,
164 previousSnapshot: viewModel.comparisonSnapshot(for: entry),
165 historyEntries: viewModel.historyEntries(for: entry.domain)
166 )
167 }
168
169 private var trackedDomain: TrackedDomain? {
170 viewModel.trackedDomains.first { $0.domain.caseInsensitiveCompare(entry.domain) == .orderedSame }
171 }
172
173 var body: some View {
174 ScrollView(.vertical) {
175 VStack(alignment: .leading, spacing: 0) {
176 snapshotBanner
177 SummaryView(fields: DomainViewModel.summaryFields(from: snapshot))
178 .padding(.top, 8)
179 RiskSummaryCardView(report: report)
180 .padding(.top, 8)
181 InsightsSummaryCardView(insights: report.insights)
182 .padding(.top, 8)
183 IntelligenceSectionView(
184 isCollapsed: .constant(false),
185 report: report,
186 showsPlaceholder: FeatureAccessService.currentTier != .proPlus
187 )
188 .padding(.top, 8)
189 DomainSectionView(
190 isCollapsed: .constant(false),
191 rows: DomainViewModel.domainRows(from: snapshot),
192 suggestions: DomainViewModel.suggestionRows(from: snapshot),
193 showSuggestions: entry.availabilityResult?.status == .registered && !entry.suggestions.isEmpty,
194 availabilityLoading: false,
195 suggestionsLoading: false,
196 provenance: snapshot.provenanceBySection[.availability],
197 confidence: snapshot.availabilityConfidence,
198 snapshotNote: entry.note,
199 trackedDomain: trackedDomain,
200 workflows: viewModel.workflowsContaining(domain: entry.domain),
201 trackingLimitMessage: nil,
202 pricingLoading: false,
203 pricingError: snapshot.domainPricingError,
204 showsPricingPlaceholder: !DataAccessService.hasAccess(to: .domainPricing),
205 onTrack: {
206 _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status)
207 },
208 onTogglePinned: {
209 guard let trackedDomain else { return }
210 viewModel.togglePinned(for: trackedDomain)
211 },
212 onEditNote: nil,
213 onAddToWorkflow: nil,
214 onOpenWorkflow: nil,
215 onRunWorkflow: nil
216 )
217 .padding(.top, appDensity.metrics.sectionSpacing)
218 OwnershipSectionView(
219 isCollapsed: .constant(false),
220 rows: DomainViewModel.ownershipRows(from: snapshot),
221 loading: false,
222 error: snapshot.ownershipError,
223 provenance: snapshot.provenanceBySection[.ownership],
224 confidence: snapshot.ownershipConfidence,
225 showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory)
226 )
227 .padding(.top, appDensity.metrics.sectionSpacing)
228 SubdomainsSectionView(
229 isCollapsed: .constant(false),
230 rows: DomainViewModel.subdomainRows(from: snapshot),
231 groups: report.subdomainGroups,
232 loading: false,
233 error: snapshot.subdomainsError,
234 provenance: snapshot.provenanceBySection[.subdomains],
235 confidence: snapshot.subdomainConfidence,
236 showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains)
237 )
238 .padding(.top, appDensity.metrics.sectionSpacing)
239 if let comparisonSnapshot = viewModel.comparisonSnapshot(for: entry) {
240 if let changeSummary = entry.changeSummary {
241 DomainChangeSummaryView(summary: changeSummary)
242 .padding(.top, appDensity.metrics.sectionSpacing)
243 }
244 DomainDiffView(
245 title: "Compared With Previous Snapshot",
246 sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot),
247 contextNote: DomainDiffService.comparisonContextNote(from: comparisonSnapshot, to: snapshot),
248 showsUnchanged: false,
249 highlightedSectionID: nil
250 )
251 .padding(.top, appDensity.metrics.sectionSpacing)
252 }
253 DNSSectionView(
254 isCollapsed: .constant(false),
255 dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot),
256 patternSummary: report.dns.patternSummary,
257 sections: DomainViewModel.dnsRows(from: snapshot),
258 ptrMessage: DomainViewModel.ptrMessage(from: snapshot),
259 loading: false,
260 dnsProvenance: snapshot.provenanceBySection[.dns],
261 ptrProvenance: snapshot.provenanceBySection[.ptr],
262 sectionError: snapshot.dnsError
263 )
264 .padding(.top, appDensity.metrics.sectionSpacing)
265 WebSectionView(
266 isCollapsed: .constant(false),
267 certificateRows: DomainViewModel.webCertificateRows(from: snapshot),
268 sslInfo: snapshot.sslInfo,
269 tlsSummary: report.web,
270 sslLoading: false,
271 sslError: snapshot.sslError,
272 tlsProvenance: snapshot.provenanceBySection[.ssl],
273 responseRows: DomainViewModel.webResponseRows(from: snapshot),
274 headers: snapshot.httpHeaders,
275 headersLoading: false,
276 headersError: snapshot.httpHeadersError,
277 httpProvenance: snapshot.provenanceBySection[.httpHeaders],
278 redirects: DomainViewModel.redirectRows(from: snapshot),
279 redirectLoading: false,
280 redirectError: snapshot.redirectChainError,
281 redirectProvenance: snapshot.provenanceBySection[.redirectChain],
282 finalURL: snapshot.redirectChain.last?.url
283 )
284 .padding(.top, appDensity.metrics.sectionSpacing)
285 EmailSectionView(
286 isCollapsed: .constant(false),
287 rows: DomainViewModel.emailRows(from: snapshot),
288 assessment: report.email,
289 loading: false,
290 provenance: snapshot.provenanceBySection[.emailSecurity],
291 confidence: snapshot.emailSecurityConfidence,
292 error: snapshot.emailSecurityError
293 )
294 .padding(.top, appDensity.metrics.sectionSpacing)
295 NetworkSectionView(
296 isCollapsed: .constant(false),
297 reachabilityRows: DomainViewModel.reachabilityRows(from: snapshot),
298 reachabilityLoading: false,
299 reachabilityError: snapshot.reachabilityError,
300 reachabilityProvenance: snapshot.provenanceBySection[.reachability],
301 locationRows: DomainViewModel.locationRows(from: snapshot),
302 geolocation: snapshot.ipGeolocation,
303 geolocationLoading: false,
304 geolocationError: snapshot.ipGeolocationError,
305 geolocationProvenance: snapshot.provenanceBySection[.ipGeolocation],
306 geolocationConfidence: snapshot.geolocationConfidence,
307 standardPortRows: DomainViewModel.portRows(from: snapshot, kind: .standard),
308 customPortRows: DomainViewModel.portRows(from: snapshot, kind: .custom),
309 portScanLoading: false,
310 portScanError: snapshot.portScanError,
311 portScanProvenance: snapshot.provenanceBySection[.portScan],
312 customPortScanLoading: false,
313 customPortScanError: nil,
314 isCloudflareProxied: snapshot.httpHeaders.contains(where: { $0.name.lowercased() == "cf-ray" }),
315 customPortsExpanded: .constant(false),
316 customPortInput: .constant(""),
317 onScanCustomPorts: { /* Historical snapshot; re-scanning is not offered here. */ }
318 )
319 .padding(.top, appDensity.metrics.sectionSpacing)
320 }
321 .padding(.horizontal)
322 .padding(.bottom, 32)
323 }
324 .background(Color(.appBackground))
325 .navigationTitle(entry.domain)
326 .toolbar {
327 ToolbarItemGroup(placement: .topBarTrailing) {
328 Button("Note") {
329 noteDraft = entry.note ?? ""
330 isEditingNote = true
331 }
332 Button("Re-run") {
333 showRerunOptions = true
334 }
335 }
336 }
337 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
338 dismiss()
339 }
340 .confirmationDialog("Re-run lookup", isPresented: $showRerunOptions) {
341 Button("Run with Current Settings") {
342 viewModel.rerunLookup(from: entry, useSnapshotResolver: false)
343 }
344 Button("Run with Snapshot Resolver") {
345 viewModel.rerunLookup(from: entry, useSnapshotResolver: true)
346 }
347 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
348 } message: {
349 Text(viewModel.resolverMismatchNote(for: entry) ?? "Choose how to reproduce this snapshot.")
350 }
351 .sheet(isPresented: $isEditingNote) {
352 NavigationStack {
353 Form {
354 Section("Audit Note") {
355 TextField("Optional note", text: $noteDraft, axis: .vertical)
356 .lineLimit(3...6)
357 }
358 }
359 .navigationTitle(entry.domain)
360 .toolbar {
361 ToolbarItem(placement: .cancellationAction) {
362 Button("Cancel") {
363 isEditingNote = false
364 }
365 }
366 ToolbarItem(placement: .confirmationAction) {
367 Button("Save") {
368 viewModel.updateHistoryNote(noteDraft, for: entry)
369 isEditingNote = false
370 }
371 }
372 }
373 }
374 }
375 }
376
377 private var snapshotBanner: some View {
378 VStack(alignment: .leading, spacing: 8) {
379 HStack(spacing: 8) {
380 Image(systemName: "archivebox")
381 .font(.caption)
382 Text("Snapshot from \(dateFormatter.string(from: entry.timestamp))")
383 .font(appDensity.font(.caption))
384 Spacer()
385 Text("Live re-run available")
386 .font(appDensity.font(.caption2))
387 .foregroundStyle(Color(.appTextSecondary))
388 }
389 if let mismatchNote = viewModel.resolverMismatchNote(for: entry) {
390 Text(mismatchNote)
391 .font(appDensity.font(.caption2))
392 .foregroundStyle(Color(.statusWarning))
393 }
394 if entry.isPartialSnapshot {
395 Text("Partial snapshot: \(entry.validationIssues.joined(separator: " | "))")
396 .font(appDensity.font(.caption2))
397 .foregroundStyle(Color(.statusWarning))
398 }
399 if let note = entry.note, !note.isEmpty {
400 Text(note)
401 .font(appDensity.font(.caption2))
402 .foregroundStyle(Color(.appTextSecondary))
403 }
404 }
405 .foregroundStyle(Color(.appTextSecondary))
406 .padding(8)
407 .frame(maxWidth: .infinity, alignment: .leading)
408 .background(Color(.appSurface))
409 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
410 .padding(.vertical, 12)
411 }
412}
413
414private struct HistoryGroup: Identifiable {
415 let title: String
416 let entries: [HistoryEntry]
417
418 var id: String { title }
419
420 static func groups(for entries: [HistoryEntry]) -> [HistoryGroup] {
421 let calendar = Calendar.current
422 let today = Date()
423 let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
424
425 let grouped = Dictionary(grouping: entries) { entry -> String in
426 if calendar.isDate(entry.timestamp, inSameDayAs: today) {
427 return "Today"
428 }
429 if calendar.isDate(entry.timestamp, inSameDayAs: yesterday) {
430 return "Yesterday"
431 }
432 return "Older"
433 }
434
435 return ["Today", "Yesterday", "Older"].compactMap { title in
436 guard let entries = grouped[title], !entries.isEmpty else { return nil }
437 return HistoryGroup(title: title, entries: entries)
438 }
439 }
440}