summaryrefslogtreecommitdiff
path: root/DomainDig/HistoryView.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-20 15:28:12 -0500
committerChristian Cleberg <[email protected]>2026-04-20 15:28:12 -0500
commitee2520f2c0c2d806fd284c3d6b8a81a3822f5270 (patch)
tree0680550a1071e01546ba074c2f20b1bf5a452238 /DomainDig/HistoryView.swift
parent4e94ee723cd19c964b1cf37792931e0c7c4614f4 (diff)
downloaddomain-dig-ee2520f2c0c2d806fd284c3d6b8a81a3822f5270.tar.gz
domain-dig-ee2520f2c0c2d806fd284c3d6b8a81a3822f5270.tar.bz2
domain-dig-ee2520f2c0c2d806fd284c3d6b8a81a3822f5270.zip
feat(v1.8): improve result structure, history, and consistency
- normalize sections and add summary card - fix resolver usage in reverse DNS - include custom port scans in history/export - standardize error handling - decompose ContentView into smaller views
Diffstat (limited to 'DomainDig/HistoryView.swift')
-rw-r--r--DomainDig/HistoryView.swift562
1 files changed, 93 insertions, 469 deletions
diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift
index 52b0070..cc4321f 100644
--- a/DomainDig/HistoryView.swift
+++ b/DomainDig/HistoryView.swift
@@ -1,15 +1,13 @@
import SwiftUI
-import MapKit
struct HistoryView: View {
@Bindable var viewModel: DomainViewModel
- @Environment(\.dismiss) private var dismiss
- private let dateFmt: DateFormatter = {
- let f = DateFormatter()
- f.dateStyle = .medium
- f.timeStyle = .short
- return f
+ private let dateFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .medium
+ formatter.timeStyle = .short
+ return formatter
}()
var body: some View {
@@ -22,15 +20,22 @@ struct HistoryView: View {
} else {
ForEach(viewModel.history) { entry in
NavigationLink {
- HistoryDetailView(entry: entry)
+ HistoryDetailView(viewModel: viewModel, entry: entry)
} label: {
- VStack(alignment: .leading, spacing: 2) {
+ VStack(alignment: .leading, spacing: 4) {
Text(entry.domain)
.font(.system(.callout, design: .monospaced))
.foregroundStyle(.primary)
- Text(dateFmt.string(from: entry.timestamp))
- .font(.system(.caption2, design: .monospaced))
- .foregroundStyle(.secondary)
+ HStack(spacing: 8) {
+ Text(dateFormatter.string(from: entry.timestamp))
+ Text("Snapshot")
+ Text(entry.resolverDisplayName)
+ if let totalLookupDurationMs = entry.totalLookupDurationMs {
+ Text("\(totalLookupDurationMs) ms")
+ }
+ }
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
}
}
.listRowBackground(Color(.systemGray6).opacity(0.5))
@@ -52,47 +57,102 @@ struct HistoryView: View {
}
}
-// MARK: - History Detail View (Read-Only Cached Results)
-
struct HistoryDetailView: View {
+ @Bindable var viewModel: DomainViewModel
let entry: HistoryEntry
- private let dateFmt: DateFormatter = {
- let f = DateFormatter()
- f.dateStyle = .medium
- f.timeStyle = .short
- return f
+ private let dateFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .medium
+ formatter.timeStyle = .short
+ return formatter
}()
+ private var snapshot: LookupSnapshot {
+ entry.snapshot
+ }
+
var body: some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 0) {
- cachedBanner
- reachabilitySection
- redirectChainSection
- dnsSection
- emailSecuritySection
- sslSection
- httpHeadersSection
- ipGeolocationSection
- portScanSection
+ snapshotBanner
+ SummaryView(fields: DomainViewModel.summaryFields(from: snapshot))
+ .padding(.top, 8)
+ DomainSectionView(rows: DomainViewModel.domainRows(from: snapshot))
+ .padding(.top, 16)
+ DNSSectionView(
+ dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot),
+ sections: DomainViewModel.dnsRows(from: snapshot),
+ ptrMessage: DomainViewModel.ptrMessage(from: snapshot),
+ loading: false,
+ sectionError: snapshot.dnsError
+ )
+ .padding(.top, 16)
+ WebSectionView(
+ certificateRows: DomainViewModel.webCertificateRows(from: snapshot),
+ sslInfo: snapshot.sslInfo,
+ sslLoading: false,
+ sslError: snapshot.sslError,
+ responseRows: DomainViewModel.webResponseRows(from: snapshot),
+ headers: snapshot.httpHeaders,
+ headersLoading: false,
+ headersError: snapshot.httpHeadersError,
+ redirects: DomainViewModel.redirectRows(from: snapshot),
+ redirectLoading: false,
+ redirectError: snapshot.redirectChainError,
+ finalURL: snapshot.redirectChain.last?.url
+ )
+ .padding(.top, 16)
+ EmailSectionView(
+ rows: DomainViewModel.emailRows(from: snapshot),
+ loading: false,
+ error: snapshot.emailSecurityError
+ )
+ .padding(.top, 16)
+ NetworkSectionView(
+ reachabilityRows: DomainViewModel.reachabilityRows(from: snapshot),
+ reachabilityLoading: false,
+ reachabilityError: snapshot.reachabilityError,
+ locationRows: DomainViewModel.locationRows(from: snapshot),
+ geolocation: snapshot.ipGeolocation,
+ geolocationLoading: false,
+ geolocationError: snapshot.ipGeolocationError,
+ standardPortRows: DomainViewModel.portRows(from: snapshot, kind: .standard),
+ customPortRows: DomainViewModel.portRows(from: snapshot, kind: .custom),
+ portScanLoading: false,
+ portScanError: snapshot.portScanError,
+ customPortScanLoading: false,
+ customPortScanError: nil,
+ isCloudflareProxied: snapshot.httpHeaders.contains(where: { $0.name.lowercased() == "cf-ray" }),
+ customPortsExpanded: .constant(false),
+ customPortInput: .constant(""),
+ onScanCustomPorts: {}
+ )
+ .padding(.top, 16)
}
.padding(.horizontal)
.padding(.bottom, 32)
}
.background(Color.black)
.navigationTitle(entry.domain)
+ .toolbar {
+ Button("Re-run") {
+ viewModel.rerunLookup(from: entry)
+ }
+ }
.preferredColorScheme(.dark)
}
- // MARK: - Cached Banner
-
- private var cachedBanner: some View {
- HStack(spacing: 6) {
+ private var snapshotBanner: some View {
+ HStack(spacing: 8) {
Image(systemName: "archivebox")
.font(.caption)
- Text("Cached result from \(dateFmt.string(from: entry.timestamp))")
+ Text("Snapshot from \(dateFormatter.string(from: entry.timestamp))")
.font(.system(.caption, design: .monospaced))
+ Spacer()
+ Text("Live re-run available")
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
}
.foregroundStyle(.secondary)
.padding(8)
@@ -101,440 +161,4 @@ struct HistoryDetailView: View {
.cornerRadius(6)
.padding(.vertical, 12)
}
-
- // MARK: - Reachability
-
- private var reachabilitySection: some View {
- VStack(alignment: .leading, spacing: 12) {
- if !entry.reachabilityResults.isEmpty {
- sectionHeader("Reachability")
- VStack(alignment: .leading, spacing: 4) {
- ForEach(entry.reachabilityResults) { result in
- HStack(spacing: 8) {
- Circle()
- .fill(result.reachable ? Color.green : Color.red)
- .frame(width: 8, height: 8)
- Text("Port \(result.port)")
- .font(.system(.caption, design: .monospaced))
- if result.reachable, let ms = result.latencyMs {
- Text("\(ms)ms")
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(.secondary)
- } else if !result.reachable {
- Text("—")
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(.secondary)
- }
- Spacer()
- Text(result.reachable ? "Reachable" : "Unreachable")
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(result.reachable ? .green : .red)
- }
- }
- }
- .padding(10)
- .background(Color(.systemGray6).opacity(0.5))
- .cornerRadius(6)
- }
- }
- .padding(.top, 8)
- }
-
- // MARK: - Redirect Chain
-
- private var redirectChainSection: some View {
- VStack(alignment: .leading, spacing: 12) {
- if !entry.redirectChain.isEmpty {
- sectionHeader("Redirect Chain")
- if entry.redirectChain.count == 1,
- let only = entry.redirectChain.first,
- only.isFinal, !(300...399).contains(only.statusCode) {
- Text("No redirects — direct connection")
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(.secondary)
- .padding(10)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(Color(.systemGray6).opacity(0.5))
- .cornerRadius(6)
- } else {
- horizontallyScrollableCard {
- ForEach(entry.redirectChain) { hop in
- HStack(alignment: .top, spacing: 6) {
- Text("\(hop.stepNumber)")
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(.secondary)
- .frame(width: 16, alignment: .trailing)
- Text("\(hop.statusCode)")
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(.cyan)
- .frame(width: 30, alignment: .leading)
- Text(hop.url)
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(.primary)
- .textSelection(.enabled)
- if hop.isFinal {
- Text("(final)")
- .font(.system(.caption2, design: .monospaced))
- .foregroundStyle(.secondary)
- }
- }
- }
- }
- }
- }
- }
- .padding(.top, 16)
- }
-
- // MARK: - DNS
-
- private var dnsSection: some View {
- VStack(alignment: .leading, spacing: 12) {
- sectionHeader("DNS Records")
- ForEach(entry.dnsSections) { section in
- horizontallyScrollableCard {
- Text(section.recordType.rawValue)
- .font(.system(.subheadline, design: .monospaced))
- .fontWeight(.semibold)
- .foregroundStyle(.cyan)
-
- if let error = section.error {
- errorLabel(error)
- } else if section.records.isEmpty {
- Text("No records found")
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(.secondary)
- } else {
- recordRows(section.records)
- }
-
- if !section.wildcardRecords.isEmpty {
- Text("*.\(entry.domain)")
- .font(.system(.caption, design: .monospaced))
- .fontWeight(.medium)
- .foregroundStyle(.cyan.opacity(0.7))
- .padding(.top, 4)
- recordRows(section.wildcardRecords)
- }
- }
-
- if section.recordType == .A {
- horizontallyScrollableCard {
- Text("PTR (Reverse DNS)")
- .font(.system(.subheadline, design: .monospaced))
- .fontWeight(.semibold)
- .foregroundStyle(.cyan)
-
- if let ptr = entry.ptrRecord {
- Text(ptr)
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(.primary)
- .textSelection(.enabled)
- } else {
- Text("No PTR record found")
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(.secondary)
- }
- }
- }
- }
- }
- .padding(.top, 16)
- }
-
- // MARK: - Email Security
-
- @State private var expandedEmailField: String?
-
- private var emailSecuritySection: some View {
- VStack(alignment: .leading, spacing: 12) {
- if let email = entry.emailSecurity {
- sectionHeader("Email Security")
- horizontallyScrollableCard(spacing: 6) {
- historyEmailRow("SPF", record: email.spf)
- historyEmailRow("DMARC", record: email.dmarc)
- historyEmailRow("DKIM", record: email.dkim)
- historyEmailRow("MTA-STS", mtaSts: entry.mtaSts ?? email.mtaSts)
- historyEmailRow("BIMI", record: email.bimi)
- }
- }
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.top, 16)
- }
-
- private func historyEmailRow(_ label: String, record: EmailSecurityRecord) -> some View {
- VStack(alignment: .leading, spacing: 2) {
- HStack(spacing: 8) {
- Text(label)
- .font(.system(.caption, design: .monospaced))
- .fontWeight(.semibold)
- .frame(width: 72, alignment: .leading)
- Text(record.found ? "✓" : "✗")
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(record.found ? .green : .red)
- if let value = record.value {
- let isExpanded = expandedEmailField == label
- let displayValue = isExpanded ? value : String(value.prefix(80))
- Text(displayValue)
- .font(.system(.caption2, design: .monospaced))
- .foregroundStyle(.primary)
- .textSelection(.enabled)
- .lineLimit(isExpanded ? nil : 1)
- .onTapGesture {
- withAnimation {
- expandedEmailField = isExpanded ? nil : label
- }
- }
- if let selector = record.matchedSelector {
- Text("(selector: \(selector))")
- .font(.system(.caption2, design: .monospaced))
- .foregroundStyle(.secondary)
- }
- } else {
- Text("No record found")
- .font(.system(.caption2, design: .monospaced))
- .foregroundStyle(.secondary)
- }
- }
- }
- }
-
- private func historyEmailRow(_ label: String, mtaSts: MTASTSResult?) -> some View {
- VStack(alignment: .leading, spacing: 2) {
- HStack(spacing: 8) {
- Text(label)
- .font(.system(.caption, design: .monospaced))
- .fontWeight(.semibold)
- .frame(width: 72, alignment: .leading)
- Text(mtaSts?.txtFound == true ? "✓" : "✗")
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(mtaSts?.txtFound == true ? .green : .red)
- if let policyMode = mtaSts?.policyMode {
- Text(policyMode)
- .font(.system(.caption2, design: .monospaced))
- .foregroundStyle(.primary)
- .textSelection(.enabled)
- } else {
- Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found")
- .font(.system(.caption2, design: .monospaced))
- .foregroundStyle(.secondary)
- }
- }
- }
- }
-
- // MARK: - SSL
-
- private var sslSection: some View {
- VStack(alignment: .leading, spacing: 12) {
- if let info = entry.sslInfo {
- sectionHeader("SSL / TLS Certificate")
- horizontallyScrollableCard(spacing: 8) {
- labelRow("Common Name", info.commonName)
- labelRow("Issuer", info.issuer)
-
- VStack(alignment: .leading, spacing: 2) {
- Text("SANs")
- .font(.system(.caption2, design: .monospaced))
- .foregroundStyle(.secondary)
- ForEach(info.subjectAltNames, id: \.self) { san in
- Text(san)
- .font(.system(.caption, design: .monospaced))
- .textSelection(.enabled)
- }
- }
-
- labelRow("Valid From", DateFormatter.certDate.string(from: info.validFrom))
- labelRow("Valid Until", DateFormatter.certDate.string(from: info.validUntil))
-
- HStack {
- Text("Days Until Expiry")
- .font(.system(.caption2, design: .monospaced))
- .foregroundStyle(.secondary)
- Spacer()
- Text("\(info.daysUntilExpiry)")
- .font(.system(.caption, design: .monospaced))
- .fontWeight(.bold)
- .foregroundStyle(expiryColor(info.daysUntilExpiry))
- }
-
- labelRow("Chain Depth", "\(info.chainDepth)")
- }
- }
- }
- .padding(.top, 16)
- }
-
- // MARK: - HTTP Headers
-
- private var httpHeadersSection: some View {
- VStack(alignment: .leading, spacing: 12) {
- if !entry.httpHeaders.isEmpty {
- sectionHeader("HTTP Headers")
- horizontallyScrollableCard {
- ForEach(entry.httpHeaders) { header in
- HStack(alignment: .top, spacing: 4) {
- Text(header.name + ":")
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
- Text(header.value)
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(.primary)
- .textSelection(.enabled)
- }
- }
- }
- }
- }
- .padding(.top, 16)
- }
-
- // MARK: - IP Geolocation
-
- private var ipGeolocationSection: some View {
- VStack(alignment: .leading, spacing: 12) {
- if let geo = entry.ipGeolocation {
- sectionHeader("IP Location")
- VStack(alignment: .leading, spacing: 6) {
- horizontallyScrollableContent(spacing: 6) {
- labelRow("IP", geo.ip)
- if let org = geo.org {
- labelRow("Org / ISP", org)
- }
- let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
- if !location.isEmpty {
- labelRow("Location", location)
- }
- }
-
- if let lat = geo.latitude, let lon = geo.longitude {
- let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
- Map(initialPosition: .region(MKCoordinateRegion(
- center: coordinate,
- span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
- ))) {
- Marker(geo.ip, coordinate: coordinate)
- }
- .mapStyle(.standard)
- .frame(maxWidth: .infinity)
- .frame(height: 180)
- .cornerRadius(8)
- }
- }
- .padding(10)
- .background(Color(.systemGray6).opacity(0.5))
- .cornerRadius(6)
- }
- }
- .padding(.top, 16)
- }
-
- // MARK: - Port Scan
-
- private var portScanSection: some View {
- VStack(alignment: .leading, spacing: 12) {
- if !entry.portScanResults.isEmpty {
- sectionHeader("Open Ports")
- VStack(alignment: .leading, spacing: 4) {
- ForEach(entry.portScanResults) { result in
- HStack(spacing: 8) {
- Circle()
- .fill(result.open ? Color.green : Color(.systemGray4))
- .frame(width: 8, height: 8)
- Text("\(result.port)")
- .font(.system(.caption, design: .monospaced))
- .frame(width: 44, alignment: .leading)
- Text(result.service)
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(result.open ? .primary : .secondary)
- Spacer()
- if result.open {
- Text("Open")
- .font(.system(.caption2, design: .monospaced))
- .foregroundStyle(.green)
- }
- }
- }
- }
- .padding(10)
- .background(Color(.systemGray6).opacity(0.5))
- .cornerRadius(6)
- }
- }
- .padding(.top, 16)
- }
-
- // MARK: - Helpers
-
- private func sectionHeader(_ title: String) -> some View {
- Text(title)
- .font(.system(.headline, design: .default))
- .foregroundStyle(.white)
- }
-
- private func labelRow(_ label: String, _ value: String) -> some View {
- VStack(alignment: .leading, spacing: 2) {
- Text(label)
- .font(.system(.caption2, design: .monospaced))
- .foregroundStyle(.secondary)
- Text(value)
- .font(.system(.caption, design: .monospaced))
- .textSelection(.enabled)
- }
- }
-
- private func recordRows(_ records: [DNSRecord]) -> some View {
- ForEach(records) { record in
- HStack(alignment: .top) {
- Text(record.value)
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(.primary)
- .textSelection(.enabled)
- Spacer()
- Text("TTL \(record.ttl)")
- .font(.system(.caption2, design: .monospaced))
- .foregroundStyle(.secondary)
- }
- }
- }
-
- private func horizontallyScrollableCard<Content: View>(
- spacing: CGFloat = 4,
- @ViewBuilder content: () -> Content
- ) -> some View {
- horizontallyScrollableContent(spacing: spacing) {
- content()
- }
- .padding(10)
- .background(Color(.systemGray6).opacity(0.5))
- .cornerRadius(6)
- }
-
- private func horizontallyScrollableContent<Content: View>(
- spacing: CGFloat = 4,
- @ViewBuilder content: () -> Content
- ) -> some View {
- ScrollView(.horizontal) {
- VStack(alignment: .leading, spacing: spacing) {
- content()
- }
- .scrollTargetLayout()
- }
- .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
- .frame(maxWidth: .infinity, alignment: .leading)
- }
-
- private func errorLabel(_ message: String) -> some View {
- Label(message, systemImage: "exclamationmark.triangle.fill")
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(.red)
- .padding(8)
- }
-
- private func expiryColor(_ days: Int) -> Color {
- if days < 30 { return .red }
- if days < 60 { return .yellow }
- return .green
- }
}