aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-25 00:03:59 -0500
committerChristian Cleberg <[email protected]>2026-04-25 00:03:59 -0500
commitfd34553ab0e63b4d3bc22532b61f4fef32846f34 (patch)
tree1317656b64970b113e195dcb29d71e443eb4354d
parent3f0b7746b1bee4c2f626eb805e3fd13c5f2f5d22 (diff)
downloaddomain-dig-fd34553ab0e63b4d3bc22532b61f4fef32846f34.tar.gz
domain-dig-fd34553ab0e63b4d3bc22532b61f4fef32846f34.tar.bz2
domain-dig-fd34553ab0e63b4d3bc22532b61f4fef32846f34.zip
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
-rw-r--r--DomainDig.xcodeproj/project.pbxproj8
-rw-r--r--DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist4
-rw-r--r--DomainDig/ContentView.swift10
-rw-r--r--DomainDig/DashboardView.swift383
-rw-r--r--DomainDig/DomainViewModel.swift474
-rw-r--r--DomainDig/Models.swift94
-rw-r--r--DomainDig/RootTabView.swift42
-rw-r--r--DomainReportBuilder.swift34
8 files changed, 1025 insertions, 24 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index d015b63..a6799c2 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -366,7 +366,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 30;
+ CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -383,7 +383,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 3.8.0;
+ MARKETING_VERSION = 3.9.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -403,7 +403,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 30;
+ CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -420,7 +420,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 3.8.0;
+ MARKETING_VERSION = 3.9.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
index b7ae4b0..fbb9f70 100644
--- a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -7,12 +7,12 @@
<key>DomainDig.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
- <integer>1</integer>
+ <integer>0</integer>
</dict>
<key>DomainDigCLI.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
- <integer>0</integer>
+ <integer>1</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index 5f119ca..754909c 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -2518,6 +2518,10 @@ struct SettingsView: View {
}
Section("Preferences") {
+ NavigationLink("Tracked Domains") {
+ WatchlistView(viewModel: viewModel)
+ }
+
NavigationLink("Workflows") {
WorkflowsView(viewModel: viewModel)
}
@@ -2532,6 +2536,10 @@ struct SettingsView: View {
}
Section("Services") {
+ NavigationLink("Monitoring Activity") {
+ MonitoringView(viewModel: viewModel)
+ }
+
NavigationLink("iCloud Sync") {
CloudSyncSettingsView()
}
@@ -2557,7 +2565,7 @@ struct SettingsView: View {
}
}
}
- .navigationTitle("More")
+ .navigationTitle("Settings")
}
}
diff --git a/DomainDig/DashboardView.swift b/DomainDig/DashboardView.swift
new file mode 100644
index 0000000..087f080
--- /dev/null
+++ b/DomainDig/DashboardView.swift
@@ -0,0 +1,383 @@
+import SwiftUI
+
+struct DashboardView: View {
+ @Environment(\.appDensity) private var appDensity
+ @Bindable var viewModel: DomainViewModel
+ @State private var collapsedGroups = Set<String>()
+
+ private let summaryColumns = [
+ GridItem(.flexible(), spacing: 10),
+ GridItem(.flexible(), spacing: 10)
+ ]
+
+ var body: some View {
+ List {
+ if viewModel.trackedDomains.isEmpty {
+ Section {
+ EmptyStateCardView(
+ title: "No Portfolio Yet",
+ message: "Track domains to get portfolio health, recent changes, expiry visibility, and an attention queue in one place.",
+ suggestion: "Inspect a domain and use the Track action, or add one directly from Tracked Domains in Settings.",
+ systemImage: "square.stack.3d.up.fill",
+ showsCardBackground: false
+ )
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+ } else {
+ Section {
+ LazyVGrid(columns: summaryColumns, spacing: 10) {
+ summaryCard(title: "Total Domains", value: viewModel.portfolioDashboardData.snapshot.totalDomains, filter: .all, tint: .cyan)
+ summaryCard(title: "Healthy", value: viewModel.portfolioDashboardData.snapshot.healthyCount, filter: .healthy, tint: .green)
+ summaryCard(title: "Warning", value: viewModel.portfolioDashboardData.snapshot.warningCount, filter: .warning, tint: .yellow)
+ summaryCard(title: "Critical", value: viewModel.portfolioDashboardData.snapshot.criticalCount, filter: .critical, tint: .red)
+ summaryCard(title: "Changes (24h)", value: viewModel.portfolioDashboardData.snapshot.changedLast24h, filter: .changed, tint: .orange)
+ summaryCard(title: "Unreachable", value: viewModel.portfolioDashboardData.snapshot.unreachableCount, filter: .unreachable, tint: .pink)
+ }
+ .padding(.vertical, 4)
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+ Section("Quick Filters") {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ ForEach(PortfolioFilterOption.allCases) { filter in
+ Button {
+ viewModel.dashboardFilter = filter
+ } label: {
+ Text(filter.title)
+ .font(appDensity.font(.caption, weight: .semibold))
+ .foregroundStyle(viewModel.dashboardFilter == filter ? Color.black : Color.white)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .background(viewModel.dashboardFilter == filter ? Color.cyan : Color(.systemGray5).opacity(0.6))
+ .clipShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+ Section("Recent Activity") {
+ if viewModel.filteredPortfolioRecentActivity.isEmpty {
+ dashboardEmptyRow("No recent portfolio changes")
+ } else {
+ ForEach(viewModel.filteredPortfolioRecentActivity.prefix(8)) { item in
+ if let trackedDomain = viewModel.trackedDomain(withID: item.trackedDomainID) {
+ NavigationLink {
+ TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
+ } label: {
+ PortfolioActivityRow(item: item)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+ Section("Attention Required") {
+ if viewModel.filteredPortfolioAttentionRequired.isEmpty {
+ dashboardEmptyRow("Nothing urgent right now")
+ } else {
+ ForEach(viewModel.filteredPortfolioAttentionRequired.prefix(8)) { item in
+ if let trackedDomain = viewModel.trackedDomain(withID: item.trackedDomainID) {
+ NavigationLink {
+ TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
+ } label: {
+ PortfolioAttentionRow(item: item)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+ Section("Expiring Soon") {
+ if viewModel.filteredPortfolioExpiringSoon.isEmpty {
+ dashboardEmptyRow("No certificates expiring within 30 days")
+ } else {
+ ForEach(viewModel.filteredPortfolioExpiringSoon.prefix(8)) { state in
+ NavigationLink {
+ TrackedDomainDetailView(viewModel: viewModel, trackedDomain: state.trackedDomain)
+ } label: {
+ PortfolioExpiryRow(state: state)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+ Section("Portfolio List") {
+ if viewModel.filteredPortfolioGroups.isEmpty {
+ dashboardEmptyRow("No domains match the current filter")
+ } else {
+ ForEach(viewModel.filteredPortfolioGroups) { group in
+ DisclosureGroup(
+ isExpanded: disclosureBinding(for: group.apexDomain),
+ content: {
+ ForEach(group.domains) { state in
+ NavigationLink {
+ TrackedDomainDetailView(viewModel: viewModel, trackedDomain: state.trackedDomain)
+ } label: {
+ WatchlistRowView(
+ trackedDomain: state.trackedDomain,
+ isRefreshing: viewModel.refreshingTrackedDomainID == state.trackedDomain.id
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ },
+ label: {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(group.apexDomain)
+ .font(appDensity.font(.headline, design: .default, weight: .semibold))
+ Text("\(group.domains.count) domain\(group.domains.count == 1 ? "" : "s")")
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ groupBadge(for: group.domains)
+ }
+ .padding(.vertical, 4)
+ }
+ )
+ }
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+ }
+ }
+ .scrollContentBackground(.hidden)
+ .background(Color.black)
+ .navigationTitle("Dashboard")
+ .searchable(text: $viewModel.dashboardSearchText, prompt: "Search portfolio")
+ .toolbar {
+ if !viewModel.trackedDomains.isEmpty {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ AppHaptics.refresh()
+ viewModel.refreshAllTrackedDomains()
+ } label: {
+ Image(systemName: "arrow.clockwise")
+ }
+ .disabled(viewModel.batchLookupRunning)
+ }
+ }
+ }
+ .preferredColorScheme(.dark)
+ }
+
+ private func disclosureBinding(for apexDomain: String) -> Binding<Bool> {
+ Binding(
+ get: { !collapsedGroups.contains(apexDomain) },
+ set: { isExpanded in
+ if isExpanded {
+ collapsedGroups.remove(apexDomain)
+ } else {
+ collapsedGroups.insert(apexDomain)
+ }
+ }
+ )
+ }
+
+ private func summaryCard(title: String, value: Int, filter: PortfolioFilterOption, tint: Color) -> some View {
+ Button {
+ viewModel.dashboardFilter = filter
+ } label: {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(title)
+ .font(appDensity.font(.caption, design: .default, weight: .semibold))
+ .foregroundStyle(.secondary)
+ Text("\(value)")
+ .font(.system(size: 28, weight: .bold, design: .rounded))
+ .foregroundStyle(.white)
+ HStack {
+ Circle()
+ .fill(tint)
+ .frame(width: 8, height: 8)
+ Text(filter.title)
+ .font(appDensity.font(.caption2, design: .default, weight: .semibold))
+ .foregroundStyle(tint)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(appDensity.metrics.cardPadding)
+ .background(cardBackground(for: filter))
+ .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
+ }
+ .buttonStyle(.plain)
+ }
+
+ private func cardBackground(for filter: PortfolioFilterOption) -> some ShapeStyle {
+ if viewModel.dashboardFilter == filter {
+ return AnyShapeStyle(
+ LinearGradient(
+ colors: [Color.cyan.opacity(0.28), Color.cyan.opacity(0.12)],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ )
+ }
+ return AnyShapeStyle(Color(.systemGray5).opacity(0.45))
+ }
+
+ private func dashboardEmptyRow(_ message: String) -> some View {
+ Text(message)
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
+ .padding(.vertical, 4)
+ }
+
+ private func groupBadge(for states: [PortfolioDomainStatus]) -> some View {
+ let criticalCount = states.filter { $0.health == .critical }.count
+ let warningCount = states.filter { $0.health == .warning }.count
+ let title: String
+ let color: Color
+
+ if criticalCount > 0 {
+ title = "\(criticalCount) critical"
+ color = .red
+ } else if warningCount > 0 {
+ title = "\(warningCount) warning"
+ color = .yellow
+ } else {
+ title = "Healthy"
+ color = .green
+ }
+
+ return AppStatusBadgeView(
+ model: .init(
+ title: title,
+ systemImage: criticalCount > 0 ? "exclamationmark.octagon.fill" : (warningCount > 0 ? "exclamationmark.triangle.fill" : "checkmark.circle.fill"),
+ foregroundColor: color,
+ backgroundColor: color.opacity(0.16)
+ )
+ )
+ }
+}
+
+private struct PortfolioActivityRow: View {
+ @Environment(\.appDensity) private var appDensity
+ let item: PortfolioActivityItem
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 10) {
+ Image(systemName: item.systemImage)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(iconColor)
+ .frame(width: 22, height: 22)
+ .background(iconColor.opacity(0.14))
+ .clipShape(RoundedRectangle(cornerRadius: 7))
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(item.message)
+ .font(appDensity.font(.callout, design: .default))
+ .foregroundStyle(.primary)
+ HStack(spacing: 8) {
+ Text(item.domain)
+ Text(relativeTimestamp(item.timestamp))
+ }
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+
+ private var iconColor: Color {
+ switch item.health {
+ case .healthy:
+ return .cyan
+ case .warning:
+ return .yellow
+ case .critical:
+ return .red
+ }
+ }
+}
+
+private struct PortfolioAttentionRow: View {
+ @Environment(\.appDensity) private var appDensity
+ let item: PortfolioAttentionItem
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 10) {
+ AppStatusBadgeView(model: badgeModel)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(item.domain)
+ .font(appDensity.font(.callout))
+ .foregroundStyle(.primary)
+ Text(item.reason)
+ .font(appDensity.font(.caption, design: .default))
+ .foregroundStyle(.secondary)
+ Text(relativeTimestamp(item.timestamp))
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+
+ private var badgeModel: AppStatusBadgeModel {
+ switch item.health {
+ case .healthy:
+ return .init(title: "Healthy", systemImage: "checkmark.circle.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
+ case .warning:
+ return .init(title: "Warning", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
+ case .critical:
+ return .init(title: "Critical", systemImage: "exclamationmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
+ }
+ }
+}
+
+private struct PortfolioExpiryRow: View {
+ @Environment(\.appDensity) private var appDensity
+ let state: PortfolioDomainStatus
+
+ var body: some View {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(state.trackedDomain.domain)
+ .font(appDensity.font(.callout))
+ .foregroundStyle(.primary)
+ Text(expirySubtitle)
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ AppStatusBadgeView(model: badgeModel)
+ }
+ .padding(.vertical, 4)
+ }
+
+ private var expirySubtitle: String {
+ if let days = state.certificateDaysRemaining {
+ return "Expires in \(days) day\(days == 1 ? "" : "s")"
+ }
+ return "Certificate needs review"
+ }
+
+ private var badgeModel: AppStatusBadgeModel {
+ switch state.certificateExpiryState {
+ case .none:
+ return .init(title: "Healthy", systemImage: "lock.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
+ case .warning:
+ return .init(title: "Warning", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
+ case .critical:
+ return .init(title: "Critical", systemImage: "xmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
+ }
+ }
+}
+
+private func relativeTimestamp(_ date: Date) -> String {
+ let formatter = RelativeDateTimeFormatter()
+ formatter.unitsStyle = .short
+ return formatter.localizedString(for: date, relativeTo: Date())
+}
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 03e9ad4..b65c6df 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -104,6 +104,66 @@ private struct WorkflowExportPayload: Codable {
let reports: [DomainReport]
}
+struct PortfolioDomainStatus: Identifiable {
+ let trackedDomain: TrackedDomain
+ let latestEntry: HistoryEntry?
+ let report: DomainReport
+ let apexDomain: String
+ let health: DomainHealth
+ let lastChangeDate: Date?
+ let lastMonitoringFailure: Date?
+ let instabilityScore: Int
+ let certificateExpiryState: CertificateWarningLevel
+ let certificateDaysRemaining: Int?
+ let isUnreachable: Bool
+ let recentDNSChange: Bool
+ let recentCriticalChange: Bool
+ let recentFailureCount: Int
+
+ var id: UUID { trackedDomain.id }
+}
+
+struct PortfolioActivityItem: Identifiable {
+ let id: String
+ let trackedDomainID: UUID
+ let domain: String
+ let message: String
+ let timestamp: Date
+ let health: DomainHealth
+ let systemImage: String
+}
+
+struct PortfolioAttentionItem: Identifiable {
+ let id: String
+ let trackedDomainID: UUID
+ let domain: String
+ let reason: String
+ let timestamp: Date
+ let health: DomainHealth
+}
+
+struct PortfolioGroup: Identifiable {
+ let apexDomain: String
+ let domains: [PortfolioDomainStatus]
+
+ var id: String { apexDomain }
+}
+
+struct PortfolioDashboardData {
+ let snapshot: PortfolioSnapshot
+ let domainStates: [PortfolioDomainStatus]
+ let recentActivity: [PortfolioActivityItem]
+ let attentionRequired: [PortfolioAttentionItem]
+ let expiringSoon: [PortfolioDomainStatus]
+ let groups: [PortfolioGroup]
+}
+
+private struct PortfolioDashboardStamp: Equatable {
+ let trackedDomainSignature: [String]
+ let historySignature: [String]
+ let monitoringSignature: [String]
+}
+
@MainActor
@Observable
final class DomainViewModel {
@@ -214,6 +274,8 @@ final class DomainViewModel {
private var trackedDomainsPersistenceDirty = false
private let reportBuilder = DomainReportBuilder()
private let inspectionService = DomainInspectionService()
+ private var cachedPortfolioDashboardData: PortfolioDashboardData?
+ private var cachedPortfolioDashboardStamp: PortfolioDashboardStamp?
private(set) var currentResultSource: LookupResultSource = .live
private(set) var currentCachedSections: [LookupSectionKind] = []
private(set) var currentStatusMessage: String?
@@ -246,6 +308,8 @@ final class DomainViewModel {
var watchlistSearchText = ""
var watchlistFilter: WatchlistFilterOption = .all
var watchlistSortOption: WatchlistSortOption = .pinned
+ var dashboardSearchText = ""
+ var dashboardFilter: PortfolioFilterOption = .all
var monitoringSettings: MonitoringSettings = MonitoringStorage.loadSettings()
var monitoringLogs: [MonitoringLog] = MonitoringStorage.loadLogs()
var monitoringRunInProgress = false
@@ -377,6 +441,73 @@ final class DomainViewModel {
return sortedTrackedDomains(from: filtered, using: watchlistSortOption)
}
+ var portfolioDashboardData: PortfolioDashboardData {
+ let stamp = portfolioDashboardStamp()
+ if let cachedPortfolioDashboardData, cachedPortfolioDashboardStamp == stamp {
+ return cachedPortfolioDashboardData
+ }
+
+ let domainStates = buildPortfolioDomainStates()
+ let recentActivity = buildPortfolioActivity(from: domainStates)
+ let attentionRequired = buildAttentionQueue(from: domainStates)
+ let expiringSoon = domainStates
+ .filter { $0.certificateExpiryState != .none }
+ .sorted {
+ ($0.certificateDaysRemaining ?? .max, $0.trackedDomain.domain)
+ < ($1.certificateDaysRemaining ?? .max, $1.trackedDomain.domain)
+ }
+ let groups = buildPortfolioGroups(from: domainStates)
+ let snapshot = PortfolioSnapshot(
+ totalDomains: domainStates.count,
+ healthyCount: domainStates.filter { $0.health == .healthy }.count,
+ warningCount: domainStates.filter { $0.health == .warning }.count,
+ criticalCount: domainStates.filter { $0.health == .critical }.count,
+ changedLast24h: domainStates.filter {
+ guard let lastChangeDate = $0.lastChangeDate else { return false }
+ return Date().timeIntervalSince(lastChangeDate) <= 24 * 60 * 60
+ }.count,
+ expiringSoonCount: expiringSoon.count,
+ unreachableCount: domainStates.filter(\.isUnreachable).count
+ )
+ let data = PortfolioDashboardData(
+ snapshot: snapshot,
+ domainStates: domainStates,
+ recentActivity: recentActivity,
+ attentionRequired: attentionRequired,
+ expiringSoon: expiringSoon,
+ groups: groups
+ )
+ cachedPortfolioDashboardStamp = stamp
+ cachedPortfolioDashboardData = data
+ return data
+ }
+
+ var filteredPortfolioDomainStates: [PortfolioDomainStatus] {
+ let query = dashboardSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
+ return portfolioDashboardData.domainStates.filter { state in
+ matchesPortfolioFilter(state) && matchesDashboardSearch(state, query: query)
+ }
+ }
+
+ var filteredPortfolioGroups: [PortfolioGroup] {
+ buildPortfolioGroups(from: filteredPortfolioDomainStates)
+ }
+
+ var filteredPortfolioRecentActivity: [PortfolioActivityItem] {
+ let visibleDomainIDs = Set(filteredPortfolioDomainStates.map(\.trackedDomain.id))
+ return portfolioDashboardData.recentActivity.filter { visibleDomainIDs.contains($0.trackedDomainID) }
+ }
+
+ var filteredPortfolioAttentionRequired: [PortfolioAttentionItem] {
+ let visibleDomainIDs = Set(filteredPortfolioDomainStates.map(\.trackedDomain.id))
+ return portfolioDashboardData.attentionRequired.filter { visibleDomainIDs.contains($0.trackedDomainID) }
+ }
+
+ var filteredPortfolioExpiringSoon: [PortfolioDomainStatus] {
+ let visibleDomainIDs = Set(filteredPortfolioDomainStates.map(\.trackedDomain.id))
+ return portfolioDashboardData.expiringSoon.filter { visibleDomainIDs.contains($0.trackedDomain.id) }
+ }
+
var timelineDomains: [String] {
let query = timelineDomainFilter.trimmingCharacters(in: .whitespacesAndNewlines)
let domains = history.map(\.domain)
@@ -2807,6 +2938,349 @@ final class DomainViewModel {
recentSnapshots(for: trackedDomain, limit: 1).first?.snapshot
}
+ func trackedDomain(withID id: UUID) -> TrackedDomain? {
+ trackedDomains.first(where: { $0.id == id })
+ }
+
+ private func portfolioDashboardStamp() -> PortfolioDashboardStamp {
+ PortfolioDashboardStamp(
+ trackedDomainSignature: trackedDomains
+ .sorted { $0.id.uuidString < $1.id.uuidString }
+ .map {
+ [
+ $0.id.uuidString,
+ $0.updatedAt.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
+ String($0.pendingMonitoringAlerts.count),
+ $0.lastMonitoredAt?.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))) ?? "0",
+ $0.lastAlertAt?.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))) ?? "0"
+ ].joined(separator: "|")
+ },
+ historySignature: history
+ .prefix(250)
+ .map {
+ [
+ $0.id.uuidString,
+ $0.timestamp.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
+ String($0.changeCount),
+ $0.severitySummary?.rawValue.description ?? "-"
+ ].joined(separator: "|")
+ },
+ monitoringSignature: monitoringLogs
+ .prefix(100)
+ .map {
+ [
+ $0.id.uuidString,
+ $0.timestamp.timeIntervalSinceReferenceDate.formatted(.number.precision(.fractionLength(3))),
+ String($0.alertsTriggered),
+ String($0.changesFound)
+ ].joined(separator: "|")
+ }
+ )
+ }
+
+ private func buildPortfolioDomainStates() -> [PortfolioDomainStatus] {
+ let now = Date()
+ return sortedTrackedDomains(from: trackedDomains, using: .pinned).map { trackedDomain in
+ let recentEntries = recentSnapshots(for: trackedDomain, limit: 8)
+ let latestEntry = recentEntries.first
+ let report = latestEntry.map { self.report(for: $0) } ?? reportBuilder.build(from: placeholderSnapshot(for: trackedDomain))
+ let recentMonitoringResults = monitoringLogs
+ .flatMap(\.checkedDomains)
+ .filter { $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }
+ .sorted { $0.checkedAt > $1.checkedAt }
+ let recentFailureResults = recentMonitoringResults.filter {
+ let isFailure = $0.errorMessage != nil || ($0.alertSeverity ?? .info) >= .warning
+ return isFailure && now.timeIntervalSince($0.checkedAt) <= 7 * 24 * 60 * 60
+ }
+ let recentChangeCount = recentEntries.filter {
+ guard let changeSummary = $0.changeSummary, changeSummary.hasChanges else { return false }
+ return now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
+ }.count
+ let recentDNSChange = recentEntries.contains {
+ guard let changeSummary = $0.changeSummary else { return false }
+ return changeSummary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") })
+ && now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
+ }
+ let recentCriticalChange = recentEntries.contains {
+ guard let changeSummary = $0.changeSummary else { return false }
+ return changeSummary.impactClassification == .critical
+ && now.timeIntervalSince($0.timestamp) <= 7 * 24 * 60 * 60
+ }
+ let certificateExpiryState = trackedDomain.certificateWarningLevel == .none
+ ? report.certificateExpiryState
+ : trackedDomain.certificateWarningLevel
+ let isUnreachable: Bool = {
+ if let latestEntry {
+ if !latestEntry.reachabilityResults.isEmpty {
+ return !latestEntry.reachabilityResults.contains(where: \.reachable)
+ }
+ if latestEntry.reachabilityError != nil {
+ return true
+ }
+ }
+ return recentFailureResults.contains { ($0.alertSeverity ?? .info) == .critical && $0.errorMessage != nil }
+ }()
+ let hasInvalidTLS = latestEntry?.sslInfo == nil && latestEntry?.sslError != nil
+ let instabilityScore = DomainHealth.instabilityScore(
+ recentChangeCount: recentChangeCount,
+ recentFailureCount: recentFailureResults.count,
+ pendingAlertCount: trackedDomain.pendingMonitoringAlerts.count,
+ hasRecentDNSChange: recentDNSChange
+ )
+ let health = DomainHealth.classify(
+ certificateExpiryState: certificateExpiryState,
+ isReachable: !isUnreachable,
+ recentMonitoringFailureCount: recentFailureResults.count,
+ hasRecentDNSChange: recentDNSChange,
+ instabilityScore: instabilityScore,
+ hasRecentCriticalChange: recentCriticalChange,
+ hasInvalidTLS: hasInvalidTLS
+ )
+
+ return PortfolioDomainStatus(
+ trackedDomain: trackedDomain,
+ latestEntry: latestEntry,
+ report: report,
+ apexDomain: apexDomain(for: trackedDomain.domain),
+ health: health,
+ lastChangeDate: latestEntry?.changeSummary?.hasChanges == true
+ ? latestEntry?.timestamp
+ : trackedDomain.monitoringState.lastChangeDate ?? report.lastChangeDate,
+ lastMonitoringFailure: recentFailureResults.first?.checkedAt,
+ instabilityScore: instabilityScore,
+ certificateExpiryState: certificateExpiryState,
+ certificateDaysRemaining: trackedDomain.certificateDaysRemaining ?? latestEntry?.sslInfo?.daysUntilExpiry,
+ isUnreachable: isUnreachable,
+ recentDNSChange: recentDNSChange,
+ recentCriticalChange: recentCriticalChange,
+ recentFailureCount: recentFailureResults.count
+ )
+ }
+ }
+
+ private func buildPortfolioActivity(from domainStates: [PortfolioDomainStatus]) -> [PortfolioActivityItem] {
+ var items: [PortfolioActivityItem] = []
+ let healthByDomainID = Dictionary(uniqueKeysWithValues: domainStates.map { ($0.trackedDomain.id, $0.health) })
+
+ for state in domainStates {
+ if let latestEntry = state.latestEntry,
+ let changeSummary = latestEntry.changeSummary,
+ changeSummary.hasChanges {
+ items.append(
+ PortfolioActivityItem(
+ id: "history|\(latestEntry.id.uuidString)",
+ trackedDomainID: state.trackedDomain.id,
+ domain: state.trackedDomain.domain,
+ message: portfolioHistoryMessage(for: latestEntry),
+ timestamp: latestEntry.timestamp,
+ health: state.health,
+ systemImage: portfolioHistoryIcon(for: latestEntry)
+ )
+ )
+ }
+ }
+
+ for log in monitoringLogs.prefix(25) {
+ for result in log.checkedDomains where result.didChange || result.errorMessage != nil || result.certificateWarningLevel != .none {
+ guard let trackedDomain = trackedDomain(for: result.domain) else { continue }
+ items.append(
+ PortfolioActivityItem(
+ id: "monitoring|\(log.id.uuidString)|\(result.id.uuidString)",
+ trackedDomainID: trackedDomain.id,
+ domain: trackedDomain.domain,
+ message: portfolioMonitoringMessage(for: result),
+ timestamp: result.checkedAt,
+ health: healthByDomainID[trackedDomain.id] ?? (result.alertSeverity == .critical ? .critical : .warning),
+ systemImage: portfolioMonitoringIcon(for: result)
+ )
+ )
+ }
+ }
+
+ var seen = Set<String>()
+ return items
+ .sorted { $0.timestamp > $1.timestamp }
+ .filter { item in
+ let key = "\(item.domain.lowercased())|\(item.message.lowercased())"
+ return seen.insert(key).inserted
+ }
+ }
+
+ private func buildAttentionQueue(from domainStates: [PortfolioDomainStatus]) -> [PortfolioAttentionItem] {
+ domainStates.compactMap { state in
+ guard state.health != .healthy else { return nil }
+ let reason: String
+ let timestamp: Date
+
+ if state.isUnreachable {
+ reason = "Endpoint is unreachable"
+ timestamp = state.lastMonitoringFailure ?? state.trackedDomain.updatedAt
+ } else if state.certificateExpiryState == .critical {
+ reason = "Certificate is expiring in under 14 days"
+ timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
+ } else if state.certificateExpiryState == .warning {
+ reason = "Certificate expires within 30 days"
+ timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
+ } else if state.recentFailureCount >= 2 || state.instabilityScore >= 70 {
+ reason = "Repeated instability detected"
+ timestamp = state.lastMonitoringFailure ?? state.trackedDomain.updatedAt
+ } else if state.recentDNSChange {
+ reason = "DNS changed recently"
+ timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
+ } else if state.recentCriticalChange {
+ reason = "Recent critical change detected"
+ timestamp = state.lastChangeDate ?? state.trackedDomain.updatedAt
+ } else {
+ reason = "Needs attention"
+ timestamp = state.trackedDomain.updatedAt
+ }
+
+ return PortfolioAttentionItem(
+ id: "\(state.trackedDomain.id.uuidString)|\(reason)",
+ trackedDomainID: state.trackedDomain.id,
+ domain: state.trackedDomain.domain,
+ reason: reason,
+ timestamp: timestamp,
+ health: state.health
+ )
+ }
+ .sorted { lhs, rhs in
+ if lhs.health != rhs.health {
+ return healthRank(lhs.health) > healthRank(rhs.health)
+ }
+ return lhs.timestamp > rhs.timestamp
+ }
+ }
+
+ private func buildPortfolioGroups(from domainStates: [PortfolioDomainStatus]) -> [PortfolioGroup] {
+ Dictionary(grouping: domainStates, by: \.apexDomain)
+ .map { apexDomain, domains in
+ PortfolioGroup(
+ apexDomain: apexDomain,
+ domains: domains.sorted { lhs, rhs in
+ if lhs.health != rhs.health {
+ return healthRank(lhs.health) > healthRank(rhs.health)
+ }
+ return lhs.trackedDomain.domain.localizedCaseInsensitiveCompare(rhs.trackedDomain.domain) == .orderedAscending
+ }
+ )
+ }
+ .sorted { lhs, rhs in
+ if let lhsMostSevere = lhs.domains.map(\.health).map(healthRank).max(),
+ let rhsMostSevere = rhs.domains.map(\.health).map(healthRank).max(),
+ lhsMostSevere != rhsMostSevere {
+ return lhsMostSevere > rhsMostSevere
+ }
+ return lhs.apexDomain.localizedCaseInsensitiveCompare(rhs.apexDomain) == .orderedAscending
+ }
+ }
+
+ private func matchesPortfolioFilter(_ state: PortfolioDomainStatus) -> Bool {
+ switch dashboardFilter {
+ case .all:
+ return true
+ case .healthy:
+ return state.health == .healthy
+ case .warning:
+ return state.health == .warning
+ case .critical:
+ return state.health == .critical
+ case .changed:
+ guard let lastChangeDate = state.lastChangeDate else { return false }
+ return Date().timeIntervalSince(lastChangeDate) <= 24 * 60 * 60
+ case .expiring:
+ return state.certificateExpiryState != .none
+ case .unreachable:
+ return state.isUnreachable
+ }
+ }
+
+ private func matchesDashboardSearch(_ state: PortfolioDomainStatus, query: String) -> Bool {
+ guard !query.isEmpty else { return true }
+ let normalizedQuery = query.lowercased()
+ return state.trackedDomain.domain.lowercased().contains(normalizedQuery)
+ || state.apexDomain.lowercased().contains(normalizedQuery)
+ }
+
+ private func portfolioHistoryMessage(for entry: HistoryEntry) -> String {
+ guard let summary = entry.changeSummary else {
+ return "Configuration changed for \(entry.domain)"
+ }
+ if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) {
+ return "DNS changed for \(entry.domain)"
+ }
+ if summary.changedSections.contains(where: {
+ $0.localizedCaseInsensitiveContains("certificate")
+ || $0.localizedCaseInsensitiveContains("tls")
+ }) {
+ return "Certificate updated for \(entry.domain)"
+ }
+ if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("redirect") }) {
+ return "Redirect chain changed for \(entry.domain)"
+ }
+ return summary.message
+ }
+
+ private func portfolioHistoryIcon(for entry: HistoryEntry) -> String {
+ guard let summary = entry.changeSummary else { return "clock.arrow.trianglehead.counterclockwise.rotate.90" }
+ if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) {
+ return "point.3.connected.trianglepath.dotted"
+ }
+ if summary.changedSections.contains(where: {
+ $0.localizedCaseInsensitiveContains("certificate")
+ || $0.localizedCaseInsensitiveContains("tls")
+ }) {
+ return "lock.rotation"
+ }
+ if summary.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("redirect") }) {
+ return "arrow.triangle.branch"
+ }
+ return "clock.arrow.trianglehead.counterclockwise.rotate.90"
+ }
+
+ private func portfolioMonitoringMessage(for result: MonitoringDomainResult) -> String {
+ if result.errorMessage != nil {
+ return "Monitoring failed for \(result.domain)"
+ }
+ if result.certificateWarningLevel != .none {
+ return "Certificate needs attention for \(result.domain)"
+ }
+ if result.didChange {
+ return result.summaryMessage.isEmpty ? "Monitoring detected a change for \(result.domain)" : result.summaryMessage
+ }
+ return "Monitoring updated \(result.domain)"
+ }
+
+ private func portfolioMonitoringIcon(for result: MonitoringDomainResult) -> String {
+ if result.errorMessage != nil {
+ return "xmark.octagon.fill"
+ }
+ if result.certificateWarningLevel != .none {
+ return "exclamationmark.triangle.fill"
+ }
+ return "waveform.path.ecg"
+ }
+
+ private func apexDomain(for domain: String) -> String {
+ let parts = domain
+ .lowercased()
+ .split(separator: ".")
+ .map(String.init)
+ guard parts.count > 2 else { return domain.lowercased() }
+ return parts.suffix(2).joined(separator: ".")
+ }
+
+ private func healthRank(_ health: DomainHealth) -> Int {
+ switch health {
+ case .healthy:
+ return 0
+ case .warning:
+ return 1
+ case .critical:
+ return 2
+ }
+ }
+
func resolverMismatchNote(for entry: HistoryEntry) -> String? {
guard entry.resolverURLString != resolverURLString else { return nil }
return "Current resolver differs from this snapshot. Re-running may produce different evidence."
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index f872aab..54bea10 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -176,6 +176,26 @@ enum CertificateWarningLevel: String, Codable {
}
}
+enum DomainHealth: String, Codable, Sendable, CaseIterable {
+ case healthy
+ case warning
+ case critical
+
+ var title: String {
+ rawValue.capitalized
+ }
+}
+
+struct PortfolioSnapshot: Codable, Sendable {
+ var totalDomains: Int
+ var healthyCount: Int
+ var warningCount: Int
+ var criticalCount: Int
+ var changedLast24h: Int
+ var expiringSoonCount: Int
+ var unreachableCount: Int
+}
+
struct DomainChangeSummary: Codable, Equatable {
let hasChanges: Bool
let changedSections: [String]
@@ -656,6 +676,80 @@ enum WatchlistSortOption: String, CaseIterable, Identifiable {
}
}
+enum PortfolioFilterOption: String, CaseIterable, Identifiable {
+ case all
+ case healthy
+ case warning
+ case critical
+ case changed
+ case expiring
+ case unreachable
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .all:
+ return "All"
+ case .healthy:
+ return "Healthy"
+ case .warning:
+ return "Warning"
+ case .critical:
+ return "Critical"
+ case .changed:
+ return "Changed"
+ case .expiring:
+ return "Expiring"
+ case .unreachable:
+ return "Unreachable"
+ }
+ }
+}
+
+extension DomainHealth {
+ static func classify(
+ certificateExpiryState: CertificateWarningLevel,
+ isReachable: Bool,
+ recentMonitoringFailureCount: Int,
+ hasRecentDNSChange: Bool,
+ instabilityScore: Int,
+ hasRecentCriticalChange: Bool,
+ hasInvalidTLS: Bool
+ ) -> DomainHealth {
+ if !isReachable
+ || hasInvalidTLS
+ || certificateExpiryState == .critical
+ || recentMonitoringFailureCount >= 2
+ || instabilityScore >= 70
+ || hasRecentCriticalChange {
+ return .critical
+ }
+
+ if certificateExpiryState == .warning
+ || recentMonitoringFailureCount == 1
+ || hasRecentDNSChange
+ || instabilityScore >= 35 {
+ return .warning
+ }
+
+ return .healthy
+ }
+
+ static func instabilityScore(
+ recentChangeCount: Int,
+ recentFailureCount: Int,
+ pendingAlertCount: Int,
+ hasRecentDNSChange: Bool
+ ) -> Int {
+ let rawScore = (recentChangeCount * 12)
+ + (recentFailureCount * 24)
+ + (pendingAlertCount * 8)
+ + (hasRecentDNSChange ? 14 : 0)
+ return min(rawScore, 100)
+ }
+}
+
enum PremiumCapability: String, Codable {
case unlimitedTrackedDomains
case automatedMonitoring
diff --git a/DomainDig/RootTabView.swift b/DomainDig/RootTabView.swift
index 06a9352..cba1062 100644
--- a/DomainDig/RootTabView.swift
+++ b/DomainDig/RootTabView.swift
@@ -1,31 +1,28 @@
import SwiftUI
+private enum RootTab: Hashable {
+ case dashboard
+ case history
+ case inspect
+ case settings
+}
+
struct RootTabView: View {
@Bindable var viewModel: DomainViewModel
@State private var purchaseService = PurchaseService.shared
+ @State private var selectedTab: RootTab = FeatureAccessService.currentTier == .free ? .inspect : .dashboard
var body: some View {
let _ = purchaseService.currentTier
- TabView {
- ContentView(viewModel: viewModel)
- .tabItem {
- Label("Inspect", systemImage: "magnifyingglass")
- }
-
+ TabView(selection: $selectedTab) {
NavigationStack {
- WatchlistView(viewModel: viewModel)
+ DashboardView(viewModel: viewModel)
}
.tabItem {
- Label("Watchlist", systemImage: "eye")
- }
-
- NavigationStack {
- MonitoringView(viewModel: viewModel)
- }
- .tabItem {
- Label("Monitoring", systemImage: "waveform.path.ecg")
+ Label("Dashboard", systemImage: "square.grid.2x2")
}
+ .tag(RootTab.dashboard)
NavigationStack {
HistoryView(viewModel: viewModel)
@@ -33,13 +30,21 @@ struct RootTabView: View {
.tabItem {
Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
}
+ .tag(RootTab.history)
+
+ ContentView(viewModel: viewModel)
+ .tabItem {
+ Label("Inspect", systemImage: "magnifyingglass")
+ }
+ .tag(RootTab.inspect)
NavigationStack {
SettingsView(viewModel: viewModel)
}
.tabItem {
- Label("More", systemImage: "ellipsis.circle")
+ Label("Settings", systemImage: "gearshape")
}
+ .tag(RootTab.settings)
}
.sheet(isPresented: Binding(
get: { viewModel.isPaywallPresented },
@@ -63,5 +68,10 @@ struct RootTabView: View {
}
)
}
+ .onChange(of: purchaseService.currentTier) { _, newValue in
+ if newValue != .free, selectedTab == .inspect, viewModel.trackedDomains.isEmpty == false {
+ selectedTab = .dashboard
+ }
+ }
}
}
diff --git a/DomainReportBuilder.swift b/DomainReportBuilder.swift
index e48b673..afed9db 100644
--- a/DomainReportBuilder.swift
+++ b/DomainReportBuilder.swift
@@ -34,6 +34,11 @@ struct DomainReport: Codable {
let riskAssessment: DomainRiskAssessment
let insights: [String]
let changeSummary: DomainChangeSummary?
+ let lastChangeDate: Date?
+ let health: DomainHealth
+ let lastMonitoringFailure: Date?
+ let instabilityScore: Int
+ let certificateExpiryState: CertificateWarningLevel
let workflowContext: DomainWorkflowContext?
let metadata: DomainReportMetadata
}
@@ -223,7 +228,29 @@ struct DomainReportBuilder {
primaryIP: String?,
changeSummary: DomainChangeSummary?
) -> DomainReport {
- DomainReport(
+ let certificateExpiryState = DomainDiffService.certificateWarningLevel(for: snapshot)
+ let recentChangeCount = changeSummary?.hasChanges == true ? 1 : 0
+ let instabilityScore = DomainHealth.instabilityScore(
+ recentChangeCount: recentChangeCount,
+ recentFailureCount: 0,
+ pendingAlertCount: 0,
+ hasRecentDNSChange: changeSummary?.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) == true
+ )
+ let hasInvalidTLS = snapshot.sslInfo == nil && snapshot.sslError != nil
+ let isReachable = !snapshot.reachabilityResults.isEmpty
+ ? snapshot.reachabilityResults.contains(where: \.reachable)
+ : snapshot.reachabilityError == nil
+ let health = DomainHealth.classify(
+ certificateExpiryState: certificateExpiryState,
+ isReachable: isReachable,
+ recentMonitoringFailureCount: 0,
+ hasRecentDNSChange: changeSummary?.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) == true,
+ instabilityScore: instabilityScore,
+ hasRecentCriticalChange: changeSummary?.impactClassification == .critical,
+ hasInvalidTLS: hasInvalidTLS
+ )
+
+ return DomainReport(
domain: snapshot.domain,
timestamp: snapshot.timestamp,
provenance: DomainReportProvenance(
@@ -309,6 +336,11 @@ struct DomainReportBuilder {
riskAssessment: analysis.riskAssessment,
insights: analysis.insights,
changeSummary: changeSummary,
+ lastChangeDate: changeSummary?.hasChanges == true ? snapshot.timestamp : nil,
+ health: health,
+ lastMonitoringFailure: nil,
+ instabilityScore: instabilityScore,
+ certificateExpiryState: certificateExpiryState,
workflowContext: workflowContext,
metadata: DomainReportMetadata(
schemaVersion: "3.7.0",