krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v4.8.1: DomainDig/DashboardView.swift · raw

  1import SwiftUI
  2
  3struct DashboardView: View {
  4    @Environment(\.appDensity) private var appDensity
  5    @Bindable var viewModel: DomainViewModel
  6    @State private var collapsedGroups = Set<String>()
  7
  8    private let summaryColumns = [
  9        GridItem(.flexible(), spacing: 10),
 10        GridItem(.flexible(), spacing: 10)
 11    ]
 12
 13    var body: some View {
 14        List {
 15            if viewModel.trackedDomains.isEmpty {
 16                Section {
 17                    EmptyStateCardView(
 18                        title: "No Portfolio Yet",
 19                        message: "Track domains to get portfolio health, recent changes, expiry visibility, and an attention queue in one place.",
 20                        suggestion: "Inspect a domain and use the Track action, or add one directly from Tracked Domains in Settings.",
 21                        systemImage: "square.stack.3d.up.fill",
 22                        showsCardBackground: false
 23                    )
 24                }
 25                .listRowBackground(Color(.systemGray6).opacity(0.5))
 26            } else {
 27                Section {
 28                    LazyVGrid(columns: summaryColumns, spacing: 10) {
 29                        summaryCard(title: "Total Domains", value: viewModel.portfolioDashboardData.snapshot.totalDomains, filter: .all, tint: .cyan)
 30                        summaryCard(title: "Healthy", value: viewModel.portfolioDashboardData.snapshot.healthyCount, filter: .healthy, tint: .green)
 31                        summaryCard(title: "Warning", value: viewModel.portfolioDashboardData.snapshot.warningCount, filter: .warning, tint: .yellow)
 32                        summaryCard(title: "Critical", value: viewModel.portfolioDashboardData.snapshot.criticalCount, filter: .critical, tint: .red)
 33                        summaryCard(title: "Changes (24h)", value: viewModel.portfolioDashboardData.snapshot.changedLast24h, filter: .changed, tint: .orange)
 34                        summaryCard(title: "Unreachable", value: viewModel.portfolioDashboardData.snapshot.unreachableCount, filter: .unreachable, tint: .pink)
 35                    }
 36                    .padding(.vertical, 4)
 37                }
 38                .listRowBackground(Color(.systemGray6).opacity(0.5))
 39
 40                Section("Quick Filters") {
 41                    ScrollView(.horizontal, showsIndicators: false) {
 42                        HStack(spacing: 8) {
 43                            ForEach(PortfolioFilterOption.allCases) { filter in
 44                                Button {
 45                                    viewModel.dashboardFilter = filter
 46                                } label: {
 47                                    Text(filter.title)
 48                                        .font(appDensity.font(.caption, weight: .semibold))
 49                                        .foregroundStyle(viewModel.dashboardFilter == filter ? Color.black : Color.white)
 50                                        .padding(.horizontal, 12)
 51                                        .padding(.vertical, 8)
 52                                        .background(viewModel.dashboardFilter == filter ? Color.cyan : Color(.systemGray5).opacity(0.6))
 53                                        .clipShape(Capsule())
 54                                }
 55                                .buttonStyle(.plain)
 56                            }
 57                        }
 58                        .padding(.vertical, 4)
 59                    }
 60                }
 61                .listRowBackground(Color(.systemGray6).opacity(0.5))
 62
 63                Section("Recent Activity") {
 64                    if viewModel.filteredPortfolioRecentActivity.isEmpty {
 65                        dashboardEmptyRow("No recent portfolio changes")
 66                    } else {
 67                        ForEach(viewModel.filteredPortfolioRecentActivity.prefix(8)) { item in
 68                            if let trackedDomain = viewModel.trackedDomain(withID: item.trackedDomainID) {
 69                                NavigationLink {
 70                                    TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
 71                                } label: {
 72                                    PortfolioActivityRow(item: item)
 73                                }
 74                                .buttonStyle(.plain)
 75                            }
 76                        }
 77                    }
 78                }
 79                .listRowBackground(Color(.systemGray6).opacity(0.5))
 80
 81                Section("Attention Required") {
 82                    if viewModel.filteredPortfolioAttentionRequired.isEmpty {
 83                        dashboardEmptyRow("Nothing urgent right now")
 84                    } else {
 85                        ForEach(viewModel.filteredPortfolioAttentionRequired.prefix(8)) { item in
 86                            if let trackedDomain = viewModel.trackedDomain(withID: item.trackedDomainID) {
 87                                NavigationLink {
 88                                    TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
 89                                } label: {
 90                                    PortfolioAttentionRow(item: item)
 91                                }
 92                                .buttonStyle(.plain)
 93                            }
 94                        }
 95                    }
 96                }
 97                .listRowBackground(Color(.systemGray6).opacity(0.5))
 98
 99                Section("Expiring Soon") {
100                    if viewModel.filteredPortfolioExpiringSoon.isEmpty {
101                        dashboardEmptyRow("No certificates expiring within 30 days")
102                    } else {
103                        ForEach(viewModel.filteredPortfolioExpiringSoon.prefix(8)) { state in
104                            NavigationLink {
105                                TrackedDomainDetailView(viewModel: viewModel, trackedDomain: state.trackedDomain)
106                            } label: {
107                                PortfolioExpiryRow(state: state)
108                            }
109                            .buttonStyle(.plain)
110                        }
111                    }
112                }
113                .listRowBackground(Color(.systemGray6).opacity(0.5))
114
115                Section("Portfolio List") {
116                    if viewModel.filteredPortfolioGroups.isEmpty {
117                        dashboardEmptyRow("No domains match the current filter")
118                    } else {
119                        ForEach(viewModel.filteredPortfolioGroups) { group in
120                            DisclosureGroup(
121                                isExpanded: disclosureBinding(for: group.apexDomain),
122                                content: {
123                                    ForEach(group.domains) { state in
124                                        NavigationLink {
125                                            TrackedDomainDetailView(viewModel: viewModel, trackedDomain: state.trackedDomain)
126                                        } label: {
127                                            WatchlistRowView(
128                                                trackedDomain: state.trackedDomain,
129                                                isRefreshing: viewModel.refreshingTrackedDomainID == state.trackedDomain.id
130                                            )
131                                        }
132                                        .buttonStyle(.plain)
133                                    }
134                                },
135                                label: {
136                                    HStack {
137                                        VStack(alignment: .leading, spacing: 4) {
138                                            Text(group.apexDomain)
139                                                .font(appDensity.font(.headline, design: .default, weight: .semibold))
140                                            Text("\(group.domains.count) domain\(group.domains.count == 1 ? "" : "s")")
141                                                .font(appDensity.font(.caption))
142                                                .foregroundStyle(.secondary)
143                                        }
144                                        Spacer()
145                                        groupBadge(for: group.domains)
146                                    }
147                                    .padding(.vertical, 4)
148                                }
149                            )
150                        }
151                    }
152                }
153                .listRowBackground(Color(.systemGray6).opacity(0.5))
154            }
155        }
156        .scrollContentBackground(.hidden)
157        .background(Color.black)
158        .navigationTitle("Dashboard")
159        .searchable(text: $viewModel.dashboardSearchText, prompt: "Search portfolio")
160        .toolbar {
161            if !viewModel.trackedDomains.isEmpty {
162                ToolbarItem(placement: .topBarTrailing) {
163                    Button {
164                        AppHaptics.refresh()
165                        viewModel.refreshAllTrackedDomains()
166                    } label: {
167                        Image(systemName: "arrow.clockwise")
168                    }
169                    .disabled(viewModel.batchLookupRunning)
170                }
171            }
172        }
173        .preferredColorScheme(.dark)
174    }
175
176    private func disclosureBinding(for apexDomain: String) -> Binding<Bool> {
177        Binding(
178            get: { !collapsedGroups.contains(apexDomain) },
179            set: { isExpanded in
180                if isExpanded {
181                    collapsedGroups.remove(apexDomain)
182                } else {
183                    collapsedGroups.insert(apexDomain)
184                }
185            }
186        )
187    }
188
189    private func summaryCard(title: String, value: Int, filter: PortfolioFilterOption, tint: Color) -> some View {
190        Button {
191            viewModel.dashboardFilter = filter
192        } label: {
193            VStack(alignment: .leading, spacing: 8) {
194                Text(title)
195                    .font(appDensity.font(.caption, design: .default, weight: .semibold))
196                    .foregroundStyle(.secondary)
197                Text("\(value)")
198                    .font(.system(size: 28, weight: .bold, design: .rounded))
199                    .foregroundStyle(.white)
200                HStack {
201                    Circle()
202                        .fill(tint)
203                        .frame(width: 8, height: 8)
204                    Text(filter.title)
205                        .font(appDensity.font(.caption2, design: .default, weight: .semibold))
206                        .foregroundStyle(tint)
207                }
208            }
209            .frame(maxWidth: .infinity, alignment: .leading)
210            .padding(appDensity.metrics.cardPadding)
211            .background(cardBackground(for: filter))
212            .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
213        }
214        .buttonStyle(.plain)
215    }
216
217    private func cardBackground(for filter: PortfolioFilterOption) -> some ShapeStyle {
218        if viewModel.dashboardFilter == filter {
219            return AnyShapeStyle(
220                LinearGradient(
221                    colors: [Color.cyan.opacity(0.28), Color.cyan.opacity(0.12)],
222                    startPoint: .topLeading,
223                    endPoint: .bottomTrailing
224                )
225            )
226        }
227        return AnyShapeStyle(Color(.systemGray5).opacity(0.45))
228    }
229
230    private func dashboardEmptyRow(_ message: String) -> some View {
231        Text(message)
232            .font(appDensity.font(.caption))
233            .foregroundStyle(.secondary)
234            .padding(.vertical, 4)
235    }
236
237    private func groupBadge(for states: [PortfolioDomainStatus]) -> some View {
238        let criticalCount = states.filter { $0.health == .critical }.count
239        let warningCount = states.filter { $0.health == .warning }.count
240        let title: String
241        let color: Color
242
243        if criticalCount > 0 {
244            title = "\(criticalCount) critical"
245            color = .red
246        } else if warningCount > 0 {
247            title = "\(warningCount) warning"
248            color = .yellow
249        } else {
250            title = "Healthy"
251            color = .green
252        }
253
254        return AppStatusBadgeView(
255            model: .init(
256                title: title,
257                systemImage: criticalCount > 0 ? "exclamationmark.octagon.fill" : (warningCount > 0 ? "exclamationmark.triangle.fill" : "checkmark.circle.fill"),
258                foregroundColor: color,
259                backgroundColor: color.opacity(0.16)
260            )
261        )
262    }
263}
264
265private struct PortfolioActivityRow: View {
266    @Environment(\.appDensity) private var appDensity
267    let item: PortfolioActivityItem
268
269    var body: some View {
270        HStack(alignment: .top, spacing: 10) {
271            Image(systemName: item.systemImage)
272                .font(.caption.weight(.semibold))
273                .foregroundStyle(iconColor)
274                .frame(width: 22, height: 22)
275                .background(iconColor.opacity(0.14))
276                .clipShape(RoundedRectangle(cornerRadius: 7))
277
278            VStack(alignment: .leading, spacing: 4) {
279                Text(item.message)
280                    .font(appDensity.font(.callout, design: .default))
281                    .foregroundStyle(.primary)
282                HStack(spacing: 8) {
283                    Text(item.domain)
284                    Text(relativeTimestamp(item.timestamp))
285                }
286                .font(appDensity.font(.caption2))
287                .foregroundStyle(.secondary)
288            }
289        }
290        .padding(.vertical, 4)
291    }
292
293    private var iconColor: Color {
294        switch item.health {
295        case .healthy:
296            return .cyan
297        case .warning:
298            return .yellow
299        case .critical:
300            return .red
301        }
302    }
303}
304
305private struct PortfolioAttentionRow: View {
306    @Environment(\.appDensity) private var appDensity
307    let item: PortfolioAttentionItem
308
309    var body: some View {
310        HStack(alignment: .top, spacing: 10) {
311            AppStatusBadgeView(model: badgeModel)
312
313            VStack(alignment: .leading, spacing: 4) {
314                Text(item.domain)
315                    .font(appDensity.font(.callout))
316                    .foregroundStyle(.primary)
317                Text(item.reason)
318                    .font(appDensity.font(.caption, design: .default))
319                    .foregroundStyle(.secondary)
320                Text(relativeTimestamp(item.timestamp))
321                    .font(appDensity.font(.caption2))
322                    .foregroundStyle(.secondary)
323            }
324        }
325        .padding(.vertical, 4)
326    }
327
328    private var badgeModel: AppStatusBadgeModel {
329        switch item.health {
330        case .healthy:
331            return .init(title: "Healthy", systemImage: "checkmark.circle.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
332        case .warning:
333            return .init(title: "Warning", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
334        case .critical:
335            return .init(title: "Critical", systemImage: "exclamationmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
336        }
337    }
338}
339
340private struct PortfolioExpiryRow: View {
341    @Environment(\.appDensity) private var appDensity
342    let state: PortfolioDomainStatus
343
344    var body: some View {
345        HStack {
346            VStack(alignment: .leading, spacing: 4) {
347                Text(state.trackedDomain.domain)
348                    .font(appDensity.font(.callout))
349                    .foregroundStyle(.primary)
350                Text(expirySubtitle)
351                    .font(appDensity.font(.caption))
352                    .foregroundStyle(.secondary)
353            }
354            Spacer()
355            AppStatusBadgeView(model: badgeModel)
356        }
357        .padding(.vertical, 4)
358    }
359
360    private var expirySubtitle: String {
361        if let days = state.certificateDaysRemaining {
362            return "Expires in \(days) day\(days == 1 ? "" : "s")"
363        }
364        return "Certificate needs review"
365    }
366
367    private var badgeModel: AppStatusBadgeModel {
368        switch state.certificateExpiryState {
369        case .none:
370            return .init(title: "Healthy", systemImage: "lock.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
371        case .warning:
372            return .init(title: "Warning", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
373        case .critical:
374            return .init(title: "Critical", systemImage: "xmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
375        }
376    }
377}
378
379private func relativeTimestamp(_ date: Date) -> String {
380    let formatter = RelativeDateTimeFormatter()
381    formatter.unitsStyle = .short
382    return formatter.localizedString(for: date, relativeTo: Date())
383}