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}