Commit d624802338
Unsigned
Layout: unified · split
DomainDig/DiffService.swift +51
| @@ -105,6 +105,32 @@ struct DomainDiff: Identifiable, Equatable, Codable { | ||
| 105 | 105 | typealias DomainDiffItem = DiffItem |
| 106 | 106 | typealias DomainDiffSection = DiffSection |
| 107 | 107 | |
| 108 | /// Result of comparing two distinct domains' latest reports side by side, as | |
| 109 | /// opposed to `DomainDiff`, which compares the same domain across time. | |
| 110 | struct DomainComparisonResult: Identifiable, Equatable { | |
| 111 | let domainA: String | |
| 112 | let domainB: String | |
| 113 | let generatedAt: Date | |
| 114 | let sections: [DiffSection] | |
| 115 | let contextNote: String? | |
| 116 | ||
| 117 | var id: String { | |
| 118 | "\(domainA)-\(domainB)-\(generatedAt.timeIntervalSince1970)" | |
| 119 | } | |
| 120 | ||
| 121 | var changedSections: [DiffSection] { | |
| 122 | sections.filter(\.hasChanges) | |
| 123 | } | |
| 124 | ||
| 125 | var changeCount: Int { | |
| 126 | sections.reduce(0) { $0 + $1.changeCount } | |
| 127 | } | |
| 128 | ||
| 129 | var severity: ChangeSeverity { | |
| 130 | sections.map(\.severity).max() ?? .low | |
| 131 | } | |
| 132 | } | |
| 133 | ||
| 108 | 134 | enum DiffService { |
| 109 | 135 | static func compare(from oldReport: DomainReport, to newReport: DomainReport) -> DomainDiff { |
| 110 | 136 | let sections = [ |
| @@ -181,6 +207,31 @@ enum DiffService { | ||
| 181 | 207 | ) |
| 182 | 208 | } |
| 183 | 209 | |
| 210 | /// Compares two distinct domains' reports section by section. Reuses the same | |
| 211 | /// field-level diff logic as time-based comparison; the two reports are simply | |
| 212 | /// unrelated domains rather than the same domain at different times. | |
| 213 | static func compare(domainA: DomainReport, domainB: DomainReport) -> DomainComparisonResult { | |
| 214 | let sections = [ | |
| 215 | availabilitySection(from: domainA, to: domainB), | |
| 216 | ownershipSection(from: domainA, to: domainB), | |
| 217 | dnsSection(from: domainA, to: domainB), | |
| 218 | webSection(from: domainA, to: domainB), | |
| 219 | emailSection(from: domainA, to: domainB), | |
| 220 | networkSection(from: domainA, to: domainB), | |
| 221 | subdomainsSection(from: domainA, to: domainB), | |
| 222 | intelligenceSection(from: domainA, to: domainB), | |
| 223 | riskSection(from: domainA, to: domainB) | |
| 224 | ] | |
| 225 | ||
| 226 | return DomainComparisonResult( | |
| 227 | domainA: domainA.domain, | |
| 228 | domainB: domainB.domain, | |
| 229 | generatedAt: Date(), | |
| 230 | sections: sections, | |
| 231 | contextNote: comparisonContextNote(from: domainA, to: domainB) | |
| 232 | ) | |
| 233 | } | |
| 234 | ||
| 184 | 235 | static func comparisonContextNote(from oldReport: DomainReport, to newReport: DomainReport) -> String? { |
| 185 | 236 | var notes: [String] = [] |
| 186 | 237 | if oldReport.resolverURLString != newReport.resolverURLString { |
DomainDig/DomainCompareView.swift added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// Side-by-side comparison of two tracked domains' latest reports, reusing the | |
| 4 | /// same section-diff rendering (`DomainDiffView`) that time-based history diffs | |
| 5 | /// use, via `DiffService.compare(domainA:domainB:)`. | |
| 6 | struct DomainCompareView: View { | |
| 7 | @Bindable var viewModel: DomainViewModel | |
| 8 | @State private var domainAID: UUID? | |
| 9 | @State private var domainBID: UUID? | |
| 10 | ||
| 11 | private var states: [PortfolioDomainStatus] { | |
| 12 | viewModel.portfolioDashboardData.domainStates | |
| 13 | } | |
| 14 | ||
| 15 | private var stateA: PortfolioDomainStatus? { | |
| 16 | states.first { $0.trackedDomain.id == domainAID } | |
| 17 | } | |
| 18 | ||
| 19 | private var stateB: PortfolioDomainStatus? { | |
| 20 | states.first { $0.trackedDomain.id == domainBID } | |
| 21 | } | |
| 22 | ||
| 23 | private var result: DomainComparisonResult? { | |
| 24 | guard let stateA, let stateB, stateA.trackedDomain.id != stateB.trackedDomain.id else { | |
| 25 | return nil | |
| 26 | } | |
| 27 | return DiffService.compare(domainA: stateA.report, domainB: stateB.report) | |
| 28 | } | |
| 29 | ||
| 30 | var body: some View { | |
| 31 | List { | |
| 32 | Section("Domains") { | |
| 33 | domainPicker("Domain A", selection: $domainAID) | |
| 34 | domainPicker("Domain B", selection: $domainBID) | |
| 35 | } | |
| 36 | ||
| 37 | if domainAID != nil, domainAID == domainBID { | |
| 38 | Section { | |
| 39 | Text("Choose two different domains to compare.") | |
| 40 | .font(.callout) | |
| 41 | .foregroundStyle(.secondary) | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | if let result { | |
| 46 | Section { | |
| 47 | DomainDiffView( | |
| 48 | title: "\(result.domainA) vs \(result.domainB)", | |
| 49 | sections: result.sections, | |
| 50 | contextNote: result.contextNote, | |
| 51 | showsUnchanged: true, | |
| 52 | highlightedSectionID: nil | |
| 53 | ) | |
| 54 | } | |
| 55 | .listRowBackground(Color.clear) | |
| 56 | } else if domainAID == nil || domainBID == nil { | |
| 57 | Section { | |
| 58 | EmptyStateCardView( | |
| 59 | title: "Pick Two Domains", | |
| 60 | message: "Select a domain in each slot to see a side-by-side comparison across DNS, TLS, ownership, and risk.", | |
| 61 | suggestion: "Both domains must already be tracked.", | |
| 62 | systemImage: "arrow.left.arrow.right", | |
| 63 | showsCardBackground: false | |
| 64 | ) | |
| 65 | } | |
| 66 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | |
| 67 | } | |
| 68 | } | |
| 69 | .scrollContentBackground(.hidden) | |
| 70 | .background(Color.black) | |
| 71 | .navigationTitle("Compare Domains") | |
| 72 | } | |
| 73 | ||
| 74 | private func domainPicker(_ label: String, selection: Binding<UUID?>) -> some View { | |
| 75 | Picker(label, selection: selection) { | |
| 76 | Text("Select a domain").tag(UUID?.none) | |
| 77 | ForEach(states) { state in | |
| 78 | Text(state.trackedDomain.domain).tag(Optional(state.trackedDomain.id)) | |
| 79 | } | |
| 80 | } | |
| 81 | } | |
| 82 | } | |
DomainDig/WatchlistView.swift +6
| @@ -120,6 +120,12 @@ struct WatchlistView: View { | ||
| 120 | 120 | showWorkflowAddSheet = true |
| 121 | 121 | } |
| 122 | 122 | |
| 123 | if viewModel.trackedDomains.count >= 2 { | |
| 124 | NavigationLink("Compare Domains") { | |
| 125 | DomainCompareView(viewModel: viewModel) | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 123 | 129 | Button("Export TXT") { |
| 124 | 130 | shareTrackedDomains(format: .text) |
| 125 | 131 | } |