diff options
| author | Christian Cleberg <[email protected]> | 2026-04-12 00:15:13 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-12 00:15:13 -0500 |
| commit | cb1ea5ea4f163d87053285d3fb7999b12a3558b9 (patch) | |
| tree | 84b562d12e642ff494194387b1b4e1d15b065eb0 /Hutch/Views | |
| parent | 1c5a417277d986ee6fb7dc9dff0332338bdf6f3e (diff) | |
| download | hutch-2.13.1.tar.gz hutch-2.13.1.tar.bz2 hutch-2.13.1.zip | |
harden system status and app reliabilityv2.13.1
Diffstat (limited to 'Hutch/Views')
| -rw-r--r-- | Hutch/Views/Home/HomeView.swift | 48 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 28 | ||||
| -rw-r--r-- | Hutch/Views/More/MoreView.swift | 27 | ||||
| -rw-r--r-- | Hutch/Views/More/MoreViewModel.swift | 39 | ||||
| -rw-r--r-- | Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift | 119 | ||||
| -rw-r--r-- | Hutch/Views/SystemStatus/SystemStatusView.swift | 8 | ||||
| -rw-r--r-- | Hutch/Views/SystemStatus/SystemStatusViewModel.swift | 34 |
7 files changed, 256 insertions, 47 deletions
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift index ab0cdf8..632e804 100644 --- a/Hutch/Views/Home/HomeView.swift +++ b/Hutch/Views/Home/HomeView.swift @@ -78,19 +78,25 @@ struct HomeView: View { .refreshable { await viewModel.loadDashboard() } + .connectivityOverlay(hasContent: viewModel.hasDashboardContent) { + await viewModel.loadDashboard() + } } @ViewBuilder private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View { - if let bannerTitle = viewModel.systemStatusBannerTitle { - Section { - NavigationLink { - SystemStatusView() - } label: { - HomeSystemStatusBanner(title: bannerTitle) - } - .buttonStyle(.plain) + Section { + NavigationLink { + SystemStatusView() + } label: { + SystemStatusSummaryRow( + snapshot: viewModel.systemStatusSnapshot, + isLoading: viewModel.isLoadingSystemStatus, + errorMessage: viewModel.systemStatusErrorMessage, + isShowingStaleData: viewModel.isShowingStaleSystemStatus + ) } + .buttonStyle(.plain) } } @@ -252,32 +258,6 @@ private struct HomeInboxToolbarIcon: View { } } -private struct HomeSystemStatusBanner: View { - let title: String - - var body: some View { - HStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - VStack(alignment: .leading, spacing: 2) { - Text("SourceHut service disruption") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - Text(title) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Spacer() - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) - } - .padding(.vertical, 4) - .contentShape(Rectangle()) - } -} - private struct HomeProjectRow: View { let project: Project diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift index a06df20..ba00a36 100644 --- a/Hutch/Views/Home/HomeViewModel.swift +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -144,6 +144,9 @@ final class HomeViewModel { var assignedTickets: [HomeAssignedTicket] = [] var recentBuilds: [HomeBuildItem] = [] private(set) var systemStatusSnapshot: SystemStatusSnapshot? + private(set) var isLoadingSystemStatus = false + private(set) var isShowingStaleSystemStatus = false + private(set) var systemStatusErrorMessage: String? private(set) var hasUnreadInboxThreads = false private(set) var unreadInboxThreadCount: Int? private(set) var isLoadingProjects = false @@ -279,8 +282,11 @@ final class HomeViewModel { isLoadingProjects = true isLoadingAssignedTickets = true isLoadingRecentBuilds = true + isLoadingSystemStatus = true assignedTicketsError = nil recentBuildsError = nil + isShowingStaleSystemStatus = false + systemStatusErrorMessage = nil async let projectsTask = loadProjects() async let jobsTask = loadRecentJobs() @@ -324,13 +330,21 @@ final class HomeViewModel { unreadInboxThreadCount = await inboxUnreadTask hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 - systemStatusSnapshot = await systemStatusTask + let systemStatusResult = await systemStatusTask + switch systemStatusResult { + case .success(let result): + systemStatusSnapshot = result.value + isShowingStaleSystemStatus = result.isStale + systemStatusErrorMessage = result.isStale ? result.refreshErrorMessage : nil + case .failure(let error): + systemStatusErrorMessage = error.userFacingMessage + } + isLoadingSystemStatus = false persistNeedsAttentionSnapshot() } - var systemStatusBannerTitle: String? { - guard let systemStatusSnapshot, systemStatusSnapshot.hasDisruption else { return nil } - return systemStatusSnapshot.bannerSummary + var hasDashboardContent: Bool { + !projects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || systemStatusSnapshot != nil } func resolveTicket(_ ticket: HomeAssignedTicket) async { @@ -430,11 +444,11 @@ final class HomeViewModel { } } - private func loadSystemStatusSnapshot() async -> SystemStatusSnapshot? { + private func loadSystemStatusSnapshot() async -> Result<CachedSystemStatusValue<SystemStatusSnapshot>, Error> { do { - return try await systemStatusRepository.snapshot() + return .success(try await systemStatusRepository.snapshotResult()) } catch { - return systemStatusSnapshot + return .failure(error) } } diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift index 284b1ad..6325954 100644 --- a/Hutch/Views/More/MoreView.swift +++ b/Hutch/Views/More/MoreView.swift @@ -7,6 +7,7 @@ struct MoreView: View { ("chat.sr.ht", SRHTWebURL.chat) ] + @State private var viewModel: MoreViewModel? @State private var showAccountSwitcher = false var body: some View { @@ -29,9 +30,14 @@ struct MoreView: View { NavigationLink(value: MoreRoute.pastes) { Label("Pastes", systemImage: "doc.on.clipboard") } - + NavigationLink(value: MoreRoute.systemStatus) { - Label("System Status", systemImage: "server.rack") + SystemStatusSummaryRow( + snapshot: viewModel?.systemStatusSnapshot, + isLoading: viewModel?.isLoadingSystemStatus ?? true, + errorMessage: viewModel?.systemStatusErrorMessage, + isShowingStaleData: viewModel?.isShowingStaleSystemStatus ?? false + ) } } @@ -58,6 +64,12 @@ struct MoreView: View { } } .navigationTitle("More") + .refreshable { + await ensureViewModel().loadSystemStatus(forceRefresh: true) + } + .task { + await ensureViewModel().loadIfNeeded() + } .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { @@ -71,4 +83,15 @@ struct MoreView: View { AccountSwitcherView() } } + + @MainActor + private func ensureViewModel() -> MoreViewModel { + if let viewModel { + return viewModel + } + + let newViewModel = MoreViewModel(repository: appState.systemStatusRepository) + viewModel = newViewModel + return newViewModel + } } diff --git a/Hutch/Views/More/MoreViewModel.swift b/Hutch/Views/More/MoreViewModel.swift new file mode 100644 index 0000000..8f23975 --- /dev/null +++ b/Hutch/Views/More/MoreViewModel.swift @@ -0,0 +1,39 @@ +import Foundation + +@Observable +@MainActor +final class MoreViewModel { + private let repository: SystemStatusRepository + + private(set) var systemStatusSnapshot: SystemStatusSnapshot? + private(set) var isLoadingSystemStatus = false + private(set) var isShowingStaleSystemStatus = false + private(set) var systemStatusErrorMessage: String? + + init(repository: SystemStatusRepository) { + self.repository = repository + } + + func loadIfNeeded() async { + guard systemStatusSnapshot == nil, !isLoadingSystemStatus else { return } + await loadSystemStatus() + } + + func loadSystemStatus(forceRefresh: Bool = false) async { + isLoadingSystemStatus = true + defer { isLoadingSystemStatus = false } + isShowingStaleSystemStatus = false + systemStatusErrorMessage = nil + + do { + let result = try await repository.snapshotResult(forceRefresh: forceRefresh) + systemStatusSnapshot = result.value + isShowingStaleSystemStatus = result.isStale + systemStatusErrorMessage = result.isStale ? result.refreshErrorMessage : nil + } catch { + if systemStatusSnapshot == nil { + systemStatusErrorMessage = error.userFacingMessage + } + } + } +} diff --git a/Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift b/Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift new file mode 100644 index 0000000..5d94dad --- /dev/null +++ b/Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift @@ -0,0 +1,119 @@ +import SwiftUI + +struct SystemStatusSummaryRow: View { + let title: String + let snapshot: SystemStatusSnapshot? + let isLoading: Bool + let errorMessage: String? + let isShowingStaleData: Bool + + init( + title: String = "System Status", + snapshot: SystemStatusSnapshot?, + isLoading: Bool = false, + errorMessage: String? = nil, + isShowingStaleData: Bool = false + ) { + self.title = title + self.snapshot = snapshot + self.isLoading = isLoading + self.errorMessage = errorMessage + self.isShowingStaleData = isShowingStaleData + } + + var body: some View { + HStack(spacing: 12) { + icon + .frame(width: 20) + + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + + Text(primaryMessage) + .font(.caption) + .foregroundStyle(primaryMessageColor) + .lineLimit(2) + + if let metadataMessage { + Text(metadataMessage) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + + Spacer(minLength: 8) + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + + @ViewBuilder + private var icon: some View { + if isLoading && snapshot == nil { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: iconName) + .foregroundStyle(iconColor) + } + } + + private var primaryMessage: String { + if let snapshot { + return snapshot.hasDisruption ? snapshot.bannerSummary : snapshot.overallStatusText + } + if let errorMessage, !errorMessage.isEmpty { + return errorMessage + } + if isLoading { + return "Loading system status…" + } + return "System status is unavailable right now." + } + + private var metadataMessage: String? { + if let snapshot { + if isShowingStaleData { + return "Updated \(snapshot.lastUpdated.relativeDescription) • Showing saved data" + } + return "Updated \(snapshot.lastUpdated.relativeDescription)" + } + if errorMessage != nil { + return "Open System Status to retry." + } + return nil + } + + private var iconName: String { + if let snapshot { + return snapshot.hasDisruption ? "exclamationmark.triangle.fill" : "checkmark.circle.fill" + } + if errorMessage != nil { + return "exclamationmark.triangle" + } + return "server.rack" + } + + private var iconColor: Color { + if let snapshot { + return snapshot.hasDisruption ? .orange : .green + } + if errorMessage != nil { + return .secondary + } + return .secondary + } + + private var primaryMessageColor: Color { + if snapshot != nil { + return .secondary + } + if errorMessage != nil { + return .secondary + } + return .secondary + } +} diff --git a/Hutch/Views/SystemStatus/SystemStatusView.swift b/Hutch/Views/SystemStatus/SystemStatusView.swift index 053210e..08a46eb 100644 --- a/Hutch/Views/SystemStatus/SystemStatusView.swift +++ b/Hutch/Views/SystemStatus/SystemStatusView.swift @@ -31,6 +31,14 @@ struct SystemStatusView: View { @ViewBuilder private func content(_ viewModel: SystemStatusViewModel) -> some View { List { + if viewModel.isShowingStaleData, let staleDataMessage = viewModel.staleDataMessage { + Section { + Label(staleDataMessage, systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + if let snapshot = viewModel.snapshot { summarySection(snapshot) servicesSection(snapshot) diff --git a/Hutch/Views/SystemStatus/SystemStatusViewModel.swift b/Hutch/Views/SystemStatus/SystemStatusViewModel.swift index 646d7ca..7700208 100644 --- a/Hutch/Views/SystemStatus/SystemStatusViewModel.swift +++ b/Hutch/Views/SystemStatus/SystemStatusViewModel.swift @@ -8,6 +8,8 @@ final class SystemStatusViewModel { private(set) var snapshot: SystemStatusSnapshot? private(set) var recentIncidents: [StatusIncident] = [] private(set) var isLoading = false + private(set) var isShowingStaleData = false + private(set) var staleDataMessage: String? var errorMessage: String? init(repository: SystemStatusRepository) { @@ -25,12 +27,24 @@ final class SystemStatusViewModel { defer { isLoading = false } errorMessage = nil + staleDataMessage = nil + isShowingStaleData = false - async let snapshotTask = repository.snapshot(forceRefresh: forceRefresh) - async let incidentsTask = repository.recentIncidents(forceRefresh: forceRefresh) + async let snapshotTask = repository.snapshotResult(forceRefresh: forceRefresh) + async let incidentsTask = repository.recentIncidentsResult(forceRefresh: forceRefresh) + + var refreshWarnings: [String] = [] do { - snapshot = try await snapshotTask + let result = try await snapshotTask + snapshot = result.value + if result.isStale { + isShowingStaleData = true + staleDataMessage = "Showing the last saved system status snapshot." + if let warning = result.refreshErrorMessage { + refreshWarnings.append(warning) + } + } } catch { if snapshot == nil { errorMessage = error.userFacingMessage @@ -38,11 +52,23 @@ final class SystemStatusViewModel { } do { - recentIncidents = try await incidentsTask + let result = try await incidentsTask + recentIncidents = result.value + if result.isStale { + isShowingStaleData = true + staleDataMessage = staleDataMessage ?? "Showing the last saved incident history." + if let warning = result.refreshErrorMessage { + refreshWarnings.append(warning) + } + } } catch { if errorMessage == nil && recentIncidents.isEmpty { errorMessage = error.userFacingMessage } } + + if hasContent, let firstWarning = refreshWarnings.first { + errorMessage = "Showing cached system status. \(firstWarning)" + } } } |
