krz/domain-dig

an ios app for DNS & SSL analysis

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

v5.0.2: 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}