Commit fd34553ab0
Verified · cmc
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +4 −4
| @@ -366,7 +366,7 @@ | ||
| 366 | 366 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 367 | 367 | CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; |
| 368 | 368 | CODE_SIGN_STYLE = Automatic; |
| 369 | CURRENT_PROJECT_VERSION = 30; | |
| 369 | CURRENT_PROJECT_VERSION = 31; | |
| 370 | 370 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 371 | 371 | ENABLE_PREVIEWS = YES; |
| 372 | 372 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -383,7 +383,7 @@ | ||
| 383 | 383 | "$(inherited)", |
| 384 | 384 | "@executable_path/Frameworks", |
| 385 | 385 | ); |
| 386 | MARKETING_VERSION = 3.8.0; | |
| 386 | MARKETING_VERSION = 3.9.0; | |
| 387 | 387 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 388 | 388 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 389 | 389 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -403,7 +403,7 @@ | ||
| 403 | 403 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 404 | 404 | CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; |
| 405 | 405 | CODE_SIGN_STYLE = Automatic; |
| 406 | CURRENT_PROJECT_VERSION = 30; | |
| 406 | CURRENT_PROJECT_VERSION = 31; | |
| 407 | 407 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 408 | 408 | ENABLE_PREVIEWS = YES; |
| 409 | 409 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -420,7 +420,7 @@ | ||
| 420 | 420 | "$(inherited)", |
| 421 | 421 | "@executable_path/Frameworks", |
| 422 | 422 | ); |
| 423 | MARKETING_VERSION = 3.8.0; | |
| 423 | MARKETING_VERSION = 3.9.0; | |
| 424 | 424 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 425 | 425 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 426 | 426 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist +2 −2
| @@ -7,12 +7,12 @@ | ||
| 7 | 7 | <key>DomainDig.xcscheme_^#shared#^_</key> |
| 8 | 8 | <dict> |
| 9 | 9 | <key>orderHint</key> |
| 10 | <integer>1</integer> | |
| 10 | <integer>0</integer> | |
| 11 | 11 | </dict> |
| 12 | 12 | <key>DomainDigCLI.xcscheme_^#shared#^_</key> |
| 13 | 13 | <dict> |
| 14 | 14 | <key>orderHint</key> |
| 15 | <integer>0</integer> | |
| 15 | <integer>1</integer> | |
| 16 | 16 | </dict> |
| 17 | 17 | </dict> |
| 18 | 18 | <key>SuppressBuildableAutocreation</key> |
DomainDig/ContentView.swift +9 −1
| @@ -2518,6 +2518,10 @@ struct SettingsView: View { | ||
| 2518 | 2518 | } |
| 2519 | 2519 | |
| 2520 | 2520 | Section("Preferences") { |
| 2521 | NavigationLink("Tracked Domains") { | |
| 2522 | WatchlistView(viewModel: viewModel) | |
| 2523 | } | |
| 2524 | ||
| 2521 | 2525 | NavigationLink("Workflows") { |
| 2522 | 2526 | WorkflowsView(viewModel: viewModel) |
| 2523 | 2527 | } |
| @@ -2532,6 +2536,10 @@ struct SettingsView: View { | ||
| 2532 | 2536 | } |
| 2533 | 2537 | |
| 2534 | 2538 | Section("Services") { |
| 2539 | NavigationLink("Monitoring Activity") { | |
| 2540 | MonitoringView(viewModel: viewModel) | |
| 2541 | } | |
| 2542 | ||
| 2535 | 2543 | NavigationLink("iCloud Sync") { |
| 2536 | 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 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct 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 | ||
| 265 | private 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 | ||
| 305 | private 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 | ||
| 340 | private 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 | ||
| 379 | private 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 | 104 | let reports: [DomainReport] |
| 105 | 105 | } |
| 106 | 106 | |
| 107 | struct 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 | ||
| 126 | struct 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 | ||
| 136 | struct 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 | ||
| 145 | struct PortfolioGroup: Identifiable { | |
| 146 | let apexDomain: String | |
| 147 | let domains: [PortfolioDomainStatus] | |
| 148 | ||
| 149 | var id: String { apexDomain } | |
| 150 | } | |
| 151 | ||
| 152 | struct 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 | ||
| 161 | private struct PortfolioDashboardStamp: Equatable { | |
| 162 | let trackedDomainSignature: [String] | |
| 163 | let historySignature: [String] | |
| 164 | let monitoringSignature: [String] | |
| 165 | } | |
| 166 | ||
| 107 | 167 | @MainActor |
| 108 | 168 | @Observable |
| 109 | 169 | final class DomainViewModel { |
| @@ -214,6 +274,8 @@ final class DomainViewModel { | ||
| 214 | 274 | private var trackedDomainsPersistenceDirty = false |
| 215 | 275 | private let reportBuilder = DomainReportBuilder() |
| 216 | 276 | private let inspectionService = DomainInspectionService() |
| 277 | private var cachedPortfolioDashboardData: PortfolioDashboardData? | |
| 278 | private var cachedPortfolioDashboardStamp: PortfolioDashboardStamp? | |
| 217 | 279 | private(set) var currentResultSource: LookupResultSource = .live |
| 218 | 280 | private(set) var currentCachedSections: [LookupSectionKind] = [] |
| 219 | 281 | private(set) var currentStatusMessage: String? |
| @@ -246,6 +308,8 @@ final class DomainViewModel { | ||
| 246 | 308 | var watchlistSearchText = "" |
| 247 | 309 | var watchlistFilter: WatchlistFilterOption = .all |
| 248 | 310 | var watchlistSortOption: WatchlistSortOption = .pinned |
| 311 | var dashboardSearchText = "" | |
| 312 | var dashboardFilter: PortfolioFilterOption = .all | |
| 249 | 313 | var monitoringSettings: MonitoringSettings = MonitoringStorage.loadSettings() |
| 250 | 314 | var monitoringLogs: [MonitoringLog] = MonitoringStorage.loadLogs() |
| 251 | 315 | var monitoringRunInProgress = false |
| @@ -377,6 +441,73 @@ final class DomainViewModel { | ||
| 377 | 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 | 511 | var timelineDomains: [String] { |
| 381 | 512 | let query = timelineDomainFilter.trimmingCharacters(in: .whitespacesAndNewlines) |
| 382 | 513 | let domains = history.map(\.domain) |
| @@ -2807,6 +2938,349 @@ final class DomainViewModel { | ||
| 2807 | 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 | 3284 | func resolverMismatchNote(for entry: HistoryEntry) -> String? { |
| 2811 | 3285 | guard entry.resolverURLString != resolverURLString else { return nil } |
| 2812 | 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 | |
| 179 | enum 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 | ||
| 189 | struct 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 | ||
| 179 | 199 | struct DomainChangeSummary: Codable, Equatable { |
| 180 | 200 | let hasChanges: Bool |
| 181 | 201 | let changedSections: [String] |
| @@ -656,6 +676,80 @@ enum WatchlistSortOption: String, CaseIterable, Identifiable { | ||
| 656 | 676 | } |
| 657 | 677 | } |
| 658 | 678 | |
| 679 | enum 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 | ||
| 710 | extension 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 | ||
| 659 | 753 | enum PremiumCapability: String, Codable { |
| 660 | 754 | case unlimitedTrackedDomains |
| 661 | 755 | case automatedMonitoring |
DomainDig/RootTabView.swift +26 −16
| @@ -1,31 +1,28 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | 2 | |
| 3 | private enum RootTab: Hashable { | |
| 4 | case dashboard | |
| 5 | case history | |
| 6 | case inspect | |
| 7 | case settings | |
| 8 | } | |
| 9 | ||
| 3 | 10 | struct RootTabView: View { |
| 4 | 11 | @Bindable var viewModel: DomainViewModel |
| 5 | 12 | @State private var purchaseService = PurchaseService.shared |
| 13 | @State private var selectedTab: RootTab = FeatureAccessService.currentTier == .free ? .inspect : .dashboard | |
| 6 | 14 | |
| 7 | 15 | var body: some View { |
| 8 | 16 | let _ = purchaseService.currentTier |
| 9 | 17 | |
| 10 | TabView { | |
| 11 | ContentView(viewModel: viewModel) | |
| 12 | .tabItem { | |
| 13 | Label("Inspect", systemImage: "magnifyingglass") | |
| 14 | } | |
| 15 | ||
| 18 | TabView(selection: $selectedTab) { | |
| 16 | 19 | NavigationStack { |
| 17 | WatchlistView(viewModel: viewModel) | |
| 20 | DashboardView(viewModel: viewModel) | |
| 18 | 21 | } |
| 19 | 22 | .tabItem { |
| 20 | Label("Watchlist", systemImage: "eye") | |
| 21 | } | |
| 22 | ||
| 23 | NavigationStack { | |
| 24 | MonitoringView(viewModel: viewModel) | |
| 25 | } | |
| 26 | .tabItem { | |
| 27 | Label("Monitoring", systemImage: "waveform.path.ecg") | |
| 23 | Label("Dashboard", systemImage: "square.grid.2x2") | |
| 28 | 24 | } |
| 25 | .tag(RootTab.dashboard) | |
| 29 | 26 | |
| 30 | 27 | NavigationStack { |
| 31 | 28 | HistoryView(viewModel: viewModel) |
| @@ -33,13 +30,21 @@ struct RootTabView: View { | ||
| 33 | 30 | .tabItem { |
| 34 | 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 | 41 | NavigationStack { |
| 38 | 42 | SettingsView(viewModel: viewModel) |
| 39 | 43 | } |
| 40 | 44 | .tabItem { |
| 41 | Label("More", systemImage: "ellipsis.circle") | |
| 45 | Label("Settings", systemImage: "gearshape") | |
| 42 | 46 | } |
| 47 | .tag(RootTab.settings) | |
| 43 | 48 | } |
| 44 | 49 | .sheet(isPresented: Binding( |
| 45 | 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 | 34 | let riskAssessment: DomainRiskAssessment |
| 35 | 35 | let insights: [String] |
| 36 | 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 | 42 | let workflowContext: DomainWorkflowContext? |
| 38 | 43 | let metadata: DomainReportMetadata |
| 39 | 44 | } |
| @@ -223,7 +228,29 @@ struct DomainReportBuilder { | ||
| 223 | 228 | primaryIP: String?, |
| 224 | 229 | changeSummary: DomainChangeSummary? |
| 225 | 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 | 254 | domain: snapshot.domain, |
| 228 | 255 | timestamp: snapshot.timestamp, |
| 229 | 256 | provenance: DomainReportProvenance( |
| @@ -309,6 +336,11 @@ struct DomainReportBuilder { | ||
| 309 | 336 | riskAssessment: analysis.riskAssessment, |
| 310 | 337 | insights: analysis.insights, |
| 311 | 338 | changeSummary: changeSummary, |
| 339 | lastChangeDate: changeSummary?.hasChanges == true ? snapshot.timestamp : nil, | |
| 340 | health: health, | |
| 341 | lastMonitoringFailure: nil, | |
| 342 | instabilityScore: instabilityScore, | |
| 343 | certificateExpiryState: certificateExpiryState, | |
| 312 | 344 | workflowContext: workflowContext, |
| 313 | 345 | metadata: DomainReportMetadata( |
| 314 | 346 | schemaVersion: "3.7.0", |