Commit fd34553ab0

fd34553ab0e63b4d3bc22532b61f4fef32846f34

parent: 3f0b7746b1

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-25 05:03 UTC

DomainDig v3.9.0 — Portfolio Dashboard

* Portfolio summary cards
* Recent activity feed across tracked domains
* Attention-required queue prioritized by severity
* Certificate expiry visibility and expiring-soon section
* Quick portfolio filters for triage
* Deterministic per-domain health scoring
* Local portfolio search
* Apex-domain grouping for tracked assets

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 = 30; 369 CURRENT_PROJECT_VERSION = 31;
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.8.0; 386 MARKETING_VERSION = 3.9.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 = 30; 406 CURRENT_PROJECT_VERSION = 31;
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.8.0; 423 MARKETING_VERSION = 3.9.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>1</integer> 10 <integer>0</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>0</integer> 15 <integer>1</integer>
16 </dict> 16 </dict>
17 </dict> 17 </dict>
18 <key>SuppressBuildableAutocreation</key> 18 <key>SuppressBuildableAutocreation</key>
DomainDig/ContentView.swift +9 −1
@@ -2518,6 +2518,10 @@ struct SettingsView: View {
2518 } 2518 }
2519 2519
2520 Section("Preferences") { 2520 Section("Preferences") {
2521 NavigationLink("Tracked Domains") {
2522 WatchlistView(viewModel: viewModel)
2523 }
2524
2521 NavigationLink("Workflows") { 2525 NavigationLink("Workflows") {
2522 WorkflowsView(viewModel: viewModel) 2526 WorkflowsView(viewModel: viewModel)
2523 } 2527 }
@@ -2532,6 +2536,10 @@ struct SettingsView: View {
2532 } 2536 }
2533 2537
2534 Section("Services") { 2538 Section("Services") {
2539 NavigationLink("Monitoring Activity") {
2540 MonitoringView(viewModel: viewModel)
2541 }
2542
2535 NavigationLink("iCloud Sync") { 2543 NavigationLink("iCloud Sync") {
2536 CloudSyncSettingsView() 2544 CloudSyncSettingsView()
2537 } 2545 }
@@ -2557,7 +2565,7 @@ struct SettingsView: View {
2557 } 2565 }
2558 } 2566 }
2559 } 2567 }
2560 .navigationTitle("More") 2568 .navigationTitle("Settings")
2561 } 2569 }
2562} 2570}
2563 2571
DomainDig/DashboardView.swift added +383
@@ -0,0 +1,383 @@
1import SwiftUI
2
3struct DashboardView: View {
4 @Environment(\.appDensity) private var appDensity
5 @Bindable var viewModel: DomainViewModel
6 @State private var collapsedGroups = Set<String>()
7
8 private let summaryColumns = [
9 GridItem(.flexible(), spacing: 10),
10 GridItem(.flexible(), spacing: 10)
11 ]
12
13 var body: some View {
14 List {
15 if viewModel.trackedDomains.isEmpty {
16 Section {
17 EmptyStateCardView(
18 title: "No Portfolio Yet",
19 message: "Track domains to get portfolio health, recent changes, expiry visibility, and an attention queue in one place.",
20 suggestion: "Inspect a domain and use the Track action, or add one directly from Tracked Domains in Settings.",
21 systemImage: "square.stack.3d.up.fill",
22 showsCardBackground: false
23 )
24 }
25 .listRowBackground(Color(.systemGray6).opacity(0.5))
26 } else {
27 Section {
28 LazyVGrid(columns: summaryColumns, spacing: 10) {
29 summaryCard(title: "Total Domains", value: viewModel.portfolioDashboardData.snapshot.totalDomains, filter: .all, tint: .cyan)
30 summaryCard(title: "Healthy", value: viewModel.portfolioDashboardData.snapshot.healthyCount, filter: .healthy, tint: .green)
31 summaryCard(title: "Warning", value: viewModel.portfolioDashboardData.snapshot.warningCount, filter: .warning, tint: .yellow)
32 summaryCard(title: "Critical", value: viewModel.portfolioDashboardData.snapshot.criticalCount, filter: .critical, tint: .red)
33 summaryCard(title: "Changes (24h)", value: viewModel.portfolioDashboardData.snapshot.changedLast24h, filter: .changed, tint: .orange)
34 summaryCard(title: "Unreachable", value: viewModel.portfolioDashboardData.snapshot.unreachableCount, filter: .unreachable, tint: .pink)
35 }
36 .padding(.vertical, 4)
37 }
38 .listRowBackground(Color(.systemGray6).opacity(0.5))
39
40 Section("Quick Filters") {
41 ScrollView(.horizontal, showsIndicators: false) {
42 HStack(spacing: 8) {
43 ForEach(PortfolioFilterOption.allCases) { filter in
44 Button {
45 viewModel.dashboardFilter = filter
46 } label: {
47 Text(filter.title)
48 .font(appDensity.font(.caption, weight: .semibold))
49 .foregroundStyle(viewModel.dashboardFilter == filter ? Color.black : Color.white)
50 .padding(.horizontal, 12)
51 .padding(.vertical, 8)
52 .background(viewModel.dashboardFilter == filter ? Color.cyan : Color(.systemGray5).opacity(0.6))
53 .clipShape(Capsule())
54 }
55 .buttonStyle(.plain)
56 }
57 }
58 .padding(.vertical, 4)
59 }
60 }
61 .listRowBackground(Color(.systemGray6).opacity(0.5))
62
63 Section("Recent Activity") {
64 if viewModel.filteredPortfolioRecentActivity.isEmpty {
65 dashboardEmptyRow("No recent portfolio changes")
66 } else {
67 ForEach(viewModel.filteredPortfolioRecentActivity.prefix(8)) { item in
68 if let trackedDomain = viewModel.trackedDomain(withID: item.trackedDomainID) {
69 NavigationLink {
70 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
71 } label: {
72 PortfolioActivityRow(item: item)
73 }
74 .buttonStyle(.plain)
75 }
76 }
77 }
78 }
79 .listRowBackground(Color(.systemGray6).opacity(0.5))
80
81 Section("Attention Required") {
82 if viewModel.filteredPortfolioAttentionRequired.isEmpty {
83 dashboardEmptyRow("Nothing urgent right now")
84 } else {
85 ForEach(viewModel.filteredPortfolioAttentionRequired.prefix(8)) { item in
86 if let trackedDomain = viewModel.trackedDomain(withID: item.trackedDomainID) {
87 NavigationLink {
88 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
89 } label: {
90 PortfolioAttentionRow(item: item)
91 }
92 .buttonStyle(.plain)
93 }
94 }
95 }
96 }
97 .listRowBackground(Color(.systemGray6).opacity(0.5))
98
99 Section("Expiring Soon") {
100 if viewModel.filteredPortfolioExpiringSoon.isEmpty {
101 dashboardEmptyRow("No certificates expiring within 30 days")
102 } else {
103 ForEach(viewModel.filteredPortfolioExpiringSoon.prefix(8)) { state in
104 NavigationLink {
105 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: state.trackedDomain)
106 } label: {
107 PortfolioExpiryRow(state: state)
108 }
109 .buttonStyle(.plain)
110 }
111 }
112 }
113 .listRowBackground(Color(.systemGray6).opacity(0.5))
114
115 Section("Portfolio List") {
116 if viewModel.filteredPortfolioGroups.isEmpty {
117 dashboardEmptyRow("No domains match the current filter")
118 } else {
119 ForEach(viewModel.filteredPortfolioGroups) { group in
120 DisclosureGroup(
121 isExpanded: disclosureBinding(for: group.apexDomain),
122 content: {
123 ForEach(group.domains) { state in
124 NavigationLink {
125 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: state.trackedDomain)
126 } label: {
127 WatchlistRowView(
128 trackedDomain: state.trackedDomain,
129 isRefreshing: viewModel.refreshingTrackedDomainID == state.trackedDomain.id
130 )
131 }
132 .buttonStyle(.plain)
133 }
134 },
135 label: {
136 HStack {
137 VStack(alignment: .leading, spacing: 4) {
138 Text(group.apexDomain)
139 .font(appDensity.font(.headline, design: .default, weight: .semibold))
140 Text("\(group.domains.count) domain\(group.domains.count == 1 ? "" : "s")")
141 .font(appDensity.font(.caption))
142 .foregroundStyle(.secondary)
143 }
144 Spacer()
145 groupBadge(for: group.domains)
146 }
147 .padding(.vertical, 4)
148 }
149 )
150 }
151 }
152 }
153 .listRowBackground(Color(.systemGray6).opacity(0.5))
154 }
155 }
156 .scrollContentBackground(.hidden)
157 .background(Color.black)
158 .navigationTitle("Dashboard")
159 .searchable(text: $viewModel.dashboardSearchText, prompt: "Search portfolio")
160 .toolbar {
161 if !viewModel.trackedDomains.isEmpty {
162 ToolbarItem(placement: .topBarTrailing) {
163 Button {
164 AppHaptics.refresh()
165 viewModel.refreshAllTrackedDomains()
166 } label: {
167 Image(systemName: "arrow.clockwise")
168 }
169 .disabled(viewModel.batchLookupRunning)
170 }
171 }
172 }
173 .preferredColorScheme(.dark)
174 }
175
176 private func disclosureBinding(for apexDomain: String) -> Binding<Bool> {
177 Binding(
178 get: { !collapsedGroups.contains(apexDomain) },
179 set: { isExpanded in
180 if isExpanded {
181 collapsedGroups.remove(apexDomain)
182 } else {
183 collapsedGroups.insert(apexDomain)
184 }
185 }
186 )
187 }
188
189 private func summaryCard(title: String, value: Int, filter: PortfolioFilterOption, tint: Color) -> some View {
190 Button {
191 viewModel.dashboardFilter = filter
192 } label: {
193 VStack(alignment: .leading, spacing: 8) {
194 Text(title)
195 .font(appDensity.font(.caption, design: .default, weight: .semibold))
196 .foregroundStyle(.secondary)
197 Text("\(value)")
198 .font(.system(size: 28, weight: .bold, design: .rounded))
199 .foregroundStyle(.white)
200 HStack {
201 Circle()
202 .fill(tint)
203 .frame(width: 8, height: 8)
204 Text(filter.title)
205 .font(appDensity.font(.caption2, design: .default, weight: .semibold))
206 .foregroundStyle(tint)
207 }
208 }
209 .frame(maxWidth: .infinity, alignment: .leading)
210 .padding(appDensity.metrics.cardPadding)
211 .background(cardBackground(for: filter))
212 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
213 }
214 .buttonStyle(.plain)
215 }
216
217 private func cardBackground(for filter: PortfolioFilterOption) -> some ShapeStyle {
218 if viewModel.dashboardFilter == filter {
219 return AnyShapeStyle(
220 LinearGradient(
221 colors: [Color.cyan.opacity(0.28), Color.cyan.opacity(0.12)],
222 startPoint: .topLeading,
223 endPoint: .bottomTrailing
224 )
225 )
226 }
227 return AnyShapeStyle(Color(.systemGray5).opacity(0.45))
228 }
229
230 private func dashboardEmptyRow(_ message: String) -> some View {
231 Text(message)
232 .font(appDensity.font(.caption))
233 .foregroundStyle(.secondary)
234 .padding(.vertical, 4)
235 }
236
237 private func groupBadge(for states: [PortfolioDomainStatus]) -> some View {
238 let criticalCount = states.filter { $0.health == .critical }.count
239 let warningCount = states.filter { $0.health == .warning }.count
240 let title: String
241 let color: Color
242
243 if criticalCount > 0 {
244 title = "\(criticalCount) critical"
245 color = .red
246 } else if warningCount > 0 {
247 title = "\(warningCount) warning"
248 color = .yellow
249 } else {
250 title = "Healthy"
251 color = .green
252 }
253
254 return AppStatusBadgeView(
255 model: .init(
256 title: title,
257 systemImage: criticalCount > 0 ? "exclamationmark.octagon.fill" : (warningCount > 0 ? "exclamationmark.triangle.fill" : "checkmark.circle.fill"),
258 foregroundColor: color,
259 backgroundColor: color.opacity(0.16)
260 )
261 )
262 }
263}
264
265private struct PortfolioActivityRow: View {
266 @Environment(\.appDensity) private var appDensity
267 let item: PortfolioActivityItem
268
269 var body: some View {
270 HStack(alignment: .top, spacing: 10) {
271 Image(systemName: item.systemImage)
272 .font(.caption.weight(.semibold))
273 .foregroundStyle(iconColor)
274 .frame(width: 22, height: 22)
275 .background(iconColor.opacity(0.14))
276 .clipShape(RoundedRectangle(cornerRadius: 7))
277
278 VStack(alignment: .leading, spacing: 4) {
279 Text(item.message)
280 .font(appDensity.font(.callout, design: .default))
281 .foregroundStyle(.primary)
282 HStack(spacing: 8) {
283 Text(item.domain)
284 Text(relativeTimestamp(item.timestamp))
285 }
286 .font(appDensity.font(.caption2))
287 .foregroundStyle(.secondary)
288 }
289 }
290 .padding(.vertical, 4)
291 }
292
293 private var iconColor: Color {
294 switch item.health {
295 case .healthy:
296 return .cyan
297 case .warning:
298 return .yellow
299 case .critical:
300 return .red
301 }
302 }
303}
304
305private struct PortfolioAttentionRow: View {
306 @Environment(\.appDensity) private var appDensity
307 let item: PortfolioAttentionItem
308
309 var body: some View {
310 HStack(alignment: .top, spacing: 10) {
311 AppStatusBadgeView(model: badgeModel)
312
313 VStack(alignment: .leading, spacing: 4) {
314 Text(item.domain)
315 .font(appDensity.font(.callout))
316 .foregroundStyle(.primary)
317 Text(item.reason)
318 .font(appDensity.font(.caption, design: .default))
319 .foregroundStyle(.secondary)
320 Text(relativeTimestamp(item.timestamp))
321 .font(appDensity.font(.caption2))
322 .foregroundStyle(.secondary)
323 }
324 }
325 .padding(.vertical, 4)
326 }
327
328 private var badgeModel: AppStatusBadgeModel {
329 switch item.health {
330 case .healthy:
331 return .init(title: "Healthy", systemImage: "checkmark.circle.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
332 case .warning:
333 return .init(title: "Warning", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
334 case .critical:
335 return .init(title: "Critical", systemImage: "exclamationmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
336 }
337 }
338}
339
340private struct PortfolioExpiryRow: View {
341 @Environment(\.appDensity) private var appDensity
342 let state: PortfolioDomainStatus
343
344 var body: some View {
345 HStack {
346 VStack(alignment: .leading, spacing: 4) {
347 Text(state.trackedDomain.domain)
348 .font(appDensity.font(.callout))
349 .foregroundStyle(.primary)
350 Text(expirySubtitle)
351 .font(appDensity.font(.caption))
352 .foregroundStyle(.secondary)
353 }
354 Spacer()
355 AppStatusBadgeView(model: badgeModel)
356 }
357 .padding(.vertical, 4)
358 }
359
360 private var expirySubtitle: String {
361 if let days = state.certificateDaysRemaining {
362 return "Expires in \(days) day\(days == 1 ? "" : "s")"
363 }
364 return "Certificate needs review"
365 }
366
367 private var badgeModel: AppStatusBadgeModel {
368 switch state.certificateExpiryState {
369 case .none:
370 return .init(title: "Healthy", systemImage: "lock.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
371 case .warning:
372 return .init(title: "Warning", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
373 case .critical:
374 return .init(title: "Critical", systemImage: "xmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
375 }
376 }
377}
378
379private func relativeTimestamp(_ date: Date) -> String {
380 let formatter = RelativeDateTimeFormatter()
381 formatter.unitsStyle = .short
382 return formatter.localizedString(for: date, relativeTo: Date())
383}
DomainDig/DomainViewModel.swift +474
@@ -104,6 +104,66 @@ private struct WorkflowExportPayload: Codable {
104 let reports: [DomainReport] 104 let reports: [DomainReport]
105} 105}
106 106
107struct PortfolioDomainStatus: Identifiable {
108 let trackedDomain: TrackedDomain
109 let latestEntry: HistoryEntry?
110 let report: DomainReport
111 let apexDomain: String
112 let health: DomainHealth
113 let lastChangeDate: Date?
114 let lastMonitoringFailure: Date?
115 let instabilityScore: Int
116 let certificateExpiryState: CertificateWarningLevel
117 let certificateDaysRemaining: Int?
118 let isUnreachable: Bool
119 let recentDNSChange: Bool
120 let recentCriticalChange: Bool
121 let recentFailureCount: Int
122
123 var id: UUID { trackedDomain.id }
124}
125
126struct PortfolioActivityItem: Identifiable {
127 let id: String
128 let trackedDomainID: UUID
129 let domain: String
130 let message: String
131 let timestamp: Date
132 let health: DomainHealth
133 let systemImage: String
134}
135
136struct PortfolioAttentionItem: Identifiable {
137 let id: String
138 let trackedDomainID: UUID
139 let domain: String
140 let reason: String
141 let timestamp: Date
142 let health: DomainHealth
143}
144
145struct PortfolioGroup: Identifiable {
146 let apexDomain: String
147 let domains: [PortfolioDomainStatus]
148
149 var id: String { apexDomain }
150}
151
152struct PortfolioDashboardData {
153 let snapshot: PortfolioSnapshot
154 let domainStates: [PortfolioDomainStatus]
155 let recentActivity: [PortfolioActivityItem]
156 let attentionRequired: [PortfolioAttentionItem]
157 let expiringSoon: [PortfolioDomainStatus]
158 let groups: [PortfolioGroup]
159}
160
161private struct PortfolioDashboardStamp: Equatable {
162 let trackedDomainSignature: [String]
163 let historySignature: [String]
164 let monitoringSignature: [String]
165}
166
107@MainActor 167@MainActor
108@Observable 168@Observable
109final class DomainViewModel { 169final class DomainViewModel {
@@ -214,6 +274,8 @@ final class DomainViewModel {
214 private var trackedDomainsPersistenceDirty = false 274 private var trackedDomainsPersistenceDirty = false
215 private let reportBuilder = DomainReportBuilder() 275 private let reportBuilder = DomainReportBuilder()
216 private let inspectionService = DomainInspectionService() 276 private let inspectionService = DomainInspectionService()
277 private var cachedPortfolioDashboardData: PortfolioDashboardData?
278 private var cachedPortfolioDashboardStamp: PortfolioDashboardStamp?
217 private(set) var currentResultSource: LookupResultSource = .live 279 private(set) var currentResultSource: LookupResultSource = .live
218 private(set) var currentCachedSections: [LookupSectionKind] = [] 280 private(set) var currentCachedSections: [LookupSectionKind] = []
219 private(set) var currentStatusMessage: String? 281 private(set) var currentStatusMessage: String?
@@ -246,6 +308,8 @@ final class DomainViewModel {
246 var watchlistSearchText = "" 308 var watchlistSearchText = ""
247 var watchlistFilter: WatchlistFilterOption = .all 309 var watchlistFilter: WatchlistFilterOption = .all
248 var watchlistSortOption: WatchlistSortOption = .pinned 310 var watchlistSortOption: WatchlistSortOption = .pinned
311 var dashboardSearchText = ""
312 var dashboardFilter: PortfolioFilterOption = .all
249 var monitoringSettings: MonitoringSettings = MonitoringStorage.loadSettings() 313 var monitoringSettings: MonitoringSettings = MonitoringStorage.loadSettings()
250 var monitoringLogs: [MonitoringLog] = MonitoringStorage.loadLogs() 314 var monitoringLogs: [MonitoringLog] = MonitoringStorage.loadLogs()
251 var monitoringRunInProgress = false 315 var monitoringRunInProgress = false
@@ -377,6 +441,73 @@ final class DomainViewModel {
377 return sortedTrackedDomains(from: filtered, using: watchlistSortOption) 441 return sortedTrackedDomains(from: filtered, using: watchlistSortOption)
378 } 442 }
379 443
444 var portfolioDashboardData: PortfolioDashboardData {
445 let stamp = portfolioDashboardStamp()
446 if let cachedPortfolioDashboardData, cachedPortfolioDashboardStamp == stamp {
447 return cachedPortfolioDashboardData
448 }
449
450 let domainStates = buildPortfolioDomainStates()
451 let recentActivity = buildPortfolioActivity(from: domainStates)
452 let attentionRequired = buildAttentionQueue(from: domainStates)
453 let expiringSoon = domainStates
454 .filter { $0.certificateExpiryState != .none }
455 .sorted {
456 ($0.certificateDaysRemaining ?? .max, $0.trackedDomain.domain)
457 < ($1.certificateDaysRemaining ?? .max, $1.trackedDomain.domain)
458 }
459 let groups = buildPortfolioGroups(from: domainStates)
460 let snapshot = PortfolioSnapshot(
461 totalDomains: domainStates.count,
462 healthyCount: domainStates.filter { $0.health == .healthy }.count,
463 warningCount: domainStates.filter { $0.health == .warning }.count,
464 criticalCount: domainStates.filter { $0.health == .critical }.count,
465 changedLast24h: domainStates.filter {
466 guard let lastChangeDate = $0.lastChangeDate else { return false }
467 return Date().timeIntervalSince(lastChangeDate) <= 24 * 60 * 60
468 }.count,
469 expiringSoonCount: expiringSoon.count,
470 unreachableCount: domainStates.filter(\.isUnreachable).count
471 )
472 let data = PortfolioDashboardData(
473 snapshot: snapshot,
474 domainStates: domainStates,
475 recentActivity: recentActivity,
476 attentionRequired: attentionRequired,
477 expiringSoon: expiringSoon,
478 groups: groups
479 )
480 cachedPortfolioDashboardStamp = stamp
481 cachedPortfolioDashboardData = data
482 return data
483 }
484
485 var filteredPortfolioDomainStates: [PortfolioDomainStatus] {
486 let query = dashboardSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
487 return portfolioDashboardData.domainStates.filter { state in
488 matchesPortfolioFilter(state) && matchesDashboardSearch(state, query: query)
489 }
490 }
491
492 var filteredPortfolioGroups: [PortfolioGroup] {
493 buildPortfolioGroups(from: filteredPortfolioDomainStates)
494 }
495
496 var filteredPortfolioRecentActivity: [PortfolioActivityItem] {
497 let visibleDomainIDs = Set(filteredPortfolioDomainStates.map(\.trackedDomain.id))
498 return portfolioDashboardData.recentActivity.filter { visibleDomainIDs.contains($0.trackedDomainID) }
499 }
500
501 var filteredPortfolioAttentionRequired: [PortfolioAttentionItem] {
502 let visibleDomainIDs = Set(filteredPortfolioDomainStates.map(\.trackedDomain.id))
503 return portfolioDashboardData.attentionRequired.filter { visibleDomainIDs.contains($0.trackedDomainID) }
504 }
505
506 var filteredPortfolioExpiringSoon: [PortfolioDomainStatus] {
507 let visibleDomainIDs = Set(filteredPortfolioDomainStates.map(\.trackedDomain.id))
508 return portfolioDashboardData.expiringSoon.filter { visibleDomainIDs.contains($0.trackedDomain.id) }
509 }
510
380 var timelineDomains: [String] { 511 var timelineDomains: [String] {
381 let query = timelineDomainFilter.trimmingCharacters(in: .whitespacesAndNewlines) 512 let query = timelineDomainFilter.trimmingCharacters(in: .whitespacesAndNewlines)
382 let domains = history.map(\.domain) 513 let domains = history.map(\.domain)
@@ -2807,6 +2938,349 @@ final class DomainViewModel {
2807 recentSnapshots(for: trackedDomain, limit: 1).first?.snapshot 2938 recentSnapshots(for: trackedDomain, limit: 1).first?.snapshot
2808 } 2939 }
2809 2940
2941 func trackedDomain(withID id: UUID) -> TrackedDomain? {
2942 trackedDomains.first(where: { $0.id == id })
2943 }
2944
2945 private func portfolioDashboardStamp() -> PortfolioDashboardStamp {
2946 PortfolioDashboardStamp(
2947 trackedDomainSignature: trackedDomains
2948 .sorted { $0.id.uuidString < $1.id.uuidString }
2949 .map {
2950 [
2951 $0.id.uuidString,
2952 $0.updatedAt.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
2953 String($0.pendingMonitoringAlerts.count),
2954 $0.lastMonitoredAt?.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))) ?? "0",
2955 $0.lastAlertAt?.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))) ?? "0"
2956 ].joined(separator: "|")
2957 },
2958 historySignature: history
2959 .prefix(250)
2960 .map {
2961 [
2962 $0.id.uuidString,
2963 $0.timestamp.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
2964 String($0.changeCount),
2965 $0.severitySummary?.rawValue.description ?? "-"
2966 ].joined(separator: "|")
2967 },
2968 monitoringSignature: monitoringLogs
2969 .prefix(100)
2970 .map {
2971 [
2972 $0.id.uuidString,
2973 $0.timestamp.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
2974 String($0.alertsTriggered),
2975 String($0.changesFound)
2976 ].joined(separator: "|")
2977 }
2978 )
2979 }
2980
2981 private func buildPortfolioDomainStates() -> [PortfolioDomainStatus] {
2982 let now = Date()
2983 return sortedTrackedDomains(from: trackedDomains, using: .pinned).map { trackedDomain in
2984 let recentEntries = recentSnapshots(for: trackedDomain, limit: 8)
2985 let latestEntry = recentEntries.first
2986 let report = latestEntry.map { self.report(for: $0) } ?? reportBuilder.build(from: placeholderSnapshot(for: trackedDomain))
2987 let recentMonitoringResults = monitoringLogs
2988 .flatMap(\.checkedDomains)
2989 .filter { $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }
2990 .sorted { $0.checkedAt > $1.checkedAt }
2991 let recentFailureResults = recentMonitoringResults.filter {
2992 let isFailure = $0.errorMessage != nil || ($0.alertSeverity ?? .info) >= .warning
2993 return isFailure && now.timeIntervalSince($0.checkedAt) <= 7 * 24 * 60 * 60
2994 }
2995 let recentChangeCount = recentEntries.filter {
2996 guard let changeSummary = $0.changeSummary, changeSummary.hasChanges else { return false }
2997 return now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
2998 }.count
2999 let recentDNSChange = recentEntries.contains {
3000 guard let changeSummary = $0.changeSummary else { return false }
3001 return changeSummary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") })
3002 && now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
3003 }
3004 let recentCriticalChange = recentEntries.contains {
3005 guard let changeSummary = $0.changeSummary else { return false }
3006 return changeSummary.impactClassification == .critical
3007 && now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
3008 }
3009 let certificateExpiryState = trackedDomain.certificateWarningLevel == .none
3010 ? report.certificateExpiryState
3011 : trackedDomain.certificateWarningLevel
3012 let isUnreachable: Bool = {
3013 if let latestEntry {
3014 if !latestEntry.reachabilityResults.isEmpty {
3015 return !latestEntry.reachabilityResults.contains(where: \.reachable)
3016 }
3017 if latestEntry.reachabilityError != nil {
3018 return true
3019 }
3020 }
3021 return recentFailureResults.contains { ($0.alertSeverity ?? .info) == .critical && $0.errorMessage != nil }
3022 }()
3023 let hasInvalidTLS = latestEntry?.sslInfo == nil && latestEntry?.sslError != nil
3024 let instabilityScore = DomainHealth.instabilityScore(
3025 recentChangeCount: recentChangeCount,
3026 recentFailureCount: recentFailureResults.count,
3027 pendingAlertCount: trackedDomain.pendingMonitoringAlerts.count,
3028 hasRecentDNSChange: recentDNSChange
3029 )
3030 let health = DomainHealth.classify(
3031 certificateExpiryState: certificateExpiryState,
3032 isReachable: !isUnreachable,
3033 recentMonitoringFailureCount: recentFailureResults.count,
3034 hasRecentDNSChange: recentDNSChange,
3035 instabilityScore: instabilityScore,
3036 hasRecentCriticalChange: recentCriticalChange,
3037 hasInvalidTLS: hasInvalidTLS
3038 )
3039
3040 return PortfolioDomainStatus(
3041 trackedDomain: trackedDomain,
3042 latestEntry: latestEntry,
3043 report: report,
3044 apexDomain: apexDomain(for: trackedDomain.domain),
3045 health: health,
3046 lastChangeDate: latestEntry?.changeSummary?.hasChanges == true
3047 ? latestEntry?.timestamp
3048 : trackedDomain.monitoringState.lastChangeDate ?? report.lastChangeDate,
3049 lastMonitoringFailure: recentFailureResults.first?.checkedAt,
3050 instabilityScore: instabilityScore,
3051 certificateExpiryState: certificateExpiryState,
3052 certificateDaysRemaining: trackedDomain.certificateDaysRemaining ?? latestEntry?.sslInfo?.daysUntilExpiry,
3053 isUnreachable: isUnreachable,
3054 recentDNSChange: recentDNSChange,
3055 recentCriticalChange: recentCriticalChange,
3056 recentFailureCount: recentFailureResults.count
3057 )
3058 }
3059 }
3060
3061 private func buildPortfolioActivity(from domainStates: [PortfolioDomainStatus]) -> [PortfolioActivityItem] {
3062 var items: [PortfolioActivityItem] = []
3063 let healthByDomainID = Dictionary(uniqueKeysWithValues: domainStates.map { ($0.trackedDomain.id, $0.health) })
3064
3065 for state in domainStates {
3066 if let latestEntry = state.latestEntry,
3067 let changeSummary = latestEntry.changeSummary,
3068 changeSummary.hasChanges {
3069 items.append(
3070 PortfolioActivityItem(
3071 id: "history|\(latestEntry.id.uuidString)",
3072 trackedDomainID: state.trackedDomain.id,
3073 domain: state.trackedDomain.domain,
3074 message: portfolioHistoryMessage(for: latestEntry),
3075 timestamp: latestEntry.timestamp,
3076 health: state.health,
3077 systemImage: portfolioHistoryIcon(for: latestEntry)
3078 )
3079 )
3080 }
3081 }
3082
3083 for log in monitoringLogs.prefix(25) {
3084 for result in log.checkedDomains where result.didChange || result.errorMessage != nil || result.certificateWarningLevel != .none {
3085 guard let trackedDomain = trackedDomain(for: result.domain) else { continue }
3086 items.append(
3087 PortfolioActivityItem(
3088 id: "monitoring|\(log.id.uuidString)|\(result.id.uuidString)",
3089 trackedDomainID: trackedDomain.id,
3090 domain: trackedDomain.domain,
3091 message: portfolioMonitoringMessage(for: result),
3092 timestamp: result.checkedAt,
3093 health: healthByDomainID[trackedDomain.id] ?? (result.alertSeverity == .critical ? .critical : .warning),
3094 systemImage: portfolioMonitoringIcon(for: result)
3095 )
3096 )
3097 }
3098 }
3099
3100 var seen = Set<String>()
3101 return items
3102 .sorted { $0.timestamp > $1.timestamp }
3103 .filter { item in
3104 let key = "\(item.domain.lowercased())|\(item.message.lowercased())"
3105 return seen.insert(key).inserted
3106 }
3107 }
3108
3109 private func buildAttentionQueue(from domainStates: [PortfolioDomainStatus]) -> [PortfolioAttentionItem] {
3110 domainStates.compactMap { state in
3111 guard state.health != .healthy else { return nil }
3112 let reason: String
3113 let timestamp: Date
3114
3115 if state.isUnreachable {
3116 reason = "Endpoint is unreachable"
3117 timestamp = state.lastMonitoringFailure ?? state.trackedDomain.updatedAt
3118 } else if state.certificateExpiryState == .critical {
3119 reason = "Certificate is expiring in under 14 days"
3120 timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
3121 } else if state.certificateExpiryState == .warning {
3122 reason = "Certificate expires within 30 days"
3123 timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
3124 } else if state.recentFailureCount >= 2 || state.instabilityScore >= 70 {
3125 reason = "Repeated instability detected"
3126 timestamp = state.lastMonitoringFailure ?? state.trackedDomain.updatedAt
3127 } else if state.recentDNSChange {
3128 reason = "DNS changed recently"
3129 timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
3130 } else if state.recentCriticalChange {
3131 reason = "Recent critical change detected"
3132 timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
3133 } else {
3134 reason = "Needs attention"
3135 timestamp = state.trackedDomain.updatedAt
3136 }
3137
3138 return PortfolioAttentionItem(
3139 id: "\(state.trackedDomain.id.uuidString)|\(reason)",
3140 trackedDomainID: state.trackedDomain.id,
3141 domain: state.trackedDomain.domain,
3142 reason: reason,
3143 timestamp: timestamp,
3144 health: state.health
3145 )
3146 }
3147 .sorted { lhs, rhs in
3148 if lhs.health != rhs.health {
3149 return healthRank(lhs.health) > healthRank(rhs.health)
3150 }
3151 return lhs.timestamp > rhs.timestamp
3152 }
3153 }
3154
3155 private func buildPortfolioGroups(from domainStates: [PortfolioDomainStatus]) -> [PortfolioGroup] {
3156 Dictionary(grouping: domainStates, by: \.apexDomain)
3157 .map { apexDomain, domains in
3158 PortfolioGroup(
3159 apexDomain: apexDomain,
3160 domains: domains.sorted { lhs, rhs in
3161 if lhs.health != rhs.health {
3162 return healthRank(lhs.health) > healthRank(rhs.health)
3163 }
3164 return lhs.trackedDomain.domain.localizedCaseInsensitiveCompare(rhs.trackedDomain.domain) == .orderedAscending
3165 }
3166 )
3167 }
3168 .sorted { lhs, rhs in
3169 if let lhsMostSevere = lhs.domains.map(\.health).map(healthRank).max(),
3170 let rhsMostSevere = rhs.domains.map(\.health).map(healthRank).max(),
3171 lhsMostSevere != rhsMostSevere {
3172 return lhsMostSevere > rhsMostSevere
3173 }
3174 return lhs.apexDomain.localizedCaseInsensitiveCompare(rhs.apexDomain) == .orderedAscending
3175 }
3176 }
3177
3178 private func matchesPortfolioFilter(_ state: PortfolioDomainStatus) -> Bool {
3179 switch dashboardFilter {
3180 case .all:
3181 return true
3182 case .healthy:
3183 return state.health == .healthy
3184 case .warning:
3185 return state.health == .warning
3186 case .critical:
3187 return state.health == .critical
3188 case .changed:
3189 guard let lastChangeDate = state.lastChangeDate else { return false }
3190 return Date().timeIntervalSince(lastChangeDate) <= 24 * 60 * 60
3191 case .expiring:
3192 return state.certificateExpiryState != .none
3193 case .unreachable:
3194 return state.isUnreachable
3195 }
3196 }
3197
3198 private func matchesDashboardSearch(_ state: PortfolioDomainStatus, query: String) -> Bool {
3199 guard !query.isEmpty else { return true }
3200 let normalizedQuery = query.lowercased()
3201 return state.trackedDomain.domain.lowercased().contains(normalizedQuery)
3202 || state.apexDomain.lowercased().contains(normalizedQuery)
3203 }
3204
3205 private func portfolioHistoryMessage(for entry: HistoryEntry) -> String {
3206 guard let summary = entry.changeSummary else {
3207 return "Configuration changed for \(entry.domain)"
3208 }
3209 if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) {
3210 return "DNS changed for \(entry.domain)"
3211 }
3212 if summary.changedSections.contains(where: {
3213 $0.localizedCaseInsensitiveContains("certificate")
3214 || $0.localizedCaseInsensitiveContains("tls")
3215 }) {
3216 return "Certificate updated for \(entry.domain)"
3217 }
3218 if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("redirect") }) {
3219 return "Redirect chain changed for \(entry.domain)"
3220 }
3221 return summary.message
3222 }
3223
3224 private func portfolioHistoryIcon(for entry: HistoryEntry) -> String {
3225 guard let summary = entry.changeSummary else { return "clock.arrow.trianglehead.counterclockwise.rotate.90" }
3226 if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) {
3227 return "point.3.connected.trianglepath.dotted"
3228 }
3229 if summary.changedSections.contains(where: {
3230 $0.localizedCaseInsensitiveContains("certificate")
3231 || $0.localizedCaseInsensitiveContains("tls")
3232 }) {
3233 return "lock.rotation"
3234 }
3235 if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("redirect") }) {
3236 return "arrow.triangle.branch"
3237 }
3238 return "clock.arrow.trianglehead.counterclockwise.rotate.90"
3239 }
3240
3241 private func portfolioMonitoringMessage(for result: MonitoringDomainResult) -> String {
3242 if result.errorMessage != nil {
3243 return "Monitoring failed for \(result.domain)"
3244 }
3245 if result.certificateWarningLevel != .none {
3246 return "Certificate needs attention for \(result.domain)"
3247 }
3248 if result.didChange {
3249 return result.summaryMessage.isEmpty ? "Monitoring detected a change for \(result.domain)" : result.summaryMessage
3250 }
3251 return "Monitoring updated \(result.domain)"
3252 }
3253
3254 private func portfolioMonitoringIcon(for result: MonitoringDomainResult) -> String {
3255 if result.errorMessage != nil {
3256 return "xmark.octagon.fill"
3257 }
3258 if result.certificateWarningLevel != .none {
3259 return "exclamationmark.triangle.fill"
3260 }
3261 return "waveform.path.ecg"
3262 }
3263
3264 private func apexDomain(for domain: String) -> String {
3265 let parts = domain
3266 .lowercased()
3267 .split(separator: ".")
3268 .map(String.init)
3269 guard parts.count > 2 else { return domain.lowercased() }
3270 return parts.suffix(2).joined(separator: ".")
3271 }
3272
3273 private func healthRank(_ health: DomainHealth) -> Int {
3274 switch health {
3275 case .healthy:
3276 return 0
3277 case .warning:
3278 return 1
3279 case .critical:
3280 return 2
3281 }
3282 }
3283
2810 func resolverMismatchNote(for entry: HistoryEntry) -> String? { 3284 func resolverMismatchNote(for entry: HistoryEntry) -> String? {
2811 guard entry.resolverURLString != resolverURLString else { return nil } 3285 guard entry.resolverURLString != resolverURLString else { return nil }
2812 return "Current resolver differs from this snapshot. Re-running may produce different evidence." 3286 return "Current resolver differs from this snapshot. Re-running may produce different evidence."
DomainDig/Models.swift +94
@@ -176,6 +176,26 @@ enum CertificateWarningLevel: String, Codable {
176 } 176 }
177} 177}
178 178
179enum DomainHealth: String, Codable, Sendable, CaseIterable {
180 case healthy
181 case warning
182 case critical
183
184 var title: String {
185 rawValue.capitalized
186 }
187}
188
189struct PortfolioSnapshot: Codable, Sendable {
190 var totalDomains: Int
191 var healthyCount: Int
192 var warningCount: Int
193 var criticalCount: Int
194 var changedLast24h: Int
195 var expiringSoonCount: Int
196 var unreachableCount: Int
197}
198
179struct DomainChangeSummary: Codable, Equatable { 199struct DomainChangeSummary: Codable, Equatable {
180 let hasChanges: Bool 200 let hasChanges: Bool
181 let changedSections: [String] 201 let changedSections: [String]
@@ -656,6 +676,80 @@ enum WatchlistSortOption: String, CaseIterable, Identifiable {
656 } 676 }
657} 677}
658 678
679enum PortfolioFilterOption: String, CaseIterable, Identifiable {
680 case all
681 case healthy
682 case warning
683 case critical
684 case changed
685 case expiring
686 case unreachable
687
688 var id: String { rawValue }
689
690 var title: String {
691 switch self {
692 case .all:
693 return "All"
694 case .healthy:
695 return "Healthy"
696 case .warning:
697 return "Warning"
698 case .critical:
699 return "Critical"
700 case .changed:
701 return "Changed"
702 case .expiring:
703 return "Expiring"
704 case .unreachable:
705 return "Unreachable"
706 }
707 }
708}
709
710extension DomainHealth {
711 static func classify(
712 certificateExpiryState: CertificateWarningLevel,
713 isReachable: Bool,
714 recentMonitoringFailureCount: Int,
715 hasRecentDNSChange: Bool,
716 instabilityScore: Int,
717 hasRecentCriticalChange: Bool,
718 hasInvalidTLS: Bool
719 ) -> DomainHealth {
720 if !isReachable
721 || hasInvalidTLS
722 || certificateExpiryState == .critical
723 || recentMonitoringFailureCount >= 2
724 || instabilityScore >= 70
725 || hasRecentCriticalChange {
726 return .critical
727 }
728
729 if certificateExpiryState == .warning
730 || recentMonitoringFailureCount == 1
731 || hasRecentDNSChange
732 || instabilityScore >= 35 {
733 return .warning
734 }
735
736 return .healthy
737 }
738
739 static func instabilityScore(
740 recentChangeCount: Int,
741 recentFailureCount: Int,
742 pendingAlertCount: Int,
743 hasRecentDNSChange: Bool
744 ) -> Int {
745 let rawScore = (recentChangeCount * 12)
746 + (recentFailureCount * 24)
747 + (pendingAlertCount * 8)
748 + (hasRecentDNSChange ? 14 : 0)
749 return min(rawScore, 100)
750 }
751}
752
659enum PremiumCapability: String, Codable { 753enum PremiumCapability: String, Codable {
660 case unlimitedTrackedDomains 754 case unlimitedTrackedDomains
661 case automatedMonitoring 755 case automatedMonitoring
DomainDig/RootTabView.swift +26 −16
@@ -1,31 +1,28 @@
1import SwiftUI 1import SwiftUI
2 2
3private enum RootTab: Hashable {
4 case dashboard
5 case history
6 case inspect
7 case settings
8}
9
3struct RootTabView: View { 10struct RootTabView: View {
4 @Bindable var viewModel: DomainViewModel 11 @Bindable var viewModel: DomainViewModel
5 @State private var purchaseService = PurchaseService.shared 12 @State private var purchaseService = PurchaseService.shared
13 @State private var selectedTab: RootTab = FeatureAccessService.currentTier == .free ? .inspect : .dashboard
6 14
7 var body: some View { 15 var body: some View {
8 let _ = purchaseService.currentTier 16 let _ = purchaseService.currentTier
9 17
10 TabView { 18 TabView(selection: $selectedTab) {
11 ContentView(viewModel: viewModel)
12 .tabItem {
13 Label("Inspect", systemImage: "magnifyingglass")
14 }
15
16 NavigationStack { 19 NavigationStack {
17 WatchlistView(viewModel: viewModel) 20 DashboardView(viewModel: viewModel)
18 } 21 }
19 .tabItem { 22 .tabItem {
20 Label("Watchlist", systemImage: "eye") 23 Label("Dashboard", systemImage: "square.grid.2x2")
21 }
22
23 NavigationStack {
24 MonitoringView(viewModel: viewModel)
25 }
26 .tabItem {
27 Label("Monitoring", systemImage: "waveform.path.ecg")
28 } 24 }
25 .tag(RootTab.dashboard)
29 26
30 NavigationStack { 27 NavigationStack {
31 HistoryView(viewModel: viewModel) 28 HistoryView(viewModel: viewModel)
@@ -33,13 +30,21 @@ struct RootTabView: View {
33 .tabItem { 30 .tabItem {
34 Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") 31 Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
35 } 32 }
33 .tag(RootTab.history)
34
35 ContentView(viewModel: viewModel)
36 .tabItem {
37 Label("Inspect", systemImage: "magnifyingglass")
38 }
39 .tag(RootTab.inspect)
36 40
37 NavigationStack { 41 NavigationStack {
38 SettingsView(viewModel: viewModel) 42 SettingsView(viewModel: viewModel)
39 } 43 }
40 .tabItem { 44 .tabItem {
41 Label("More", systemImage: "ellipsis.circle") 45 Label("Settings", systemImage: "gearshape")
42 } 46 }
47 .tag(RootTab.settings)
43 } 48 }
44 .sheet(isPresented: Binding( 49 .sheet(isPresented: Binding(
45 get: { viewModel.isPaywallPresented }, 50 get: { viewModel.isPaywallPresented },
@@ -63,5 +68,10 @@ struct RootTabView: View {
63 } 68 }
64 ) 69 )
65 } 70 }
71 .onChange(of: purchaseService.currentTier) { _, newValue in
72 if newValue != .free, selectedTab == .inspect, viewModel.trackedDomains.isEmpty == false {
73 selectedTab = .dashboard
74 }
75 }
66 } 76 }
67} 77}
DomainReportBuilder.swift +33 −1
@@ -34,6 +34,11 @@ struct DomainReport: Codable {
34 let riskAssessment: DomainRiskAssessment 34 let riskAssessment: DomainRiskAssessment
35 let insights: [String] 35 let insights: [String]
36 let changeSummary: DomainChangeSummary? 36 let changeSummary: DomainChangeSummary?
37 let lastChangeDate: Date?
38 let health: DomainHealth
39 let lastMonitoringFailure: Date?
40 let instabilityScore: Int
41 let certificateExpiryState: CertificateWarningLevel
37 let workflowContext: DomainWorkflowContext? 42 let workflowContext: DomainWorkflowContext?
38 let metadata: DomainReportMetadata 43 let metadata: DomainReportMetadata
39} 44}
@@ -223,7 +228,29 @@ struct DomainReportBuilder {
223 primaryIP: String?, 228 primaryIP: String?,
224 changeSummary: DomainChangeSummary? 229 changeSummary: DomainChangeSummary?
225 ) -> DomainReport { 230 ) -> DomainReport {
226 DomainReport( 231 let certificateExpiryState = DomainDiffService.certificateWarningLevel(for: snapshot)
232 let recentChangeCount = changeSummary?.hasChanges == true ? 1 : 0
233 let instabilityScore = DomainHealth.instabilityScore(
234 recentChangeCount: recentChangeCount,
235 recentFailureCount: 0,
236 pendingAlertCount: 0,
237 hasRecentDNSChange: changeSummary?.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) == true
238 )
239 let hasInvalidTLS = snapshot.sslInfo == nil && snapshot.sslError != nil
240 let isReachable = !snapshot.reachabilityResults.isEmpty
241 ? snapshot.reachabilityResults.contains(where: \.reachable)
242 : snapshot.reachabilityError == nil
243 let health = DomainHealth.classify(
244 certificateExpiryState: certificateExpiryState,
245 isReachable: isReachable,
246 recentMonitoringFailureCount: 0,
247 hasRecentDNSChange: changeSummary?.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) == true,
248 instabilityScore: instabilityScore,
249 hasRecentCriticalChange: changeSummary?.impactClassification == .critical,
250 hasInvalidTLS: hasInvalidTLS
251 )
252
253 return DomainReport(
227 domain: snapshot.domain, 254 domain: snapshot.domain,
228 timestamp: snapshot.timestamp, 255 timestamp: snapshot.timestamp,
229 provenance: DomainReportProvenance( 256 provenance: DomainReportProvenance(
@@ -309,6 +336,11 @@ struct DomainReportBuilder {
309 riskAssessment: analysis.riskAssessment, 336 riskAssessment: analysis.riskAssessment,
310 insights: analysis.insights, 337 insights: analysis.insights,
311 changeSummary: changeSummary, 338 changeSummary: changeSummary,
339 lastChangeDate: changeSummary?.hasChanges == true ? snapshot.timestamp : nil,
340 health: health,
341 lastMonitoringFailure: nil,
342 instabilityScore: instabilityScore,
343 certificateExpiryState: certificateExpiryState,
312 workflowContext: workflowContext, 344 workflowContext: workflowContext,
313 metadata: DomainReportMetadata( 345 metadata: DomainReportMetadata(
314 schemaVersion: "3.7.0", 346 schemaVersion: "3.7.0",