krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.7.0: 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}