Commit d0b3b7a15b
Verified · cmc
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +4 −4
| @@ -366,7 +366,7 @@ | |||
| 366 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 366 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 367 | CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; | 367 | CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; |
| 368 | CODE_SIGN_STYLE = Automatic; | 368 | CODE_SIGN_STYLE = Automatic; |
| 369 | CURRENT_PROJECT_VERSION = 28; | 369 | CURRENT_PROJECT_VERSION = 29; |
| 370 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 370 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 371 | ENABLE_PREVIEWS = YES; | 371 | ENABLE_PREVIEWS = YES; |
| 372 | GENERATE_INFOPLIST_FILE = YES; | 372 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -383,7 +383,7 @@ | |||
| 383 | "$(inherited)", | 383 | "$(inherited)", |
| 384 | "@executable_path/Frameworks", | 384 | "@executable_path/Frameworks", |
| 385 | ); | 385 | ); |
| 386 | MARKETING_VERSION = 3.6.0; | 386 | MARKETING_VERSION = 3.7.0; |
| 387 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; | 387 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 388 | PRODUCT_NAME = "$(TARGET_NAME)"; | 388 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 389 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 389 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -403,7 +403,7 @@ | |||
| 403 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 403 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 404 | CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; | 404 | CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; |
| 405 | CODE_SIGN_STYLE = Automatic; | 405 | CODE_SIGN_STYLE = Automatic; |
| 406 | CURRENT_PROJECT_VERSION = 28; | 406 | CURRENT_PROJECT_VERSION = 29; |
| 407 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 407 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 408 | ENABLE_PREVIEWS = YES; | 408 | ENABLE_PREVIEWS = YES; |
| 409 | GENERATE_INFOPLIST_FILE = YES; | 409 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -420,7 +420,7 @@ | |||
| 420 | "$(inherited)", | 420 | "$(inherited)", |
| 421 | "@executable_path/Frameworks", | 421 | "@executable_path/Frameworks", |
| 422 | ); | 422 | ); |
| 423 | MARKETING_VERSION = 3.6.0; | 423 | MARKETING_VERSION = 3.7.0; |
| 424 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; | 424 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 425 | PRODUCT_NAME = "$(TARGET_NAME)"; | 425 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 426 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 426 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist +2 −2
| @@ -7,12 +7,12 @@ | |||
| 7 | <key>DomainDig.xcscheme_^#shared#^_</key> | 7 | <key>DomainDig.xcscheme_^#shared#^_</key> |
| 8 | <dict> | 8 | <dict> |
| 9 | <key>orderHint</key> | 9 | <key>orderHint</key> |
| 10 | <integer>0</integer> | 10 | <integer>1</integer> |
| 11 | </dict> | 11 | </dict> |
| 12 | <key>DomainDigCLI.xcscheme_^#shared#^_</key> | 12 | <key>DomainDigCLI.xcscheme_^#shared#^_</key> |
| 13 | <dict> | 13 | <dict> |
| 14 | <key>orderHint</key> | 14 | <key>orderHint</key> |
| 15 | <integer>1</integer> | 15 | <integer>0</integer> |
| 16 | </dict> | 16 | </dict> |
| 17 | </dict> | 17 | </dict> |
| 18 | </dict> | 18 | </dict> |
DomainDig/ContentView.swift +45 −4
| @@ -37,6 +37,7 @@ struct ContentView: View { | |||
| 37 | @State private var collapsedSections: Set<ResultSection> = [.network] | 37 | @State private var collapsedSections: Set<ResultSection> = [.network] |
| 38 | @State private var showingCurrentDomainWorkflowSheet = false | 38 | @State private var showingCurrentDomainWorkflowSheet = false |
| 39 | @State private var showingBatchWorkflowSheet = false | 39 | @State private var showingBatchWorkflowSheet = false |
| 40 | @State private var showingTimeline = false | ||
| 40 | 41 | ||
| 41 | var body: some View { | 42 | var body: some View { |
| 42 | let _ = purchaseService.currentTier | 43 | let _ = purchaseService.currentTier |
| @@ -83,7 +84,8 @@ struct ContentView: View { | |||
| 83 | title: "Latest Changes", | 84 | title: "Latest Changes", |
| 84 | sections: viewModel.currentDiffSections, | 85 | sections: viewModel.currentDiffSections, |
| 85 | contextNote: viewModel.currentChangeSummary?.contextNote, | 86 | contextNote: viewModel.currentChangeSummary?.contextNote, |
| 86 | showsUnchanged: false | 87 | showsUnchanged: false, |
| 88 | highlightedSectionID: nil | ||
| 87 | ) | 89 | ) |
| 88 | .padding(.top, appDensity.metrics.sectionSpacing) | 90 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 89 | } | 91 | } |
| @@ -230,6 +232,11 @@ struct ContentView: View { | |||
| 230 | availableDomains: viewModel.batchResults.map(\.domain) | 232 | availableDomains: viewModel.batchResults.map(\.domain) |
| 231 | ) | 233 | ) |
| 232 | } | 234 | } |
| 235 | .sheet(isPresented: $showingTimeline) { | ||
| 236 | NavigationStack { | ||
| 237 | TimelineView(viewModel: viewModel, domain: viewModel.searchedDomain) | ||
| 238 | } | ||
| 239 | } | ||
| 233 | } | 240 | } |
| 234 | 241 | ||
| 235 | private var inputSection: some View { | 242 | private var inputSection: some View { |
| @@ -439,6 +446,11 @@ struct ContentView: View { | |||
| 439 | Button("Add to workflow") { | 446 | Button("Add to workflow") { |
| 440 | showingCurrentDomainWorkflowSheet = true | 447 | showingCurrentDomainWorkflowSheet = true |
| 441 | } | 448 | } |
| 449 | if !viewModel.historyEntries(for: viewModel.searchedDomain).isEmpty { | ||
| 450 | Button("Open timeline") { | ||
| 451 | showingTimeline = true | ||
| 452 | } | ||
| 453 | } | ||
| 442 | if FeatureAccessService.hasAccess(to: .advancedExports) { | 454 | if FeatureAccessService.hasAccess(to: .advancedExports) { |
| 443 | Button("Copy report JSON") { | 455 | Button("Copy report JSON") { |
| 444 | guard let json = viewModel.exportJSONString() else { return } | 456 | guard let json = viewModel.exportJSONString() else { return } |
| @@ -1048,8 +1060,9 @@ struct DomainDiffView: View { | |||
| 1048 | let sections: [DomainDiffSection] | 1060 | let sections: [DomainDiffSection] |
| 1049 | let contextNote: String? | 1061 | let contextNote: String? |
| 1050 | let showsUnchanged: Bool | 1062 | let showsUnchanged: Bool |
| 1063 | let highlightedSectionID: String? | ||
| 1051 | 1064 | ||
| 1052 | @State private var collapsedSections = Set<UUID>() | 1065 | @State private var collapsedSections = Set<String>() |
| 1053 | @State private var showsLowSeverity = false | 1066 | @State private var showsLowSeverity = false |
| 1054 | 1067 | ||
| 1055 | private var filteredSections: [DomainDiffSection] { | 1068 | private var filteredSections: [DomainDiffSection] { |
| @@ -1064,7 +1077,7 @@ struct DomainDiffView: View { | |||
| 1064 | } | 1077 | } |
| 1065 | return item.severity >= .medium || (showsUnchanged && item.changeType == .unchanged) | 1078 | return item.severity >= .medium || (showsUnchanged && item.changeType == .unchanged) |
| 1066 | } | 1079 | } |
| 1067 | return DomainDiffSection(title: section.title, items: items) | 1080 | return DomainDiffSection(id: section.id, title: section.title, items: items) |
| 1068 | } | 1081 | } |
| 1069 | .filter { !$0.items.isEmpty } | 1082 | .filter { !$0.items.isEmpty } |
| 1070 | } | 1083 | } |
| @@ -1104,7 +1117,7 @@ struct DomainDiffView: View { | |||
| 1104 | .font(.system(.caption, design: .monospaced)) | 1117 | .font(.system(.caption, design: .monospaced)) |
| 1105 | .foregroundStyle(.secondary) | 1118 | .foregroundStyle(.secondary) |
| 1106 | Spacer() | 1119 | Spacer() |
| 1107 | Text("\(item.severity.title) • \(changeLabel(for: item.changeType))") | 1120 | Text("\(item.changeType.marker) \(item.severity.title) • \(changeLabel(for: item.changeType))") |
| 1108 | .font(.system(.caption2, design: .monospaced)) | 1121 | .font(.system(.caption2, design: .monospaced)) |
| 1109 | .foregroundStyle(changeColor(for: item)) | 1122 | .foregroundStyle(changeColor(for: item)) |
| 1110 | .padding(.horizontal, 8) | 1123 | .padding(.horizontal, 8) |
| @@ -1154,9 +1167,19 @@ struct DomainDiffView: View { | |||
| 1154 | } | 1167 | } |
| 1155 | } | 1168 | } |
| 1156 | } | 1169 | } |
| 1170 | .id(section.id) | ||
| 1171 | .overlay { | ||
| 1172 | if highlightedSectionID == section.id { | ||
| 1173 | RoundedRectangle(cornerRadius: 12) | ||
| 1174 | .stroke(Color.cyan.opacity(0.55), lineWidth: 1) | ||
| 1175 | } | ||
| 1176 | } | ||
| 1157 | } | 1177 | } |
| 1158 | } | 1178 | } |
| 1159 | } | 1179 | } |
| 1180 | .onAppear { | ||
| 1181 | collapsedSections = Set(sections.filter { !showsUnchanged && !$0.hasChanges }.map(\.id)) | ||
| 1182 | } | ||
| 1160 | } | 1183 | } |
| 1161 | 1184 | ||
| 1162 | private func changeLabel(for changeType: DiffChangeType) -> String { | 1185 | private func changeLabel(for changeType: DiffChangeType) -> String { |
| @@ -2460,6 +2483,24 @@ struct SettingsView: View { | |||
| 2460 | } | 2483 | } |
| 2461 | } | 2484 | } |
| 2462 | 2485 | ||
| 2486 | Section("History") { | ||
| 2487 | Picker( | ||
| 2488 | "Auto-prune", | ||
| 2489 | selection: Binding( | ||
| 2490 | get: { viewModel.historyAutoPruneOption }, | ||
| 2491 | set: { viewModel.setHistoryAutoPruneOption($0) } | ||
| 2492 | ) | ||
| 2493 | ) { | ||
| 2494 | ForEach(HistoryAutoPruneOption.allCases) { option in | ||
| 2495 | Text(option.title).tag(option) | ||
| 2496 | } | ||
| 2497 | } | ||
| 2498 | |||
| 2499 | Text("History remains local-first. Auto-prune only trims older local snapshots on this device and defaults to unlimited.") | ||
| 2500 | .font(appDensity.font(.caption, design: .default)) | ||
| 2501 | .foregroundStyle(.secondary) | ||
| 2502 | } | ||
| 2503 | |||
| 2463 | Section("Network") { | 2504 | Section("Network") { |
| 2464 | Picker("Resolver", selection: $resolverOption) { | 2505 | Picker("Resolver", selection: $resolverOption) { |
| 2465 | ForEach(DNSResolverOption.allCases) { option in | 2506 | ForEach(DNSResolverOption.allCases) { option in |
DomainDig/DiffService.swift added +563
| @@ -0,0 +1,563 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | enum DiffChangeType: String, Codable { | ||
| 4 | case added | ||
| 5 | case removed | ||
| 6 | case changed | ||
| 7 | case unchanged | ||
| 8 | |||
| 9 | var marker: String { | ||
| 10 | switch self { | ||
| 11 | case .added: | ||
| 12 | return "+" | ||
| 13 | case .removed: | ||
| 14 | return "-" | ||
| 15 | case .changed: | ||
| 16 | return "~" | ||
| 17 | case .unchanged: | ||
| 18 | return "=" | ||
| 19 | } | ||
| 20 | } | ||
| 21 | |||
| 22 | var title: String { | ||
| 23 | switch self { | ||
| 24 | case .added: | ||
| 25 | return "Added" | ||
| 26 | case .removed: | ||
| 27 | return "Removed" | ||
| 28 | case .changed: | ||
| 29 | return "Changed" | ||
| 30 | case .unchanged: | ||
| 31 | return "Unchanged" | ||
| 32 | } | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 36 | struct DiffItem: Identifiable, Equatable, Codable { | ||
| 37 | let id: String | ||
| 38 | let label: String | ||
| 39 | let changeType: DiffChangeType | ||
| 40 | let oldValue: String? | ||
| 41 | let newValue: String? | ||
| 42 | let severity: ChangeSeverity | ||
| 43 | |||
| 44 | init( | ||
| 45 | id: String, | ||
| 46 | label: String, | ||
| 47 | changeType: DiffChangeType, | ||
| 48 | oldValue: String?, | ||
| 49 | newValue: String?, | ||
| 50 | severity: ChangeSeverity | ||
| 51 | ) { | ||
| 52 | self.id = id | ||
| 53 | self.label = label | ||
| 54 | self.changeType = changeType | ||
| 55 | self.oldValue = oldValue | ||
| 56 | self.newValue = newValue | ||
| 57 | self.severity = severity | ||
| 58 | } | ||
| 59 | |||
| 60 | var hasChanges: Bool { | ||
| 61 | changeType != .unchanged | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | struct DiffSection: Identifiable, Equatable, Codable { | ||
| 66 | let id: String | ||
| 67 | let title: String | ||
| 68 | let items: [DiffItem] | ||
| 69 | |||
| 70 | var hasChanges: Bool { | ||
| 71 | items.contains(where: \.hasChanges) | ||
| 72 | } | ||
| 73 | |||
| 74 | var severity: ChangeSeverity { | ||
| 75 | items.map(\.severity).max() ?? .low | ||
| 76 | } | ||
| 77 | |||
| 78 | var changeCount: Int { | ||
| 79 | items.filter(\.hasChanges).count | ||
| 80 | } | ||
| 81 | } | ||
| 82 | |||
| 83 | struct DomainDiff: Identifiable, Equatable, Codable { | ||
| 84 | let domain: String | ||
| 85 | let fromTimestamp: Date | ||
| 86 | let toTimestamp: Date | ||
| 87 | let sections: [DiffSection] | ||
| 88 | let changedSectionIDs: [String] | ||
| 89 | let changedSectionTitles: [String] | ||
| 90 | let contextNote: String? | ||
| 91 | |||
| 92 | var id: String { | ||
| 93 | "\(domain)-\(fromTimestamp.timeIntervalSince1970)-\(toTimestamp.timeIntervalSince1970)" | ||
| 94 | } | ||
| 95 | |||
| 96 | var changeCount: Int { | ||
| 97 | sections.reduce(0) { $0 + $1.changeCount } | ||
| 98 | } | ||
| 99 | |||
| 100 | var severity: ChangeSeverity { | ||
| 101 | sections.map(\.severity).max() ?? .low | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 105 | typealias DomainDiffItem = DiffItem | ||
| 106 | typealias DomainDiffSection = DiffSection | ||
| 107 | |||
| 108 | enum DiffService { | ||
| 109 | static func compare(from oldReport: DomainReport, to newReport: DomainReport) -> DomainDiff { | ||
| 110 | let sections = [ | ||
| 111 | availabilitySection(from: oldReport, to: newReport), | ||
| 112 | ownershipSection(from: oldReport, to: newReport), | ||
| 113 | dnsSection(from: oldReport, to: newReport), | ||
| 114 | webSection(from: oldReport, to: newReport), | ||
| 115 | emailSection(from: oldReport, to: newReport), | ||
| 116 | networkSection(from: oldReport, to: newReport), | ||
| 117 | subdomainsSection(from: oldReport, to: newReport), | ||
| 118 | riskSection(from: oldReport, to: newReport) | ||
| 119 | ] | ||
| 120 | |||
| 121 | let changedSections = sections.filter(\.hasChanges) | ||
| 122 | return DomainDiff( | ||
| 123 | domain: newReport.domain, | ||
| 124 | fromTimestamp: oldReport.timestamp, | ||
| 125 | toTimestamp: newReport.timestamp, | ||
| 126 | sections: sections, | ||
| 127 | changedSectionIDs: changedSections.map(\.id), | ||
| 128 | changedSectionTitles: changedSections.map(\.title), | ||
| 129 | contextNote: comparisonContextNote(from: oldReport, to: newReport) | ||
| 130 | ) | ||
| 131 | } | ||
| 132 | |||
| 133 | static func compare(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiff { | ||
| 134 | let builder = DomainReportBuilder() | ||
| 135 | let oldReport = builder.build(from: oldSnapshot, deriveChangeSummary: false) | ||
| 136 | let newReport = builder.build(from: newSnapshot, previousSnapshot: oldSnapshot, deriveChangeSummary: false) | ||
| 137 | return compare(from: oldReport, to: newReport) | ||
| 138 | } | ||
| 139 | |||
| 140 | static func summary( | ||
| 141 | from oldSnapshot: LookupSnapshot, | ||
| 142 | to newSnapshot: LookupSnapshot, | ||
| 143 | generatedAt: Date = Date(), | ||
| 144 | riskAssessment: DomainRiskAssessment? = nil, | ||
| 145 | insights: [String]? = nil | ||
| 146 | ) -> DomainChangeSummary { | ||
| 147 | let diff = compare(from: oldSnapshot, to: newSnapshot) | ||
| 148 | let changedItems = diff.sections.flatMap(\.items).filter(\.hasChanges) | ||
| 149 | let highlights = diff.changedSectionTitles | ||
| 150 | let severity = changedItems.map(\.severity).max() ?? .low | ||
| 151 | let message = summaryMessage(from: highlights, changeCount: changedItems.count) | ||
| 152 | let observedFacts = changedItems.prefix(4).map { item in | ||
| 153 | "\(item.label): \(item.oldValue ?? "none") -> \(item.newValue ?? "none")" | ||
| 154 | } | ||
| 155 | |||
| 156 | let analysis = DomainInsightEngine.analyze(snapshot: newSnapshot, previousSnapshot: oldSnapshot) | ||
| 157 | let currentRiskAssessment = riskAssessment ?? analysis.riskAssessment | ||
| 158 | let currentInsights = insights ?? analysis.insights | ||
| 159 | let previousRiskScore = DomainInsightEngine.analyze(snapshot: oldSnapshot).riskAssessment.score | ||
| 160 | let riskScoreDelta = currentRiskAssessment.score - previousRiskScore | ||
| 161 | let impactClassification = DomainInsightEngine.impactClassification( | ||
| 162 | severity: severity, | ||
| 163 | riskDelta: riskScoreDelta, | ||
| 164 | changedSections: highlights | ||
| 165 | ) | ||
| 166 | |||
| 167 | return DomainChangeSummary( | ||
| 168 | hasChanges: !changedItems.isEmpty, | ||
| 169 | changedSections: highlights, | ||
| 170 | message: message, | ||
| 171 | severity: severity, | ||
| 172 | impactClassification: impactClassification, | ||
| 173 | generatedAt: generatedAt, | ||
| 174 | observedFacts: observedFacts, | ||
| 175 | inferredConclusions: highlights.isEmpty ? [] : [message], | ||
| 176 | contextNote: diff.contextNote, | ||
| 177 | riskAssessment: currentRiskAssessment, | ||
| 178 | insights: currentInsights, | ||
| 179 | riskScoreDelta: riskScoreDelta | ||
| 180 | ) | ||
| 181 | } | ||
| 182 | |||
| 183 | static func comparisonContextNote(from oldReport: DomainReport, to newReport: DomainReport) -> String? { | ||
| 184 | var notes: [String] = [] | ||
| 185 | if oldReport.resolverURLString != newReport.resolverURLString { | ||
| 186 | notes.append("Compared snapshots used different DNS resolvers.") | ||
| 187 | } | ||
| 188 | if oldReport.resultSource != newReport.resultSource { | ||
| 189 | notes.append("Compared snapshots came from different collection modes.") | ||
| 190 | } | ||
| 191 | return notes.isEmpty ? nil : notes.joined(separator: " ") | ||
| 192 | } | ||
| 193 | |||
| 194 | static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? { | ||
| 195 | comparisonContextNote( | ||
| 196 | from: DomainReportBuilder().build(from: oldSnapshot, deriveChangeSummary: false), | ||
| 197 | to: DomainReportBuilder().build(from: newSnapshot, previousSnapshot: oldSnapshot, deriveChangeSummary: false) | ||
| 198 | ) | ||
| 199 | } | ||
| 200 | |||
| 201 | static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel { | ||
| 202 | guard let days = snapshot.sslInfo?.daysUntilExpiry else { | ||
| 203 | return .none | ||
| 204 | } | ||
| 205 | if days < 14 { | ||
| 206 | return .critical | ||
| 207 | } | ||
| 208 | if days < 30 { | ||
| 209 | return .warning | ||
| 210 | } | ||
| 211 | return .none | ||
| 212 | } | ||
| 213 | |||
| 214 | private static func availabilitySection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { | ||
| 215 | DiffSection( | ||
| 216 | id: "availability", | ||
| 217 | title: "Domain / Availability", | ||
| 218 | items: [ | ||
| 219 | compare(id: "domain", label: "Domain", oldValue: oldReport.domain, newValue: newReport.domain, severity: .low), | ||
| 220 | compare( | ||
| 221 | id: "availability", | ||
| 222 | label: "Availability", | ||
| 223 | oldValue: availabilityLabel(oldReport.availability), | ||
| 224 | newValue: availabilityLabel(newReport.availability), | ||
| 225 | severity: .high | ||
| 226 | ), | ||
| 227 | compare(id: "primary-ip", label: "Primary IP", oldValue: oldReport.dns.primaryIP, newValue: newReport.dns.primaryIP, severity: .high), | ||
| 228 | compare( | ||
| 229 | id: "tls-status", | ||
| 230 | label: "TLS Status", | ||
| 231 | oldValue: oldReport.web.tlsStatus, | ||
| 232 | newValue: newReport.web.tlsStatus, | ||
| 233 | severity: .medium | ||
| 234 | ) | ||
| 235 | ].compactMap { $0 } | ||
| 236 | ) | ||
| 237 | } | ||
| 238 | |||
| 239 | private static func ownershipSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { | ||
| 240 | DiffSection( | ||
| 241 | id: "ownership", | ||
| 242 | title: "Ownership", | ||
| 243 | items: [ | ||
| 244 | compare(id: "registrar", label: "Registrar", oldValue: oldReport.ownership?.registrar, newValue: newReport.ownership?.registrar, severity: .high), | ||
| 245 | compare(id: "registrant", label: "Registrant", oldValue: oldReport.ownership?.registrant, newValue: newReport.ownership?.registrant, severity: .medium), | ||
| 246 | compare( | ||
| 247 | id: "ownership-created", | ||
| 248 | label: "Registration Date", | ||
| 249 | oldValue: ownershipDateLabel(oldReport.ownership?.createdDate), | ||
| 250 | newValue: ownershipDateLabel(newReport.ownership?.createdDate), | ||
| 251 | severity: .low | ||
| 252 | ), | ||
| 253 | compare( | ||
| 254 | id: "ownership-expires", | ||
| 255 | label: "Expiration Date", | ||
| 256 | oldValue: ownershipDateLabel(oldReport.ownership?.expirationDate), | ||
| 257 | newValue: ownershipDateLabel(newReport.ownership?.expirationDate), | ||
| 258 | severity: .medium | ||
| 259 | ), | ||
| 260 | compare( | ||
| 261 | id: "ownership-status", | ||
| 262 | label: "Status", | ||
| 263 | oldValue: joined(oldReport.ownership?.status), | ||
| 264 | newValue: joined(newReport.ownership?.status), | ||
| 265 | severity: .low | ||
| 266 | ), | ||
| 267 | compare( | ||
| 268 | id: "ownership-nameservers", | ||
| 269 | label: "Nameservers", | ||
| 270 | oldValue: joined(oldReport.ownership?.nameservers), | ||
| 271 | newValue: joined(newReport.ownership?.nameservers), | ||
| 272 | severity: .medium | ||
| 273 | ), | ||
| 274 | compare(id: "ownership-abuse", label: "Abuse Contact", oldValue: oldReport.ownership?.abuseEmail, newValue: newReport.ownership?.abuseEmail, severity: .low) | ||
| 275 | ].compactMap { $0 } | ||
| 276 | ) | ||
| 277 | } | ||
| 278 | |||
| 279 | private static func dnsSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { | ||
| 280 | let oldSections = Dictionary(uniqueKeysWithValues: oldReport.dns.recordSections.map { ($0.recordType, $0) }) | ||
| 281 | let newSections = Dictionary(uniqueKeysWithValues: newReport.dns.recordSections.map { ($0.recordType, $0) }) | ||
| 282 | let recordTypes = Set(oldSections.keys).union(newSections.keys).sorted { $0.rawValue < $1.rawValue } | ||
| 283 | |||
| 284 | var items: [DiffItem] = [ | ||
| 285 | compare(id: "dnssec", label: "DNSSEC", oldValue: dnssecLabel(oldReport.dns.dnssecSigned), newValue: dnssecLabel(newReport.dns.dnssecSigned), severity: .medium), | ||
| 286 | compare(id: "ptr", label: "PTR", oldValue: oldReport.dns.ptrRecord, newValue: newReport.dns.ptrRecord, severity: .low) | ||
| 287 | ].compactMap { $0 } | ||
| 288 | |||
| 289 | for type in recordTypes { | ||
| 290 | items.append( | ||
| 291 | compare( | ||
| 292 | id: "dns-\(type.rawValue.lowercased())-records", | ||
| 293 | label: "\(type.rawValue) Records", | ||
| 294 | oldValue: normalizedRecordValues(for: oldSections[type]), | ||
| 295 | newValue: normalizedRecordValues(for: newSections[type]), | ||
| 296 | severity: type == .A || type == .NS ? .high : .medium | ||
| 297 | ) ?? DiffItem(id: "", label: "", changeType: .unchanged, oldValue: nil, newValue: nil, severity: .low) | ||
| 298 | ) | ||
| 299 | if let ttlChange = compare( | ||
| 300 | id: "dns-\(type.rawValue.lowercased())-ttl", | ||
| 301 | label: "\(type.rawValue) TTL", | ||
| 302 | oldValue: normalizedTTLValues(for: oldSections[type]), | ||
| 303 | newValue: normalizedTTLValues(for: newSections[type]), | ||
| 304 | severity: .low | ||
| 305 | ) { | ||
| 306 | items.append(ttlChange) | ||
| 307 | } | ||
| 308 | } | ||
| 309 | |||
| 310 | return DiffSection( | ||
| 311 | id: "dns", | ||
| 312 | title: "DNS", | ||
| 313 | items: items.filter { !$0.id.isEmpty } | ||
| 314 | ) | ||
| 315 | } | ||
| 316 | |||
| 317 | private static func webSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { | ||
| 318 | DiffSection( | ||
| 319 | id: "web", | ||
| 320 | title: "Web", | ||
| 321 | items: [ | ||
| 322 | compare(id: "web-status", label: "HTTP Status", oldValue: oldReport.web.statusCode.map(String.init), newValue: newReport.web.statusCode.map(String.init), severity: .medium), | ||
| 323 | compare(id: "web-grade", label: "Security Grade", oldValue: oldReport.web.securityGrade, newValue: newReport.web.securityGrade, severity: .medium), | ||
| 324 | compare(id: "web-final-url", label: "Final URL", oldValue: oldReport.web.finalURL, newValue: newReport.web.finalURL, severity: .high), | ||
| 325 | compare(id: "web-tls-issuer", label: "TLS Issuer", oldValue: oldReport.web.tls?.issuer, newValue: newReport.web.tls?.issuer, severity: .medium), | ||
| 326 | compare(id: "web-tls-expiry", label: "TLS Expiration", oldValue: expirationLabel(oldReport.web.tls), newValue: expirationLabel(newReport.web.tls), severity: .medium), | ||
| 327 | compare(id: "web-headers", label: "Headers", oldValue: normalizedHeaders(oldReport.web.headers), newValue: normalizedHeaders(newReport.web.headers), severity: .low), | ||
| 328 | compare(id: "web-redirects", label: "Redirect Chain", oldValue: redirectChainSummary(oldReport.web.redirectChain), newValue: redirectChainSummary(newReport.web.redirectChain), severity: .medium) | ||
| 329 | ].compactMap { $0 } | ||
| 330 | ) | ||
| 331 | } | ||
| 332 | |||
| 333 | private static func emailSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { | ||
| 334 | DiffSection( | ||
| 335 | id: "email", | ||
| 336 | title: "Email Security", | ||
| 337 | items: [ | ||
| 338 | compare(id: "email-summary", label: "Summary", oldValue: oldReport.email.summary, newValue: newReport.email.summary, severity: .medium), | ||
| 339 | compare(id: "email-grade", label: "Grade", oldValue: oldReport.email.grade?.rawValue, newValue: newReport.email.grade?.rawValue, severity: .medium), | ||
| 340 | compare(id: "email-spf", label: "SPF", oldValue: recordLabel(oldReport.email.records?.spf), newValue: recordLabel(newReport.email.records?.spf), severity: .medium), | ||
| 341 | compare(id: "email-dmarc", label: "DMARC", oldValue: recordLabel(oldReport.email.records?.dmarc), newValue: recordLabel(newReport.email.records?.dmarc), severity: .high), | ||
| 342 | compare(id: "email-dkim", label: "DKIM", oldValue: recordLabel(oldReport.email.records?.dkim), newValue: recordLabel(newReport.email.records?.dkim), severity: .medium), | ||
| 343 | compare(id: "email-bimi", label: "BIMI", oldValue: recordLabel(oldReport.email.records?.bimi), newValue: recordLabel(newReport.email.records?.bimi), severity: .low), | ||
| 344 | compare(id: "email-mta-sts", label: "MTA-STS", oldValue: mtaStsLabel(oldReport.email.records?.mtaSts), newValue: mtaStsLabel(newReport.email.records?.mtaSts), severity: .medium) | ||
| 345 | ].compactMap { $0 } | ||
| 346 | ) | ||
| 347 | } | ||
| 348 | |||
| 349 | private static func networkSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { | ||
| 350 | DiffSection( | ||
| 351 | id: "network", | ||
| 352 | title: "Network", | ||
| 353 | items: [ | ||
| 354 | compare(id: "network-reachability", label: "Reachability", oldValue: oldReport.network.reachabilitySummary, newValue: newReport.network.reachabilitySummary, severity: .medium), | ||
| 355 | compare(id: "network-geolocation", label: "Geolocation", oldValue: oldReport.network.geolocationSummary, newValue: newReport.network.geolocationSummary, severity: .medium), | ||
| 356 | compare(id: "network-open-ports", label: "Open Ports", oldValue: joined(oldReport.network.openPorts.map(String.init)), newValue: joined(newReport.network.openPorts.map(String.init)), severity: .high), | ||
| 357 | compare(id: "network-port-scan", label: "Port Scan", oldValue: portScanSummary(oldReport.network.portScan), newValue: portScanSummary(newReport.network.portScan), severity: .medium) | ||
| 358 | ].compactMap { $0 } | ||
| 359 | ) | ||
| 360 | } | ||
| 361 | |||
| 362 | private static func subdomainsSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { | ||
| 363 | DiffSection( | ||
| 364 | id: "subdomains", | ||
| 365 | title: "Subdomains", | ||
| 366 | items: [ | ||
| 367 | compare(id: "subdomains-primary", label: "Primary Subdomains", oldValue: joined(oldReport.subdomains), newValue: joined(newReport.subdomains), severity: .low), | ||
| 368 | compare(id: "subdomains-extended", label: "Extended Subdomains", oldValue: joined(oldReport.extendedSubdomains), newValue: joined(newReport.extendedSubdomains), severity: .low), | ||
| 369 | compare(id: "subdomains-groups", label: "Groups", oldValue: groupSummary(oldReport.subdomainGroups), newValue: groupSummary(newReport.subdomainGroups), severity: .low) | ||
| 370 | ].compactMap { $0 } | ||
| 371 | ) | ||
| 372 | } | ||
| 373 | |||
| 374 | private static func riskSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { | ||
| 375 | DiffSection( | ||
| 376 | id: "risk", | ||
| 377 | title: "Risk / Insights", | ||
| 378 | items: [ | ||
| 379 | compare(id: "risk-score", label: "Risk Score", oldValue: "\(oldReport.riskAssessment.score)", newValue: "\(newReport.riskAssessment.score)", severity: .high), | ||
| 380 | compare(id: "risk-level", label: "Risk Level", oldValue: oldReport.riskAssessment.level.title, newValue: newReport.riskAssessment.level.title, severity: .high), | ||
| 381 | compare(id: "risk-factors", label: "Risk Factors", oldValue: joined(oldReport.riskAssessment.factors.map(\.description)), newValue: joined(newReport.riskAssessment.factors.map(\.description)), severity: .medium), | ||
| 382 | compare(id: "risk-insights", label: "Insights", oldValue: joined(oldReport.insights), newValue: joined(newReport.insights), severity: .medium) | ||
| 383 | ].compactMap { $0 } | ||
| 384 | ) | ||
| 385 | } | ||
| 386 | |||
| 387 | private static func compare( | ||
| 388 | id: String, | ||
| 389 | label: String, | ||
| 390 | oldValue: String?, | ||
| 391 | newValue: String?, | ||
| 392 | severity: ChangeSeverity | ||
| 393 | ) -> DiffItem? { | ||
| 394 | let oldValue = normalized(oldValue) | ||
| 395 | let newValue = normalized(newValue) | ||
| 396 | |||
| 397 | guard oldValue != nil || newValue != nil else { | ||
| 398 | return nil | ||
| 399 | } | ||
| 400 | |||
| 401 | let changeType: DiffChangeType | ||
| 402 | switch (oldValue?.lowercased(), newValue?.lowercased()) { | ||
| 403 | case let (old?, new?) where old == new: | ||
| 404 | changeType = .unchanged | ||
| 405 | case (nil, _?): | ||
| 406 | changeType = .added | ||
| 407 | case (_?, nil): | ||
| 408 | changeType = .removed | ||
| 409 | default: | ||
| 410 | changeType = .changed | ||
| 411 | } | ||
| 412 | |||
| 413 | return DiffItem( | ||
| 414 | id: id, | ||
| 415 | label: label, | ||
| 416 | changeType: changeType, | ||
| 417 | oldValue: oldValue, | ||
| 418 | newValue: newValue, | ||
| 419 | severity: severity | ||
| 420 | ) | ||
| 421 | } | ||
| 422 | |||
| 423 | static func summaryMessage(from sectionTitles: [String], changeCount: Int) -> String { | ||
| 424 | guard !sectionTitles.isEmpty else { | ||
| 425 | return "No meaningful changes" | ||
| 426 | } | ||
| 427 | if sectionTitles.count == 1 { | ||
| 428 | return "\(sectionTitles[0]) changed" | ||
| 429 | } | ||
| 430 | return "\(sectionTitles[0]) and \(sectionTitles[1].lowercased()) changed (\(changeCount) items)" | ||
| 431 | } | ||
| 432 | |||
| 433 | private static func normalized(_ value: String?) -> String? { | ||
| 434 | guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { | ||
| 435 | return nil | ||
| 436 | } | ||
| 437 | return value | ||
| 438 | } | ||
| 439 | |||
| 440 | private static func availabilityLabel(_ status: DomainAvailabilityStatus) -> String { | ||
| 441 | switch status { | ||
| 442 | case .available: | ||
| 443 | return "Available" | ||
| 444 | case .registered: | ||
| 445 | return "Registered" | ||
| 446 | case .unknown: | ||
| 447 | return "Unknown" | ||
| 448 | } | ||
| 449 | } | ||
| 450 | |||
| 451 | private static func ownershipDateLabel(_ date: Date?) -> String? { | ||
| 452 | date?.formatted(date: .abbreviated, time: .omitted) | ||
| 453 | } | ||
| 454 | |||
| 455 | private static func expirationLabel(_ certificate: SSLCertificateInfo?) -> String? { | ||
| 456 | guard let certificate else { return nil } | ||
| 457 | return "\(certificate.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(certificate.daysUntilExpiry)d)" | ||
| 458 | } | ||
| 459 | |||
| 460 | private static func joined(_ values: [String]?) -> String? { | ||
| 461 | guard let values else { return nil } | ||
| 462 | let normalizedValues = values | ||
| 463 | .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } | ||
| 464 | .filter { !$0.isEmpty } | ||
| 465 | .sorted() | ||
| 466 | return normalizedValues.isEmpty ? nil : normalizedValues.joined(separator: ", ") | ||
| 467 | } | ||
| 468 | |||
| 469 | private static func normalizedRecordValues(for section: DNSSection?) -> String? { | ||
| 470 | guard let section else { return nil } | ||
| 471 | let values = (section.records + section.wildcardRecords) | ||
| 472 | .map(\.value) | ||
| 473 | .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } | ||
| 474 | .sorted() | ||
| 475 | return values.isEmpty ? nil : values.joined(separator: ", ") | ||
| 476 | } | ||
| 477 | |||
| 478 | private static func normalizedTTLValues(for section: DNSSection?) -> String? { | ||
| 479 | guard let section else { return nil } | ||
| 480 | let values = (section.records + section.wildcardRecords) | ||
| 481 | .map { "\($0.value.lowercased()):\($0.ttl)" } | ||
| 482 | .sorted() | ||
| 483 | return values.isEmpty ? nil : values.joined(separator: ", ") | ||
| 484 | } | ||
| 485 | |||
| 486 | private static func normalizedHeaders(_ headers: [HTTPHeader]) -> String? { | ||
| 487 | let values = headers | ||
| 488 | .map { "\($0.name.lowercased()): \($0.value.trimmingCharacters(in: .whitespacesAndNewlines))" } | ||
| 489 | .sorted() | ||
| 490 | return values.isEmpty ? nil : values.joined(separator: " | ") | ||
| 491 | } | ||
| 492 | |||
| 493 | private static func redirectChainSummary(_ redirects: [RedirectHop]) -> String? { | ||
| 494 | let values = redirects.map { "\($0.statusCode) \($0.url)" } | ||
| 495 | return values.isEmpty ? nil : values.joined(separator: " -> ") | ||
| 496 | } | ||
| 497 | |||
| 498 | private static func portScanSummary(_ results: [PortScanResult]) -> String? { | ||
| 499 | let values = results | ||
| 500 | .sorted { $0.port < $1.port } | ||
| 501 | .map { "\($0.port):\($0.open ? "open" : "closed")" } | ||
| 502 | return values.isEmpty ? nil : values.joined(separator: ", ") | ||
| 503 | } | ||
| 504 | |||
| 505 | private static func groupSummary(_ groups: [SubdomainGroup]) -> String? { | ||
| 506 | joined(groups.map { "\($0.label): \($0.subdomains.count)" }) | ||
| 507 | } | ||
| 508 | |||
| 509 | private static func recordLabel(_ record: EmailSecurityRecord?) -> String? { | ||
| 510 | guard let record else { return nil } | ||
| 511 | if record.found { | ||
| 512 | return record.value ?? "Present" | ||
| 513 | } | ||
| 514 | return "Missing" | ||
| 515 | } | ||
| 516 | |||
| 517 | private static func mtaStsLabel(_ result: MTASTSResult?) -> String? { | ||
| 518 | guard let result else { return nil } | ||
| 519 | guard result.txtFound else { return "Missing" } | ||
| 520 | return result.policyMode ?? "Present" | ||
| 521 | } | ||
| 522 | |||
| 523 | private static func dnssecLabel(_ value: Bool?) -> String? { | ||
| 524 | switch value { | ||
| 525 | case true: | ||
| 526 | return "Signed" | ||
| 527 | case false: | ||
| 528 | return "Unsigned" | ||
| 529 | case nil: | ||
| 530 | return nil | ||
| 531 | } | ||
| 532 | } | ||
| 533 | } | ||
| 534 | |||
| 535 | enum DomainDiffService { | ||
| 536 | static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] { | ||
| 537 | DiffService.compare(from: oldSnapshot, to: newSnapshot).sections | ||
| 538 | } | ||
| 539 | |||
| 540 | static func summary( | ||
| 541 | from oldSnapshot: LookupSnapshot, | ||
| 542 | to newSnapshot: LookupSnapshot, | ||
| 543 | generatedAt: Date = Date(), | ||
| 544 | riskAssessment: DomainRiskAssessment? = nil, | ||
| 545 | insights: [String]? = nil | ||
| 546 | ) -> DomainChangeSummary { | ||
| 547 | DiffService.summary( | ||
| 548 | from: oldSnapshot, | ||
| 549 | to: newSnapshot, | ||
| 550 | generatedAt: generatedAt, | ||
| 551 | riskAssessment: riskAssessment, | ||
| 552 | insights: insights | ||
| 553 | ) | ||
| 554 | } | ||
| 555 | |||
| 556 | static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? { | ||
| 557 | DiffService.comparisonContextNote(from: oldSnapshot, to: newSnapshot) | ||
| 558 | } | ||
| 559 | |||
| 560 | static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel { | ||
| 561 | DiffService.certificateWarningLevel(for: snapshot) | ||
| 562 | } | ||
| 563 | } | ||
DomainDig/DomainDiffService.swift deleted −555
| @@ -1,555 +0,0 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | enum DiffChangeType: String, Codable { | ||
| 4 | case added | ||
| 5 | case removed | ||
| 6 | case changed | ||
| 7 | case unchanged | ||
| 8 | } | ||
| 9 | |||
| 10 | struct DomainDiffItem: Identifiable, Equatable { | ||
| 11 | let id = UUID() | ||
| 12 | let label: String | ||
| 13 | let changeType: DiffChangeType | ||
| 14 | let oldValue: String? | ||
| 15 | let newValue: String? | ||
| 16 | let severity: ChangeSeverity | ||
| 17 | |||
| 18 | var hasChanges: Bool { | ||
| 19 | changeType != .unchanged | ||
| 20 | } | ||
| 21 | |||
| 22 | var isMeaningful: Bool { | ||
| 23 | hasChanges && severity >= .medium | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | struct DomainDiffSection: Identifiable, Equatable { | ||
| 28 | let id = UUID() | ||
| 29 | let title: String | ||
| 30 | let items: [DomainDiffItem] | ||
| 31 | |||
| 32 | var hasChanges: Bool { | ||
| 33 | items.contains(where: \.hasChanges) | ||
| 34 | } | ||
| 35 | |||
| 36 | var severity: ChangeSeverity { | ||
| 37 | items.map(\.severity).max() ?? .low | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | enum DomainDiffService { | ||
| 42 | static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] { | ||
| 43 | [ | ||
| 44 | availabilitySection(from: oldSnapshot, to: newSnapshot), | ||
| 45 | primaryIPSection(from: oldSnapshot, to: newSnapshot), | ||
| 46 | ownershipSection(from: oldSnapshot, to: newSnapshot), | ||
| 47 | dnsSection(from: oldSnapshot, to: newSnapshot), | ||
| 48 | redirectSection(from: oldSnapshot, to: newSnapshot), | ||
| 49 | tlsSection(from: oldSnapshot, to: newSnapshot), | ||
| 50 | httpSection(from: oldSnapshot, to: newSnapshot), | ||
| 51 | emailSection(from: oldSnapshot, to: newSnapshot), | ||
| 52 | subdomainSection(from: oldSnapshot, to: newSnapshot) | ||
| 53 | ] | ||
| 54 | .filter { !$0.items.isEmpty } | ||
| 55 | } | ||
| 56 | |||
| 57 | static func summary( | ||
| 58 | from oldSnapshot: LookupSnapshot, | ||
| 59 | to newSnapshot: LookupSnapshot, | ||
| 60 | generatedAt: Date = Date(), | ||
| 61 | riskAssessment: DomainRiskAssessment? = nil, | ||
| 62 | insights: [String]? = nil | ||
| 63 | ) -> DomainChangeSummary { | ||
| 64 | let sections = diff(from: oldSnapshot, to: newSnapshot) | ||
| 65 | let allChangedItems = sections | ||
| 66 | .flatMap(\.items) | ||
| 67 | .filter(\.hasChanges) | ||
| 68 | |||
| 69 | let highlights = summaryHighlights(from: allChangedItems) | ||
| 70 | let severity = allChangedItems.map(\.severity).max() ?? .low | ||
| 71 | let message = summaryMessage(from: allChangedItems, highlights: highlights) | ||
| 72 | let observedFacts = observedFacts(from: allChangedItems) | ||
| 73 | let inferredConclusions = highlights.isEmpty ? [] : [message] | ||
| 74 | let contextNote = comparisonContextNote(from: oldSnapshot, to: newSnapshot) | ||
| 75 | let newAnalysis = DomainInsightEngine.analyze(snapshot: newSnapshot, previousSnapshot: oldSnapshot) | ||
| 76 | let currentRiskAssessment = riskAssessment ?? newAnalysis.riskAssessment | ||
| 77 | let currentInsights = insights ?? newAnalysis.insights | ||
| 78 | let oldRiskAssessment = DomainInsightEngine.analyze(snapshot: oldSnapshot).riskAssessment | ||
| 79 | let riskScoreDelta = currentRiskAssessment.score - oldRiskAssessment.score | ||
| 80 | let impactClassification = DomainInsightEngine.impactClassification( | ||
| 81 | severity: severity, | ||
| 82 | riskDelta: riskScoreDelta, | ||
| 83 | changedSections: highlights | ||
| 84 | ) | ||
| 85 | |||
| 86 | return DomainChangeSummary( | ||
| 87 | hasChanges: !allChangedItems.isEmpty, | ||
| 88 | changedSections: highlights, | ||
| 89 | message: message, | ||
| 90 | severity: severity, | ||
| 91 | impactClassification: impactClassification, | ||
| 92 | generatedAt: generatedAt, | ||
| 93 | observedFacts: observedFacts, | ||
| 94 | inferredConclusions: inferredConclusions, | ||
| 95 | contextNote: contextNote, | ||
| 96 | riskAssessment: currentRiskAssessment, | ||
| 97 | insights: currentInsights, | ||
| 98 | riskScoreDelta: riskScoreDelta | ||
| 99 | ) | ||
| 100 | } | ||
| 101 | |||
| 102 | static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? { | ||
| 103 | var notes: [String] = [] | ||
| 104 | if oldSnapshot.resolverURLString != newSnapshot.resolverURLString { | ||
| 105 | notes.append("Compared snapshots used different DNS resolvers.") | ||
| 106 | } | ||
| 107 | if oldSnapshot.resultSource != newSnapshot.resultSource { | ||
| 108 | notes.append("Compared snapshots came from different collection modes.") | ||
| 109 | } | ||
| 110 | return notes.isEmpty ? nil : notes.joined(separator: " ") | ||
| 111 | } | ||
| 112 | |||
| 113 | static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel { | ||
| 114 | guard let days = snapshot.sslInfo?.daysUntilExpiry else { | ||
| 115 | return .none | ||
| 116 | } | ||
| 117 | if days < 14 { | ||
| 118 | return .critical | ||
| 119 | } | ||
| 120 | if days < 30 { | ||
| 121 | return .warning | ||
| 122 | } | ||
| 123 | return .none | ||
| 124 | } | ||
| 125 | |||
| 126 | private static func availabilitySection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { | ||
| 127 | DomainDiffSection( | ||
| 128 | title: "Availability", | ||
| 129 | items: [ | ||
| 130 | compare( | ||
| 131 | label: "Availability", | ||
| 132 | oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status), | ||
| 133 | newValue: availabilityLabel(newSnapshot.availabilityResult?.status), | ||
| 134 | severity: .high | ||
| 135 | ) | ||
| 136 | ].compactMap { $0 } | ||
| 137 | ) | ||
| 138 | } | ||
| 139 | |||
| 140 | private static func primaryIPSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { | ||
| 141 | DomainDiffSection( | ||
| 142 | title: "Primary IP", | ||
| 143 | items: [ | ||
| 144 | compare( | ||
| 145 | label: "Primary IP", | ||
| 146 | oldValue: primaryIP(from: oldSnapshot), | ||
| 147 | newValue: primaryIP(from: newSnapshot), | ||
| 148 | severity: .high | ||
| 149 | ) | ||
| 150 | ].compactMap { $0 } | ||
| 151 | ) | ||
| 152 | } | ||
| 153 | |||
| 154 | private static func dnsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { | ||
| 155 | let oldSections = Dictionary(uniqueKeysWithValues: oldSnapshot.dnsSections.map { ($0.recordType, $0) }) | ||
| 156 | let newSections = Dictionary(uniqueKeysWithValues: newSnapshot.dnsSections.map { ($0.recordType, $0) }) | ||
| 157 | let types = Set(oldSections.keys).union(newSections.keys).sorted { $0.rawValue < $1.rawValue } | ||
| 158 | |||
| 159 | var items: [DomainDiffItem] = [] | ||
| 160 | for type in types { | ||
| 161 | let oldSection = oldSections[type] | ||
| 162 | let newSection = newSections[type] | ||
| 163 | |||
| 164 | if let recordChange = compare( | ||
| 165 | label: "\(type.rawValue) Records", | ||
| 166 | oldValue: normalizedRecordValues(for: oldSection), | ||
| 167 | newValue: normalizedRecordValues(for: newSection), | ||
| 168 | severity: .medium | ||
| 169 | ) { | ||
| 170 | items.append(recordChange) | ||
| 171 | } | ||
| 172 | |||
| 173 | if let ttlChange = compare( | ||
| 174 | label: "\(type.rawValue) TTL", | ||
| 175 | oldValue: normalizedTTLValues(for: oldSection), | ||
| 176 | newValue: normalizedTTLValues(for: newSection), | ||
| 177 | severity: .low | ||
| 178 | ), let oldSection, let newSection, | ||
| 179 | normalizedRecordValues(for: oldSection) == normalizedRecordValues(for: newSection) { | ||
| 180 | items.append(ttlChange) | ||
| 181 | } | ||
| 182 | } | ||
| 183 | |||
| 184 | return DomainDiffSection(title: "DNS", items: items) | ||
| 185 | } | ||
| 186 | |||
| 187 | private static func ownershipSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { | ||
| 188 | DomainDiffSection( | ||
| 189 | title: "Ownership", | ||
| 190 | items: [ | ||
| 191 | compare( | ||
| 192 | label: "Registrar", | ||
| 193 | oldValue: normalized(oldSnapshot.ownership?.registrar), | ||
| 194 | newValue: normalized(newSnapshot.ownership?.registrar), | ||
| 195 | severity: .high | ||
| 196 | ), | ||
| 197 | compare( | ||
| 198 | label: "Registration Date", | ||
| 199 | oldValue: ownershipDateLabel(oldSnapshot.ownership?.createdDate), | ||
| 200 | newValue: ownershipDateLabel(newSnapshot.ownership?.createdDate), | ||
| 201 | severity: .low | ||
| 202 | ), | ||
| 203 | compare( | ||
| 204 | label: "Expiration Date", | ||
| 205 | oldValue: ownershipDateLabel(oldSnapshot.ownership?.expirationDate), | ||
| 206 | newValue: ownershipDateLabel(newSnapshot.ownership?.expirationDate), | ||
| 207 | severity: .low | ||
| 208 | ), | ||
| 209 | compare( | ||
| 210 | label: "Ownership Status", | ||
| 211 | oldValue: ownershipList(oldSnapshot.ownership?.status), | ||
| 212 | newValue: ownershipList(newSnapshot.ownership?.status), | ||
| 213 | severity: .low | ||
| 214 | ), | ||
| 215 | compare( | ||
| 216 | label: "Nameservers", | ||
| 217 | oldValue: ownershipList(oldSnapshot.ownership?.nameservers), | ||
| 218 | newValue: ownershipList(newSnapshot.ownership?.nameservers), | ||
| 219 | severity: .medium | ||
| 220 | ), | ||
| 221 | compare( | ||
| 222 | label: "Abuse Contact", | ||
| 223 | oldValue: normalized(oldSnapshot.ownership?.abuseEmail), | ||
| 224 | newValue: normalized(newSnapshot.ownership?.abuseEmail), | ||
| 225 | severity: .low | ||
| 226 | ) | ||
| 227 | ].compactMap { $0 } | ||
| 228 | ) | ||
| 229 | } | ||
| 230 | |||
| 231 | private static func redirectSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { | ||
| 232 | DomainDiffSection( | ||
| 233 | title: "Redirect", | ||
| 234 | items: [ | ||
| 235 | compare( | ||
| 236 | label: "Redirect Target", | ||
| 237 | oldValue: finalRedirectURL(from: oldSnapshot), | ||
| 238 | newValue: finalRedirectURL(from: newSnapshot), | ||
| 239 | severity: .high | ||
| 240 | ) | ||
| 241 | ].compactMap { $0 } | ||
| 242 | ) | ||
| 243 | } | ||
| 244 | |||
| 245 | private static func tlsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { | ||
| 246 | var items: [DomainDiffItem] = [] | ||
| 247 | |||
| 248 | if let issuerChange = compare( | ||
| 249 | label: "TLS Issuer", | ||
| 250 | oldValue: normalized(oldSnapshot.sslInfo?.issuer), | ||
| 251 | newValue: normalized(newSnapshot.sslInfo?.issuer), | ||
| 252 | severity: .medium | ||
| 253 | ) { | ||
| 254 | items.append(issuerChange) | ||
| 255 | } | ||
| 256 | |||
| 257 | if let expiryChange = compare( | ||
| 258 | label: "TLS Expiration", | ||
| 259 | oldValue: expirationLabel(oldSnapshot.sslInfo), | ||
| 260 | newValue: expirationLabel(newSnapshot.sslInfo), | ||
| 261 | severity: .medium | ||
| 262 | ) { | ||
| 263 | items.append(expiryChange) | ||
| 264 | } | ||
| 265 | |||
| 266 | let oldWarning = certificateWarningLevel(for: oldSnapshot) | ||
| 267 | let newWarning = certificateWarningLevel(for: newSnapshot) | ||
| 268 | if oldWarning != newWarning, newWarning != .none { | ||
| 269 | let days = newSnapshot.sslInfo?.daysUntilExpiry ?? 0 | ||
| 270 | items.append( | ||
| 271 | DomainDiffItem( | ||
| 272 | label: "Certificate Warning", | ||
| 273 | changeType: .changed, | ||
| 274 | oldValue: oldWarning.title, | ||
| 275 | newValue: "Certificate expires in \(days) days", | ||
| 276 | severity: newWarning == .critical ? .high : .medium | ||
| 277 | ) | ||
| 278 | ) | ||
| 279 | } | ||
| 280 | |||
| 281 | return DomainDiffSection(title: "TLS", items: items) | ||
| 282 | } | ||
| 283 | |||
| 284 | private static func httpSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { | ||
| 285 | var items: [DomainDiffItem] = [] | ||
| 286 | |||
| 287 | if let statusChange = compare( | ||
| 288 | label: "HTTP Status", | ||
| 289 | oldValue: httpStatusSummary(from: oldSnapshot), | ||
| 290 | newValue: httpStatusSummary(from: newSnapshot), | ||
| 291 | severity: .medium | ||
| 292 | ) { | ||
| 293 | items.append(statusChange) | ||
| 294 | } | ||
| 295 | |||
| 296 | if let gradeChange = compare( | ||
| 297 | label: "Security Grade", | ||
| 298 | oldValue: normalized(oldSnapshot.httpSecurityGrade), | ||
| 299 | newValue: normalized(newSnapshot.httpSecurityGrade), | ||
| 300 | severity: .low | ||
| 301 | ) { | ||
| 302 | items.append(gradeChange) | ||
| 303 | } | ||
| 304 | |||
| 305 | if let headerChange = compare( | ||
| 306 | label: "Headers", | ||
| 307 | oldValue: normalizedHeaders(from: oldSnapshot), | ||
| 308 | newValue: normalizedHeaders(from: newSnapshot), | ||
| 309 | severity: .low | ||
| 310 | ) { | ||
| 311 | items.append(headerChange) | ||
| 312 | } | ||
| 313 | |||
| 314 | return DomainDiffSection(title: "HTTP", items: items) | ||
| 315 | } | ||
| 316 | |||
| 317 | private static func emailSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { | ||
| 318 | DomainDiffSection( | ||
| 319 | title: "Email Security", | ||
| 320 | items: [ | ||
| 321 | compare( | ||
| 322 | label: "Email Security", | ||
| 323 | oldValue: normalized(emailSummary(from: oldSnapshot)), | ||
| 324 | newValue: normalized(emailSummary(from: newSnapshot)), | ||
| 325 | severity: .medium | ||
| 326 | ) | ||
| 327 | ].compactMap { $0 } | ||
| 328 | ) | ||
| 329 | } | ||
| 330 | |||
| 331 | private static func subdomainSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { | ||
| 332 | DomainDiffSection( | ||
| 333 | title: "Subdomains", | ||
| 334 | items: [ | ||
| 335 | compare( | ||
| 336 | label: "Passive Subdomains", | ||
| 337 | oldValue: subdomainList(from: oldSnapshot), | ||
| 338 | newValue: subdomainList(from: newSnapshot), | ||
| 339 | severity: .low | ||
| 340 | ) | ||
| 341 | ].compactMap { $0 } | ||
| 342 | ) | ||
| 343 | } | ||
| 344 | |||
| 345 | private static func compare( | ||
| 346 | label: String, | ||
| 347 | oldValue: String?, | ||
| 348 | newValue: String?, | ||
| 349 | severity: ChangeSeverity | ||
| 350 | ) -> DomainDiffItem? { | ||
| 351 | let oldValue = normalized(oldValue) | ||
| 352 | let newValue = normalized(newValue) | ||
| 353 | let normalizedOldValue = comparisonValue(oldValue) | ||
| 354 | let normalizedNewValue = comparisonValue(newValue) | ||
| 355 | |||
| 356 | guard oldValue != nil || newValue != nil else { | ||
| 357 | return nil | ||
| 358 | } | ||
| 359 | |||
| 360 | let changeType: DiffChangeType | ||
| 361 | switch (normalizedOldValue, normalizedNewValue) { | ||
| 362 | case let (old?, new?) where old == new: | ||
| 363 | changeType = .unchanged | ||
| 364 | case (nil, _?): | ||
| 365 | changeType = .added | ||
| 366 | case (_?, nil): | ||
| 367 | changeType = .removed | ||
| 368 | default: | ||
| 369 | changeType = .changed | ||
| 370 | } | ||
| 371 | |||
| 372 | return DomainDiffItem( | ||
| 373 | label: label, | ||
| 374 | changeType: changeType, | ||
| 375 | oldValue: oldValue, | ||
| 376 | newValue: newValue, | ||
| 377 | severity: severity | ||
| 378 | ) | ||
| 379 | } | ||
| 380 | |||
| 381 | private static func summaryHighlights(from items: [DomainDiffItem]) -> [String] { | ||
| 382 | var highlights: [String] = [] | ||
| 383 | |||
| 384 | let labels = Set(items.map(\.label)) | ||
| 385 | if labels.contains("Availability") { | ||
| 386 | highlights.append("Availability changed") | ||
| 387 | } | ||
| 388 | if labels.contains("Primary IP"), labels.contains(where: { $0.hasSuffix("Records") }) { | ||
| 389 | highlights.append("IP changed") | ||
| 390 | highlights.append("DNS changed") | ||
| 391 | return highlights | ||
| 392 | } | ||
| 393 | if labels.contains("Primary IP") { | ||
| 394 | highlights.append("IP changed") | ||
| 395 | } | ||
| 396 | if labels.contains("Redirect Target") { | ||
| 397 | highlights.append("Redirect target changed") | ||
| 398 | } | ||
| 399 | if labels.contains("Registrar") { | ||
| 400 | highlights.append("Registrar changed") | ||
| 401 | } else if labels.contains("Nameservers") { | ||
| 402 | highlights.append("Nameservers changed") | ||
| 403 | } else if labels.contains("Expiration Date") || labels.contains("Registration Date") || labels.contains("Ownership Status") || labels.contains("Abuse Contact") { | ||
| 404 | highlights.append("Ownership metadata changed") | ||
| 405 | } | ||
| 406 | if let certificateItem = items.first(where: { $0.label == "Certificate Warning" }), | ||
| 407 | let message = certificateItem.newValue { | ||
| 408 | highlights.append(message) | ||
| 409 | } else if labels.contains("TLS Issuer") { | ||
| 410 | highlights.append("TLS issuer changed") | ||
| 411 | } else if labels.contains("TLS Expiration") { | ||
| 412 | highlights.append("Certificate expiration changed") | ||
| 413 | } | ||
| 414 | if labels.contains(where: { $0.hasSuffix("Records") }) { | ||
| 415 | highlights.append("DNS changed") | ||
| 416 | } | ||
| 417 | if labels.contains("HTTP Status") { | ||
| 418 | highlights.append("HTTP status changed") | ||
| 419 | } | ||
| 420 | if labels.contains("Email Security") { | ||
| 421 | highlights.append("Email security changed") | ||
| 422 | } | ||
| 423 | if labels.contains("Passive Subdomains") { | ||
| 424 | highlights.append("Subdomains changed") | ||
| 425 | } | ||
| 426 | |||
| 427 | var deduplicated: [String] = [] | ||
| 428 | for highlight in highlights where !deduplicated.contains(highlight) { | ||
| 429 | deduplicated.append(highlight) | ||
| 430 | } | ||
| 431 | return deduplicated | ||
| 432 | } | ||
| 433 | |||
| 434 | private static func summaryMessage(from items: [DomainDiffItem], highlights: [String]) -> String { | ||
| 435 | guard !items.isEmpty, !highlights.isEmpty else { | ||
| 436 | return "No meaningful changes" | ||
| 437 | } | ||
| 438 | |||
| 439 | if highlights.count == 1 { | ||
| 440 | return highlights[0] | ||
| 441 | } | ||
| 442 | |||
| 443 | return "\(highlights[0]) and \(highlights[1].lowercased())" | ||
| 444 | } | ||
| 445 | |||
| 446 | private static func observedFacts(from items: [DomainDiffItem]) -> [String] { | ||
| 447 | items.prefix(3).map { item in | ||
| 448 | let oldValue = item.oldValue ?? "none" | ||
| 449 | let newValue = item.newValue ?? "none" | ||
| 450 | return "\(item.label): \(oldValue) -> \(newValue)" | ||
| 451 | } | ||
| 452 | } | ||
| 453 | |||
| 454 | private static func normalized(_ value: String?) -> String? { | ||
| 455 | guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { | ||
| 456 | return nil | ||
| 457 | } | ||
| 458 | return value | ||
| 459 | } | ||
| 460 | |||
| 461 | private static func comparisonValue(_ value: String?) -> String? { | ||
| 462 | value?.lowercased() | ||
| 463 | } | ||
| 464 | |||
| 465 | private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String? { | ||
| 466 | switch status { | ||
| 467 | case .available: | ||
| 468 | return "available" | ||
| 469 | case .registered: | ||
| 470 | return "registered" | ||
| 471 | case .unknown: | ||
| 472 | return "unknown" | ||
| 473 | case .none: | ||
| 474 | return nil | ||
| 475 | } | ||
| 476 | } | ||
| 477 | |||
| 478 | private static func primaryIP(from snapshot: LookupSnapshot) -> String? { | ||
| 479 | snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value | ||
| 480 | } | ||
| 481 | |||
| 482 | private static func finalRedirectURL(from snapshot: LookupSnapshot) -> String? { | ||
| 483 | snapshot.redirectChain.last?.url | ||
| 484 | } | ||
| 485 | |||
| 486 | private static func expirationLabel(_ sslInfo: SSLCertificateInfo?) -> String? { | ||
| 487 | guard let sslInfo else { return nil } | ||
| 488 | return "\(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(sslInfo.daysUntilExpiry)d)" | ||
| 489 | } | ||
| 490 | |||
| 491 | private static func ownershipDateLabel(_ date: Date?) -> String? { | ||
| 492 | date?.formatted(date: .abbreviated, time: .omitted) | ||
| 493 | } | ||
| 494 | |||
| 495 | private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? { | ||
| 496 | if let httpStatusCode = snapshot.httpStatusCode { | ||
| 497 | return "\(httpStatusCode)" | ||
| 498 | } | ||
| 499 | return snapshot.httpHeadersError | ||
| 500 | } | ||
| 501 | |||
| 502 | private static func emailSummary(from snapshot: LookupSnapshot) -> String? { | ||
| 503 | if let emailSecurity = snapshot.emailSecurity { | ||
| 504 | return [ | ||
| 505 | "spf:\(emailSecurity.spf.found)", | ||
| 506 | "dmarc:\(emailSecurity.dmarc.found)", | ||
| 507 | "dkim:\(emailSecurity.dkim.found)", | ||
| 508 | "bimi:\(emailSecurity.bimi.found)", | ||
| 509 | "mta-sts:\(emailSecurity.mtaSts?.txtFound == true)" | ||
| 510 | ].joined(separator: "|") | ||
| 511 | } | ||
| 512 | return snapshot.emailSecurityError | ||
| 513 | } | ||
| 514 | |||
| 515 | private static func normalizedRecordValues(for section: DNSSection?) -> String? { | ||
| 516 | guard let section else { return nil } | ||
| 517 | let values = (section.records + section.wildcardRecords) | ||
| 518 | .map(\.value) | ||
| 519 | .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } | ||
| 520 | .sorted() | ||
| 521 | return values.isEmpty ? nil : values.joined(separator: ",") | ||
| 522 | } | ||
| 523 | |||
| 524 | private static func normalizedTTLValues(for section: DNSSection?) -> String? { | ||
| 525 | guard let section else { return nil } | ||
| 526 | let values = (section.records + section.wildcardRecords) | ||
| 527 | .map { "\($0.value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()):\($0.ttl)" } | ||
| 528 | .sorted() | ||
| 529 | return values.isEmpty ? nil : values.joined(separator: ",") | ||
| 530 | } | ||
| 531 | |||
| 532 | private static func normalizedHeaders(from snapshot: LookupSnapshot) -> String? { | ||
| 533 | let headers = snapshot.httpHeaders | ||
| 534 | .map { "\($0.name.lowercased()):\($0.value.trimmingCharacters(in: .whitespacesAndNewlines))" } | ||
| 535 | .sorted() | ||
| 536 | return headers.isEmpty ? nil : headers.joined(separator: "|") | ||
| 537 | } | ||
| 538 | |||
| 539 | private static func ownershipList(_ values: [String]?) -> String? { | ||
| 540 | guard let values else { return nil } | ||
| 541 | let normalizedValues = values | ||
| 542 | .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } | ||
| 543 | .filter { !$0.isEmpty } | ||
| 544 | .sorted() | ||
| 545 | return normalizedValues.isEmpty ? nil : normalizedValues.joined(separator: ",") | ||
| 546 | } | ||
| 547 | |||
| 548 | private static func subdomainList(from snapshot: LookupSnapshot) -> String? { | ||
| 549 | let values = snapshot.subdomains | ||
| 550 | .map(\.hostname) | ||
| 551 | .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } | ||
| 552 | .sorted() | ||
| 553 | return values.isEmpty ? nil : values.joined(separator: ",") | ||
| 554 | } | ||
| 555 | } | ||
DomainDig/DomainDig/DomainDebugLog.swift added +37
| @@ -0,0 +1,37 @@ | |||
| 1 | import Foundation | ||
| 2 | import os | ||
| 3 | |||
| 4 | enum DomainDebugLog { | ||
| 5 | static let enabled = true | ||
| 6 | private static let logger = Logger(subsystem: "co.zerolabs.domain-dig", category: "Debug") | ||
| 7 | |||
| 8 | static func debug(_ message: String) { | ||
| 9 | guard enabled else { return } | ||
| 10 | logger.debug("\(message, privacy: .public)") | ||
| 11 | } | ||
| 12 | |||
| 13 | static func error(_ message: String) { | ||
| 14 | guard enabled else { return } | ||
| 15 | logger.error("\(message, privacy: .public)") | ||
| 16 | } | ||
| 17 | |||
| 18 | static func signpostStart(_ scope: String, domain: String? = nil) -> CFAbsoluteTime { | ||
| 19 | let start = CFAbsoluteTimeGetCurrent() | ||
| 20 | if let domain { | ||
| 21 | debug("[START] \(scope) domain=\(domain)") | ||
| 22 | } else { | ||
| 23 | debug("[START] \(scope)") | ||
| 24 | } | ||
| 25 | return start | ||
| 26 | } | ||
| 27 | |||
| 28 | static func signpostEnd(_ scope: String, start: CFAbsoluteTime, domain: String? = nil, extra: String? = nil) { | ||
| 29 | let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - start) * 1000) | ||
| 30 | let suffix = extra.map { " \($0)" } ?? "" | ||
| 31 | if let domain { | ||
| 32 | debug("[END] \(scope) domain=\(domain) elapsedMs=\(elapsedMs)\(suffix)") | ||
| 33 | } else { | ||
| 34 | debug("[END] \(scope) elapsedMs=\(elapsedMs)\(suffix)") | ||
| 35 | } | ||
| 36 | } | ||
| 37 | } | ||
DomainDig/DomainMonitoringService.swift +4
| @@ -509,6 +509,10 @@ final class DomainMonitoringService { | |||
| 509 | isPartialSnapshot: previousSnapshot.isPartialSnapshot, | 509 | isPartialSnapshot: previousSnapshot.isPartialSnapshot, |
| 510 | validationIssues: previousSnapshot.validationIssues, | 510 | validationIssues: previousSnapshot.validationIssues, |
| 511 | totalLookupDurationMs: previousSnapshot.totalLookupDurationMs, | 511 | totalLookupDurationMs: previousSnapshot.totalLookupDurationMs, |
| 512 | snapshotIndex: previousSnapshot.snapshotIndex, | ||
| 513 | previousSnapshotID: previousSnapshot.previousSnapshotID, | ||
| 514 | changeCount: previousSnapshot.changeCount, | ||
| 515 | severitySummary: previousSnapshot.severitySummary, | ||
| 512 | dnsSections: previousSnapshot.dnsSections, | 516 | dnsSections: previousSnapshot.dnsSections, |
| 513 | dnsError: previousSnapshot.dnsError, | 517 | dnsError: previousSnapshot.dnsError, |
| 514 | availabilityResult: previousSnapshot.availabilityResult, | 518 | availabilityResult: previousSnapshot.availabilityResult, |
DomainDig/DomainViewModel.swift +295 −26
| @@ -208,6 +208,10 @@ final class DomainViewModel { | |||
| 208 | private var lastBatchStartedAt: Date? | 208 | private var lastBatchStartedAt: Date? |
| 209 | private var activeWorkflowRunID: UUID? | 209 | private var activeWorkflowRunID: UUID? |
| 210 | private var activeWorkflowRunName: String? | 210 | private var activeWorkflowRunName: String? |
| 211 | private var historyPersistenceSuspended = false | ||
| 212 | private var trackedDomainsPersistenceSuspended = false | ||
| 213 | private var historyPersistenceDirty = false | ||
| 214 | private var trackedDomainsPersistenceDirty = false | ||
| 211 | private let reportBuilder = DomainReportBuilder() | 215 | private let reportBuilder = DomainReportBuilder() |
| 212 | private let inspectionService = DomainInspectionService() | 216 | private let inspectionService = DomainInspectionService() |
| 213 | private(set) var currentResultSource: LookupResultSource = .live | 217 | private(set) var currentResultSource: LookupResultSource = .live |
| @@ -237,6 +241,8 @@ final class DomainViewModel { | |||
| 237 | var historyDateFilter: HistoryDateFilter = .all | 241 | var historyDateFilter: HistoryDateFilter = .all |
| 238 | var historyChangeFilter: ChangeFilterOption = .all | 242 | var historyChangeFilter: ChangeFilterOption = .all |
| 239 | var historySortOption: HistorySortOption = .newest | 243 | var historySortOption: HistorySortOption = .newest |
| 244 | var timelineGrouping: TimelineGroupingOption = .relativeDay | ||
| 245 | var timelineDomainFilter = "" | ||
| 240 | var watchlistSearchText = "" | 246 | var watchlistSearchText = "" |
| 241 | var watchlistFilter: WatchlistFilterOption = .all | 247 | var watchlistFilter: WatchlistFilterOption = .all |
| 242 | var watchlistSortOption: WatchlistSortOption = .pinned | 248 | var watchlistSortOption: WatchlistSortOption = .pinned |
| @@ -249,6 +255,12 @@ final class DomainViewModel { | |||
| 249 | var portabilityStatusMessage: String? | 255 | var portabilityStatusMessage: String? |
| 250 | var upgradePrompt: UpgradePromptContext? | 256 | var upgradePrompt: UpgradePromptContext? |
| 251 | var isPaywallPresented = false | 257 | var isPaywallPresented = false |
| 258 | var selectedSnapshotIDs = Set<UUID>() | ||
| 259 | var activeDomainDiff: DomainDiff? | ||
| 260 | var activeDiffChangeIndex = 0 | ||
| 261 | |||
| 262 | private static let historyAutoPruneKey = "historyAutoPrune" | ||
| 263 | var historyAutoPruneOption: HistoryAutoPruneOption = DomainViewModel.loadHistoryAutoPruneOption() | ||
| 252 | 264 | ||
| 253 | var trimmedDomain: String { | 265 | var trimmedDomain: String { |
| 254 | domain | 266 | domain |
| @@ -365,6 +377,15 @@ final class DomainViewModel { | |||
| 365 | return sortedTrackedDomains(from: filtered, using: watchlistSortOption) | 377 | return sortedTrackedDomains(from: filtered, using: watchlistSortOption) |
| 366 | } | 378 | } |
| 367 | 379 | ||
| 380 | var timelineDomains: [String] { | ||
| 381 | let query = timelineDomainFilter.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 382 | let domains = history.map(\.domain) | ||
| 383 | let filtered = query.isEmpty | ||
| 384 | ? domains | ||
| 385 | : domains.filter { $0.localizedCaseInsensitiveContains(query) } | ||
| 386 | return Array(Set(filtered)).sorted() | ||
| 387 | } | ||
| 388 | |||
| 368 | var batchProgressLabel: String { | 389 | var batchProgressLabel: String { |
| 369 | guard batchTotalCount > 0 else { return "No active batch" } | 390 | guard batchTotalCount > 0 else { return "No active batch" } |
| 370 | if !batchLookupRunning, batchCompletedCount >= batchTotalCount { | 391 | if !batchLookupRunning, batchCompletedCount >= batchTotalCount { |
| @@ -441,6 +462,10 @@ final class DomainViewModel { | |||
| 441 | isPartialSnapshot: currentHistoryEntry?.isPartialSnapshot ?? false, | 462 | isPartialSnapshot: currentHistoryEntry?.isPartialSnapshot ?? false, |
| 442 | validationIssues: currentHistoryEntry?.validationIssues ?? [], | 463 | validationIssues: currentHistoryEntry?.validationIssues ?? [], |
| 443 | totalLookupDurationMs: lastLookupDurationMs, | 464 | totalLookupDurationMs: lastLookupDurationMs, |
| 465 | snapshotIndex: currentHistoryEntry?.snapshotIndex, | ||
| 466 | previousSnapshotID: currentHistoryEntry?.previousSnapshotID, | ||
| 467 | changeCount: currentHistoryEntry?.changeCount ?? currentChangeSummary?.changedSections.count ?? 0, | ||
| 468 | severitySummary: currentHistoryEntry?.severitySummary ?? currentChangeSummary?.severity, | ||
| 444 | dnsSections: dnsSections, | 469 | dnsSections: dnsSections, |
| 445 | dnsError: dnsError, | 470 | dnsError: dnsError, |
| 446 | availabilityResult: availabilityResult, | 471 | availabilityResult: availabilityResult, |
| @@ -1372,6 +1397,22 @@ final class DomainViewModel { | |||
| 1372 | ) | 1397 | ) |
| 1373 | } | 1398 | } |
| 1374 | 1399 | ||
| 1400 | func exportTimelineText(domain: String, includeDiffSummary: Bool) -> String { | ||
| 1401 | DomainReportExporter.timelineText( | ||
| 1402 | for: timelineReports(for: domain), | ||
| 1403 | domain: domain, | ||
| 1404 | includeDiffSummary: includeDiffSummary | ||
| 1405 | ) | ||
| 1406 | } | ||
| 1407 | |||
| 1408 | func exportTimelineJSONData(domain: String, includeDiffSummary: Bool) -> Data? { | ||
| 1409 | try? DomainReportExporter.timelineData( | ||
| 1410 | for: timelineReports(for: domain), | ||
| 1411 | domain: domain, | ||
| 1412 | includeDiffSummary: includeDiffSummary | ||
| 1413 | ) | ||
| 1414 | } | ||
| 1415 | |||
| 1375 | func exportFullBackupData() -> Data? { | 1416 | func exportFullBackupData() -> Data? { |
| 1376 | try? DomainDataPortabilityService.backupData() | 1417 | try? DomainDataPortabilityService.backupData() |
| 1377 | } | 1418 | } |
| @@ -1461,20 +1502,25 @@ final class DomainViewModel { | |||
| 1461 | } | 1502 | } |
| 1462 | 1503 | ||
| 1463 | private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? { | 1504 | private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? { |
| 1505 | let lookupStartedAt = DomainDebugLog.signpostStart("DomainViewModel.performLookup", domain: domain) | ||
| 1464 | let previous = previousSnapshot( | 1506 | let previous = previousSnapshot( |
| 1465 | for: domain, | 1507 | for: domain, |
| 1466 | trackedDomainID: currentTrackedDomain?.id, | 1508 | trackedDomainID: currentTrackedDomain?.id, |
| 1467 | replacingLatest: false | 1509 | replacingLatest: false |
| 1468 | ) | 1510 | ) |
| 1469 | let inspectedSnapshot = await inspectionService.inspectSnapshot(domain: domain, previousSnapshot: previous) | 1511 | let inspectedSnapshot = await inspectionService.inspectSnapshot(domain: domain, previousSnapshot: previous) |
| 1512 | DomainDebugLog.debug("DomainViewModel.performLookup inspectionReturned domain=\(domain)") | ||
| 1470 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil } | 1513 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil } |
| 1471 | 1514 | ||
| 1472 | let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previous) | 1515 | let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previous) |
| 1516 | let applyStartedAt = DomainDebugLog.signpostStart("DomainViewModel.applySnapshot", domain: domain) | ||
| 1473 | applySnapshot(snapshot) | 1517 | applySnapshot(snapshot) |
| 1518 | DomainDebugLog.signpostEnd("DomainViewModel.applySnapshot", start: applyStartedAt, domain: domain) | ||
| 1474 | lastLookupDurationMs = snapshot.totalLookupDurationMs | 1519 | lastLookupDurationMs = snapshot.totalLookupDurationMs |
| 1475 | refreshingTrackedDomainID = nil | 1520 | refreshingTrackedDomainID = nil |
| 1476 | 1521 | ||
| 1477 | if DataAccessService.hasAccess(to: .domainPricing), domainPricing == nil { | 1522 | if DataAccessService.hasAccess(to: .domainPricing), domainPricing == nil { |
| 1523 | DomainDebugLog.debug("DomainViewModel.performLookup loadingPricing domain=\(domain)") | ||
| 1478 | await refreshDomainPricing(for: snapshot.domain, persistAfterFetch: false) | 1524 | await refreshDomainPricing(for: snapshot.domain, persistAfterFetch: false) |
| 1479 | } | 1525 | } |
| 1480 | 1526 | ||
| @@ -1483,8 +1529,11 @@ final class DomainViewModel { | |||
| 1483 | return history.first(where: { $0.id == snapshot.historyEntryID }) | 1529 | return history.first(where: { $0.id == snapshot.historyEntryID }) |
| 1484 | } | 1530 | } |
| 1485 | 1531 | ||
| 1486 | let entry = saveHistoryEntry(replaceLatest: false) | 1532 | let saveStartedAt = DomainDebugLog.signpostStart("DomainViewModel.saveHistoryEntry", domain: domain) |
| 1533 | let entry = saveHistoryEntry(replaceLatest: false, reuseCurrentAnalysis: true) | ||
| 1534 | DomainDebugLog.signpostEnd("DomainViewModel.saveHistoryEntry", start: saveStartedAt, domain: domain) | ||
| 1487 | await refreshUsageCredits() | 1535 | await refreshUsageCredits() |
| 1536 | DomainDebugLog.signpostEnd("DomainViewModel.performLookup", start: lookupStartedAt, domain: domain) | ||
| 1488 | return entry | 1537 | return entry |
| 1489 | } | 1538 | } |
| 1490 | 1539 | ||
| @@ -1496,6 +1545,7 @@ final class DomainViewModel { | |||
| 1496 | currentStatusMessage = snapshot.statusMessage | 1545 | currentStatusMessage = snapshot.statusMessage |
| 1497 | currentDiffSections = [] | 1546 | currentDiffSections = [] |
| 1498 | ownershipDiff = [] | 1547 | ownershipDiff = [] |
| 1548 | let reportStartedAt = DomainDebugLog.signpostStart("DomainViewModel.reportBuilder.build", domain: snapshot.domain) | ||
| 1499 | currentReport = reportBuilder.build( | 1549 | currentReport = reportBuilder.build( |
| 1500 | from: snapshot, | 1550 | from: snapshot, |
| 1501 | previousSnapshot: previousSnapshot( | 1551 | previousSnapshot: previousSnapshot( |
| @@ -1504,6 +1554,7 @@ final class DomainViewModel { | |||
| 1504 | replacingLatest: false | 1554 | replacingLatest: false |
| 1505 | ) | 1555 | ) |
| 1506 | ) | 1556 | ) |
| 1557 | DomainDebugLog.signpostEnd("DomainViewModel.reportBuilder.build", start: reportStartedAt, domain: snapshot.domain) | ||
| 1507 | currentChangeSummary = currentReport?.changeSummary ?? snapshot.changeSummary | 1558 | currentChangeSummary = currentReport?.changeSummary ?? snapshot.changeSummary |
| 1508 | 1559 | ||
| 1509 | dnsSections = snapshot.dnsSections | 1560 | dnsSections = snapshot.dnsSections |
| @@ -1596,6 +1647,10 @@ final class DomainViewModel { | |||
| 1596 | isPartialSnapshot: previousSnapshot.isPartialSnapshot, | 1647 | isPartialSnapshot: previousSnapshot.isPartialSnapshot, |
| 1597 | validationIssues: previousSnapshot.validationIssues, | 1648 | validationIssues: previousSnapshot.validationIssues, |
| 1598 | totalLookupDurationMs: previousSnapshot.totalLookupDurationMs, | 1649 | totalLookupDurationMs: previousSnapshot.totalLookupDurationMs, |
| 1650 | snapshotIndex: previousSnapshot.snapshotIndex, | ||
| 1651 | previousSnapshotID: previousSnapshot.previousSnapshotID, | ||
| 1652 | changeCount: previousSnapshot.changeCount, | ||
| 1653 | severitySummary: previousSnapshot.severitySummary, | ||
| 1599 | dnsSections: previousSnapshot.dnsSections, | 1654 | dnsSections: previousSnapshot.dnsSections, |
| 1600 | dnsError: previousSnapshot.dnsError, | 1655 | dnsError: previousSnapshot.dnsError, |
| 1601 | availabilityResult: previousSnapshot.availabilityResult, | 1656 | availabilityResult: previousSnapshot.availabilityResult, |
| @@ -1956,32 +2011,61 @@ final class DomainViewModel { | |||
| 1956 | } | 2011 | } |
| 1957 | 2012 | ||
| 1958 | @discardableResult | 2013 | @discardableResult |
| 1959 | private func saveHistoryEntry(replaceLatest: Bool) -> HistoryEntry? { | 2014 | private func saveHistoryEntry(replaceLatest: Bool, reuseCurrentAnalysis: Bool = false) -> HistoryEntry? { |
| 1960 | guard !searchedDomain.isEmpty else { return nil } | 2015 | guard !searchedDomain.isEmpty else { return nil } |
| 1961 | return saveHistoryEntry(from: currentSnapshot, replaceLatest: replaceLatest, updateCurrentState: true) | 2016 | return saveHistoryEntry( |
| 2017 | from: currentSnapshot, | ||
| 2018 | replaceLatest: replaceLatest, | ||
| 2019 | updateCurrentState: true, | ||
| 2020 | reuseCurrentAnalysis: reuseCurrentAnalysis | ||
| 2021 | ) | ||
| 1962 | } | 2022 | } |
| 1963 | 2023 | ||
| 1964 | @discardableResult | 2024 | @discardableResult |
| 1965 | private func saveHistoryEntry(from snapshot: LookupSnapshot, replaceLatest: Bool, updateCurrentState: Bool) -> HistoryEntry? { | 2025 | private func saveHistoryEntry( |
| 2026 | from snapshot: LookupSnapshot, | ||
| 2027 | replaceLatest: Bool, | ||
| 2028 | updateCurrentState: Bool, | ||
| 2029 | reuseCurrentAnalysis: Bool = false | ||
| 2030 | ) -> HistoryEntry? { | ||
| 1966 | let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id | 2031 | let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id |
| 1967 | let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest) | 2032 | let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest) |
| 1968 | let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot) | 2033 | let analysis = reuseCurrentAnalysis ? nil : DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot) |
| 1969 | let changeSummary = previousSnapshot.map { | 2034 | let changeSummary = reuseCurrentAnalysis |
| 1970 | DomainDiffService.summary( | 2035 | ? currentChangeSummary ?? snapshot.changeSummary |
| 1971 | from: $0, | 2036 | : previousSnapshot.map { |
| 1972 | to: snapshot, | 2037 | DiffService.summary( |
| 1973 | generatedAt: snapshot.timestamp, | 2038 | from: $0, |
| 1974 | riskAssessment: analysis.riskAssessment, | 2039 | to: snapshot, |
| 1975 | insights: analysis.insights | 2040 | generatedAt: snapshot.timestamp, |
| 1976 | ) | 2041 | riskAssessment: analysis?.riskAssessment, |
| 1977 | } | 2042 | insights: analysis?.insights |
| 1978 | let diffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? [] | 2043 | ) |
| 2044 | } | ||
| 2045 | let domainDiff = reuseCurrentAnalysis | ||
| 2046 | ? previousSnapshot.map { | ||
| 2047 | DomainDiff( | ||
| 2048 | domain: snapshot.domain, | ||
| 2049 | fromTimestamp: $0.timestamp, | ||
| 2050 | toTimestamp: snapshot.timestamp, | ||
| 2051 | sections: currentDiffSections, | ||
| 2052 | changedSectionIDs: currentDiffSections.filter(\.hasChanges).map(\.id), | ||
| 2053 | changedSectionTitles: currentDiffSections.filter(\.hasChanges).map(\.title), | ||
| 2054 | contextNote: currentChangeSummary?.contextNote | ||
| 2055 | ) | ||
| 2056 | } | ||
| 2057 | : previousSnapshot.map { DiffService.compare(from: $0, to: snapshot) } | ||
| 2058 | let diffSections = domainDiff?.sections ?? currentDiffSections | ||
| 2059 | let previousSnapshotID = previousSnapshot?.historyEntryID | ||
| 2060 | let nextSnapshotIndex = nextSnapshotIndex(for: snapshot.domain, trackedDomainID: trackedDomainID) | ||
| 1979 | 2061 | ||
| 1980 | if updateCurrentState { | 2062 | if updateCurrentState { |
| 1981 | currentChangeSummary = changeSummary | 2063 | currentChangeSummary = changeSummary |
| 1982 | currentDiffSections = diffSections | 2064 | currentDiffSections = diffSections |
| 1983 | ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? [] | 2065 | ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? [] |
| 1984 | currentReport = reportBuilder.build(from: snapshot, previousSnapshot: previousSnapshot) | 2066 | if !reuseCurrentAnalysis { |
| 2067 | currentReport = reportBuilder.build(from: snapshot, previousSnapshot: previousSnapshot) | ||
| 2068 | } | ||
| 1985 | } | 2069 | } |
| 1986 | 2070 | ||
| 1987 | let entry = HistoryEntry( | 2071 | let entry = HistoryEntry( |
| @@ -2029,6 +2113,10 @@ final class DomainViewModel { | |||
| 2029 | emailSecuritySummary: Self.emailSummary(from: snapshot), | 2113 | emailSecuritySummary: Self.emailSummary(from: snapshot), |
| 2030 | httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError, | 2114 | httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError, |
| 2031 | changeSummary: changeSummary, | 2115 | changeSummary: changeSummary, |
| 2116 | snapshotIndex: nextSnapshotIndex, | ||
| 2117 | previousSnapshotID: previousSnapshotID, | ||
| 2118 | changeCount: domainDiff?.changeCount ?? changeSummary?.changedSections.count ?? 0, | ||
| 2119 | severitySummary: changeSummary?.severity, | ||
| 2032 | sslError: snapshot.sslError, | 2120 | sslError: snapshot.sslError, |
| 2033 | httpHeadersError: snapshot.httpHeadersError, | 2121 | httpHeadersError: snapshot.httpHeadersError, |
| 2034 | reachabilityError: snapshot.reachabilityError, | 2122 | reachabilityError: snapshot.reachabilityError, |
| @@ -2053,9 +2141,7 @@ final class DomainViewModel { | |||
| 2053 | history[0] = entry | 2141 | history[0] = entry |
| 2054 | } else { | 2142 | } else { |
| 2055 | history.insert(entry, at: 0) | 2143 | history.insert(entry, at: 0) |
| 2056 | if history.count > Self.maxHistory { | 2144 | trimHistoryToLimit() |
| 2057 | history = Array(history.prefix(Self.maxHistory)) | ||
| 2058 | } | ||
| 2059 | } | 2145 | } |
| 2060 | 2146 | ||
| 2061 | updateTrackedDomainSnapshotMetadata( | 2147 | updateTrackedDomainSnapshotMetadata( |
| @@ -2074,8 +2160,21 @@ final class DomainViewModel { | |||
| 2074 | } | 2160 | } |
| 2075 | 2161 | ||
| 2076 | private func persistHistory() { | 2162 | private func persistHistory() { |
| 2163 | if historyPersistenceSuspended { | ||
| 2164 | historyPersistenceDirty = true | ||
| 2165 | return | ||
| 2166 | } | ||
| 2167 | let persistStartedAt = DomainDebugLog.signpostStart("DomainViewModel.persistHistory") | ||
| 2077 | DomainDataPortabilityService.saveHistoryEntries(history) | 2168 | DomainDataPortabilityService.saveHistoryEntries(history) |
| 2078 | refreshDataLifecycleSummary() | 2169 | refreshDataLifecycleSummary() |
| 2170 | DomainDebugLog.signpostEnd("DomainViewModel.persistHistory", start: persistStartedAt, extra: "count=\(history.count)") | ||
| 2171 | } | ||
| 2172 | |||
| 2173 | func setHistoryAutoPruneOption(_ option: HistoryAutoPruneOption) { | ||
| 2174 | historyAutoPruneOption = option | ||
| 2175 | UserDefaults.standard.set(option.rawValue, forKey: Self.historyAutoPruneKey) | ||
| 2176 | trimHistoryToLimit() | ||
| 2177 | persistHistory() | ||
| 2079 | } | 2178 | } |
| 2080 | 2179 | ||
| 2081 | func updateHistoryNote(_ note: String, for entry: HistoryEntry) { | 2180 | func updateHistoryNote(_ note: String, for entry: HistoryEntry) { |
| @@ -2085,11 +2184,56 @@ final class DomainViewModel { | |||
| 2085 | } | 2184 | } |
| 2086 | 2185 | ||
| 2087 | private func persistTrackedDomains() { | 2186 | private func persistTrackedDomains() { |
| 2187 | if trackedDomainsPersistenceSuspended { | ||
| 2188 | trackedDomainsPersistenceDirty = true | ||
| 2189 | return | ||
| 2190 | } | ||
| 2088 | DomainDataPortabilityService.saveTrackedDomains(trackedDomains) | 2191 | DomainDataPortabilityService.saveTrackedDomains(trackedDomains) |
| 2089 | CloudSyncService.shared.scheduleSyncIfNeeded() | 2192 | CloudSyncService.shared.scheduleSyncIfNeeded() |
| 2090 | refreshDataLifecycleSummary() | 2193 | refreshDataLifecycleSummary() |
| 2091 | } | 2194 | } |
| 2092 | 2195 | ||
| 2196 | private func beginBulkPersistenceDeferral() { | ||
| 2197 | historyPersistenceSuspended = true | ||
| 2198 | trackedDomainsPersistenceSuspended = true | ||
| 2199 | historyPersistenceDirty = false | ||
| 2200 | trackedDomainsPersistenceDirty = false | ||
| 2201 | } | ||
| 2202 | |||
| 2203 | private func endBulkPersistenceDeferral() { | ||
| 2204 | historyPersistenceSuspended = false | ||
| 2205 | trackedDomainsPersistenceSuspended = false | ||
| 2206 | |||
| 2207 | if trackedDomainsPersistenceDirty { | ||
| 2208 | trackedDomainsPersistenceDirty = false | ||
| 2209 | DomainDataPortabilityService.saveTrackedDomains(trackedDomains) | ||
| 2210 | CloudSyncService.shared.scheduleSyncIfNeeded() | ||
| 2211 | } | ||
| 2212 | |||
| 2213 | if historyPersistenceDirty { | ||
| 2214 | historyPersistenceDirty = false | ||
| 2215 | DomainDataPortabilityService.saveHistoryEntries(history) | ||
| 2216 | } | ||
| 2217 | |||
| 2218 | refreshDataLifecycleSummary() | ||
| 2219 | } | ||
| 2220 | |||
| 2221 | private func trimHistoryToLimit() { | ||
| 2222 | let hardLimit = historyAutoPruneOption.keepCount ?? Self.maxHistory | ||
| 2223 | history = Array(history.prefix(min(hardLimit, Self.maxHistory))) | ||
| 2224 | } | ||
| 2225 | |||
| 2226 | private func nextSnapshotIndex(for domain: String, trackedDomainID: UUID?) -> Int { | ||
| 2227 | let siblings = history.filter { entry in | ||
| 2228 | if let trackedDomainID { | ||
| 2229 | return entry.trackedDomainID == trackedDomainID | ||
| 2230 | } | ||
| 2231 | return entry.domain.caseInsensitiveCompare(domain) == .orderedSame | ||
| 2232 | } | ||
| 2233 | let existingMax = siblings.compactMap(\.snapshotIndex).max() ?? siblings.count | ||
| 2234 | return existingMax + 1 | ||
| 2235 | } | ||
| 2236 | |||
| 2093 | private func persistMonitoringSettings(localActivationConfirmed: Bool = false) { | 2237 | private func persistMonitoringSettings(localActivationConfirmed: Bool = false) { |
| 2094 | monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains) | 2238 | monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains) |
| 2095 | MonitoringStorage.saveSettings(monitoringSettings) | 2239 | MonitoringStorage.saveSettings(monitoringSettings) |
| @@ -2328,6 +2472,7 @@ final class DomainViewModel { | |||
| 2328 | private func runBatchLookup(domains: [String], source: BatchLookupSource) async { | 2472 | private func runBatchLookup(domains: [String], source: BatchLookupSource) async { |
| 2329 | let concurrencyLimit = min(source == .watchlistRefresh ? 4 : 3, max(domains.count, 1)) | 2473 | let concurrencyLimit = min(source == .watchlistRefresh ? 4 : 3, max(domains.count, 1)) |
| 2330 | var nextIndex = 0 | 2474 | var nextIndex = 0 |
| 2475 | beginBulkPersistenceDeferral() | ||
| 2331 | 2476 | ||
| 2332 | await withTaskGroup(of: (String, BatchLookupPayload?).self) { group in | 2477 | await withTaskGroup(of: (String, BatchLookupPayload?).self) { group in |
| 2333 | for _ in 0..<concurrencyLimit { | 2478 | for _ in 0..<concurrencyLimit { |
| @@ -2348,6 +2493,7 @@ final class DomainViewModel { | |||
| 2348 | } | 2493 | } |
| 2349 | } | 2494 | } |
| 2350 | 2495 | ||
| 2496 | endBulkPersistenceDeferral() | ||
| 2351 | finishBatchLookup(source: source) | 2497 | finishBatchLookup(source: source) |
| 2352 | } | 2498 | } |
| 2353 | 2499 | ||
| @@ -2396,7 +2542,7 @@ final class DomainViewModel { | |||
| 2396 | } | 2542 | } |
| 2397 | } | 2543 | } |
| 2398 | let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot) | 2544 | let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot) |
| 2399 | let riskAssessment = DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment | 2545 | let riskAssessment = entry?.changeSummary?.riskAssessment ?? DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment |
| 2400 | let quickStatus: String | 2546 | let quickStatus: String |
| 2401 | if entry?.changeSummary?.hasChanges == true { | 2547 | if entry?.changeSummary?.hasChanges == true { |
| 2402 | quickStatus = entry?.changeSummary?.impactClassification == .critical ? "Critical" : (entry?.changeSummary?.severity == .high ? "High" : "Changed") | 2548 | quickStatus = entry?.changeSummary?.impactClassification == .critical ? "Critical" : (entry?.changeSummary?.severity == .high ? "High" : "Changed") |
| @@ -2646,15 +2792,105 @@ final class DomainViewModel { | |||
| 2646 | } | 2792 | } |
| 2647 | 2793 | ||
| 2648 | func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? { | 2794 | func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? { |
| 2649 | let siblings = history.filter { candidate in | 2795 | previousHistoryEntry(for: entry)?.snapshot |
| 2650 | if let trackedDomainID = entry.trackedDomainID { | 2796 | } |
| 2651 | return candidate.trackedDomainID == trackedDomainID && candidate.id != entry.id | 2797 | |
| 2798 | func timelineEntries(for domain: String) -> [SnapshotSummary] { | ||
| 2799 | historyEntries(for: domain).map(\.snapshotSummary) | ||
| 2800 | } | ||
| 2801 | |||
| 2802 | func timelineSections(for domain: String, grouping: TimelineGroupingOption? = nil) -> [TimelineSection] { | ||
| 2803 | let entries = timelineEntries(for: domain) | ||
| 2804 | let grouping = grouping ?? timelineGrouping | ||
| 2805 | |||
| 2806 | guard grouping == .relativeDay else { | ||
| 2807 | return entries.isEmpty ? [] : [TimelineSection(id: "all", title: "All Snapshots", entries: entries)] | ||
| 2808 | } | ||
| 2809 | |||
| 2810 | let calendar = Calendar.current | ||
| 2811 | let today = Date() | ||
| 2812 | let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today | ||
| 2813 | let grouped = Dictionary(grouping: entries) { entry -> String in | ||
| 2814 | if calendar.isDate(entry.timestamp, inSameDayAs: today) { | ||
| 2815 | return "Today" | ||
| 2652 | } | 2816 | } |
| 2653 | return candidate.domain.caseInsensitiveCompare(entry.domain) == .orderedSame && candidate.id != entry.id | 2817 | if calendar.isDate(entry.timestamp, inSameDayAs: yesterday) { |
| 2818 | return "Yesterday" | ||
| 2819 | } | ||
| 2820 | return "Older" | ||
| 2821 | } | ||
| 2822 | |||
| 2823 | return ["Today", "Yesterday", "Older"].compactMap { title in | ||
| 2824 | guard let items = grouped[title], !items.isEmpty else { return nil } | ||
| 2825 | return TimelineSection(id: title.lowercased(), title: title, entries: items) | ||
| 2826 | } | ||
| 2827 | } | ||
| 2828 | |||
| 2829 | func historyEntries(for domain: String) -> [HistoryEntry] { | ||
| 2830 | history | ||
| 2831 | .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } | ||
| 2832 | .sorted { $0.timestamp > $1.timestamp } | ||
| 2833 | } | ||
| 2834 | |||
| 2835 | func historyEntry(withID id: UUID) -> HistoryEntry? { | ||
| 2836 | history.first(where: { $0.id == id }) | ||
| 2837 | } | ||
| 2838 | |||
| 2839 | func previousHistoryEntry(for entry: HistoryEntry) -> HistoryEntry? { | ||
| 2840 | let siblings = historyEntries(for: entry.domain) | ||
| 2841 | guard let index = siblings.firstIndex(where: { $0.id == entry.id }) else { return nil } | ||
| 2842 | let nextIndex = index + 1 | ||
| 2843 | guard siblings.indices.contains(nextIndex) else { return nil } | ||
| 2844 | return siblings[nextIndex] | ||
| 2845 | } | ||
| 2846 | |||
| 2847 | func toggleSnapshotSelection(_ entry: HistoryEntry) { | ||
| 2848 | if selectedSnapshotIDs.contains(entry.id) { | ||
| 2849 | selectedSnapshotIDs.remove(entry.id) | ||
| 2850 | } else if selectedSnapshotIDs.count < 2 { | ||
| 2851 | selectedSnapshotIDs.insert(entry.id) | ||
| 2852 | } else if let oldest = selectedSnapshotIDs.first { | ||
| 2853 | selectedSnapshotIDs.remove(oldest) | ||
| 2854 | selectedSnapshotIDs.insert(entry.id) | ||
| 2855 | } | ||
| 2856 | } | ||
| 2857 | |||
| 2858 | func clearSnapshotSelection() { | ||
| 2859 | selectedSnapshotIDs.removeAll() | ||
| 2860 | } | ||
| 2861 | |||
| 2862 | var selectedSnapshots: [HistoryEntry] { | ||
| 2863 | selectedSnapshotIDs.compactMap(historyEntry(withID:)).sorted { $0.timestamp < $1.timestamp } | ||
| 2864 | } | ||
| 2865 | |||
| 2866 | @discardableResult | ||
| 2867 | func generateDiffForSelectedSnapshots(focusSectionID: String? = nil) -> DomainDiff? { | ||
| 2868 | guard selectedSnapshots.count == 2 else { | ||
| 2869 | activeDomainDiff = nil | ||
| 2870 | activeDiffChangeIndex = 0 | ||
| 2871 | return nil | ||
| 2872 | } | ||
| 2873 | |||
| 2874 | let diff = DiffService.compare(from: selectedSnapshots[0].snapshot, to: selectedSnapshots[1].snapshot) | ||
| 2875 | activeDomainDiff = diff | ||
| 2876 | if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) { | ||
| 2877 | activeDiffChangeIndex = index | ||
| 2878 | } else { | ||
| 2879 | activeDiffChangeIndex = 0 | ||
| 2654 | } | 2880 | } |
| 2655 | .sorted { $0.timestamp > $1.timestamp } | 2881 | return diff |
| 2882 | } | ||
| 2656 | 2883 | ||
| 2657 | return siblings.first?.snapshot | 2884 | func generateDiff(from olderEntry: HistoryEntry, to newerEntry: HistoryEntry, focusSectionID: String? = nil) -> DomainDiff { |
| 2885 | selectedSnapshotIDs = [olderEntry.id, newerEntry.id] | ||
| 2886 | let diff = DiffService.compare(from: olderEntry.snapshot, to: newerEntry.snapshot) | ||
| 2887 | activeDomainDiff = diff | ||
| 2888 | if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) { | ||
| 2889 | activeDiffChangeIndex = index | ||
| 2890 | } else { | ||
| 2891 | activeDiffChangeIndex = 0 | ||
| 2892 | } | ||
| 2893 | return diff | ||
| 2658 | } | 2894 | } |
| 2659 | 2895 | ||
| 2660 | func historyEntry(for batchResult: BatchLookupResult) -> HistoryEntry? { | 2896 | func historyEntry(for batchResult: BatchLookupResult) -> HistoryEntry? { |
| @@ -2662,6 +2898,23 @@ final class DomainViewModel { | |||
| 2662 | return history.first(where: { $0.id == historyEntryID }) | 2898 | return history.first(where: { $0.id == historyEntryID }) |
| 2663 | } | 2899 | } |
| 2664 | 2900 | ||
| 2901 | var currentDiffTargetSectionID: String? { | ||
| 2902 | guard let activeDomainDiff, activeDomainDiff.changedSectionIDs.indices.contains(activeDiffChangeIndex) else { | ||
| 2903 | return nil | ||
| 2904 | } | ||
| 2905 | return activeDomainDiff.changedSectionIDs[activeDiffChangeIndex] | ||
| 2906 | } | ||
| 2907 | |||
| 2908 | func moveToNextDiffChange() { | ||
| 2909 | guard let activeDomainDiff, !activeDomainDiff.changedSectionIDs.isEmpty else { return } | ||
| 2910 | activeDiffChangeIndex = min(activeDiffChangeIndex + 1, activeDomainDiff.changedSectionIDs.count - 1) | ||
| 2911 | } | ||
| 2912 | |||
| 2913 | func moveToPreviousDiffChange() { | ||
| 2914 | guard activeDomainDiff != nil else { return } | ||
| 2915 | activeDiffChangeIndex = max(activeDiffChangeIndex - 1, 0) | ||
| 2916 | } | ||
| 2917 | |||
| 2665 | private func exportSnapshots(for domains: [TrackedDomain]) -> [LookupSnapshot] { | 2918 | private func exportSnapshots(for domains: [TrackedDomain]) -> [LookupSnapshot] { |
| 2666 | let latestEntries = latestSnapshots(for: domains) | 2919 | let latestEntries = latestSnapshots(for: domains) |
| 2667 | 2920 | ||
| @@ -2709,6 +2962,10 @@ final class DomainViewModel { | |||
| 2709 | } | 2962 | } |
| 2710 | } | 2963 | } |
| 2711 | 2964 | ||
| 2965 | private func timelineReports(for domain: String) -> [DomainReport] { | ||
| 2966 | historyEntries(for: domain).map { report(for: $0) } | ||
| 2967 | } | ||
| 2968 | |||
| 2712 | private func report(for entry: HistoryEntry, workflowContext: DomainWorkflowContext? = nil) -> DomainReport { | 2969 | private func report(for entry: HistoryEntry, workflowContext: DomainWorkflowContext? = nil) -> DomainReport { |
| 2713 | reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry), workflowContext: workflowContext) | 2970 | reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry), workflowContext: workflowContext) |
| 2714 | } | 2971 | } |
| @@ -2745,6 +3002,10 @@ final class DomainViewModel { | |||
| 2745 | isPartialSnapshot: true, | 3002 | isPartialSnapshot: true, |
| 2746 | validationIssues: ["No stored snapshot data available"], | 3003 | validationIssues: ["No stored snapshot data available"], |
| 2747 | totalLookupDurationMs: nil, | 3004 | totalLookupDurationMs: nil, |
| 3005 | snapshotIndex: nil, | ||
| 3006 | previousSnapshotID: nil, | ||
| 3007 | changeCount: 0, | ||
| 3008 | severitySummary: trackedDomain.lastChangeSeverity, | ||
| 2748 | dnsSections: [], | 3009 | dnsSections: [], |
| 2749 | dnsError: nil, | 3010 | dnsError: nil, |
| 2750 | availabilityResult: DomainAvailabilityResult(domain: trackedDomain.domain, status: trackedDomain.lastKnownAvailability ?? .unknown), | 3011 | availabilityResult: DomainAvailabilityResult(domain: trackedDomain.domain, status: trackedDomain.lastKnownAvailability ?? .unknown), |
| @@ -2795,6 +3056,14 @@ final class DomainViewModel { | |||
| 2795 | return DomainDataPortabilityService.loadHistoryEntries() | 3056 | return DomainDataPortabilityService.loadHistoryEntries() |
| 2796 | } | 3057 | } |
| 2797 | 3058 | ||
| 3059 | private static func loadHistoryAutoPruneOption() -> HistoryAutoPruneOption { | ||
| 3060 | guard let rawValue = UserDefaults.standard.string(forKey: historyAutoPruneKey), | ||
| 3061 | let option = HistoryAutoPruneOption(rawValue: rawValue) else { | ||
| 3062 | return .unlimited | ||
| 3063 | } | ||
| 3064 | return option | ||
| 3065 | } | ||
| 3066 | |||
| 2798 | private static func loadTrackedDomains() -> [TrackedDomain] { | 3067 | private static func loadTrackedDomains() -> [TrackedDomain] { |
| 2799 | DataMigrationService.migrateIfNeeded() | 3068 | DataMigrationService.migrateIfNeeded() |
| 2800 | return DomainDataPortabilityService.loadTrackedDomains() | 3069 | return DomainDataPortabilityService.loadTrackedDomains() |
DomainDig/HistoryView.swift +38 −47
| @@ -7,8 +7,12 @@ struct HistoryView: View { | |||
| 7 | @State private var showClearAllConfirmation = false | 7 | @State private var showClearAllConfirmation = false |
| 8 | @State private var showWorkflowAddSheet = false | 8 | @State private var showWorkflowAddSheet = false |
| 9 | 9 | ||
| 10 | private var groupedHistory: [HistoryGroup] { | 10 | private var domainSummaries: [(domain: String, latest: SnapshotSummary, count: Int)] { |
| 11 | HistoryGroup.groups(for: viewModel.filteredHistory) | 11 | viewModel.timelineDomains.compactMap { domain in |
| 12 | let entries = viewModel.timelineEntries(for: domain) | ||
| 13 | guard let latest = entries.first else { return nil } | ||
| 14 | return (domain, latest, entries.count) | ||
| 15 | } | ||
| 12 | } | 16 | } |
| 13 | 17 | ||
| 14 | var body: some View { | 18 | var body: some View { |
| @@ -22,55 +26,41 @@ struct HistoryView: View { | |||
| 22 | ) | 26 | ) |
| 23 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | 27 | .listRowBackground(Color(.systemGray6).opacity(0.5)) |
| 24 | } else { | 28 | } else { |
| 25 | ForEach(groupedHistory) { group in | 29 | Section("Domains") { |
| 26 | Section(group.title) { | 30 | ForEach(domainSummaries, id: \.domain) { item in |
| 27 | ForEach(group.entries) { entry in | 31 | NavigationLink { |
| 28 | NavigationLink { | 32 | TimelineView(viewModel: viewModel, domain: item.domain) |
| 29 | HistoryDetailView(viewModel: viewModel, entry: entry) | 33 | } label: { |
| 30 | } label: { | 34 | VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) { |
| 31 | VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) { | 35 | HStack(alignment: .center, spacing: 8) { |
| 32 | HStack(alignment: .center, spacing: 8) { | 36 | Text(item.domain) |
| 33 | Text(entry.domain) | 37 | .font(appDensity.font(.callout)) |
| 34 | .font(appDensity.font(.callout)) | 38 | .foregroundStyle(.primary) |
| 35 | .foregroundStyle(.primary) | 39 | Spacer() |
| 36 | Spacer() | 40 | Text("\(item.count) snapshots") |
| 37 | AppStatusBadgeView(model: AppStatusFactory.change(entry.changeSummary)) | 41 | .font(appDensity.font(.caption2)) |
| 38 | } | 42 | .foregroundStyle(.secondary) |
| 39 | 43 | } | |
| 40 | HStack(spacing: 8) { | ||
| 41 | AppStatusBadgeView(model: AppStatusFactory.availability(entry.availabilityResult?.status)) | ||
| 42 | AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: entry.sslInfo, error: entry.sslError)) | ||
| 43 | if entry.isPartialSnapshot { | ||
| 44 | AppStatusBadgeView(model: .init(title: "Partial", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))) | ||
| 45 | } | ||
| 46 | } | ||
| 47 | 44 | ||
| 48 | HStack(spacing: 8) { | 45 | Text(item.latest.changeSummaryMessage ?? "No change summary") |
| 49 | Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened)) | 46 | .font(appDensity.font(.caption)) |
| 50 | Text(entry.timestamp.formatted(.relative(presentation: .named))) | ||
| 51 | Text(entry.resolverDisplayName) | ||
| 52 | if let totalLookupDurationMs = entry.totalLookupDurationMs { | ||
| 53 | Text("\(totalLookupDurationMs) ms") | ||
| 54 | } | ||
| 55 | } | ||
| 56 | .font(appDensity.font(.caption2)) | ||
| 57 | .foregroundStyle(.secondary) | 47 | .foregroundStyle(.secondary) |
| 48 | .lineLimit(2) | ||
| 58 | 49 | ||
| 59 | if let note = entry.note, !note.isEmpty { | 50 | HStack(spacing: 8) { |
| 60 | Text(note) | 51 | AppStatusBadgeView(model: AppStatusFactory.availability(item.latest.availability)) |
| 61 | .font(appDensity.font(.caption2)) | 52 | if let severity = item.latest.severitySummary { |
| 62 | .foregroundStyle(.secondary) | 53 | AppStatusBadgeView( |
| 63 | .lineLimit(1) | 54 | model: .init( |
| 55 | title: severity.title, | ||
| 56 | systemImage: "arrow.triangle.2.circlepath", | ||
| 57 | foregroundColor: severity == .high ? .red : .yellow, | ||
| 58 | backgroundColor: (severity == .high ? Color.red : .yellow).opacity(0.16) | ||
| 59 | ) | ||
| 60 | ) | ||
| 64 | } | 61 | } |
| 65 | } | 62 | } |
| 66 | } | 63 | } |
| 67 | .swipeActions(edge: .trailing, allowsFullSwipe: true) { | ||
| 68 | Button(role: .destructive) { | ||
| 69 | viewModel.removeHistoryEntries(withIDs: [entry.id]) | ||
| 70 | } label: { | ||
| 71 | Label("Delete", systemImage: "trash") | ||
| 72 | } | ||
| 73 | } | ||
| 74 | } | 64 | } |
| 75 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | 65 | .listRowBackground(Color(.systemGray6).opacity(0.5)) |
| 76 | } | 66 | } |
| @@ -80,7 +70,7 @@ struct HistoryView: View { | |||
| 80 | .scrollContentBackground(.hidden) | 70 | .scrollContentBackground(.hidden) |
| 81 | .background(Color.black) | 71 | .background(Color.black) |
| 82 | .navigationTitle("History") | 72 | .navigationTitle("History") |
| 83 | .searchable(text: $viewModel.historySearchText, prompt: "Search domains") | 73 | .searchable(text: $viewModel.timelineDomainFilter, prompt: "Search domains") |
| 84 | .toolbar { | 74 | .toolbar { |
| 85 | if !viewModel.history.isEmpty { | 75 | if !viewModel.history.isEmpty { |
| 86 | ToolbarItemGroup(placement: .topBarTrailing) { | 76 | ToolbarItemGroup(placement: .topBarTrailing) { |
| @@ -244,7 +234,8 @@ struct HistoryDetailView: View { | |||
| 244 | title: "Compared With Previous Snapshot", | 234 | title: "Compared With Previous Snapshot", |
| 245 | sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot), | 235 | sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot), |
| 246 | contextNote: DomainDiffService.comparisonContextNote(from: comparisonSnapshot, to: snapshot), | 236 | contextNote: DomainDiffService.comparisonContextNote(from: comparisonSnapshot, to: snapshot), |
| 247 | showsUnchanged: false | 237 | showsUnchanged: false, |
| 238 | highlightedSectionID: nil | ||
| 248 | ) | 239 | ) |
| 249 | .padding(.top, appDensity.metrics.sectionSpacing) | 240 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 250 | } | 241 | } |
DomainDig/Models.swift +113 −1
| @@ -541,6 +541,83 @@ enum HistorySortOption: String, CaseIterable, Identifiable { | |||
| 541 | } | 541 | } |
| 542 | } | 542 | } |
| 543 | 543 | ||
| 544 | enum TimelineGroupingOption: String, CaseIterable, Identifiable { | ||
| 545 | case none | ||
| 546 | case relativeDay | ||
| 547 | |||
| 548 | var id: String { rawValue } | ||
| 549 | |||
| 550 | var title: String { | ||
| 551 | switch self { | ||
| 552 | case .none: | ||
| 553 | return "Ungrouped" | ||
| 554 | case .relativeDay: | ||
| 555 | return "Today / Yesterday / Older" | ||
| 556 | } | ||
| 557 | } | ||
| 558 | } | ||
| 559 | |||
| 560 | enum HistoryAutoPruneOption: String, CaseIterable, Codable, Identifiable { | ||
| 561 | case keep50 | ||
| 562 | case keep100 | ||
| 563 | case unlimited | ||
| 564 | |||
| 565 | var id: String { rawValue } | ||
| 566 | |||
| 567 | var title: String { | ||
| 568 | switch self { | ||
| 569 | case .keep50: | ||
| 570 | return "Keep Last 50" | ||
| 571 | case .keep100: | ||
| 572 | return "Keep Last 100" | ||
| 573 | case .unlimited: | ||
| 574 | return "Unlimited" | ||
| 575 | } | ||
| 576 | } | ||
| 577 | |||
| 578 | var keepCount: Int? { | ||
| 579 | switch self { | ||
| 580 | case .keep50: | ||
| 581 | return 50 | ||
| 582 | case .keep100: | ||
| 583 | return 100 | ||
| 584 | case .unlimited: | ||
| 585 | return nil | ||
| 586 | } | ||
| 587 | } | ||
| 588 | } | ||
| 589 | |||
| 590 | struct SnapshotSummary: Identifiable, Codable, Equatable { | ||
| 591 | let id: UUID | ||
| 592 | let domain: String | ||
| 593 | let timestamp: Date | ||
| 594 | let trackedDomainID: UUID? | ||
| 595 | let snapshotIndex: Int? | ||
| 596 | let previousSnapshotID: UUID? | ||
| 597 | let changeCount: Int | ||
| 598 | let severitySummary: ChangeSeverity? | ||
| 599 | let changeSummaryMessage: String? | ||
| 600 | let availability: DomainAvailabilityStatus? | ||
| 601 | let primaryIP: String? | ||
| 602 | let tlsStatus: String? | ||
| 603 | let riskScore: Int? | ||
| 604 | let historyEntryID: UUID | ||
| 605 | |||
| 606 | var hasChanges: Bool { | ||
| 607 | changeCount > 0 | ||
| 608 | } | ||
| 609 | } | ||
| 610 | |||
| 611 | struct FullSnapshot: Codable { | ||
| 612 | let historyEntry: HistoryEntry | ||
| 613 | } | ||
| 614 | |||
| 615 | struct TimelineSection: Identifiable, Equatable { | ||
| 616 | let id: String | ||
| 617 | let title: String | ||
| 618 | let entries: [SnapshotSummary] | ||
| 619 | } | ||
| 620 | |||
| 544 | enum WatchlistFilterOption: String, CaseIterable, Identifiable { | 621 | enum WatchlistFilterOption: String, CaseIterable, Identifiable { |
| 545 | case all | 622 | case all |
| 546 | case pinnedOnly | 623 | case pinnedOnly |
| @@ -1304,6 +1381,10 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 1304 | var emailSecuritySummary: String? | 1381 | var emailSecuritySummary: String? |
| 1305 | var httpGradeSummary: String? | 1382 | var httpGradeSummary: String? |
| 1306 | var changeSummary: DomainChangeSummary? | 1383 | var changeSummary: DomainChangeSummary? |
| 1384 | var snapshotIndex: Int? | ||
| 1385 | var previousSnapshotID: UUID? | ||
| 1386 | var changeCount: Int | ||
| 1387 | var severitySummary: ChangeSeverity? | ||
| 1307 | var sslError: String? | 1388 | var sslError: String? |
| 1308 | var httpHeadersError: String? | 1389 | var httpHeadersError: String? |
| 1309 | var reachabilityError: String? | 1390 | var reachabilityError: String? |
| @@ -1339,7 +1420,8 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 1339 | validationIssues: [String] = [], resolverDisplayName: String, resolverURLString: String, | 1420 | validationIssues: [String] = [], resolverDisplayName: String, resolverURLString: String, |
| 1340 | totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil, | 1421 | totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil, |
| 1341 | tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil, | 1422 | tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil, |
| 1342 | changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil, | 1423 | changeSummary: DomainChangeSummary? = nil, snapshotIndex: Int? = nil, previousSnapshotID: UUID? = nil, |
| 1424 | changeCount: Int = 0, severitySummary: ChangeSeverity? = nil, sslError: String? = nil, httpHeadersError: String? = nil, | ||
| 1343 | reachabilityError: String? = nil, ipGeolocationError: String? = nil, | 1425 | reachabilityError: String? = nil, ipGeolocationError: String? = nil, |
| 1344 | emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil, | 1426 | emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil, |
| 1345 | ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil, | 1427 | ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil, |
| @@ -1389,6 +1471,10 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 1389 | self.emailSecuritySummary = emailSecuritySummary | 1471 | self.emailSecuritySummary = emailSecuritySummary |
| 1390 | self.httpGradeSummary = httpGradeSummary | 1472 | self.httpGradeSummary = httpGradeSummary |
| 1391 | self.changeSummary = changeSummary | 1473 | self.changeSummary = changeSummary |
| 1474 | self.snapshotIndex = snapshotIndex | ||
| 1475 | self.previousSnapshotID = previousSnapshotID | ||
| 1476 | self.changeCount = changeCount | ||
| 1477 | self.severitySummary = severitySummary | ||
| 1392 | self.sslError = sslError | 1478 | self.sslError = sslError |
| 1393 | self.httpHeadersError = httpHeadersError | 1479 | self.httpHeadersError = httpHeadersError |
| 1394 | self.reachabilityError = reachabilityError | 1480 | self.reachabilityError = reachabilityError |
| @@ -1452,6 +1538,13 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 1452 | emailSecuritySummary = try container.decodeIfPresent(String.self, forKey: .emailSecuritySummary) | 1538 | emailSecuritySummary = try container.decodeIfPresent(String.self, forKey: .emailSecuritySummary) |
| 1453 | httpGradeSummary = try container.decodeIfPresent(String.self, forKey: .httpGradeSummary) | 1539 | httpGradeSummary = try container.decodeIfPresent(String.self, forKey: .httpGradeSummary) |
| 1454 | changeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .changeSummary) | 1540 | changeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .changeSummary) |
| 1541 | snapshotIndex = try container.decodeIfPresent(Int.self, forKey: .snapshotIndex) | ||
| 1542 | previousSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .previousSnapshotID) | ||
| 1543 | changeCount = try container.decodeIfPresent(Int.self, forKey: .changeCount) | ||
| 1544 | ?? changeSummary?.changedSections.count | ||
| 1545 | ?? 0 | ||
| 1546 | severitySummary = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severitySummary) | ||
| 1547 | ?? changeSummary?.severity | ||
| 1455 | sslError = try container.decodeIfPresent(String.self, forKey: .sslError) | 1548 | sslError = try container.decodeIfPresent(String.self, forKey: .sslError) |
| 1456 | httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError) | 1549 | httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError) |
| 1457 | reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError) | 1550 | reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError) |
| @@ -1478,6 +1571,25 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 1478 | } | 1571 | } |
| 1479 | return issues | 1572 | return issues |
| 1480 | } | 1573 | } |
| 1574 | |||
| 1575 | var snapshotSummary: SnapshotSummary { | ||
| 1576 | SnapshotSummary( | ||
| 1577 | id: id, | ||
| 1578 | domain: domain, | ||
| 1579 | timestamp: timestamp, | ||
| 1580 | trackedDomainID: trackedDomainID, | ||
| 1581 | snapshotIndex: snapshotIndex, | ||
| 1582 | previousSnapshotID: previousSnapshotID, | ||
| 1583 | changeCount: changeCount, | ||
| 1584 | severitySummary: severitySummary ?? changeSummary?.severity, | ||
| 1585 | changeSummaryMessage: changeSummary?.message, | ||
| 1586 | availability: availabilityResult?.status, | ||
| 1587 | primaryIP: primaryIP, | ||
| 1588 | tlsStatus: tlsStatusSummary, | ||
| 1589 | riskScore: changeSummary?.riskAssessment?.score, | ||
| 1590 | historyEntryID: id | ||
| 1591 | ) | ||
| 1592 | } | ||
| 1481 | } | 1593 | } |
| 1482 | 1594 | ||
| 1483 | // MARK: - Cloudflare DNS-over-HTTPS Response | 1595 | // MARK: - Cloudflare DNS-over-HTTPS Response |
DomainDig/PortScanService.swift +1 −1
| @@ -124,7 +124,7 @@ struct PortScanService { | |||
| 124 | } | 124 | } |
| 125 | 125 | ||
| 126 | private static func probe(domain: String, port: UInt16) async -> PortProbeResult { | 126 | private static func probe(domain: String, port: UInt16) async -> PortProbeResult { |
| 127 | await probe(domain: domain, port: port, timeout: 5) | 127 | await probe(domain: domain, port: port, timeout: 1.5) |
| 128 | } | 128 | } |
| 129 | 129 | ||
| 130 | private static func probe(domain: String, port: UInt16, timeout: TimeInterval) async -> PortProbeResult { | 130 | private static func probe(domain: String, port: UInt16, timeout: TimeInterval) async -> PortProbeResult { |
DomainDig/RDAPService.swift +10
| @@ -48,6 +48,7 @@ enum RDAPService { | |||
| 48 | } | 48 | } |
| 49 | 49 | ||
| 50 | private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDomainResponse> { | 50 | private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDomainResponse> { |
| 51 | let startedAt = DomainDebugLog.signpostStart("RDAP.fetch", domain: domain) | ||
| 51 | guard let url = URL(string: "https://rdap.org/domain/\(domain)") else { | 52 | guard let url = URL(string: "https://rdap.org/domain/\(domain)") else { |
| 52 | return .error("Unavailable") | 53 | return .error("Unavailable") |
| 53 | } | 54 | } |
| @@ -55,9 +56,11 @@ private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDo | |||
| 55 | do { | 56 | do { |
| 56 | var request = URLRequest(url: url, timeoutInterval: 8) | 57 | var request = URLRequest(url: url, timeoutInterval: 8) |
| 57 | request.setValue("application/rdap+json, application/json", forHTTPHeaderField: "Accept") | 58 | request.setValue("application/rdap+json, application/json", forHTTPHeaderField: "Accept") |
| 59 | DomainDebugLog.debug("RDAP.request url=\(url.absoluteString) timeout=8") | ||
| 58 | 60 | ||
| 59 | let (data, response) = try await URLSession.shared.data(for: request) | 61 | let (data, response) = try await URLSession.shared.data(for: request) |
| 60 | guard let httpResponse = response as? HTTPURLResponse else { | 62 | guard let httpResponse = response as? HTTPURLResponse else { |
| 63 | DomainDebugLog.error("RDAP.badResponse domain=\(domain) response=nil") | ||
| 61 | return .error(URLError(.badServerResponse).localizedDescription) | 64 | return .error(URLError(.badServerResponse).localizedDescription) |
| 62 | } | 65 | } |
| 63 | 66 | ||
| @@ -66,15 +69,22 @@ private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDo | |||
| 66 | let decoder = JSONDecoder() | 69 | let decoder = JSONDecoder() |
| 67 | let rdapResponse = try decoder.decode(RDAPDomainResponse.self, from: data) | 70 | let rdapResponse = try decoder.decode(RDAPDomainResponse.self, from: data) |
| 68 | guard rdapResponse.isDomainRecord else { | 71 | guard rdapResponse.isDomainRecord else { |
| 72 | DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=200 nonDomainRecord") | ||
| 69 | return .empty("Unavailable") | 73 | return .empty("Unavailable") |
| 70 | } | 74 | } |
| 75 | DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=200 bytes=\(data.count)") | ||
| 71 | return .success(rdapResponse) | 76 | return .success(rdapResponse) |
| 72 | case 404: | 77 | case 404: |
| 78 | DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=404") | ||
| 73 | return .empty("Unavailable") | 79 | return .empty("Unavailable") |
| 74 | default: | 80 | default: |
| 81 | DomainDebugLog.error("RDAP.httpError domain=\(domain) status=\(httpResponse.statusCode)") | ||
| 82 | DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=\(httpResponse.statusCode)") | ||
| 75 | return .error("Unavailable") | 83 | return .error("Unavailable") |
| 76 | } | 84 | } |
| 77 | } catch { | 85 | } catch { |
| 86 | DomainDebugLog.error("RDAP.error domain=\(domain) error=\(error.localizedDescription)") | ||
| 87 | DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "error") | ||
| 78 | return .error(error.localizedDescription) | 88 | return .error(error.localizedDescription) |
| 79 | } | 89 | } |
| 80 | } | 90 | } |
DomainDig/ReachabilityService.swift +1 −1
| @@ -23,7 +23,7 @@ struct ReachabilityService { | |||
| 23 | let queue = DispatchQueue(label: "reachability.\(port)") | 23 | let queue = DispatchQueue(label: "reachability.\(port)") |
| 24 | connection.start(queue: queue) | 24 | connection.start(queue: queue) |
| 25 | 25 | ||
| 26 | queue.asyncAfter(deadline: .now() + 5) { | 26 | queue.asyncAfter(deadline: .now() + 2) { |
| 27 | context.finish(reachable: false) | 27 | context.finish(reachable: false) |
| 28 | } | 28 | } |
| 29 | } | 29 | } |
DomainDig/SubdomainDiscoveryService.swift +11
| @@ -17,6 +17,7 @@ enum SubdomainDiscoveryService { | |||
| 17 | } | 17 | } |
| 18 | 18 | ||
| 19 | private static func fetchSubdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> { | 19 | private static func fetchSubdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> { |
| 20 | let startedAt = DomainDebugLog.signpostStart("SubdomainDiscovery.fetch", domain: domain) | ||
| 20 | var components = URLComponents(string: "https://crt.sh/")! | 21 | var components = URLComponents(string: "https://crt.sh/")! |
| 21 | components.queryItems = [ | 22 | components.queryItems = [ |
| 22 | URLQueryItem(name: "q", value: "%.\(domain)"), | 23 | URLQueryItem(name: "q", value: "%.\(domain)"), |
| @@ -29,15 +30,25 @@ enum SubdomainDiscoveryService { | |||
| 29 | 30 | ||
| 30 | do { | 31 | do { |
| 31 | let request = URLRequest(url: url, timeoutInterval: 10) | 32 | let request = URLRequest(url: url, timeoutInterval: 10) |
| 33 | DomainDebugLog.debug("SubdomainDiscovery.request url=\(url.absoluteString) timeout=10") | ||
| 32 | let (data, response) = try await URLSession.shared.data(for: request) | 34 | let (data, response) = try await URLSession.shared.data(for: request) |
| 33 | guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { | 35 | guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { |
| 36 | DomainDebugLog.error("SubdomainDiscovery.badResponse domain=\(domain)") | ||
| 34 | return .error("Subdomain discovery unavailable") | 37 | return .error("Subdomain discovery unavailable") |
| 35 | } | 38 | } |
| 36 | 39 | ||
| 37 | let entries = try JSONDecoder().decode([CRTShEntry].self, from: data) | 40 | let entries = try JSONDecoder().decode([CRTShEntry].self, from: data) |
| 38 | let subdomains = parseSubdomains(from: entries, domain: domain, limit: limit) | 41 | let subdomains = parseSubdomains(from: entries, domain: domain, limit: limit) |
| 42 | DomainDebugLog.signpostEnd( | ||
| 43 | "SubdomainDiscovery.fetch", | ||
| 44 | start: startedAt, | ||
| 45 | domain: domain, | ||
| 46 | extra: "entries=\(entries.count) subdomains=\(subdomains.count)" | ||
| 47 | ) | ||
| 39 | return subdomains.isEmpty ? .empty("No passive subdomains found") : .success(subdomains) | 48 | return subdomains.isEmpty ? .empty("No passive subdomains found") : .success(subdomains) |
| 40 | } catch { | 49 | } catch { |
| 50 | DomainDebugLog.error("SubdomainDiscovery.error domain=\(domain) error=\(error.localizedDescription)") | ||
| 51 | DomainDebugLog.signpostEnd("SubdomainDiscovery.fetch", start: startedAt, domain: domain, extra: "error") | ||
| 41 | return .error(error.localizedDescription) | 52 | return .error(error.localizedDescription) |
| 42 | } | 53 | } |
| 43 | } | 54 | } |
DomainDig/TimelineView.swift added +224
| @@ -0,0 +1,224 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct TimelineView: View { | ||
| 4 | @Environment(\.appDensity) private var appDensity | ||
| 5 | @Bindable var viewModel: DomainViewModel | ||
| 6 | let domain: String | ||
| 7 | |||
| 8 | @State private var presentedDiff: DomainDiff? | ||
| 9 | @State private var focusedSectionID: String? | ||
| 10 | |||
| 11 | private var timelineSections: [TimelineSection] { | ||
| 12 | viewModel.timelineSections(for: domain) | ||
| 13 | } | ||
| 14 | |||
| 15 | var body: some View { | ||
| 16 | List { | ||
| 17 | ForEach(timelineSections) { section in | ||
| 18 | Section(section.title) { | ||
| 19 | ForEach(section.entries) { summary in | ||
| 20 | if let entry = viewModel.historyEntry(withID: summary.historyEntryID) { | ||
| 21 | NavigationLink { | ||
| 22 | HistoryDetailView(viewModel: viewModel, entry: entry) | ||
| 23 | } label: { | ||
| 24 | TimelineRow(summary: summary) | ||
| 25 | } | ||
| 26 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { | ||
| 27 | Button { | ||
| 28 | viewModel.toggleSnapshotSelection(entry) | ||
| 29 | } label: { | ||
| 30 | Label( | ||
| 31 | viewModel.selectedSnapshotIDs.contains(entry.id) ? "Selected" : "Compare", | ||
| 32 | systemImage: viewModel.selectedSnapshotIDs.contains(entry.id) ? "checkmark.circle.fill" : "arrow.left.arrow.right" | ||
| 33 | ) | ||
| 34 | } | ||
| 35 | |||
| 36 | Button(role: .destructive) { | ||
| 37 | viewModel.removeHistoryEntries(withIDs: [entry.id]) | ||
| 38 | } label: { | ||
| 39 | Label("Delete", systemImage: "trash") | ||
| 40 | } | ||
| 41 | } | ||
| 42 | } | ||
| 43 | } | ||
| 44 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | ||
| 45 | } | ||
| 46 | } | ||
| 47 | } | ||
| 48 | .scrollContentBackground(.hidden) | ||
| 49 | .background(Color.black) | ||
| 50 | .navigationTitle(domain) | ||
| 51 | .toolbar { | ||
| 52 | ToolbarItemGroup(placement: .topBarTrailing) { | ||
| 53 | Menu { | ||
| 54 | Picker("Grouping", selection: $viewModel.timelineGrouping) { | ||
| 55 | ForEach(TimelineGroupingOption.allCases) { option in | ||
| 56 | Text(option.title).tag(option) | ||
| 57 | } | ||
| 58 | } | ||
| 59 | } label: { | ||
| 60 | Image(systemName: "line.3.horizontal.decrease.circle") | ||
| 61 | } | ||
| 62 | |||
| 63 | Button("Compare") { | ||
| 64 | presentedDiff = viewModel.generateDiffForSelectedSnapshots() | ||
| 65 | focusedSectionID = viewModel.currentDiffTargetSectionID | ||
| 66 | } | ||
| 67 | .disabled(viewModel.selectedSnapshots.count != 2) | ||
| 68 | |||
| 69 | Menu("Export") { | ||
| 70 | Button("Export TXT") { | ||
| 71 | ExportPresenter.share( | ||
| 72 | filename: "\(domain)-timeline.txt", | ||
| 73 | contents: viewModel.exportTimelineText(domain: domain, includeDiffSummary: true) | ||
| 74 | ) | ||
| 75 | } | ||
| 76 | |||
| 77 | Button("Export JSON") { | ||
| 78 | guard let data = viewModel.exportTimelineJSONData(domain: domain, includeDiffSummary: true) else { return } | ||
| 79 | ExportPresenter.share(filename: "\(domain)-timeline.json", data: data) | ||
| 80 | } | ||
| 81 | } | ||
| 82 | } | ||
| 83 | } | ||
| 84 | .sheet(item: $presentedDiff) { diff in | ||
| 85 | NavigationStack { | ||
| 86 | TimelineDiffView(viewModel: viewModel, diff: diff, focusedSectionID: $focusedSectionID) | ||
| 87 | } | ||
| 88 | } | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | private struct TimelineRow: View { | ||
| 93 | @Environment(\.appDensity) private var appDensity | ||
| 94 | let summary: SnapshotSummary | ||
| 95 | |||
| 96 | var body: some View { | ||
| 97 | VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) { | ||
| 98 | HStack(alignment: .center, spacing: 8) { | ||
| 99 | Text(summary.timestamp.formatted(date: .abbreviated, time: .shortened)) | ||
| 100 | .font(appDensity.font(.callout)) | ||
| 101 | .foregroundStyle(.primary) | ||
| 102 | Spacer() | ||
| 103 | if let severity = summary.severitySummary { | ||
| 104 | AppStatusBadgeView( | ||
| 105 | model: .init( | ||
| 106 | title: severity.title, | ||
| 107 | systemImage: "arrow.triangle.2.circlepath", | ||
| 108 | foregroundColor: severity == .high ? .red : .yellow, | ||
| 109 | backgroundColor: (severity == .high ? Color.red : .yellow).opacity(0.16) | ||
| 110 | ) | ||
| 111 | ) | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | Text(summary.changeSummaryMessage ?? "No change summary") | ||
| 116 | .font(appDensity.font(.caption)) | ||
| 117 | .foregroundStyle(.secondary) | ||
| 118 | .lineLimit(2) | ||
| 119 | |||
| 120 | HStack(spacing: 8) { | ||
| 121 | AppStatusBadgeView(model: AppStatusFactory.availability(summary.availability)) | ||
| 122 | if let primaryIP = summary.primaryIP { | ||
| 123 | AppStatusBadgeView( | ||
| 124 | model: .init( | ||
| 125 | title: primaryIP, | ||
| 126 | systemImage: "network", | ||
| 127 | foregroundColor: .blue, | ||
| 128 | backgroundColor: .blue.opacity(0.16) | ||
| 129 | ) | ||
| 130 | ) | ||
| 131 | } | ||
| 132 | if let tlsStatus = summary.tlsStatus { | ||
| 133 | AppStatusBadgeView( | ||
| 134 | model: .init( | ||
| 135 | title: tlsStatus.capitalized, | ||
| 136 | systemImage: "lock.shield", | ||
| 137 | foregroundColor: .green, | ||
| 138 | backgroundColor: .green.opacity(0.16) | ||
| 139 | ) | ||
| 140 | ) | ||
| 141 | } | ||
| 142 | if let riskScore = summary.riskScore { | ||
| 143 | AppStatusBadgeView( | ||
| 144 | model: .init( | ||
| 145 | title: "Risk \(riskScore)", | ||
| 146 | systemImage: "exclamationmark.shield", | ||
| 147 | foregroundColor: riskScore >= 70 ? .red : .orange, | ||
| 148 | backgroundColor: (riskScore >= 70 ? Color.red : .orange).opacity(0.16) | ||
| 149 | ) | ||
| 150 | ) | ||
| 151 | } | ||
| 152 | } | ||
| 153 | |||
| 154 | HStack(spacing: 8) { | ||
| 155 | if let snapshotIndex = summary.snapshotIndex { | ||
| 156 | Text("#\(snapshotIndex)") | ||
| 157 | } | ||
| 158 | Text(summary.timestamp.formatted(.relative(presentation: .named))) | ||
| 159 | if summary.changeCount > 0 { | ||
| 160 | Text("\(summary.changeCount) changes") | ||
| 161 | } | ||
| 162 | } | ||
| 163 | .font(appDensity.font(.caption2)) | ||
| 164 | .foregroundStyle(.secondary) | ||
| 165 | } | ||
| 166 | } | ||
| 167 | } | ||
| 168 | |||
| 169 | struct TimelineDiffView: View { | ||
| 170 | @Bindable var viewModel: DomainViewModel | ||
| 171 | let diff: DomainDiff | ||
| 172 | @Binding var focusedSectionID: String? | ||
| 173 | |||
| 174 | var body: some View { | ||
| 175 | ScrollViewReader { proxy in | ||
| 176 | ScrollView { | ||
| 177 | VStack(alignment: .leading, spacing: 12) { | ||
| 178 | HStack { | ||
| 179 | Button("Previous Change") { | ||
| 180 | viewModel.moveToPreviousDiffChange() | ||
| 181 | focusedSectionID = viewModel.currentDiffTargetSectionID | ||
| 182 | scroll(proxy: proxy) | ||
| 183 | } | ||
| 184 | .disabled(viewModel.activeDiffChangeIndex == 0) | ||
| 185 | |||
| 186 | Button("Next Change") { | ||
| 187 | viewModel.moveToNextDiffChange() | ||
| 188 | focusedSectionID = viewModel.currentDiffTargetSectionID | ||
| 189 | scroll(proxy: proxy) | ||
| 190 | } | ||
| 191 | .disabled(viewModel.activeDomainDiff?.changedSectionIDs.isEmpty != false || viewModel.currentDiffTargetSectionID == viewModel.activeDomainDiff?.changedSectionIDs.last) | ||
| 192 | |||
| 193 | Spacer() | ||
| 194 | } | ||
| 195 | |||
| 196 | DomainDiffView( | ||
| 197 | title: "Snapshot Diff", | ||
| 198 | sections: diff.sections, | ||
| 199 | contextNote: diff.contextNote, | ||
| 200 | showsUnchanged: false, | ||
| 201 | highlightedSectionID: focusedSectionID | ||
| 202 | ) | ||
| 203 | } | ||
| 204 | .padding() | ||
| 205 | } | ||
| 206 | .background(Color.black) | ||
| 207 | .navigationTitle("Compare Snapshots") | ||
| 208 | .navigationBarTitleDisplayMode(.inline) | ||
| 209 | .onAppear { | ||
| 210 | scroll(proxy: proxy) | ||
| 211 | } | ||
| 212 | .onChange(of: focusedSectionID) { _, _ in | ||
| 213 | scroll(proxy: proxy) | ||
| 214 | } | ||
| 215 | } | ||
| 216 | } | ||
| 217 | |||
| 218 | private func scroll(proxy: ScrollViewProxy) { | ||
| 219 | guard let focusedSectionID else { return } | ||
| 220 | withAnimation { | ||
| 221 | proxy.scrollTo(focusedSectionID, anchor: .top) | ||
| 222 | } | ||
| 223 | } | ||
| 224 | } | ||
DomainDig/WatchlistView.swift +2 −1
| @@ -483,7 +483,8 @@ struct TrackedDomainDetailView: View { | |||
| 483 | contextNote: latestSnapshots.count >= 2 | 483 | contextNote: latestSnapshots.count >= 2 |
| 484 | ? DomainDiffService.comparisonContextNote(from: latestSnapshots[1].snapshot, to: latestSnapshots[0].snapshot) | 484 | ? DomainDiffService.comparisonContextNote(from: latestSnapshots[1].snapshot, to: latestSnapshots[0].snapshot) |
| 485 | : nil, | 485 | : nil, |
| 486 | showsUnchanged: false | 486 | showsUnchanged: false, |
| 487 | highlightedSectionID: nil | ||
| 487 | ) | 488 | ) |
| 488 | } | 489 | } |
| 489 | .listRowBackground(Color.clear) | 490 | .listRowBackground(Color.clear) |
DomainDigCLI.swift +117
| @@ -17,6 +17,16 @@ struct DomainDigCLI { | |||
| 17 | return | 17 | return |
| 18 | } | 18 | } |
| 19 | 19 | ||
| 20 | if arguments.first == "history" { | ||
| 21 | runHistoryCommand(arguments: Array(arguments.dropFirst()), wantsJSON: wantsJSON) | ||
| 22 | return | ||
| 23 | } | ||
| 24 | |||
| 25 | if arguments.first == "diff" { | ||
| 26 | runDiffCommand(arguments: Array(arguments.dropFirst()), wantsJSON: wantsJSON) | ||
| 27 | return | ||
| 28 | } | ||
| 29 | |||
| 20 | if arguments.first == "monitor" { | 30 | if arguments.first == "monitor" { |
| 21 | await runMonitorCommand(wantsJSON: wantsJSON) | 31 | await runMonitorCommand(wantsJSON: wantsJSON) |
| 22 | return | 32 | return |
| @@ -216,6 +226,10 @@ struct DomainDigCLI { | |||
| 216 | isPartialSnapshot: snapshot.isPartialSnapshot, | 226 | isPartialSnapshot: snapshot.isPartialSnapshot, |
| 217 | validationIssues: snapshot.validationIssues, | 227 | validationIssues: snapshot.validationIssues, |
| 218 | totalLookupDurationMs: snapshot.totalLookupDurationMs, | 228 | totalLookupDurationMs: snapshot.totalLookupDurationMs, |
| 229 | snapshotIndex: snapshot.snapshotIndex, | ||
| 230 | previousSnapshotID: snapshot.previousSnapshotID, | ||
| 231 | changeCount: snapshot.changeCount, | ||
| 232 | severitySummary: snapshot.severitySummary, | ||
| 219 | dnsSections: snapshot.dnsSections, | 233 | dnsSections: snapshot.dnsSections, |
| 220 | dnsError: snapshot.dnsError, | 234 | dnsError: snapshot.dnsError, |
| 221 | availabilityResult: snapshot.availabilityResult, | 235 | availabilityResult: snapshot.availabilityResult, |
| @@ -265,6 +279,107 @@ struct DomainDigCLI { | |||
| 265 | DomainDataPortabilityService.loadHistoryEntries() | 279 | DomainDataPortabilityService.loadHistoryEntries() |
| 266 | } | 280 | } |
| 267 | 281 | ||
| 282 | private static func runHistoryCommand(arguments: [String], wantsJSON: Bool) { | ||
| 283 | guard let domain = arguments.first(where: { !$0.hasPrefix("-") }) else { | ||
| 284 | fputs("usage: domaindig history <domain> [--json]\n", stderr) | ||
| 285 | Foundation.exit(1) | ||
| 286 | } | ||
| 287 | |||
| 288 | let entries = loadHistoryEntries() | ||
| 289 | .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } | ||
| 290 | .sorted { $0.timestamp > $1.timestamp } | ||
| 291 | |||
| 292 | if wantsJSON { | ||
| 293 | let encoder = JSONEncoder() | ||
| 294 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] | ||
| 295 | encoder.dateEncodingStrategy = .iso8601 | ||
| 296 | let payload = entries.map { entry in | ||
| 297 | [ | ||
| 298 | "id": entry.id.uuidString, | ||
| 299 | "timestamp": ISO8601DateFormatter().string(from: entry.timestamp), | ||
| 300 | "changeSummary": entry.changeSummary?.message ?? "No change summary", | ||
| 301 | "changeCount": "\(entry.changeCount)", | ||
| 302 | "severity": entry.severitySummary?.title ?? "N/A" | ||
| 303 | ] | ||
| 304 | } | ||
| 305 | if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) { | ||
| 306 | FileHandle.standardOutput.write(data) | ||
| 307 | FileHandle.standardOutput.write(Data([0x0A])) | ||
| 308 | return | ||
| 309 | } | ||
| 310 | } | ||
| 311 | |||
| 312 | let lines = entries.map { entry in | ||
| 313 | [ | ||
| 314 | entry.id.uuidString, | ||
| 315 | entry.timestamp.formatted(date: .abbreviated, time: .shortened), | ||
| 316 | entry.changeSummary?.message ?? "No change summary" | ||
| 317 | ].joined(separator: " | ") | ||
| 318 | } | ||
| 319 | |||
| 320 | FileHandle.standardOutput.write(Data((lines.isEmpty ? "No history found.\n" : lines.joined(separator: "\n") + "\n").utf8)) | ||
| 321 | } | ||
| 322 | |||
| 323 | private static func runDiffCommand(arguments: [String], wantsJSON: Bool) { | ||
| 324 | guard let domain = arguments.first(where: { !$0.hasPrefix("-") }) else { | ||
| 325 | fputs("usage: domaindig diff <domain> --from <id> --to <id> [--json]\n", stderr) | ||
| 326 | Foundation.exit(1) | ||
| 327 | } | ||
| 328 | |||
| 329 | guard let fromID = optionValue(named: "--from", in: arguments), | ||
| 330 | let toID = optionValue(named: "--to", in: arguments), | ||
| 331 | let fromUUID = UUID(uuidString: fromID), | ||
| 332 | let toUUID = UUID(uuidString: toID) else { | ||
| 333 | fputs("domaindig diff: --from and --to must be valid snapshot IDs\n", stderr) | ||
| 334 | Foundation.exit(1) | ||
| 335 | } | ||
| 336 | |||
| 337 | let entries = loadHistoryEntries().filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } | ||
| 338 | guard let fromEntry = entries.first(where: { $0.id == fromUUID }), | ||
| 339 | let toEntry = entries.first(where: { $0.id == toUUID }) else { | ||
| 340 | fputs("domaindig diff: snapshots not found for domain\n", stderr) | ||
| 341 | Foundation.exit(1) | ||
| 342 | } | ||
| 343 | |||
| 344 | let orderedEntries = [fromEntry, toEntry].sorted { $0.timestamp < $1.timestamp } | ||
| 345 | let diff = DiffService.compare(from: orderedEntries[0].snapshot, to: orderedEntries[1].snapshot) | ||
| 346 | |||
| 347 | if wantsJSON { | ||
| 348 | let encoder = JSONEncoder() | ||
| 349 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] | ||
| 350 | encoder.dateEncodingStrategy = .iso8601 | ||
| 351 | if let data = try? encoder.encode(diff) { | ||
| 352 | FileHandle.standardOutput.write(data) | ||
| 353 | FileHandle.standardOutput.write(Data([0x0A])) | ||
| 354 | return | ||
| 355 | } | ||
| 356 | } | ||
| 357 | |||
| 358 | var lines = [ | ||
| 359 | "DomainDig Diff", | ||
| 360 | "Domain: \(domain)", | ||
| 361 | "From: \(orderedEntries[0].id.uuidString)", | ||
| 362 | "To: \(orderedEntries[1].id.uuidString)" | ||
| 363 | ] | ||
| 364 | |||
| 365 | for section in diff.sections where section.hasChanges { | ||
| 366 | lines.append("") | ||
| 367 | lines.append(section.title) | ||
| 368 | for item in section.items where item.hasChanges { | ||
| 369 | lines.append("\(item.changeType.marker) \(item.label): \(item.oldValue ?? "none") -> \(item.newValue ?? "none")") | ||
| 370 | } | ||
| 371 | } | ||
| 372 | |||
| 373 | FileHandle.standardOutput.write(Data((lines.joined(separator: "\n") + "\n").utf8)) | ||
| 374 | } | ||
| 375 | |||
| 376 | private static func optionValue(named name: String, in arguments: [String]) -> String? { | ||
| 377 | guard let index = arguments.firstIndex(of: name), arguments.indices.contains(index + 1) else { | ||
| 378 | return nil | ||
| 379 | } | ||
| 380 | return arguments[index + 1] | ||
| 381 | } | ||
| 382 | |||
| 268 | private static func runBackupCommand(arguments: [String], wantsJSON: Bool) { | 383 | private static func runBackupCommand(arguments: [String], wantsJSON: Bool) { |
| 269 | guard let subcommand = arguments.first else { | 384 | guard let subcommand = arguments.first else { |
| 270 | fputs(usageText, stderr) | 385 | fputs(usageText, stderr) |
| @@ -424,6 +539,8 @@ struct DomainDigCLI { | |||
| 424 | private static var usageText: String { | 539 | private static var usageText: String { |
| 425 | """ | 540 | """ |
| 426 | usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage] | 541 | usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage] |
| 542 | domaindig history <domain> [--json] | ||
| 543 | domaindig diff <domain> --from <id> --to <id> [--json] | ||
| 427 | domaindig monitor [--json] | 544 | domaindig monitor [--json] |
| 428 | domaindig backup export [path] | 545 | domaindig backup export [path] |
| 429 | domaindig backup import <path> [--replace] | 546 | domaindig backup import <path> [--replace] |
DomainInspectionService.swift +72 −31
| @@ -15,6 +15,7 @@ struct DomainInspectionService { | |||
| 15 | 15 | ||
| 16 | func inspectSnapshot(domain: String, previousSnapshot: LookupSnapshot? = nil) async -> LookupSnapshot { | 16 | func inspectSnapshot(domain: String, previousSnapshot: LookupSnapshot? = nil) async -> LookupSnapshot { |
| 17 | let normalizedDomain = normalize(domain) | 17 | let normalizedDomain = normalize(domain) |
| 18 | let inspectionStartedAt = DomainDebugLog.signpostStart("Inspection.inspectSnapshot", domain: normalizedDomain) | ||
| 18 | let startedAt = Date() | 19 | let startedAt = Date() |
| 19 | let resolverDisplayName = DNSLookupService.currentResolverDisplayName() | 20 | let resolverDisplayName = DNSLookupService.currentResolverDisplayName() |
| 20 | let resolverURLString = DNSLookupService.currentResolverURLString() | 21 | let resolverURLString = DNSLookupService.currentResolverURLString() |
| @@ -29,13 +30,12 @@ struct DomainInspectionService { | |||
| 29 | async let sslFetch = runtime.ssl(domain: normalizedDomain) | 30 | async let sslFetch = runtime.ssl(domain: normalizedDomain) |
| 30 | async let hstsFetch = runtime.hsts(domain: normalizedDomain) | 31 | async let hstsFetch = runtime.hsts(domain: normalizedDomain) |
| 31 | async let httpFetch = runtime.http(domain: normalizedDomain) | 32 | async let httpFetch = runtime.http(domain: normalizedDomain) |
| 32 | async let reachabilityFetch = runtime.reachability(domain: normalizedDomain) | ||
| 33 | async let ownershipFetch = runtime.ownership(domain: normalizedDomain) | 33 | async let ownershipFetch = runtime.ownership(domain: normalizedDomain) |
| 34 | async let redirectFetch = runtime.redirectChain(domain: normalizedDomain) | 34 | async let redirectFetch = runtime.redirectChain(domain: normalizedDomain) |
| 35 | async let subdomainFetch = runtime.subdomains(domain: normalizedDomain) | 35 | async let subdomainFetch = runtime.subdomains(domain: normalizedDomain) |
| 36 | async let portScanFetch = runtime.portScan(domain: normalizedDomain) | ||
| 37 | 36 | ||
| 38 | let resolvedDNS = await dnsFetch | 37 | let resolvedDNS = await dnsFetch |
| 38 | DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=dns source=\(resolvedDNS.source.rawValue)") | ||
| 39 | let dnsResult = normalizeErrors(in: resolvedDNS.value) | 39 | let dnsResult = normalizeErrors(in: resolvedDNS.value) |
| 40 | track( | 40 | track( |
| 41 | .dns, | 41 | .dns, |
| @@ -49,6 +49,7 @@ struct DomainInspectionService { | |||
| 49 | captureFailure(for: .dns, result: dnsResult, into: &errorDetails) | 49 | captureFailure(for: .dns, result: dnsResult, into: &errorDetails) |
| 50 | 50 | ||
| 51 | let availability = await availabilityFetch | 51 | let availability = await availabilityFetch |
| 52 | DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=availability source=\(availability.source.rawValue)") | ||
| 52 | track( | 53 | track( |
| 53 | .availability, | 54 | .availability, |
| 54 | source: availability.source, | 55 | source: availability.source, |
| @@ -60,6 +61,7 @@ struct DomainInspectionService { | |||
| 60 | ) | 61 | ) |
| 61 | 62 | ||
| 62 | let resolvedSSL = await sslFetch | 63 | let resolvedSSL = await sslFetch |
| 64 | DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=ssl source=\(resolvedSSL.source.rawValue)") | ||
| 63 | let sslResult = normalizeErrors(in: resolvedSSL.value) | 65 | let sslResult = normalizeErrors(in: resolvedSSL.value) |
| 64 | track( | 66 | track( |
| 65 | .ssl, | 67 | .ssl, |
| @@ -73,6 +75,7 @@ struct DomainInspectionService { | |||
| 73 | captureFailure(for: .ssl, result: sslResult, into: &errorDetails) | 75 | captureFailure(for: .ssl, result: sslResult, into: &errorDetails) |
| 74 | 76 | ||
| 75 | let hsts = await hstsFetch | 77 | let hsts = await hstsFetch |
| 78 | DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=hsts source=\(hsts.source.rawValue)") | ||
| 76 | track( | 79 | track( |
| 77 | .hsts, | 80 | .hsts, |
| 78 | source: hsts.source, | 81 | source: hsts.source, |
| @@ -84,6 +87,7 @@ struct DomainInspectionService { | |||
| 84 | ) | 87 | ) |
| 85 | 88 | ||
| 86 | let http = await httpFetch | 89 | let http = await httpFetch |
| 90 | DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=http source=\(http.source.rawValue)") | ||
| 87 | let httpResult = normalizeErrors(in: http.value) | 91 | let httpResult = normalizeErrors(in: http.value) |
| 88 | track( | 92 | track( |
| 89 | .httpHeaders, | 93 | .httpHeaders, |
| @@ -96,20 +100,8 @@ struct DomainInspectionService { | |||
| 96 | ) | 100 | ) |
| 97 | captureFailure(for: .httpHeaders, result: httpResult, into: &errorDetails) | 101 | captureFailure(for: .httpHeaders, result: httpResult, into: &errorDetails) |
| 98 | 102 | ||
| 99 | let reachability = await reachabilityFetch | ||
| 100 | let reachabilityResult = normalizeErrors(in: reachability.value) | ||
| 101 | track( | ||
| 102 | .reachability, | ||
| 103 | source: reachability.source, | ||
| 104 | provenance: provenance(for: .reachability, source: reachability.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), | ||
| 105 | cachedSections: &cachedSections, | ||
| 106 | sectionSources: §ionSources, | ||
| 107 | provenanceBySection: &provenanceBySection, | ||
| 108 | dataSources: &dataSources | ||
| 109 | ) | ||
| 110 | captureFailure(for: .reachability, result: reachabilityResult, into: &errorDetails) | ||
| 111 | |||
| 112 | let resolvedOwnership = await ownershipFetch | 103 | let resolvedOwnership = await ownershipFetch |
| 104 | DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=ownership source=\(resolvedOwnership.source.rawValue)") | ||
| 113 | let ownershipResult = normalizeErrors(in: resolvedOwnership.value) | 105 | let ownershipResult = normalizeErrors(in: resolvedOwnership.value) |
| 114 | track( | 106 | track( |
| 115 | .ownership, | 107 | .ownership, |
| @@ -123,6 +115,7 @@ struct DomainInspectionService { | |||
| 123 | captureFailure(for: .ownership, result: ownershipResult, into: &errorDetails) | 115 | captureFailure(for: .ownership, result: ownershipResult, into: &errorDetails) |
| 124 | 116 | ||
| 125 | let redirects = await redirectFetch | 117 | let redirects = await redirectFetch |
| 118 | DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=redirect source=\(redirects.source.rawValue)") | ||
| 126 | let redirectResult = normalizeErrors(in: redirects.value) | 119 | let redirectResult = normalizeErrors(in: redirects.value) |
| 127 | track( | 120 | track( |
| 128 | .redirectChain, | 121 | .redirectChain, |
| @@ -136,6 +129,7 @@ struct DomainInspectionService { | |||
| 136 | captureFailure(for: .redirectChain, result: redirectResult, into: &errorDetails) | 129 | captureFailure(for: .redirectChain, result: redirectResult, into: &errorDetails) |
| 137 | 130 | ||
| 138 | let resolvedSubdomains = await subdomainFetch | 131 | let resolvedSubdomains = await subdomainFetch |
| 132 | DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=subdomains source=\(resolvedSubdomains.source.rawValue)") | ||
| 139 | let subdomainResult = normalizeErrors(in: resolvedSubdomains.value) | 133 | let subdomainResult = normalizeErrors(in: resolvedSubdomains.value) |
| 140 | track( | 134 | track( |
| 141 | .subdomains, | 135 | .subdomains, |
| @@ -148,33 +142,68 @@ struct DomainInspectionService { | |||
| 148 | ) | 142 | ) |
| 149 | captureFailure(for: .subdomains, result: subdomainResult, into: &errorDetails) | 143 | captureFailure(for: .subdomains, result: subdomainResult, into: &errorDetails) |
| 150 | 144 | ||
| 151 | let ports = await portScanFetch | ||
| 152 | let portScanResult = normalizeErrors(in: ports.value) | ||
| 153 | track( | ||
| 154 | .portScan, | ||
| 155 | source: ports.source, | ||
| 156 | provenance: provenance(for: .portScan, source: ports.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), | ||
| 157 | cachedSections: &cachedSections, | ||
| 158 | sectionSources: §ionSources, | ||
| 159 | provenanceBySection: &provenanceBySection, | ||
| 160 | dataSources: &dataSources | ||
| 161 | ) | ||
| 162 | captureFailure(for: .portScan, result: portScanResult, into: &errorDetails) | ||
| 163 | |||
| 164 | let dnsSections = mapServiceResult(dnsResult, emptyValue: []) | 145 | let dnsSections = mapServiceResult(dnsResult, emptyValue: []) |
| 165 | let sslInfo = mapOptionalValueServiceResult(sslResult) | 146 | let sslInfo = mapOptionalValueServiceResult(sslResult) |
| 166 | let httpHeadersResult = mapHTTPResult(httpResult) | 147 | let httpHeadersResult = mapHTTPResult(httpResult) |
| 167 | let reachabilityResultValue = mapServiceResult(reachabilityResult, emptyValue: []) | ||
| 168 | let redirectChain = mapServiceResult(redirectResult, emptyValue: []) | 148 | let redirectChain = mapServiceResult(redirectResult, emptyValue: []) |
| 169 | let ownership = mapOptionalValueServiceResult(ownershipResult) | 149 | let ownership = mapOptionalValueServiceResult(ownershipResult) |
| 170 | let subdomains = mapServiceResult(subdomainResult, emptyValue: []) | 150 | let subdomains = mapServiceResult(subdomainResult, emptyValue: []) |
| 171 | let portScanResults = await mapPortScanResult(portScanResult, domain: normalizedDomain) | ||
| 172 | 151 | ||
| 173 | let txtRecords = dnsSections.value.first(where: { $0.recordType == .TXT })?.records ?? [] | 152 | let txtRecords = dnsSections.value.first(where: { $0.recordType == .TXT })?.records ?? [] |
| 174 | let primaryIP = dnsSections.value.first(where: { $0.recordType == .A })?.records.first?.value | 153 | let primaryIP = dnsSections.value.first(where: { $0.recordType == .A })?.records.first?.value |
| 154 | let hasNetworkTarget = dnsSections.value.contains { section in | ||
| 155 | (section.recordType == .A || section.recordType == .AAAA) | ||
| 156 | && (!section.records.isEmpty || !section.wildcardRecords.isEmpty) | ||
| 157 | } | ||
| 175 | let canReuseDNSDependents = canReuseDependentSections(from: previousSnapshot, dnsSections: dnsSections.value) | 158 | let canReuseDNSDependents = canReuseDependentSections(from: previousSnapshot, dnsSections: dnsSections.value) |
| 176 | let canReuseIPDependents = canReuseIPBasedSections(from: previousSnapshot, primaryIP: primaryIP) | 159 | let canReuseIPDependents = canReuseIPBasedSections(from: previousSnapshot, primaryIP: primaryIP) |
| 177 | 160 | ||
| 161 | let reachabilityOutcome: CachedLookupResult<ServiceResult<[PortReachability]>> | ||
| 162 | if hasNetworkTarget { | ||
| 163 | reachabilityOutcome = await runtime.reachability(domain: normalizedDomain) | ||
| 164 | } else { | ||
| 165 | reachabilityOutcome = CachedLookupResult(value: .empty("No routable address available"), source: .live) | ||
| 166 | } | ||
| 167 | let reachabilityResult = normalizeErrors(in: reachabilityOutcome.value) | ||
| 168 | DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=reachability hasNetworkTarget=\(hasNetworkTarget) source=\(reachabilityOutcome.source.rawValue)") | ||
| 169 | track( | ||
| 170 | .reachability, | ||
| 171 | source: reachabilityOutcome.source, | ||
| 172 | provenance: provenance(for: .reachability, source: reachabilityOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), | ||
| 173 | cachedSections: &cachedSections, | ||
| 174 | sectionSources: §ionSources, | ||
| 175 | provenanceBySection: &provenanceBySection, | ||
| 176 | dataSources: &dataSources | ||
| 177 | ) | ||
| 178 | if hasNetworkTarget { | ||
| 179 | captureFailure(for: .reachability, result: reachabilityResult, into: &errorDetails) | ||
| 180 | } | ||
| 181 | let reachabilityResultValue = mapServiceResult(reachabilityResult, emptyValue: []) | ||
| 182 | |||
| 183 | let portScanOutcome: CachedLookupResult<ServiceResult<[PortScanResult]>> | ||
| 184 | if hasNetworkTarget { | ||
| 185 | portScanOutcome = await runtime.portScan(domain: normalizedDomain) | ||
| 186 | } else { | ||
| 187 | portScanOutcome = CachedLookupResult(value: .empty("No routable address available"), source: .live) | ||
| 188 | } | ||
| 189 | let portScanResult = normalizeErrors(in: portScanOutcome.value) | ||
| 190 | DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=portScan hasNetworkTarget=\(hasNetworkTarget) source=\(portScanOutcome.source.rawValue)") | ||
| 191 | track( | ||
| 192 | .portScan, | ||
| 193 | source: portScanOutcome.source, | ||
| 194 | provenance: provenance(for: .portScan, source: portScanOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), | ||
| 195 | cachedSections: &cachedSections, | ||
| 196 | sectionSources: §ionSources, | ||
| 197 | provenanceBySection: &provenanceBySection, | ||
| 198 | dataSources: &dataSources | ||
| 199 | ) | ||
| 200 | if hasNetworkTarget { | ||
| 201 | captureFailure(for: .portScan, result: portScanResult, into: &errorDetails) | ||
| 202 | } | ||
| 203 | let portScanResults = hasNetworkTarget | ||
| 204 | ? await mapPortScanResult(portScanResult, domain: normalizedDomain) | ||
| 205 | : (value: [], message: nil) | ||
| 206 | |||
| 178 | let emailOutcome: CachedLookupResult<ServiceResult<EmailSecurityResult>> | 207 | let emailOutcome: CachedLookupResult<ServiceResult<EmailSecurityResult>> |
| 179 | if canReuseDNSDependents, let previousSnapshot, let emailSecurity = previousSnapshot.emailSecurity { | 208 | if canReuseDNSDependents, let previousSnapshot, let emailSecurity = previousSnapshot.emailSecurity { |
| 180 | emailOutcome = CachedLookupResult(value: .success(emailSecurity), source: .cached) | 209 | emailOutcome = CachedLookupResult(value: .success(emailSecurity), source: .cached) |
| @@ -184,6 +213,7 @@ struct DomainInspectionService { | |||
| 184 | emailOutcome = await runtime.email(domain: normalizedDomain, txtRecords: txtRecords) | 213 | emailOutcome = await runtime.email(domain: normalizedDomain, txtRecords: txtRecords) |
| 185 | } | 214 | } |
| 186 | let normalizedEmailResult = normalizeErrors(in: emailOutcome.value) | 215 | let normalizedEmailResult = normalizeErrors(in: emailOutcome.value) |
| 216 | DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=email source=\(emailOutcome.source.rawValue)") | ||
| 187 | track( | 217 | track( |
| 188 | .emailSecurity, | 218 | .emailSecurity, |
| 189 | source: emailOutcome.source, | 219 | source: emailOutcome.source, |
| @@ -271,7 +301,7 @@ struct DomainInspectionService { | |||
| 271 | let geolocationConfidence = confidenceForGeolocation(result: geolocation.value, error: geolocation.message) | 301 | let geolocationConfidence = confidenceForGeolocation(result: geolocation.value, error: geolocation.message) |
| 272 | let validationIssues = validationIssues(for: normalizedDomain, snapshotTimestamp: startedAt, availability: availability.value, dnsSections: dnsSections.value, provenanceBySection: provenanceBySection) | 302 | let validationIssues = validationIssues(for: normalizedDomain, snapshotTimestamp: startedAt, availability: availability.value, dnsSections: dnsSections.value, provenanceBySection: provenanceBySection) |
| 273 | 303 | ||
| 274 | return LookupSnapshot( | 304 | let snapshot = LookupSnapshot( |
| 275 | historyEntryID: nil, | 305 | historyEntryID: nil, |
| 276 | domain: availability.value.domain, | 306 | domain: availability.value.domain, |
| 277 | timestamp: Date(), | 307 | timestamp: Date(), |
| @@ -291,6 +321,10 @@ struct DomainInspectionService { | |||
| 291 | isPartialSnapshot: !validationIssues.isEmpty, | 321 | isPartialSnapshot: !validationIssues.isEmpty, |
| 292 | validationIssues: validationIssues, | 322 | validationIssues: validationIssues, |
| 293 | totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000), | 323 | totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000), |
| 324 | snapshotIndex: nil, | ||
| 325 | previousSnapshotID: previousSnapshot?.historyEntryID, | ||
| 326 | changeCount: 0, | ||
| 327 | severitySummary: nil, | ||
| 294 | dnsSections: dnsSections.value, | 328 | dnsSections: dnsSections.value, |
| 295 | dnsError: dnsSections.message, | 329 | dnsError: dnsSections.message, |
| 296 | availabilityResult: availability.value, | 330 | availabilityResult: availability.value, |
| @@ -334,6 +368,13 @@ struct DomainInspectionService { | |||
| 334 | cachedSections: Array(cachedSections).sorted { $0.rawValue < $1.rawValue }, | 368 | cachedSections: Array(cachedSections).sorted { $0.rawValue < $1.rawValue }, |
| 335 | statusMessage: nil | 369 | statusMessage: nil |
| 336 | ) | 370 | ) |
| 371 | DomainDebugLog.signpostEnd( | ||
| 372 | "Inspection.inspectSnapshot", | ||
| 373 | start: inspectionStartedAt, | ||
| 374 | domain: normalizedDomain, | ||
| 375 | extra: "resultSource=\(snapshot.resultSource.rawValue) cachedSections=\(snapshot.cachedSections.count) partial=\(snapshot.isPartialSnapshot)" | ||
| 376 | ) | ||
| 377 | return snapshot | ||
| 337 | } | 378 | } |
| 338 | 379 | ||
| 339 | private func normalize(_ domain: String) -> String { | 380 | private func normalize(_ domain: String) -> String { |
DomainReportBuilder.swift +98 −21
| @@ -62,6 +62,10 @@ struct DomainReportMetadata: Codable { | |||
| 62 | let isPartialSnapshot: Bool | 62 | let isPartialSnapshot: Bool |
| 63 | let errorDetails: [LookupSectionKind: InspectionFailure] | 63 | let errorDetails: [LookupSectionKind: InspectionFailure] |
| 64 | let statusMessage: String? | 64 | let statusMessage: String? |
| 65 | let snapshotIndex: Int? | ||
| 66 | let previousSnapshotID: UUID? | ||
| 67 | let changeCount: Int | ||
| 68 | let severitySummary: ChangeSeverity? | ||
| 65 | } | 69 | } |
| 66 | 70 | ||
| 67 | struct DNSResultSummary: Codable { | 71 | struct DNSResultSummary: Codable { |
| @@ -123,26 +127,103 @@ struct DomainReportBuilder { | |||
| 123 | func build( | 127 | func build( |
| 124 | from snapshot: LookupSnapshot, | 128 | from snapshot: LookupSnapshot, |
| 125 | previousSnapshot: LookupSnapshot? = nil, | 129 | previousSnapshot: LookupSnapshot? = nil, |
| 126 | workflowContext: DomainWorkflowContext? = nil | 130 | workflowContext: DomainWorkflowContext? = nil, |
| 131 | deriveChangeSummary: Bool = true | ||
| 127 | ) -> DomainReport { | 132 | ) -> DomainReport { |
| 133 | let buildStartedAt = DomainDebugLog.signpostStart("DomainReportBuilder.build", domain: snapshot.domain) | ||
| 128 | let primaryIP = primaryIPAddress(from: snapshot) | 134 | let primaryIP = primaryIPAddress(from: snapshot) |
| 129 | let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot) | 135 | let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot) |
| 130 | let changeSummary: DomainChangeSummary? | 136 | let changeSummary: DomainChangeSummary? |
| 131 | if let existingChangeSummary = snapshot.changeSummary, existingChangeSummary.riskAssessment != nil { | 137 | if let existingChangeSummary = snapshot.changeSummary, existingChangeSummary.riskAssessment != nil { |
| 132 | changeSummary = existingChangeSummary | 138 | changeSummary = existingChangeSummary |
| 133 | } else { | 139 | } else if deriveChangeSummary, let previousSnapshot { |
| 134 | changeSummary = previousSnapshot.map { | 140 | let previousReport = build( |
| 135 | DomainDiffService.summary( | 141 | from: previousSnapshot, |
| 136 | from: $0, | 142 | workflowContext: workflowContext, |
| 137 | to: snapshot, | 143 | deriveChangeSummary: false |
| 138 | generatedAt: snapshot.timestamp, | 144 | ) |
| 139 | riskAssessment: analysis.riskAssessment, | 145 | let currentReport = buildBaseReport( |
| 140 | insights: analysis.insights | 146 | from: snapshot, |
| 141 | ) | 147 | previousSnapshot: previousSnapshot, |
| 148 | workflowContext: workflowContext, | ||
| 149 | analysis: analysis, | ||
| 150 | primaryIP: primaryIP, | ||
| 151 | changeSummary: nil as DomainChangeSummary? | ||
| 152 | ) | ||
| 153 | let diff = DiffService.compare(from: previousReport, to: currentReport) | ||
| 154 | let changedItems = diff.sections.flatMap { $0.items }.filter { $0.hasChanges } | ||
| 155 | let highlights = diff.changedSectionTitles | ||
| 156 | let severity = changedItems.map { $0.severity }.max() ?? .low | ||
| 157 | let message = DiffService.summaryMessage(from: highlights, changeCount: changedItems.count) | ||
| 158 | let observedFacts = changedItems.prefix(4).map { item in | ||
| 159 | "\(item.label): \(item.oldValue ?? "none") -> \(item.newValue ?? "none")" | ||
| 142 | } | 160 | } |
| 161 | let previousRiskScore = previousReport.riskAssessment.score | ||
| 162 | let riskScoreDelta = analysis.riskAssessment.score - previousRiskScore | ||
| 163 | let impactClassification = DomainInsightEngine.impactClassification( | ||
| 164 | severity: severity, | ||
| 165 | riskDelta: riskScoreDelta, | ||
| 166 | changedSections: highlights | ||
| 167 | ) | ||
| 168 | |||
| 169 | changeSummary = DomainChangeSummary( | ||
| 170 | hasChanges: !changedItems.isEmpty, | ||
| 171 | changedSections: highlights, | ||
| 172 | message: message, | ||
| 173 | severity: severity, | ||
| 174 | impactClassification: impactClassification, | ||
| 175 | generatedAt: snapshot.timestamp, | ||
| 176 | observedFacts: observedFacts, | ||
| 177 | inferredConclusions: highlights.isEmpty ? [] : [message], | ||
| 178 | contextNote: diff.contextNote, | ||
| 179 | riskAssessment: analysis.riskAssessment, | ||
| 180 | insights: analysis.insights, | ||
| 181 | riskScoreDelta: riskScoreDelta | ||
| 182 | ) | ||
| 183 | } else { | ||
| 184 | changeSummary = nil | ||
| 143 | } | 185 | } |
| 144 | 186 | ||
| 145 | return DomainReport( | 187 | let report = buildBaseReport( |
| 188 | from: snapshot, | ||
| 189 | previousSnapshot: previousSnapshot, | ||
| 190 | workflowContext: workflowContext, | ||
| 191 | analysis: analysis, | ||
| 192 | primaryIP: primaryIP, | ||
| 193 | changeSummary: changeSummary | ||
| 194 | ) | ||
| 195 | DomainDebugLog.signpostEnd( | ||
| 196 | "DomainReportBuilder.build", | ||
| 197 | start: buildStartedAt, | ||
| 198 | domain: snapshot.domain, | ||
| 199 | extra: "risk=\(report.riskAssessment.score) insights=\(report.insights.count)" | ||
| 200 | ) | ||
| 201 | return report | ||
| 202 | } | ||
| 203 | |||
| 204 | func build( | ||
| 205 | from entry: HistoryEntry, | ||
| 206 | previousSnapshot: LookupSnapshot? = nil, | ||
| 207 | workflowContext: DomainWorkflowContext? = nil, | ||
| 208 | deriveChangeSummary: Bool = true | ||
| 209 | ) -> DomainReport { | ||
| 210 | build( | ||
| 211 | from: entry.snapshot, | ||
| 212 | previousSnapshot: previousSnapshot, | ||
| 213 | workflowContext: workflowContext, | ||
| 214 | deriveChangeSummary: deriveChangeSummary | ||
| 215 | ) | ||
| 216 | } | ||
| 217 | |||
| 218 | private func buildBaseReport( | ||
| 219 | from snapshot: LookupSnapshot, | ||
| 220 | previousSnapshot: LookupSnapshot?, | ||
| 221 | workflowContext: DomainWorkflowContext?, | ||
| 222 | analysis: DomainAnalysisBundle, | ||
| 223 | primaryIP: String?, | ||
| 224 | changeSummary: DomainChangeSummary? | ||
| 225 | ) -> DomainReport { | ||
| 226 | DomainReport( | ||
| 146 | domain: snapshot.domain, | 227 | domain: snapshot.domain, |
| 147 | timestamp: snapshot.timestamp, | 228 | timestamp: snapshot.timestamp, |
| 148 | provenance: DomainReportProvenance( | 229 | provenance: DomainReportProvenance( |
| @@ -230,7 +311,7 @@ struct DomainReportBuilder { | |||
| 230 | changeSummary: changeSummary, | 311 | changeSummary: changeSummary, |
| 231 | workflowContext: workflowContext, | 312 | workflowContext: workflowContext, |
| 232 | metadata: DomainReportMetadata( | 313 | metadata: DomainReportMetadata( |
| 233 | schemaVersion: "3.2.0", | 314 | schemaVersion: "3.7.0", |
| 234 | resolverDisplayName: snapshot.resolverDisplayName, | 315 | resolverDisplayName: snapshot.resolverDisplayName, |
| 235 | resolverURLString: snapshot.resolverURLString, | 316 | resolverURLString: snapshot.resolverURLString, |
| 236 | appVersion: snapshot.appVersion, | 317 | appVersion: snapshot.appVersion, |
| @@ -239,19 +320,15 @@ struct DomainReportBuilder { | |||
| 239 | validationIssues: snapshot.validationIssues, | 320 | validationIssues: snapshot.validationIssues, |
| 240 | isPartialSnapshot: snapshot.isPartialSnapshot, | 321 | isPartialSnapshot: snapshot.isPartialSnapshot, |
| 241 | errorDetails: snapshot.errorDetails, | 322 | errorDetails: snapshot.errorDetails, |
| 242 | statusMessage: snapshot.statusMessage | 323 | statusMessage: snapshot.statusMessage, |
| 324 | snapshotIndex: snapshot.snapshotIndex, | ||
| 325 | previousSnapshotID: snapshot.previousSnapshotID ?? previousSnapshot?.historyEntryID, | ||
| 326 | changeCount: snapshot.changeCount == 0 ? (changeSummary?.changedSections.count ?? 0) : snapshot.changeCount, | ||
| 327 | severitySummary: snapshot.severitySummary ?? changeSummary?.severity | ||
| 243 | ) | 328 | ) |
| 244 | ) | 329 | ) |
| 245 | } | 330 | } |
| 246 | 331 | ||
| 247 | func build( | ||
| 248 | from entry: HistoryEntry, | ||
| 249 | previousSnapshot: LookupSnapshot? = nil, | ||
| 250 | workflowContext: DomainWorkflowContext? = nil | ||
| 251 | ) -> DomainReport { | ||
| 252 | build(from: entry.snapshot, previousSnapshot: previousSnapshot, workflowContext: workflowContext) | ||
| 253 | } | ||
| 254 | |||
| 255 | private func primaryIPAddress(from snapshot: LookupSnapshot) -> String? { | 332 | private func primaryIPAddress(from snapshot: LookupSnapshot) -> String? { |
| 256 | snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value | 333 | snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value |
| 257 | } | 334 | } |
DomainReportExporter.swift +56
| @@ -504,6 +504,62 @@ enum DomainReportExporter { | |||
| 504 | .joined(separator: "\n") | 504 | .joined(separator: "\n") |
| 505 | } | 505 | } |
| 506 | 506 | ||
| 507 | static func timelineText(for reports: [DomainReport], domain: String, includeDiffSummary: Bool) -> String { | ||
| 508 | guard !reports.isEmpty else { | ||
| 509 | return "Timeline Export\nNo snapshots available for \(domain)." | ||
| 510 | } | ||
| 511 | |||
| 512 | var lines = [ | ||
| 513 | "DomainDig Timeline Export", | ||
| 514 | "Domain: \(domain)", | ||
| 515 | "Snapshots: \(reports.count)" | ||
| 516 | ] | ||
| 517 | |||
| 518 | for report in reports.sorted(by: { $0.timestamp > $1.timestamp }) { | ||
| 519 | lines.append("") | ||
| 520 | lines.append("\(textDateFormatter.string(from: report.timestamp))") | ||
| 521 | lines.append("Summary: \(report.changeSummary?.message ?? "No change summary")") | ||
| 522 | lines.append("Severity: \(report.changeSummary?.severity.title ?? "N/A")") | ||
| 523 | if includeDiffSummary, let changeSummary = report.changeSummary { | ||
| 524 | lines.append("Changed Sections: \(changeSummary.changedSections.joined(separator: ", ").nilIfEmpty ?? "None")") | ||
| 525 | } | ||
| 526 | } | ||
| 527 | |||
| 528 | return lines.joined(separator: "\n") | ||
| 529 | } | ||
| 530 | |||
| 531 | static func timelineData(for reports: [DomainReport], domain: String, includeDiffSummary: Bool) throws -> Data { | ||
| 532 | struct TimelineExportEntry: Codable { | ||
| 533 | let timestamp: Date | ||
| 534 | let summary: String? | ||
| 535 | let severity: String? | ||
| 536 | let changedSections: [String]? | ||
| 537 | } | ||
| 538 | |||
| 539 | struct TimelineExportPayload: Codable { | ||
| 540 | let domain: String | ||
| 541 | let exportedAt: Date | ||
| 542 | let snapshots: [TimelineExportEntry] | ||
| 543 | } | ||
| 544 | |||
| 545 | let payload = TimelineExportPayload( | ||
| 546 | domain: domain, | ||
| 547 | exportedAt: Date(), | ||
| 548 | snapshots: reports | ||
| 549 | .sorted(by: { $0.timestamp > $1.timestamp }) | ||
| 550 | .map { report in | ||
| 551 | TimelineExportEntry( | ||
| 552 | timestamp: report.timestamp, | ||
| 553 | summary: report.changeSummary?.message, | ||
| 554 | severity: report.changeSummary?.severity.title, | ||
| 555 | changedSections: includeDiffSummary ? report.changeSummary?.changedSections : nil | ||
| 556 | ) | ||
| 557 | } | ||
| 558 | ) | ||
| 559 | |||
| 560 | return try jsonEncoder.encode(payload) | ||
| 561 | } | ||
| 562 | |||
| 507 | private static func appendSection(_ title: String, to lines: inout [String], body: () -> [String]) { | 563 | private static func appendSection(_ title: String, to lines: inout [String], body: () -> [String]) { |
| 508 | lines.append("") | 564 | lines.append("") |
| 509 | lines.append(title) | 565 | lines.append(title) |
LookupSnapshot.swift +8
| @@ -20,6 +20,10 @@ struct LookupSnapshot { | |||
| 20 | let isPartialSnapshot: Bool | 20 | let isPartialSnapshot: Bool |
| 21 | let validationIssues: [String] | 21 | let validationIssues: [String] |
| 22 | let totalLookupDurationMs: Int? | 22 | let totalLookupDurationMs: Int? |
| 23 | let snapshotIndex: Int? | ||
| 24 | let previousSnapshotID: UUID? | ||
| 25 | let changeCount: Int | ||
| 26 | let severitySummary: ChangeSeverity? | ||
| 23 | let dnsSections: [DNSSection] | 27 | let dnsSections: [DNSSection] |
| 24 | let dnsError: String? | 28 | let dnsError: String? |
| 25 | let availabilityResult: DomainAvailabilityResult? | 29 | let availabilityResult: DomainAvailabilityResult? |
| @@ -90,6 +94,10 @@ extension HistoryEntry { | |||
| 90 | isPartialSnapshot: isPartialSnapshot, | 94 | isPartialSnapshot: isPartialSnapshot, |
| 91 | validationIssues: validationIssues, | 95 | validationIssues: validationIssues, |
| 92 | totalLookupDurationMs: totalLookupDurationMs, | 96 | totalLookupDurationMs: totalLookupDurationMs, |
| 97 | snapshotIndex: snapshotIndex, | ||
| 98 | previousSnapshotID: previousSnapshotID, | ||
| 99 | changeCount: changeCount, | ||
| 100 | severitySummary: severitySummary, | ||
| 93 | dnsSections: dnsSections, | 101 | dnsSections: dnsSections, |
| 94 | dnsError: nil, | 102 | dnsError: nil, |
| 95 | availabilityResult: availabilityResult, | 103 | availabilityResult: availabilityResult, |