summaryrefslogtreecommitdiff
path: root/Hutch/Views
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views')
-rw-r--r--Hutch/Views/Home/HomeView.swift48
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift28
-rw-r--r--Hutch/Views/More/MoreView.swift27
-rw-r--r--Hutch/Views/More/MoreViewModel.swift39
-rw-r--r--Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift119
-rw-r--r--Hutch/Views/SystemStatus/SystemStatusView.swift8
-rw-r--r--Hutch/Views/SystemStatus/SystemStatusViewModel.swift34
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)"
+ }
}
}