summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/App/AppStorageKeys.swift2
-rw-r--r--Hutch/Networking/SystemStatusCacheStore.swift59
-rw-r--r--Hutch/Networking/SystemStatusRepository.swift172
-rw-r--r--Hutch/Networking/SystemStatusService.swift118
-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
11 files changed, 561 insertions, 93 deletions
diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift
index dcf38ba..c1b39c4 100644
--- a/Hutch/App/AppStorageKeys.swift
+++ b/Hutch/App/AppStorageKeys.swift
@@ -6,4 +6,6 @@ enum AppStorageKeys {
static let wrapRepositoryFileLines = "wrapRepositoryFileLines"
static let lookupHistory = "lookupHistory"
static let hutchStatsBaseURL = "hutchStatsBaseURL"
+ static let systemStatusSnapshotCache = "systemStatusSnapshotCache"
+ static let systemStatusIncidentCache = "systemStatusIncidentCache"
}
diff --git a/Hutch/Networking/SystemStatusCacheStore.swift b/Hutch/Networking/SystemStatusCacheStore.swift
new file mode 100644
index 0000000..63c64b9
--- /dev/null
+++ b/Hutch/Networking/SystemStatusCacheStore.swift
@@ -0,0 +1,59 @@
+import Foundation
+
+actor SystemStatusCacheStore {
+ private let snapshotCacheKey = "systemStatusSnapshotCache"
+ private let incidentCacheKey = "systemStatusIncidentCache"
+ private let defaults: UserDefaults
+
+ init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ }
+
+ func loadSnapshotHTML() -> (html: String, timestamp: Date)? {
+ guard let html = defaults.string(forKey: snapshotCacheKey) else {
+ return nil
+ }
+
+ let timestampValue = defaults.double(forKey: snapshotTimestampKey)
+ guard timestampValue > 0 else {
+ defaults.removeObject(forKey: snapshotCacheKey)
+ defaults.removeObject(forKey: snapshotTimestampKey)
+ return nil
+ }
+
+ return (html, Date(timeIntervalSince1970: timestampValue))
+ }
+
+ func saveSnapshotHTML(_ html: String, timestamp: Date) {
+ defaults.set(html, forKey: snapshotCacheKey)
+ defaults.set(timestamp.timeIntervalSince1970, forKey: snapshotTimestampKey)
+ }
+
+ func loadIncidentFeedData() -> (data: Data, timestamp: Date)? {
+ guard let data = defaults.data(forKey: incidentCacheKey) else {
+ return nil
+ }
+
+ let timestampValue = defaults.double(forKey: incidentTimestampKey)
+ guard timestampValue > 0 else {
+ defaults.removeObject(forKey: incidentCacheKey)
+ defaults.removeObject(forKey: incidentTimestampKey)
+ return nil
+ }
+
+ return (data, Date(timeIntervalSince1970: timestampValue))
+ }
+
+ func saveIncidentFeedData(_ data: Data, timestamp: Date) {
+ defaults.set(data, forKey: incidentCacheKey)
+ defaults.set(timestamp.timeIntervalSince1970, forKey: incidentTimestampKey)
+ }
+
+ private var snapshotTimestampKey: String {
+ "\(snapshotCacheKey).timestamp"
+ }
+
+ private var incidentTimestampKey: String {
+ "\(incidentCacheKey).timestamp"
+ }
+}
diff --git a/Hutch/Networking/SystemStatusRepository.swift b/Hutch/Networking/SystemStatusRepository.swift
index 48c5b16..5f51315 100644
--- a/Hutch/Networking/SystemStatusRepository.swift
+++ b/Hutch/Networking/SystemStatusRepository.swift
@@ -1,57 +1,193 @@
import Foundation
+protocol SystemStatusServing: Sendable {
+ func fetchSnapshotHTML() async throws -> String
+ func fetchIncidentFeedData() async throws -> Data
+}
+
+struct CachedSystemStatusValue<Value: Sendable>: Sendable {
+ let value: Value
+ let lastSuccessfulAt: Date
+ let isStale: Bool
+ let refreshErrorMessage: String?
+}
+
actor SystemStatusRepository {
- private let service: SystemStatusService
+ private let service: any SystemStatusServing
private let ttl: TimeInterval
+ private let cacheStore: SystemStatusCacheStore
+ private let now: @Sendable () -> Date
private var snapshotCache: CacheEntry<SystemStatusSnapshot>?
private var incidentsCache: CacheEntry<[StatusIncident]>?
+ private var hasLoadedPersistentCache = false
- init(service: SystemStatusService = SystemStatusService(), ttl: TimeInterval = 10 * 60) {
+ init(
+ service: any SystemStatusServing = SystemStatusService(),
+ ttl: TimeInterval = 10 * 60,
+ cacheStore: SystemStatusCacheStore = SystemStatusCacheStore(),
+ now: @escaping @Sendable () -> Date = Date.init
+ ) {
self.service = service
self.ttl = ttl
+ self.cacheStore = cacheStore
+ self.now = now
}
func snapshot(forceRefresh: Bool = false) async throws -> SystemStatusSnapshot {
- if let cached = snapshotCache, !forceRefresh, !cached.isExpired(ttl: ttl) {
- return cached.value
+ try await snapshotResult(forceRefresh: forceRefresh).value
+ }
+
+ func recentIncidents(forceRefresh: Bool = false) async throws -> [StatusIncident] {
+ try await recentIncidentsResult(forceRefresh: forceRefresh).value
+ }
+
+ func snapshotResult(forceRefresh: Bool = false) async throws -> CachedSystemStatusValue<SystemStatusSnapshot> {
+ await loadPersistentCacheIfNeeded()
+
+ if let cached = snapshotCache, !forceRefresh, !cached.isExpired(ttl: ttl, now: now) {
+ return CachedSystemStatusValue(
+ value: cached.value,
+ lastSuccessfulAt: cached.timestamp,
+ isStale: false,
+ refreshErrorMessage: nil
+ )
}
do {
- let snapshot = try await service.fetchSnapshot()
- snapshotCache = CacheEntry(value: snapshot, timestamp: Date())
- return snapshot
+ let html = try await service.fetchSnapshotHTML()
+ let snapshot = try SystemStatusService.parseSnapshotHTML(html, fetchedAt: now())
+ let entry = CacheEntry(value: snapshot, timestamp: now())
+ snapshotCache = entry
+ await cacheStore.saveSnapshotHTML(html, timestamp: entry.timestamp)
+ return CachedSystemStatusValue(
+ value: snapshot,
+ lastSuccessfulAt: entry.timestamp,
+ isStale: false,
+ refreshErrorMessage: nil
+ )
} catch {
if let cached = snapshotCache {
- return cached.value
+ return CachedSystemStatusValue(
+ value: cached.value,
+ lastSuccessfulAt: cached.timestamp,
+ isStale: true,
+ refreshErrorMessage: refreshErrorMessage(from: error)
+ )
}
throw error
}
}
- func recentIncidents(forceRefresh: Bool = false) async throws -> [StatusIncident] {
- if let cached = incidentsCache, !forceRefresh, !cached.isExpired(ttl: ttl) {
- return cached.value
+ func recentIncidentsResult(forceRefresh: Bool = false) async throws -> CachedSystemStatusValue<[StatusIncident]> {
+ await loadPersistentCacheIfNeeded()
+
+ if let cached = incidentsCache, !forceRefresh, !cached.isExpired(ttl: ttl, now: now) {
+ return CachedSystemStatusValue(
+ value: cached.value,
+ lastSuccessfulAt: cached.timestamp,
+ isStale: false,
+ refreshErrorMessage: nil
+ )
}
do {
- let incidents = try await service.fetchIncidentFeed()
- incidentsCache = CacheEntry(value: incidents, timestamp: Date())
- return incidents
+ let feedData = try await service.fetchIncidentFeedData()
+ let incidents = try await SystemStatusService.parseIncidentFeedXML(feedData)
+ let entry = CacheEntry(value: incidents, timestamp: now())
+ incidentsCache = entry
+ await cacheStore.saveIncidentFeedData(feedData, timestamp: entry.timestamp)
+ return CachedSystemStatusValue(
+ value: incidents,
+ lastSuccessfulAt: entry.timestamp,
+ isStale: false,
+ refreshErrorMessage: nil
+ )
} catch {
if let cached = incidentsCache {
- return cached.value
+ return CachedSystemStatusValue(
+ value: cached.value,
+ lastSuccessfulAt: cached.timestamp,
+ isStale: true,
+ refreshErrorMessage: refreshErrorMessage(from: error)
+ )
}
throw error
}
}
+
+ private func loadPersistentCacheIfNeeded() async {
+ guard !hasLoadedPersistentCache else { return }
+ if let persistedSnapshot = await cacheStore.loadSnapshotHTML(),
+ let snapshot = try? SystemStatusService.parseSnapshotHTML(persistedSnapshot.html, fetchedAt: persistedSnapshot.timestamp) {
+ snapshotCache = CacheEntry(value: snapshot, timestamp: persistedSnapshot.timestamp)
+ }
+ if let persistedFeed = await cacheStore.loadIncidentFeedData(),
+ let incidents = try? await SystemStatusService.parseIncidentFeedXML(persistedFeed.data) {
+ incidentsCache = CacheEntry(value: incidents, timestamp: persistedFeed.timestamp)
+ }
+ hasLoadedPersistentCache = true
+ }
+
+ private func refreshErrorMessage(from error: any Error) -> String {
+ if let error = error as? SRHTError {
+ switch error {
+ case .graphQLErrors(let errors):
+ let firstMessage = errors.first?.message.lowercased() ?? ""
+ if firstMessage.contains("unauthorized") || firstMessage.contains("forbidden") {
+ return "You do not have permission to do that."
+ }
+ if firstMessage.contains("not found") || firstMessage.contains("no rows in result set") {
+ return "That content is no longer available."
+ }
+ return "Something went wrong. Please try again."
+ case .httpError(let code):
+ if code == 401 {
+ return "Please sign in again."
+ }
+ if code == 403 {
+ return "You do not have permission to do that."
+ }
+ if code == 404 {
+ return "That content is no longer available."
+ }
+ if (500...599).contains(code) {
+ return "The server is unavailable right now. Please try again."
+ }
+ return "Something went wrong. Please try again."
+ case .invalidAuthenticatedURL:
+ return "That request could not be completed."
+ case .decodingError:
+ return "The response could not be loaded right now."
+ case .networkError(let underlyingError):
+ return refreshErrorMessage(from: underlyingError)
+ case .unauthorized:
+ return "Please sign in again."
+ }
+ }
+
+ let nsError = error as NSError
+ switch nsError.code {
+ case NSURLErrorNotConnectedToInternet,
+ NSURLErrorNetworkConnectionLost,
+ NSURLErrorTimedOut,
+ NSURLErrorCannotFindHost,
+ NSURLErrorCannotConnectToHost,
+ NSURLErrorDNSLookupFailed,
+ NSURLErrorInternationalRoamingOff,
+ NSURLErrorDataNotAllowed:
+ return "Check your connection and try again."
+ default:
+ return "Something went wrong. Please try again."
+ }
+ }
}
-private struct CacheEntry<Value: Sendable>: Sendable {
+struct CacheEntry<Value: Sendable>: Sendable {
let value: Value
let timestamp: Date
- nonisolated func isExpired(ttl: TimeInterval) -> Bool {
- Date().timeIntervalSince(timestamp) > ttl
+ nonisolated func isExpired(ttl: TimeInterval, now: @escaping @Sendable () -> Date = Date.init) -> Bool {
+ now().timeIntervalSince(timestamp) > ttl
}
}
diff --git a/Hutch/Networking/SystemStatusService.swift b/Hutch/Networking/SystemStatusService.swift
index fd38fd3..5dd27b5 100644
--- a/Hutch/Networking/SystemStatusService.swift
+++ b/Hutch/Networking/SystemStatusService.swift
@@ -13,15 +13,23 @@ struct SystemStatusService: Sendable {
}
func fetchSnapshot() async throws -> SystemStatusSnapshot {
- let html = try await fetchText(from: Self.statusURL, accept: "text/html,application/xhtml+xml")
+ let html = try await fetchSnapshotHTML()
return try Self.parseSnapshotHTML(html, fetchedAt: now())
}
func fetchIncidentFeed() async throws -> [StatusIncident] {
- let data = try await fetchData(from: Self.feedURL, accept: "application/rss+xml,application/xml,text/xml")
+ let data = try await fetchIncidentFeedData()
return try await Self.parseIncidentFeedXML(data)
}
+ func fetchSnapshotHTML() async throws -> String {
+ try await fetchText(from: Self.statusURL, accept: "text/html,application/xhtml+xml")
+ }
+
+ func fetchIncidentFeedData() async throws -> Data {
+ try await fetchData(from: Self.feedURL, accept: "application/rss+xml,application/xml,text/xml")
+ }
+
private func fetchText(from url: URL, accept: String) async throws -> String {
let data = try await fetchData(from: url, accept: accept)
guard let text = String(data: data, encoding: .utf8) else {
@@ -62,6 +70,8 @@ struct SystemStatusService: Sendable {
}
}
+extension SystemStatusService: SystemStatusServing {}
+
extension SystemStatusService {
nonisolated static func parseSnapshotHTML(_ html: String, fetchedAt: Date) throws -> SystemStatusSnapshot {
let services = parseServices(in: html)
@@ -96,7 +106,7 @@ extension SystemStatusService {
nonisolated private static func parseServices(in html: String) -> [StatusServiceState] {
firstMatches(
in: html,
- pattern: #"<div class="component" data-status="([^"]+)">([\s\S]*?)</div>"#
+ pattern: #"<div\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bcomponent\b[^"']*["'])(?=[^>]*\bdata-status\s*=\s*["']([^"']+)["'])[^>]*>([\s\S]*?)</div>"#
).compactMap { captures in
guard captures.count >= 2 else { return nil }
@@ -104,16 +114,18 @@ extension SystemStatusService {
let content = captures[1]
guard let linkCaptures = firstMatches(
in: content,
- pattern: #"<a[^>]*href="([^"]+)"[^>]*>\s*(.*?)\s*</a>"#
+ pattern: #"<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)</a>"#
).first,
- linkCaptures.count >= 2,
- let statusText = firstMatch(in: content, pattern: #"<span class="component-status">\s*(.*?)\s*</span>"#) else {
+ linkCaptures.count >= 2 else {
return nil
}
let href = linkCaptures[0]
let cleanedName = cleanText(linkCaptures[1])
- let readableStatus = cleanText(statusText)
+ let readableStatus = firstMatch(
+ in: content,
+ pattern: #"<(?:span|small|div)\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bcomponent-status\b[^"']*["'])[^>]*>([\s\S]*?)</(?:span|small|div)>"#
+ ).map(cleanText) ?? firstStatusLabel(in: content) ?? ""
let level = statusLevel(fromHTMLStatus: rawStatus)
return StatusServiceState(
@@ -129,19 +141,19 @@ extension SystemStatusService {
nonisolated private static func parseHTMLIncidentCards(in html: String) -> [StatusIncident] {
firstMatches(
in: html,
- pattern: #"<a href="([^"]+)" class="issue no-underline">([\s\S]*?)</a>"#
+ pattern: #"<a\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bissue\b[^"']*["'])(?=[^>]*\bhref\s*=\s*["']([^"']+)["'])[^>]*>([\s\S]*?)</a>"#
).compactMap { captures in
guard captures.count >= 2 else { return nil }
let href = captures[0]
let content = captures[1]
- guard let titleHTML = firstMatch(in: content, pattern: #"<h3>\s*([\s\S]*?)\s*</h3>"#),
- let titleAttribute = firstMatch(in: content, pattern: #"<small class="date[^"]*" title="([^"]+)">"#),
- let publishedAt = htmlIssueDateFormatter.date(from: cleanText(titleAttribute)) else {
+ guard let titleHTML = firstMatch(in: content, pattern: #"<h[1-6][^>]*>\s*([\s\S]*?)\s*</h[1-6]>"#)
+ ?? firstMatch(in: content, pattern: #"<strong[^>]*>\s*([\s\S]*?)\s*</strong>"#)
+ ?? firstMatch(in: content, pattern: #"<span[^>]*>\s*([\s\S]*?)\s*</span>"#),
+ let publishedAt = publishedIncidentDate(in: content) else {
return nil
}
let url = URL(string: href, relativeTo: statusURL)?.absoluteURL
- let isActive = content.localizedCaseInsensitiveContains("This issue is not resolved yet")
return StatusIncident(
id: url?.absoluteString ?? cleanText(titleHTML),
title: cleanText(titleHTML),
@@ -149,7 +161,7 @@ extension SystemStatusService {
url: url,
publishedAt: publishedAt,
updatedAt: nil,
- isActive: isActive
+ isActive: isActiveIncidentCard(content)
)
}
}
@@ -157,12 +169,12 @@ extension SystemStatusService {
nonisolated private static func parseActiveIncidentSummaries(in html: String) -> [String: String] {
firstMatches(
in: html,
- pattern: #"<div class="announcement-box"[\s\S]*?<div class="padding">([\s\S]*?)</div>\s*<hr class="clean announcement-box">"#
+ pattern: #"<div\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bannouncement-box\b[^"']*["'])[^>]*>([\s\S]*?)</div>\s*(?:<hr\b[^>]*\bannouncement-box\b[^>]*>)?"#
).reduce(into: [:]) { partialResult, captures in
guard let content = captures.first,
let titleLinkCaptures = firstMatches(
in: content,
- pattern: #"<a href="([^"]+)"><strong class="bold">([\s\S]*?)</strong></a>"#
+ pattern: #"<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)</a>"#
).first,
let href = titleLinkCaptures.first else {
return
@@ -197,17 +209,22 @@ extension SystemStatusService {
}
nonisolated private static func statusLevel(fromLabel label: String) -> StatusLevel {
- switch label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
- case "operational":
- .operational
- case "disrupted", "degraded":
- .degraded
- case "down", "major outage":
- .majorOutage
- case "maintenance":
- .maintenance
+ let normalizedLabel = label
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+ .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+
+ return switch normalizedLabel {
+ case "operational", "all systems operational":
+ StatusLevel.operational
+ case "disrupted", "degraded", "partial outage":
+ StatusLevel.degraded
+ case "down", "major outage", "outage":
+ StatusLevel.majorOutage
+ case "maintenance", "scheduled maintenance", "under maintenance":
+ StatusLevel.maintenance
default:
- .unknown
+ StatusLevel.unknown
}
}
@@ -252,6 +269,47 @@ extension SystemStatusService {
.trimmingCharacters(in: .whitespacesAndNewlines)
}
+ nonisolated private static func firstStatusLabel(in text: String) -> String? {
+ cleanText(text)
+ .split(separator: "•")
+ .map(String.init)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .first(where: { statusLevel(fromLabel: $0) != .unknown })
+ }
+
+ nonisolated private static func publishedIncidentDate(in content: String) -> Date? {
+ if let timeValue = firstMatch(in: content, pattern: #"<time\b[^>]*\bdatetime\s*=\s*["']([^"']+)["'][^>]*>"#),
+ let parsed = parseISO8601Date(cleanText(timeValue)) {
+ return parsed
+ }
+
+ if let titleAttribute = firstMatch(
+ in: content,
+ pattern: #"<(?:small|time)\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bdate\b[^"']*["'])[^>]*\btitle\s*=\s*["']([^"']+)["'][^>]*>"#
+ ) ?? firstMatch(in: content, pattern: #"\btitle\s*=\s*["']([^"']+UTC)["']"#) {
+ return htmlIssueDateFormatter.date(from: cleanText(titleAttribute))
+ }
+
+ return nil
+ }
+
+ nonisolated private static func isActiveIncidentCard(_ content: String) -> Bool {
+ let normalizedContent = cleanText(content).lowercased()
+ return normalizedContent.contains("not resolved yet")
+ || normalizedContent.contains("ongoing")
+ || normalizedContent.contains("investigating")
+ }
+
+ nonisolated fileprivate static func parseISO8601Date(_ value: String) -> Date? {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return formatter.date(from: value) ?? {
+ let fallbackFormatter = ISO8601DateFormatter()
+ fallbackFormatter.formatOptions = [.withInternetDateTime]
+ return fallbackFormatter.date(from: value)
+ }()
+ }
+
nonisolated private static let htmlIssueDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
@@ -259,9 +317,9 @@ extension SystemStatusService {
formatter.dateFormat = "MMM d HH:mm:ss yyyy zzz"
return formatter
}()
+
}
-@MainActor
private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchecked Sendable {
private var incidents: [StatusIncident] = []
private var currentItem: FeedItem?
@@ -315,7 +373,7 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec
currentItem.guid = value
case "description":
currentItem.description = value
- case "pubDate":
+ case "pubDate", "dc:date":
currentItem.pubDate = value
case "category":
currentItem.category = value
@@ -345,7 +403,7 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec
func makeIncident() -> StatusIncident? {
let cleanedTitle = title.replacingOccurrences(of: "[Resolved] ", with: "")
guard !cleanedTitle.isEmpty,
- let publishedAt = SystemStatusFeedParser.pubDateFormatter.date(from: pubDate) else {
+ let publishedAt = SystemStatusFeedParser.parsePubDate(pubDate) else {
return nil
}
@@ -382,6 +440,10 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec
return decodeHTMLEntities(stripped)
}
+ nonisolated private static func parsePubDate(_ value: String) -> Date? {
+ pubDateFormatter.date(from: value) ?? SystemStatusService.parseISO8601Date(value)
+ }
+
nonisolated private static let pubDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
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)"
+ }
}
}