krz/domain-dig

an ios app for DNS & SSL analysis

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

v2.0.0: DomainDig/HistoryView.swift · raw

  1import SwiftUI
  2
  3struct HistoryView: View {
  4    @Bindable var viewModel: DomainViewModel
  5    @Environment(\.dismiss) private var dismiss
  6    @State private var showClearAllConfirmation = false
  7
  8    private let dateFormatter: DateFormatter = {
  9        let formatter = DateFormatter()
 10        formatter.dateStyle = .medium
 11        formatter.timeStyle = .short
 12        return formatter
 13    }()
 14
 15    var body: some View {
 16        List {
 17            if viewModel.history.isEmpty {
 18                Text("No lookup history")
 19                    .font(.system(.callout, design: .monospaced))
 20                    .foregroundStyle(.secondary)
 21                    .listRowBackground(Color(.systemGray6).opacity(0.5))
 22            } else {
 23                ForEach(viewModel.history) { entry in
 24                    NavigationLink {
 25                        HistoryDetailView(viewModel: viewModel, entry: entry)
 26                    } label: {
 27                        VStack(alignment: .leading, spacing: 4) {
 28                            Text(entry.domain)
 29                                .font(.system(.callout, design: .monospaced))
 30                                .foregroundStyle(.primary)
 31                            HStack(spacing: 8) {
 32                                Text(dateFormatter.string(from: entry.timestamp))
 33                                Text("Snapshot")
 34                                Text(entry.resolverDisplayName)
 35                                if let totalLookupDurationMs = entry.totalLookupDurationMs {
 36                                    Text("\(totalLookupDurationMs) ms")
 37                                }
 38                            }
 39                            .font(.system(.caption2, design: .monospaced))
 40                            .foregroundStyle(.secondary)
 41                        }
 42                    }
 43                    .listRowBackground(Color(.systemGray6).opacity(0.5))
 44                }
 45                .onDelete { offsets in
 46                    viewModel.removeHistoryEntries(at: offsets)
 47                }
 48            }
 49        }
 50        .scrollContentBackground(.hidden)
 51        .background(Color.black)
 52        .navigationTitle("History")
 53        .toolbar {
 54            if !viewModel.history.isEmpty {
 55                ToolbarItemGroup(placement: .topBarTrailing) {
 56                    Menu {
 57                        Button("Clear All", role: .destructive) {
 58                            showClearAllConfirmation = true
 59                        }
 60                    } label: {
 61                        Image(systemName: "ellipsis.circle")
 62                    }
 63
 64                    EditButton()
 65                }
 66            }
 67        }
 68        .alert("Clear history?", isPresented: $showClearAllConfirmation) {
 69            Button("Clear All", role: .destructive) {
 70                viewModel.clearHistory()
 71            }
 72            Button("Cancel", role: .cancel) {}
 73        } message: {
 74            Text("This will delete all saved history entries. This cannot be undone.")
 75        }
 76        .onChange(of: viewModel.rerunNavigationToken) { _, _ in
 77            dismiss()
 78        }
 79        .preferredColorScheme(.dark)
 80    }
 81}
 82
 83struct HistoryDetailView: View {
 84    @Bindable var viewModel: DomainViewModel
 85    let entry: HistoryEntry
 86    @Environment(\.dismiss) private var dismiss
 87
 88    private let dateFormatter: DateFormatter = {
 89        let formatter = DateFormatter()
 90        formatter.dateStyle = .medium
 91        formatter.timeStyle = .short
 92        return formatter
 93    }()
 94
 95    private var snapshot: LookupSnapshot {
 96        entry.snapshot
 97    }
 98
 99    var body: some View {
100        ScrollView(.vertical) {
101            VStack(alignment: .leading, spacing: 0) {
102                snapshotBanner
103                SummaryView(fields: DomainViewModel.summaryFields(from: snapshot))
104                    .padding(.top, 8)
105                DomainSectionView(
106                    rows: DomainViewModel.domainRows(from: snapshot),
107                    suggestions: DomainViewModel.suggestionRows(from: snapshot),
108                    showSuggestions: entry.availabilityResult?.status == .registered && !entry.suggestions.isEmpty,
109                    availabilityLoading: false,
110                    suggestionsLoading: false,
111                    trackedDomain: viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }),
112                    trackingLimitMessage: nil,
113                    onTrack: {
114                        _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status)
115                    },
116                    onTogglePinned: {
117                        guard let trackedDomain = viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }) else { return }
118                        viewModel.togglePinned(for: trackedDomain)
119                    },
120                    onEditNote: nil
121                )
122                    .padding(.top, 16)
123                if let comparisonSnapshot = viewModel.comparisonSnapshot(for: entry) {
124                    if let changeSummary = entry.changeSummary {
125                        DomainChangeSummaryView(summary: changeSummary)
126                            .padding(.top, 16)
127                    }
128                    DomainDiffView(
129                        title: "Compared With Previous Snapshot",
130                        sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot),
131                        showsUnchanged: false
132                    )
133                    .padding(.top, 16)
134                }
135                DNSSectionView(
136                    dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot),
137                    sections: DomainViewModel.dnsRows(from: snapshot),
138                    ptrMessage: DomainViewModel.ptrMessage(from: snapshot),
139                    loading: false,
140                    sectionError: snapshot.dnsError
141                )
142                .padding(.top, 16)
143                WebSectionView(
144                    certificateRows: DomainViewModel.webCertificateRows(from: snapshot),
145                    sslInfo: snapshot.sslInfo,
146                    sslLoading: false,
147                    sslError: snapshot.sslError,
148                    responseRows: DomainViewModel.webResponseRows(from: snapshot),
149                    headers: snapshot.httpHeaders,
150                    headersLoading: false,
151                    headersError: snapshot.httpHeadersError,
152                    redirects: DomainViewModel.redirectRows(from: snapshot),
153                    redirectLoading: false,
154                    redirectError: snapshot.redirectChainError,
155                    finalURL: snapshot.redirectChain.last?.url
156                )
157                .padding(.top, 16)
158                EmailSectionView(
159                    rows: DomainViewModel.emailRows(from: snapshot),
160                    loading: false,
161                    error: snapshot.emailSecurityError
162                )
163                .padding(.top, 16)
164                NetworkSectionView(
165                    reachabilityRows: DomainViewModel.reachabilityRows(from: snapshot),
166                    reachabilityLoading: false,
167                    reachabilityError: snapshot.reachabilityError,
168                    locationRows: DomainViewModel.locationRows(from: snapshot),
169                    geolocation: snapshot.ipGeolocation,
170                    geolocationLoading: false,
171                    geolocationError: snapshot.ipGeolocationError,
172                    standardPortRows: DomainViewModel.portRows(from: snapshot, kind: .standard),
173                    customPortRows: DomainViewModel.portRows(from: snapshot, kind: .custom),
174                    portScanLoading: false,
175                    portScanError: snapshot.portScanError,
176                    customPortScanLoading: false,
177                    customPortScanError: nil,
178                    isCloudflareProxied: snapshot.httpHeaders.contains(where: { $0.name.lowercased() == "cf-ray" }),
179                    customPortsExpanded: .constant(false),
180                    customPortInput: .constant(""),
181                    onScanCustomPorts: {}
182                )
183                .padding(.top, 16)
184            }
185            .padding(.horizontal)
186            .padding(.bottom, 32)
187        }
188        .background(Color.black)
189        .navigationTitle(entry.domain)
190        .toolbar {
191            Button("Re-run") {
192                viewModel.rerunLookup(from: entry)
193            }
194        }
195        .onChange(of: viewModel.rerunNavigationToken) { _, _ in
196            dismiss()
197        }
198        .preferredColorScheme(.dark)
199    }
200
201    private var snapshotBanner: some View {
202        HStack(spacing: 8) {
203            Image(systemName: "archivebox")
204                .font(.caption)
205            Text("Snapshot from \(dateFormatter.string(from: entry.timestamp))")
206                .font(.system(.caption, design: .monospaced))
207            Spacer()
208            Text("Live re-run available")
209                .font(.system(.caption2, design: .monospaced))
210                .foregroundStyle(.secondary)
211        }
212        .foregroundStyle(.secondary)
213        .padding(8)
214        .frame(maxWidth: .infinity, alignment: .leading)
215        .background(Color(.systemGray6).opacity(0.3))
216        .cornerRadius(6)
217        .padding(.vertical, 12)
218    }
219}