krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v1.4.0: DomainDig/HistoryView.swift · raw
1import SwiftUI
2import MapKit
3
4struct HistoryView: View {
5 @Bindable var viewModel: DomainViewModel
6 @Environment(\.dismiss) private var dismiss
7
8 private let dateFmt: DateFormatter = {
9 let f = DateFormatter()
10 f.dateStyle = .medium
11 f.timeStyle = .short
12 return f
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(entry: entry)
26 } label: {
27 VStack(alignment: .leading, spacing: 2) {
28 Text(entry.domain)
29 .font(.system(.callout, design: .monospaced))
30 .foregroundStyle(.primary)
31 Text(dateFmt.string(from: entry.timestamp))
32 .font(.system(.caption2, design: .monospaced))
33 .foregroundStyle(.secondary)
34 }
35 }
36 .listRowBackground(Color(.systemGray6).opacity(0.5))
37 }
38 .onDelete { offsets in
39 viewModel.removeHistoryEntries(at: offsets)
40 }
41 }
42 }
43 .scrollContentBackground(.hidden)
44 .background(Color.black)
45 .navigationTitle("History")
46 .toolbar {
47 if !viewModel.history.isEmpty {
48 EditButton()
49 }
50 }
51 .preferredColorScheme(.dark)
52 }
53}
54
55// MARK: - History Detail View (Read-Only Cached Results)
56
57struct HistoryDetailView: View {
58 let entry: HistoryEntry
59
60 private let dateFmt: DateFormatter = {
61 let f = DateFormatter()
62 f.dateStyle = .medium
63 f.timeStyle = .short
64 return f
65 }()
66
67 var body: some View {
68 ScrollView(.vertical) {
69 VStack(alignment: .leading, spacing: 0) {
70 cachedBanner
71 reachabilitySection
72 redirectChainSection
73 dnsSection
74 emailSecuritySection
75 sslSection
76 httpHeadersSection
77 ipGeolocationSection
78 portScanSection
79 }
80 .padding(.horizontal)
81 .padding(.bottom, 32)
82 }
83 .background(Color.black)
84 .navigationTitle(entry.domain)
85 .preferredColorScheme(.dark)
86 }
87
88 // MARK: - Cached Banner
89
90 private var cachedBanner: some View {
91 HStack(spacing: 6) {
92 Image(systemName: "archivebox")
93 .font(.caption)
94 Text("Cached result from \(dateFmt.string(from: entry.timestamp))")
95 .font(.system(.caption, design: .monospaced))
96 }
97 .foregroundStyle(.secondary)
98 .padding(8)
99 .frame(maxWidth: .infinity, alignment: .leading)
100 .background(Color(.systemGray6).opacity(0.3))
101 .cornerRadius(6)
102 .padding(.vertical, 12)
103 }
104
105 // MARK: - Reachability
106
107 private var reachabilitySection: some View {
108 VStack(alignment: .leading, spacing: 12) {
109 if !entry.reachabilityResults.isEmpty {
110 sectionHeader("Reachability")
111 VStack(alignment: .leading, spacing: 4) {
112 ForEach(entry.reachabilityResults) { result in
113 HStack(spacing: 8) {
114 Circle()
115 .fill(result.reachable ? Color.green : Color.red)
116 .frame(width: 8, height: 8)
117 Text("Port \(result.port)")
118 .font(.system(.caption, design: .monospaced))
119 if result.reachable, let ms = result.latencyMs {
120 Text("\(ms)ms")
121 .font(.system(.caption, design: .monospaced))
122 .foregroundStyle(.secondary)
123 } else if !result.reachable {
124 Text("—")
125 .font(.system(.caption, design: .monospaced))
126 .foregroundStyle(.secondary)
127 }
128 Spacer()
129 Text(result.reachable ? "Reachable" : "Unreachable")
130 .font(.system(.caption, design: .monospaced))
131 .foregroundStyle(result.reachable ? .green : .red)
132 }
133 }
134 }
135 .padding(10)
136 .background(Color(.systemGray6).opacity(0.5))
137 .cornerRadius(6)
138 }
139 }
140 .padding(.top, 8)
141 }
142
143 // MARK: - Redirect Chain
144
145 private var redirectChainSection: some View {
146 VStack(alignment: .leading, spacing: 12) {
147 if !entry.redirectChain.isEmpty {
148 sectionHeader("Redirect Chain")
149 if entry.redirectChain.count == 1,
150 let only = entry.redirectChain.first,
151 only.isFinal, !(300...399).contains(only.statusCode) {
152 Text("No redirects — direct connection")
153 .font(.system(.caption, design: .monospaced))
154 .foregroundStyle(.secondary)
155 .padding(10)
156 .frame(maxWidth: .infinity, alignment: .leading)
157 .background(Color(.systemGray6).opacity(0.5))
158 .cornerRadius(6)
159 } else {
160 horizontallyScrollableCard {
161 ForEach(entry.redirectChain) { hop in
162 HStack(alignment: .top, spacing: 6) {
163 Text("\(hop.stepNumber)")
164 .font(.system(.caption, design: .monospaced))
165 .foregroundStyle(.secondary)
166 .frame(width: 16, alignment: .trailing)
167 Text("\(hop.statusCode)")
168 .font(.system(.caption, design: .monospaced))
169 .foregroundStyle(.cyan)
170 .frame(width: 30, alignment: .leading)
171 Text(hop.url)
172 .font(.system(.caption, design: .monospaced))
173 .foregroundStyle(.primary)
174 .textSelection(.enabled)
175 if hop.isFinal {
176 Text("(final)")
177 .font(.system(.caption2, design: .monospaced))
178 .foregroundStyle(.secondary)
179 }
180 }
181 }
182 }
183 }
184 }
185 }
186 .padding(.top, 16)
187 }
188
189 // MARK: - DNS
190
191 private var dnsSection: some View {
192 VStack(alignment: .leading, spacing: 12) {
193 sectionHeader("DNS Records")
194 ForEach(entry.dnsSections) { section in
195 horizontallyScrollableCard {
196 Text(section.recordType.rawValue)
197 .font(.system(.subheadline, design: .monospaced))
198 .fontWeight(.semibold)
199 .foregroundStyle(.cyan)
200
201 if let error = section.error {
202 errorLabel(error)
203 } else if section.records.isEmpty {
204 Text("No records found")
205 .font(.system(.caption, design: .monospaced))
206 .foregroundStyle(.secondary)
207 } else {
208 recordRows(section.records)
209 }
210
211 if !section.wildcardRecords.isEmpty {
212 Text("*.\(entry.domain)")
213 .font(.system(.caption, design: .monospaced))
214 .fontWeight(.medium)
215 .foregroundStyle(.cyan.opacity(0.7))
216 .padding(.top, 4)
217 recordRows(section.wildcardRecords)
218 }
219 }
220
221 if section.recordType == .A {
222 horizontallyScrollableCard {
223 Text("PTR (Reverse DNS)")
224 .font(.system(.subheadline, design: .monospaced))
225 .fontWeight(.semibold)
226 .foregroundStyle(.cyan)
227
228 if let ptr = entry.ptrRecord {
229 Text(ptr)
230 .font(.system(.caption, design: .monospaced))
231 .foregroundStyle(.primary)
232 .textSelection(.enabled)
233 } else {
234 Text("No PTR record found")
235 .font(.system(.caption, design: .monospaced))
236 .foregroundStyle(.secondary)
237 }
238 }
239 }
240 }
241 }
242 .padding(.top, 16)
243 }
244
245 // MARK: - Email Security
246
247 @State private var expandedEmailField: String?
248
249 private var emailSecuritySection: some View {
250 VStack(alignment: .leading, spacing: 12) {
251 if let email = entry.emailSecurity {
252 sectionHeader("Email Security")
253 horizontallyScrollableCard(spacing: 6) {
254 historyEmailRow("SPF", record: email.spf)
255 historyEmailRow("DMARC", record: email.dmarc)
256 historyEmailRow("DKIM", record: email.dkim)
257 }
258 }
259 }
260 .frame(maxWidth: .infinity, alignment: .leading)
261 .padding(.top, 16)
262 }
263
264 private func historyEmailRow(_ label: String, record: EmailSecurityRecord) -> some View {
265 VStack(alignment: .leading, spacing: 2) {
266 HStack(spacing: 8) {
267 Text(label)
268 .font(.system(.caption, design: .monospaced))
269 .fontWeight(.semibold)
270 .frame(width: 52, alignment: .leading)
271 Text(record.found ? "✓" : "✗")
272 .font(.system(.caption, design: .monospaced))
273 .foregroundStyle(record.found ? .green : .red)
274 if let value = record.value {
275 let isExpanded = expandedEmailField == label
276 let displayValue = isExpanded ? value : String(value.prefix(80))
277 Text(displayValue)
278 .font(.system(.caption2, design: .monospaced))
279 .foregroundStyle(.primary)
280 .textSelection(.enabled)
281 .lineLimit(isExpanded ? nil : 1)
282 .onTapGesture {
283 withAnimation {
284 expandedEmailField = isExpanded ? nil : label
285 }
286 }
287 } else {
288 Text("No record found")
289 .font(.system(.caption2, design: .monospaced))
290 .foregroundStyle(.secondary)
291 }
292 }
293 }
294 }
295
296 // MARK: - SSL
297
298 private var sslSection: some View {
299 VStack(alignment: .leading, spacing: 12) {
300 if let info = entry.sslInfo {
301 sectionHeader("SSL / TLS Certificate")
302 horizontallyScrollableCard(spacing: 8) {
303 labelRow("Common Name", info.commonName)
304 labelRow("Issuer", info.issuer)
305
306 VStack(alignment: .leading, spacing: 2) {
307 Text("SANs")
308 .font(.system(.caption2, design: .monospaced))
309 .foregroundStyle(.secondary)
310 ForEach(info.subjectAltNames, id: \.self) { san in
311 Text(san)
312 .font(.system(.caption, design: .monospaced))
313 .textSelection(.enabled)
314 }
315 }
316
317 labelRow("Valid From", DateFormatter.certDate.string(from: info.validFrom))
318 labelRow("Valid Until", DateFormatter.certDate.string(from: info.validUntil))
319
320 HStack {
321 Text("Days Until Expiry")
322 .font(.system(.caption2, design: .monospaced))
323 .foregroundStyle(.secondary)
324 Spacer()
325 Text("\(info.daysUntilExpiry)")
326 .font(.system(.caption, design: .monospaced))
327 .fontWeight(.bold)
328 .foregroundStyle(expiryColor(info.daysUntilExpiry))
329 }
330
331 labelRow("Chain Depth", "\(info.chainDepth)")
332 }
333 }
334 }
335 .padding(.top, 16)
336 }
337
338 // MARK: - HTTP Headers
339
340 private var httpHeadersSection: some View {
341 VStack(alignment: .leading, spacing: 12) {
342 if !entry.httpHeaders.isEmpty {
343 sectionHeader("HTTP Headers")
344 horizontallyScrollableCard {
345 ForEach(entry.httpHeaders) { header in
346 HStack(alignment: .top, spacing: 4) {
347 Text(header.name + ":")
348 .font(.system(.caption, design: .monospaced))
349 .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
350 Text(header.value)
351 .font(.system(.caption, design: .monospaced))
352 .foregroundStyle(.primary)
353 .textSelection(.enabled)
354 }
355 }
356 }
357 }
358 }
359 .padding(.top, 16)
360 }
361
362 // MARK: - IP Geolocation
363
364 private var ipGeolocationSection: some View {
365 VStack(alignment: .leading, spacing: 12) {
366 if let geo = entry.ipGeolocation {
367 sectionHeader("IP Location")
368 VStack(alignment: .leading, spacing: 6) {
369 horizontallyScrollableContent(spacing: 6) {
370 labelRow("IP", geo.ip)
371 if let org = geo.org {
372 labelRow("Org / ISP", org)
373 }
374 let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
375 if !location.isEmpty {
376 labelRow("Location", location)
377 }
378 }
379
380 if let lat = geo.latitude, let lon = geo.longitude {
381 let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
382 Map(initialPosition: .region(MKCoordinateRegion(
383 center: coordinate,
384 span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
385 ))) {
386 Marker(geo.ip, coordinate: coordinate)
387 }
388 .mapStyle(.standard)
389 .frame(maxWidth: .infinity)
390 .frame(height: 180)
391 .cornerRadius(8)
392 }
393 }
394 .padding(10)
395 .background(Color(.systemGray6).opacity(0.5))
396 .cornerRadius(6)
397 }
398 }
399 .padding(.top, 16)
400 }
401
402 // MARK: - Port Scan
403
404 private var portScanSection: some View {
405 VStack(alignment: .leading, spacing: 12) {
406 if !entry.portScanResults.isEmpty {
407 sectionHeader("Open Ports")
408 VStack(alignment: .leading, spacing: 4) {
409 ForEach(entry.portScanResults) { result in
410 HStack(spacing: 8) {
411 Circle()
412 .fill(result.open ? Color.green : Color(.systemGray4))
413 .frame(width: 8, height: 8)
414 Text("\(result.port)")
415 .font(.system(.caption, design: .monospaced))
416 .frame(width: 44, alignment: .leading)
417 Text(result.service)
418 .font(.system(.caption, design: .monospaced))
419 .foregroundStyle(result.open ? .primary : .secondary)
420 Spacer()
421 if result.open {
422 Text("Open")
423 .font(.system(.caption2, design: .monospaced))
424 .foregroundStyle(.green)
425 }
426 }
427 }
428 }
429 .padding(10)
430 .background(Color(.systemGray6).opacity(0.5))
431 .cornerRadius(6)
432 }
433 }
434 .padding(.top, 16)
435 }
436
437 // MARK: - Helpers
438
439 private func sectionHeader(_ title: String) -> some View {
440 Text(title)
441 .font(.system(.headline, design: .default))
442 .foregroundStyle(.white)
443 }
444
445 private func labelRow(_ label: String, _ value: String) -> some View {
446 VStack(alignment: .leading, spacing: 2) {
447 Text(label)
448 .font(.system(.caption2, design: .monospaced))
449 .foregroundStyle(.secondary)
450 Text(value)
451 .font(.system(.caption, design: .monospaced))
452 .textSelection(.enabled)
453 }
454 }
455
456 private func recordRows(_ records: [DNSRecord]) -> some View {
457 ForEach(records) { record in
458 HStack(alignment: .top) {
459 Text(record.value)
460 .font(.system(.caption, design: .monospaced))
461 .foregroundStyle(.primary)
462 .textSelection(.enabled)
463 Spacer()
464 Text("TTL \(record.ttl)")
465 .font(.system(.caption2, design: .monospaced))
466 .foregroundStyle(.secondary)
467 }
468 }
469 }
470
471 private func horizontallyScrollableCard<Content: View>(
472 spacing: CGFloat = 4,
473 @ViewBuilder content: () -> Content
474 ) -> some View {
475 horizontallyScrollableContent(spacing: spacing) {
476 content()
477 }
478 .padding(10)
479 .background(Color(.systemGray6).opacity(0.5))
480 .cornerRadius(6)
481 }
482
483 private func horizontallyScrollableContent<Content: View>(
484 spacing: CGFloat = 4,
485 @ViewBuilder content: () -> Content
486 ) -> some View {
487 ScrollView(.horizontal) {
488 VStack(alignment: .leading, spacing: spacing) {
489 content()
490 }
491 .scrollTargetLayout()
492 }
493 .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
494 .frame(maxWidth: .infinity, alignment: .leading)
495 }
496
497 private func errorLabel(_ message: String) -> some View {
498 Label(message, systemImage: "exclamationmark.triangle.fill")
499 .font(.system(.caption, design: .monospaced))
500 .foregroundStyle(.red)
501 .padding(8)
502 }
503
504 private func expiryColor(_ days: Int) -> Color {
505 if days < 30 { return .red }
506 if days < 60 { return .yellow }
507 return .green
508 }
509}