From fd34553ab0e63b4d3bc22532b61f4fef32846f34 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sat, 25 Apr 2026 00:03:59 -0500 Subject: DomainDig v3.9.0 — Portfolio Dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- DomainDig/ContentView.swift | 10 +- DomainDig/DashboardView.swift | 383 ++++++++++++++++++++++++++++++++ DomainDig/DomainViewModel.swift | 474 ++++++++++++++++++++++++++++++++++++++++ DomainDig/Models.swift | 94 ++++++++ DomainDig/RootTabView.swift | 42 ++-- 5 files changed, 986 insertions(+), 17 deletions(-) create mode 100644 DomainDig/DashboardView.swift (limited to 'DomainDig') 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() + + 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 { + 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() + 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 + } + } } } -- cgit v1.2.3