Commit 63dd5cadae

63dd5cadae5e3867fbfb6f5d9923f00484728a6b

parent: 529ffe0f5d

Unsigned

cmc <hello@cleberg.net> · 2026-04-12 05:15 UTC

harden system status and app reliability

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -515,7 +515,7 @@
515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517 CODE_SIGN_STYLE = Automatic; 517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 29; 518 CURRENT_PROJECT_VERSION = 30;
519 DEVELOPMENT_TEAM = ZCNAX3VL9D; 519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520 ENABLE_PREVIEWS = YES; 520 ENABLE_PREVIEWS = YES;
521 GENERATE_INFOPLIST_FILE = YES; 521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532 "$(inherited)", 532 "$(inherited)",
533 "@executable_path/Frameworks", 533 "@executable_path/Frameworks",
534 ); 534 );
535 MARKETING_VERSION = 2.13.0; 535 MARKETING_VERSION = 2.13.1;
536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537 PRODUCT_NAME = "$(TARGET_NAME)"; 537 PRODUCT_NAME = "$(TARGET_NAME)";
538 STRING_CATALOG_GENERATE_SYMBOLS = YES; 538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554 CODE_SIGN_STYLE = Automatic; 554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 29; 555 CURRENT_PROJECT_VERSION = 30;
556 DEVELOPMENT_TEAM = ZCNAX3VL9D; 556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557 ENABLE_PREVIEWS = YES; 557 ENABLE_PREVIEWS = YES;
558 GENERATE_INFOPLIST_FILE = YES; 558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569 "$(inherited)", 569 "$(inherited)",
570 "@executable_path/Frameworks", 570 "@executable_path/Frameworks",
571 ); 571 );
572 MARKETING_VERSION = 2.13.0; 572 MARKETING_VERSION = 2.13.1;
573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574 PRODUCT_NAME = "$(TARGET_NAME)"; 574 PRODUCT_NAME = "$(TARGET_NAME)";
575 STRING_CATALOG_GENERATE_SYMBOLS = YES; 575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632 APPLICATION_EXTENSION_API_ONLY = YES; 632 APPLICATION_EXTENSION_API_ONLY = YES;
633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634 CODE_SIGN_STYLE = Automatic; 634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 29; 635 CURRENT_PROJECT_VERSION = 30;
636 DEVELOPMENT_TEAM = ZCNAX3VL9D; 636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637 GENERATE_INFOPLIST_FILE = NO; 637 GENERATE_INFOPLIST_FILE = NO;
638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642 "@executable_path/Frameworks", 642 "@executable_path/Frameworks",
643 "@executable_path/../../Frameworks", 643 "@executable_path/../../Frameworks",
644 ); 644 );
645 MARKETING_VERSION = 2.13.0; 645 MARKETING_VERSION = 2.13.1;
646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647 PRODUCT_NAME = "$(TARGET_NAME)"; 647 PRODUCT_NAME = "$(TARGET_NAME)";
648 SKIP_INSTALL = YES; 648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661 APPLICATION_EXTENSION_API_ONLY = YES; 661 APPLICATION_EXTENSION_API_ONLY = YES;
662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663 CODE_SIGN_STYLE = Automatic; 663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 29; 664 CURRENT_PROJECT_VERSION = 30;
665 DEVELOPMENT_TEAM = ZCNAX3VL9D; 665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666 GENERATE_INFOPLIST_FILE = NO; 666 GENERATE_INFOPLIST_FILE = NO;
667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671 "@executable_path/Frameworks", 671 "@executable_path/Frameworks",
672 "@executable_path/../../Frameworks", 672 "@executable_path/../../Frameworks",
673 ); 673 );
674 MARKETING_VERSION = 2.13.0; 674 MARKETING_VERSION = 2.13.1;
675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676 PRODUCT_NAME = "$(TARGET_NAME)"; 676 PRODUCT_NAME = "$(TARGET_NAME)";
677 SKIP_INSTALL = YES; 677 SKIP_INSTALL = YES;
Hutch/App/AppStorageKeys.swift +2
@@ -6,4 +6,6 @@ enum AppStorageKeys {
6 static let wrapRepositoryFileLines = "wrapRepositoryFileLines" 6 static let wrapRepositoryFileLines = "wrapRepositoryFileLines"
7 static let lookupHistory = "lookupHistory" 7 static let lookupHistory = "lookupHistory"
8 static let hutchStatsBaseURL = "hutchStatsBaseURL" 8 static let hutchStatsBaseURL = "hutchStatsBaseURL"
9 static let systemStatusSnapshotCache = "systemStatusSnapshotCache"
10 static let systemStatusIncidentCache = "systemStatusIncidentCache"
9} 11}
Hutch/Networking/SystemStatusCacheStore.swift added +59
@@ -0,0 +1,59 @@
1import Foundation
2
3actor SystemStatusCacheStore {
4 private let snapshotCacheKey = "systemStatusSnapshotCache"
5 private let incidentCacheKey = "systemStatusIncidentCache"
6 private let defaults: UserDefaults
7
8 init(defaults: UserDefaults = .standard) {
9 self.defaults = defaults
10 }
11
12 func loadSnapshotHTML() -> (html: String, timestamp: Date)? {
13 guard let html = defaults.string(forKey: snapshotCacheKey) else {
14 return nil
15 }
16
17 let timestampValue = defaults.double(forKey: snapshotTimestampKey)
18 guard timestampValue > 0 else {
19 defaults.removeObject(forKey: snapshotCacheKey)
20 defaults.removeObject(forKey: snapshotTimestampKey)
21 return nil
22 }
23
24 return (html, Date(timeIntervalSince1970: timestampValue))
25 }
26
27 func saveSnapshotHTML(_ html: String, timestamp: Date) {
28 defaults.set(html, forKey: snapshotCacheKey)
29 defaults.set(timestamp.timeIntervalSince1970, forKey: snapshotTimestampKey)
30 }
31
32 func loadIncidentFeedData() -> (data: Data, timestamp: Date)? {
33 guard let data = defaults.data(forKey: incidentCacheKey) else {
34 return nil
35 }
36
37 let timestampValue = defaults.double(forKey: incidentTimestampKey)
38 guard timestampValue > 0 else {
39 defaults.removeObject(forKey: incidentCacheKey)
40 defaults.removeObject(forKey: incidentTimestampKey)
41 return nil
42 }
43
44 return (data, Date(timeIntervalSince1970: timestampValue))
45 }
46
47 func saveIncidentFeedData(_ data: Data, timestamp: Date) {
48 defaults.set(data, forKey: incidentCacheKey)
49 defaults.set(timestamp.timeIntervalSince1970, forKey: incidentTimestampKey)
50 }
51
52 private var snapshotTimestampKey: String {
53 "\(snapshotCacheKey).timestamp"
54 }
55
56 private var incidentTimestampKey: String {
57 "\(incidentCacheKey).timestamp"
58 }
59}
Hutch/Networking/SystemStatusRepository.swift +154 −18
@@ -1,57 +1,193 @@
1import Foundation 1import Foundation
2 2
3protocol SystemStatusServing: Sendable {
4 func fetchSnapshotHTML() async throws -> String
5 func fetchIncidentFeedData() async throws -> Data
6}
7
8struct CachedSystemStatusValue<Value: Sendable>: Sendable {
9 let value: Value
10 let lastSuccessfulAt: Date
11 let isStale: Bool
12 let refreshErrorMessage: String?
13}
14
3actor SystemStatusRepository { 15actor SystemStatusRepository {
4 private let service: SystemStatusService 16 private let service: any SystemStatusServing
5 private let ttl: TimeInterval 17 private let ttl: TimeInterval
18 private let cacheStore: SystemStatusCacheStore
19 private let now: @Sendable () -> Date
6 20
7 private var snapshotCache: CacheEntry<SystemStatusSnapshot>? 21 private var snapshotCache: CacheEntry<SystemStatusSnapshot>?
8 private var incidentsCache: CacheEntry<[StatusIncident]>? 22 private var incidentsCache: CacheEntry<[StatusIncident]>?
23 private var hasLoadedPersistentCache = false
9 24
10 init(service: SystemStatusService = SystemStatusService(), ttl: TimeInterval = 10 * 60) { 25 init(
26 service: any SystemStatusServing = SystemStatusService(),
27 ttl: TimeInterval = 10 * 60,
28 cacheStore: SystemStatusCacheStore = SystemStatusCacheStore(),
29 now: @escaping @Sendable () -> Date = Date.init
30 ) {
11 self.service = service 31 self.service = service
12 self.ttl = ttl 32 self.ttl = ttl
33 self.cacheStore = cacheStore
34 self.now = now
13 } 35 }
14 36
15 func snapshot(forceRefresh: Bool = false) async throws -> SystemStatusSnapshot { 37 func snapshot(forceRefresh: Bool = false) async throws -> SystemStatusSnapshot {
16 if let cached = snapshotCache, !forceRefresh, !cached.isExpired(ttl: ttl) { 38 try await snapshotResult(forceRefresh: forceRefresh).value
17 return cached.value 39 }
40
41 func recentIncidents(forceRefresh: Bool = false) async throws -> [StatusIncident] {
42 try await recentIncidentsResult(forceRefresh: forceRefresh).value
43 }
44
45 func snapshotResult(forceRefresh: Bool = false) async throws -> CachedSystemStatusValue<SystemStatusSnapshot> {
46 await loadPersistentCacheIfNeeded()
47
48 if let cached = snapshotCache, !forceRefresh, !cached.isExpired(ttl: ttl, now: now) {
49 return CachedSystemStatusValue(
50 value: cached.value,
51 lastSuccessfulAt: cached.timestamp,
52 isStale: false,
53 refreshErrorMessage: nil
54 )
18 } 55 }
19 56
20 do { 57 do {
21 let snapshot = try await service.fetchSnapshot() 58 let html = try await service.fetchSnapshotHTML()
22 snapshotCache = CacheEntry(value: snapshot, timestamp: Date()) 59 let snapshot = try SystemStatusService.parseSnapshotHTML(html, fetchedAt: now())
23 return snapshot 60 let entry = CacheEntry(value: snapshot, timestamp: now())
61 snapshotCache = entry
62 await cacheStore.saveSnapshotHTML(html, timestamp: entry.timestamp)
63 return CachedSystemStatusValue(
64 value: snapshot,
65 lastSuccessfulAt: entry.timestamp,
66 isStale: false,
67 refreshErrorMessage: nil
68 )
24 } catch { 69 } catch {
25 if let cached = snapshotCache { 70 if let cached = snapshotCache {
26 return cached.value 71 return CachedSystemStatusValue(
72 value: cached.value,
73 lastSuccessfulAt: cached.timestamp,
74 isStale: true,
75 refreshErrorMessage: refreshErrorMessage(from: error)
76 )
27 } 77 }
28 throw error 78 throw error
29 } 79 }
30 } 80 }
31 81
32 func recentIncidents(forceRefresh: Bool = false) async throws -> [StatusIncident] { 82 func recentIncidentsResult(forceRefresh: Bool = false) async throws -> CachedSystemStatusValue<[StatusIncident]> {
33 if let cached = incidentsCache, !forceRefresh, !cached.isExpired(ttl: ttl) { 83 await loadPersistentCacheIfNeeded()
34 return cached.value 84
85 if let cached = incidentsCache, !forceRefresh, !cached.isExpired(ttl: ttl, now: now) {
86 return CachedSystemStatusValue(
87 value: cached.value,
88 lastSuccessfulAt: cached.timestamp,
89 isStale: false,
90 refreshErrorMessage: nil
91 )
35 } 92 }
36 93
37 do { 94 do {
38 let incidents = try await service.fetchIncidentFeed() 95 let feedData = try await service.fetchIncidentFeedData()
39 incidentsCache = CacheEntry(value: incidents, timestamp: Date()) 96 let incidents = try await SystemStatusService.parseIncidentFeedXML(feedData)
40 return incidents 97 let entry = CacheEntry(value: incidents, timestamp: now())
98 incidentsCache = entry
99 await cacheStore.saveIncidentFeedData(feedData, timestamp: entry.timestamp)
100 return CachedSystemStatusValue(
101 value: incidents,
102 lastSuccessfulAt: entry.timestamp,
103 isStale: false,
104 refreshErrorMessage: nil
105 )
41 } catch { 106 } catch {
42 if let cached = incidentsCache { 107 if let cached = incidentsCache {
43 return cached.value 108 return CachedSystemStatusValue(
109 value: cached.value,
110 lastSuccessfulAt: cached.timestamp,
111 isStale: true,
112 refreshErrorMessage: refreshErrorMessage(from: error)
113 )
44 } 114 }
45 throw error 115 throw error
46 } 116 }
47 } 117 }
118
119 private func loadPersistentCacheIfNeeded() async {
120 guard !hasLoadedPersistentCache else { return }
121 if let persistedSnapshot = await cacheStore.loadSnapshotHTML(),
122 let snapshot = try? SystemStatusService.parseSnapshotHTML(persistedSnapshot.html, fetchedAt: persistedSnapshot.timestamp) {
123 snapshotCache = CacheEntry(value: snapshot, timestamp: persistedSnapshot.timestamp)
124 }
125 if let persistedFeed = await cacheStore.loadIncidentFeedData(),
126 let incidents = try? await SystemStatusService.parseIncidentFeedXML(persistedFeed.data) {
127 incidentsCache = CacheEntry(value: incidents, timestamp: persistedFeed.timestamp)
128 }
129 hasLoadedPersistentCache = true
130 }
131
132 private func refreshErrorMessage(from error: any Error) -> String {
133 if let error = error as? SRHTError {
134 switch error {
135 case .graphQLErrors(let errors):
136 let firstMessage = errors.first?.message.lowercased() ?? ""
137 if firstMessage.contains("unauthorized") || firstMessage.contains("forbidden") {
138 return "You do not have permission to do that."
139 }
140 if firstMessage.contains("not found") || firstMessage.contains("no rows in result set") {
141 return "That content is no longer available."
142 }
143 return "Something went wrong. Please try again."
144 case .httpError(let code):
145 if code == 401 {
146 return "Please sign in again."
147 }
148 if code == 403 {
149 return "You do not have permission to do that."
150 }
151 if code == 404 {
152 return "That content is no longer available."
153 }
154 if (500...599).contains(code) {
155 return "The server is unavailable right now. Please try again."
156 }
157 return "Something went wrong. Please try again."
158 case .invalidAuthenticatedURL:
159 return "That request could not be completed."
160 case .decodingError:
161 return "The response could not be loaded right now."
162 case .networkError(let underlyingError):
163 return refreshErrorMessage(from: underlyingError)
164 case .unauthorized:
165 return "Please sign in again."
166 }
167 }
168
169 let nsError = error as NSError
170 switch nsError.code {
171 case NSURLErrorNotConnectedToInternet,
172 NSURLErrorNetworkConnectionLost,
173 NSURLErrorTimedOut,
174 NSURLErrorCannotFindHost,
175 NSURLErrorCannotConnectToHost,
176 NSURLErrorDNSLookupFailed,
177 NSURLErrorInternationalRoamingOff,
178 NSURLErrorDataNotAllowed:
179 return "Check your connection and try again."
180 default:
181 return "Something went wrong. Please try again."
182 }
183 }
48} 184}
49 185
50private struct CacheEntry<Value: Sendable>: Sendable { 186struct CacheEntry<Value: Sendable>: Sendable {
51 let value: Value 187 let value: Value
52 let timestamp: Date 188 let timestamp: Date
53 189
54 nonisolated func isExpired(ttl: TimeInterval) -> Bool { 190 nonisolated func isExpired(ttl: TimeInterval, now: @escaping @Sendable () -> Date = Date.init) -> Bool {
55 Date().timeIntervalSince(timestamp) > ttl 191 now().timeIntervalSince(timestamp) > ttl
56 } 192 }
57} 193}
Hutch/Networking/SystemStatusService.swift +90 −28
@@ -13,15 +13,23 @@ struct SystemStatusService: Sendable {
13 } 13 }
14 14
15 func fetchSnapshot() async throws -> SystemStatusSnapshot { 15 func fetchSnapshot() async throws -> SystemStatusSnapshot {
16 let html = try await fetchText(from: Self.statusURL, accept: "text/html,application/xhtml+xml") 16 let html = try await fetchSnapshotHTML()
17 return try Self.parseSnapshotHTML(html, fetchedAt: now()) 17 return try Self.parseSnapshotHTML(html, fetchedAt: now())
18 } 18 }
19 19
20 func fetchIncidentFeed() async throws -> [StatusIncident] { 20 func fetchIncidentFeed() async throws -> [StatusIncident] {
21 let data = try await fetchData(from: Self.feedURL, accept: "application/rss+xml,application/xml,text/xml") 21 let data = try await fetchIncidentFeedData()
22 return try await Self.parseIncidentFeedXML(data) 22 return try await Self.parseIncidentFeedXML(data)
23 } 23 }
24 24
25 func fetchSnapshotHTML() async throws -> String {
26 try await fetchText(from: Self.statusURL, accept: "text/html,application/xhtml+xml")
27 }
28
29 func fetchIncidentFeedData() async throws -> Data {
30 try await fetchData(from: Self.feedURL, accept: "application/rss+xml,application/xml,text/xml")
31 }
32
25 private func fetchText(from url: URL, accept: String) async throws -> String { 33 private func fetchText(from url: URL, accept: String) async throws -> String {
26 let data = try await fetchData(from: url, accept: accept) 34 let data = try await fetchData(from: url, accept: accept)
27 guard let text = String(data: data, encoding: .utf8) else { 35 guard let text = String(data: data, encoding: .utf8) else {
@@ -62,6 +70,8 @@ struct SystemStatusService: Sendable {
62 } 70 }
63} 71}
64 72
73extension SystemStatusService: SystemStatusServing {}
74
65extension SystemStatusService { 75extension SystemStatusService {
66 nonisolated static func parseSnapshotHTML(_ html: String, fetchedAt: Date) throws -> SystemStatusSnapshot { 76 nonisolated static func parseSnapshotHTML(_ html: String, fetchedAt: Date) throws -> SystemStatusSnapshot {
67 let services = parseServices(in: html) 77 let services = parseServices(in: html)
@@ -96,7 +106,7 @@ extension SystemStatusService {
96 nonisolated private static func parseServices(in html: String) -> [StatusServiceState] { 106 nonisolated private static func parseServices(in html: String) -> [StatusServiceState] {
97 firstMatches( 107 firstMatches(
98 in: html, 108 in: html,
99 pattern: #"<div class="component" data-status="([^"]+)">([\s\S]*?)</div>"# 109 pattern: #"<div\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bcomponent\b[^"']*["'])(?=[^>]*\bdata-status\s*=\s*["']([^"']+)["'])[^>]*>([\s\S]*?)</div>"#
100 ).compactMap { captures in 110 ).compactMap { captures in
101 guard captures.count >= 2 else { return nil } 111 guard captures.count >= 2 else { return nil }
102 112
@@ -104,16 +114,18 @@ extension SystemStatusService {
104 let content = captures[1] 114 let content = captures[1]
105 guard let linkCaptures = firstMatches( 115 guard let linkCaptures = firstMatches(
106 in: content, 116 in: content,
107 pattern: #"<a[^>]*href="([^"]+)"[^>]*>\s*(.*?)\s*</a>"# 117 pattern: #"<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)</a>"#
108 ).first, 118 ).first,
109 linkCaptures.count >= 2, 119 linkCaptures.count >= 2 else {
110 let statusText = firstMatch(in: content, pattern: #"<span class="component-status">\s*(.*?)\s*</span>"#) else {
111 return nil 120 return nil
112 } 121 }
113 122
114 let href = linkCaptures[0] 123 let href = linkCaptures[0]
115 let cleanedName = cleanText(linkCaptures[1]) 124 let cleanedName = cleanText(linkCaptures[1])
116 let readableStatus = cleanText(statusText) 125 let readableStatus = firstMatch(
126 in: content,
127 pattern: #"<(?:span|small|div)\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bcomponent-status\b[^"']*["'])[^>]*>([\s\S]*?)</(?:span|small|div)>"#
128 ).map(cleanText) ?? firstStatusLabel(in: content) ?? ""
117 let level = statusLevel(fromHTMLStatus: rawStatus) 129 let level = statusLevel(fromHTMLStatus: rawStatus)
118 130
119 return StatusServiceState( 131 return StatusServiceState(
@@ -129,19 +141,19 @@ extension SystemStatusService {
129 nonisolated private static func parseHTMLIncidentCards(in html: String) -> [StatusIncident] { 141 nonisolated private static func parseHTMLIncidentCards(in html: String) -> [StatusIncident] {
130 firstMatches( 142 firstMatches(
131 in: html, 143 in: html,
132 pattern: #"<a href="([^"]+)" class="issue no-underline">([\s\S]*?)</a>"# 144 pattern: #"<a\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bissue\b[^"']*["'])(?=[^>]*\bhref\s*=\s*["']([^"']+)["'])[^>]*>([\s\S]*?)</a>"#
133 ).compactMap { captures in 145 ).compactMap { captures in
134 guard captures.count >= 2 else { return nil } 146 guard captures.count >= 2 else { return nil }
135 let href = captures[0] 147 let href = captures[0]
136 let content = captures[1] 148 let content = captures[1]
137 guard let titleHTML = firstMatch(in: content, pattern: #"<h3>\s*([\s\S]*?)\s*</h3>"#), 149 guard let titleHTML = firstMatch(in: content, pattern: #"<h[1-6][^>]*>\s*([\s\S]*?)\s*</h[1-6]>"#)
138 let titleAttribute = firstMatch(in: content, pattern: #"<small class="date[^"]*" title="([^"]+)">"#), 150 ?? firstMatch(in: content, pattern: #"<strong[^>]*>\s*([\s\S]*?)\s*</strong>"#)
139 let publishedAt = htmlIssueDateFormatter.date(from: cleanText(titleAttribute)) else { 151 ?? firstMatch(in: content, pattern: #"<span[^>]*>\s*([\s\S]*?)\s*</span>"#),
152 let publishedAt = publishedIncidentDate(in: content) else {
140 return nil 153 return nil
141 } 154 }
142 155
143 let url = URL(string: href, relativeTo: statusURL)?.absoluteURL 156 let url = URL(string: href, relativeTo: statusURL)?.absoluteURL
144 let isActive = content.localizedCaseInsensitiveContains("This issue is not resolved yet")
145 return StatusIncident( 157 return StatusIncident(
146 id: url?.absoluteString ?? cleanText(titleHTML), 158 id: url?.absoluteString ?? cleanText(titleHTML),
147 title: cleanText(titleHTML), 159 title: cleanText(titleHTML),
@@ -149,7 +161,7 @@ extension SystemStatusService {
149 url: url, 161 url: url,
150 publishedAt: publishedAt, 162 publishedAt: publishedAt,
151 updatedAt: nil, 163 updatedAt: nil,
152 isActive: isActive 164 isActive: isActiveIncidentCard(content)
153 ) 165 )
154 } 166 }
155 } 167 }
@@ -157,12 +169,12 @@ extension SystemStatusService {
157 nonisolated private static func parseActiveIncidentSummaries(in html: String) -> [String: String] { 169 nonisolated private static func parseActiveIncidentSummaries(in html: String) -> [String: String] {
158 firstMatches( 170 firstMatches(
159 in: html, 171 in: html,
160 pattern: #"<div class="announcement-box"[\s\S]*?<div class="padding">([\s\S]*?)</div>\s*<hr class="clean announcement-box">"# 172 pattern: #"<div\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bannouncement-box\b[^"']*["'])[^>]*>([\s\S]*?)</div>\s*(?:<hr\b[^>]*\bannouncement-box\b[^>]*>)?"#
161 ).reduce(into: [:]) { partialResult, captures in 173 ).reduce(into: [:]) { partialResult, captures in
162 guard let content = captures.first, 174 guard let content = captures.first,
163 let titleLinkCaptures = firstMatches( 175 let titleLinkCaptures = firstMatches(
164 in: content, 176 in: content,
165 pattern: #"<a href="([^"]+)"><strong class="bold">([\s\S]*?)</strong></a>"# 177 pattern: #"<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)</a>"#
166 ).first, 178 ).first,
167 let href = titleLinkCaptures.first else { 179 let href = titleLinkCaptures.first else {
168 return 180 return
@@ -197,17 +209,22 @@ extension SystemStatusService {
197 } 209 }
198 210
199 nonisolated private static func statusLevel(fromLabel label: String) -> StatusLevel { 211 nonisolated private static func statusLevel(fromLabel label: String) -> StatusLevel {
200 switch label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { 212 let normalizedLabel = label
201 case "operational": 213 .trimmingCharacters(in: .whitespacesAndNewlines)
202 .operational 214 .lowercased()
203 case "disrupted", "degraded": 215 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
204 .degraded 216
205 case "down", "major outage": 217 return switch normalizedLabel {
206 .majorOutage 218 case "operational", "all systems operational":
207 case "maintenance": 219 StatusLevel.operational
208 .maintenance 220 case "disrupted", "degraded", "partial outage":
221 StatusLevel.degraded
222 case "down", "major outage", "outage":
223 StatusLevel.majorOutage
224 case "maintenance", "scheduled maintenance", "under maintenance":
225 StatusLevel.maintenance
209 default: 226 default:
210 .unknown 227 StatusLevel.unknown
211 } 228 }
212 } 229 }
213 230
@@ -252,6 +269,47 @@ extension SystemStatusService {
252 .trimmingCharacters(in: .whitespacesAndNewlines) 269 .trimmingCharacters(in: .whitespacesAndNewlines)
253 } 270 }
254 271
272 nonisolated private static func firstStatusLabel(in text: String) -> String? {
273 cleanText(text)
274 .split(separator: "•")
275 .map(String.init)
276 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
277 .first(where: { statusLevel(fromLabel: $0) != .unknown })
278 }
279
280 nonisolated private static func publishedIncidentDate(in content: String) -> Date? {
281 if let timeValue = firstMatch(in: content, pattern: #"<time\b[^>]*\bdatetime\s*=\s*["']([^"']+)["'][^>]*>"#),
282 let parsed = parseISO8601Date(cleanText(timeValue)) {
283 return parsed
284 }
285
286 if let titleAttribute = firstMatch(
287 in: content,
288 pattern: #"<(?:small|time)\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bdate\b[^"']*["'])[^>]*\btitle\s*=\s*["']([^"']+)["'][^>]*>"#
289 ) ?? firstMatch(in: content, pattern: #"\btitle\s*=\s*["']([^"']+UTC)["']"#) {
290 return htmlIssueDateFormatter.date(from: cleanText(titleAttribute))
291 }
292
293 return nil
294 }
295
296 nonisolated private static func isActiveIncidentCard(_ content: String) -> Bool {
297 let normalizedContent = cleanText(content).lowercased()
298 return normalizedContent.contains("not resolved yet")
299 || normalizedContent.contains("ongoing")
300 || normalizedContent.contains("investigating")
301 }
302
303 nonisolated fileprivate static func parseISO8601Date(_ value: String) -> Date? {
304 let formatter = ISO8601DateFormatter()
305 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
306 return formatter.date(from: value) ?? {
307 let fallbackFormatter = ISO8601DateFormatter()
308 fallbackFormatter.formatOptions = [.withInternetDateTime]
309 return fallbackFormatter.date(from: value)
310 }()
311 }
312
255 nonisolated private static let htmlIssueDateFormatter: DateFormatter = { 313 nonisolated private static let htmlIssueDateFormatter: DateFormatter = {
256 let formatter = DateFormatter() 314 let formatter = DateFormatter()
257 formatter.locale = Locale(identifier: "en_US_POSIX") 315 formatter.locale = Locale(identifier: "en_US_POSIX")
@@ -259,9 +317,9 @@ extension SystemStatusService {
259 formatter.dateFormat = "MMM d HH:mm:ss yyyy zzz" 317 formatter.dateFormat = "MMM d HH:mm:ss yyyy zzz"
260 return formatter 318 return formatter
261 }() 319 }()
320
262} 321}
263 322
264@MainActor
265private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchecked Sendable { 323private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchecked Sendable {
266 private var incidents: [StatusIncident] = [] 324 private var incidents: [StatusIncident] = []
267 private var currentItem: FeedItem? 325 private var currentItem: FeedItem?
@@ -315,7 +373,7 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec
315 currentItem.guid = value 373 currentItem.guid = value
316 case "description": 374 case "description":
317 currentItem.description = value 375 currentItem.description = value
318 case "pubDate": 376 case "pubDate", "dc:date":
319 currentItem.pubDate = value 377 currentItem.pubDate = value
320 case "category": 378 case "category":
321 currentItem.category = value 379 currentItem.category = value
@@ -345,7 +403,7 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec
345 func makeIncident() -> StatusIncident? { 403 func makeIncident() -> StatusIncident? {
346 let cleanedTitle = title.replacingOccurrences(of: "[Resolved] ", with: "") 404 let cleanedTitle = title.replacingOccurrences(of: "[Resolved] ", with: "")
347 guard !cleanedTitle.isEmpty, 405 guard !cleanedTitle.isEmpty,
348 let publishedAt = SystemStatusFeedParser.pubDateFormatter.date(from: pubDate) else { 406 let publishedAt = SystemStatusFeedParser.parsePubDate(pubDate) else {
349 return nil 407 return nil
350 } 408 }
351 409
@@ -382,6 +440,10 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec
382 return decodeHTMLEntities(stripped) 440 return decodeHTMLEntities(stripped)
383 } 441 }
384 442
443 nonisolated private static func parsePubDate(_ value: String) -> Date? {
444 pubDateFormatter.date(from: value) ?? SystemStatusService.parseISO8601Date(value)
445 }
446
385 nonisolated private static let pubDateFormatter: DateFormatter = { 447 nonisolated private static let pubDateFormatter: DateFormatter = {
386 let formatter = DateFormatter() 448 let formatter = DateFormatter()
387 formatter.locale = Locale(identifier: "en_US_POSIX") 449 formatter.locale = Locale(identifier: "en_US_POSIX")
Hutch/Views/Home/HomeView.swift +14 −34
@@ -78,19 +78,25 @@ struct HomeView: View {
78 .refreshable { 78 .refreshable {
79 await viewModel.loadDashboard() 79 await viewModel.loadDashboard()
80 } 80 }
81 .connectivityOverlay(hasContent: viewModel.hasDashboardContent) {
82 await viewModel.loadDashboard()
83 }
81 } 84 }
82 85
83 @ViewBuilder 86 @ViewBuilder
84 private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View { 87 private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View {
85 if let bannerTitle = viewModel.systemStatusBannerTitle { 88 Section {
86 Section { 89 NavigationLink {
87 NavigationLink { 90 SystemStatusView()
88 SystemStatusView() 91 } label: {
89 } label: { 92 SystemStatusSummaryRow(
90 HomeSystemStatusBanner(title: bannerTitle) 93 snapshot: viewModel.systemStatusSnapshot,
91 } 94 isLoading: viewModel.isLoadingSystemStatus,
92 .buttonStyle(.plain) 95 errorMessage: viewModel.systemStatusErrorMessage,
96 isShowingStaleData: viewModel.isShowingStaleSystemStatus
97 )
93 } 98 }
99 .buttonStyle(.plain)
94 } 100 }
95 } 101 }
96 102
@@ -252,32 +258,6 @@ private struct HomeInboxToolbarIcon: View {
252 } 258 }
253} 259}
254 260
255private struct HomeSystemStatusBanner: View {
256 let title: String
257
258 var body: some View {
259 HStack(spacing: 12) {
260 Image(systemName: "exclamationmark.triangle.fill")
261 .foregroundStyle(.orange)
262 VStack(alignment: .leading, spacing: 2) {
263 Text("SourceHut service disruption")
264 .font(.subheadline.weight(.semibold))
265 .foregroundStyle(.primary)
266 Text(title)
267 .font(.caption)
268 .foregroundStyle(.secondary)
269 .lineLimit(1)
270 }
271 Spacer()
272 Image(systemName: "chevron.right")
273 .font(.caption.weight(.semibold))
274 .foregroundStyle(.tertiary)
275 }
276 .padding(.vertical, 4)
277 .contentShape(Rectangle())
278 }
279}
280
281private struct HomeProjectRow: View { 261private struct HomeProjectRow: View {
282 let project: Project 262 let project: Project
283 263
Hutch/Views/Home/HomeViewModel.swift +21 −7
@@ -144,6 +144,9 @@ final class HomeViewModel {
144 var assignedTickets: [HomeAssignedTicket] = [] 144 var assignedTickets: [HomeAssignedTicket] = []
145 var recentBuilds: [HomeBuildItem] = [] 145 var recentBuilds: [HomeBuildItem] = []
146 private(set) var systemStatusSnapshot: SystemStatusSnapshot? 146 private(set) var systemStatusSnapshot: SystemStatusSnapshot?
147 private(set) var isLoadingSystemStatus = false
148 private(set) var isShowingStaleSystemStatus = false
149 private(set) var systemStatusErrorMessage: String?
147 private(set) var hasUnreadInboxThreads = false 150 private(set) var hasUnreadInboxThreads = false
148 private(set) var unreadInboxThreadCount: Int? 151 private(set) var unreadInboxThreadCount: Int?
149 private(set) var isLoadingProjects = false 152 private(set) var isLoadingProjects = false
@@ -279,8 +282,11 @@ final class HomeViewModel {
279 isLoadingProjects = true 282 isLoadingProjects = true
280 isLoadingAssignedTickets = true 283 isLoadingAssignedTickets = true
281 isLoadingRecentBuilds = true 284 isLoadingRecentBuilds = true
285 isLoadingSystemStatus = true
282 assignedTicketsError = nil 286 assignedTicketsError = nil
283 recentBuildsError = nil 287 recentBuildsError = nil
288 isShowingStaleSystemStatus = false
289 systemStatusErrorMessage = nil
284 290
285 async let projectsTask = loadProjects() 291 async let projectsTask = loadProjects()
286 async let jobsTask = loadRecentJobs() 292 async let jobsTask = loadRecentJobs()
@@ -324,13 +330,21 @@ final class HomeViewModel {
324 330
325 unreadInboxThreadCount = await inboxUnreadTask 331 unreadInboxThreadCount = await inboxUnreadTask
326 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 332 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
327 systemStatusSnapshot = await systemStatusTask 333 let systemStatusResult = await systemStatusTask
334 switch systemStatusResult {
335 case .success(let result):
336 systemStatusSnapshot = result.value
337 isShowingStaleSystemStatus = result.isStale
338 systemStatusErrorMessage = result.isStale ? result.refreshErrorMessage : nil
339 case .failure(let error):
340 systemStatusErrorMessage = error.userFacingMessage
341 }
342 isLoadingSystemStatus = false
328 persistNeedsAttentionSnapshot() 343 persistNeedsAttentionSnapshot()
329 } 344 }
330 345
331 var systemStatusBannerTitle: String? { 346 var hasDashboardContent: Bool {
332 guard let systemStatusSnapshot, systemStatusSnapshot.hasDisruption else { return nil } 347 !projects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || systemStatusSnapshot != nil
333 return systemStatusSnapshot.bannerSummary
334 } 348 }
335 349
336 func resolveTicket(_ ticket: HomeAssignedTicket) async { 350 func resolveTicket(_ ticket: HomeAssignedTicket) async {
@@ -430,11 +444,11 @@ final class HomeViewModel {
430 } 444 }
431 } 445 }
432 446
433 private func loadSystemStatusSnapshot() async -> SystemStatusSnapshot? { 447 private func loadSystemStatusSnapshot() async -> Result<CachedSystemStatusValue<SystemStatusSnapshot>, Error> {
434 do { 448 do {
435 return try await systemStatusRepository.snapshot() 449 return .success(try await systemStatusRepository.snapshotResult())
436 } catch { 450 } catch {
437 return systemStatusSnapshot 451 return .failure(error)
438 } 452 }
439 } 453 }
440 454
Hutch/Views/More/MoreView.swift +25 −2
@@ -7,6 +7,7 @@ struct MoreView: View {
7 ("chat.sr.ht", SRHTWebURL.chat) 7 ("chat.sr.ht", SRHTWebURL.chat)
8 ] 8 ]
9 9
10 @State private var viewModel: MoreViewModel?
10 @State private var showAccountSwitcher = false 11 @State private var showAccountSwitcher = false
11 12
12 var body: some View { 13 var body: some View {
@@ -29,9 +30,14 @@ struct MoreView: View {
29 NavigationLink(value: MoreRoute.pastes) { 30 NavigationLink(value: MoreRoute.pastes) {
30 Label("Pastes", systemImage: "doc.on.clipboard") 31 Label("Pastes", systemImage: "doc.on.clipboard")
31 } 32 }
32 33
33 NavigationLink(value: MoreRoute.systemStatus) { 34 NavigationLink(value: MoreRoute.systemStatus) {
34 Label("System Status", systemImage: "server.rack") 35 SystemStatusSummaryRow(
36 snapshot: viewModel?.systemStatusSnapshot,
37 isLoading: viewModel?.isLoadingSystemStatus ?? true,
38 errorMessage: viewModel?.systemStatusErrorMessage,
39 isShowingStaleData: viewModel?.isShowingStaleSystemStatus ?? false
40 )
35 } 41 }
36 } 42 }
37 43
@@ -58,6 +64,12 @@ struct MoreView: View {
58 } 64 }
59 } 65 }
60 .navigationTitle("More") 66 .navigationTitle("More")
67 .refreshable {
68 await ensureViewModel().loadSystemStatus(forceRefresh: true)
69 }
70 .task {
71 await ensureViewModel().loadIfNeeded()
72 }
61 .toolbar { 73 .toolbar {
62 ToolbarItem(placement: .topBarTrailing) { 74 ToolbarItem(placement: .topBarTrailing) {
63 Button { 75 Button {
@@ -71,4 +83,15 @@ struct MoreView: View {
71 AccountSwitcherView() 83 AccountSwitcherView()
72 } 84 }
73 } 85 }
86
87 @MainActor
88 private func ensureViewModel() -> MoreViewModel {
89 if let viewModel {
90 return viewModel
91 }
92
93 let newViewModel = MoreViewModel(repository: appState.systemStatusRepository)
94 viewModel = newViewModel
95 return newViewModel
96 }
74} 97}
Hutch/Views/More/MoreViewModel.swift added +39
@@ -0,0 +1,39 @@
1import Foundation
2
3@Observable
4@MainActor
5final class MoreViewModel {
6 private let repository: SystemStatusRepository
7
8 private(set) var systemStatusSnapshot: SystemStatusSnapshot?
9 private(set) var isLoadingSystemStatus = false
10 private(set) var isShowingStaleSystemStatus = false
11 private(set) var systemStatusErrorMessage: String?
12
13 init(repository: SystemStatusRepository) {
14 self.repository = repository
15 }
16
17 func loadIfNeeded() async {
18 guard systemStatusSnapshot == nil, !isLoadingSystemStatus else { return }
19 await loadSystemStatus()
20 }
21
22 func loadSystemStatus(forceRefresh: Bool = false) async {
23 isLoadingSystemStatus = true
24 defer { isLoadingSystemStatus = false }
25 isShowingStaleSystemStatus = false
26 systemStatusErrorMessage = nil
27
28 do {
29 let result = try await repository.snapshotResult(forceRefresh: forceRefresh)
30 systemStatusSnapshot = result.value
31 isShowingStaleSystemStatus = result.isStale
32 systemStatusErrorMessage = result.isStale ? result.refreshErrorMessage : nil
33 } catch {
34 if systemStatusSnapshot == nil {
35 systemStatusErrorMessage = error.userFacingMessage
36 }
37 }
38 }
39}
Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift added +119
@@ -0,0 +1,119 @@
1import SwiftUI
2
3struct SystemStatusSummaryRow: View {
4 let title: String
5 let snapshot: SystemStatusSnapshot?
6 let isLoading: Bool
7 let errorMessage: String?
8 let isShowingStaleData: Bool
9
10 init(
11 title: String = "System Status",
12 snapshot: SystemStatusSnapshot?,
13 isLoading: Bool = false,
14 errorMessage: String? = nil,
15 isShowingStaleData: Bool = false
16 ) {
17 self.title = title
18 self.snapshot = snapshot
19 self.isLoading = isLoading
20 self.errorMessage = errorMessage
21 self.isShowingStaleData = isShowingStaleData
22 }
23
24 var body: some View {
25 HStack(spacing: 12) {
26 icon
27 .frame(width: 20)
28
29 VStack(alignment: .leading, spacing: 3) {
30 Text(title)
31 .font(.subheadline.weight(.semibold))
32 .foregroundStyle(.primary)
33
34 Text(primaryMessage)
35 .font(.caption)
36 .foregroundStyle(primaryMessageColor)
37 .lineLimit(2)
38
39 if let metadataMessage {
40 Text(metadataMessage)
41 .font(.caption2)
42 .foregroundStyle(.tertiary)
43 .lineLimit(1)
44 }
45 }
46
47 Spacer(minLength: 8)
48 }
49 .padding(.vertical, 4)
50 .contentShape(Rectangle())
51 }
52
53 @ViewBuilder
54 private var icon: some View {
55 if isLoading && snapshot == nil {
56 ProgressView()
57 .controlSize(.small)
58 } else {
59 Image(systemName: iconName)
60 .foregroundStyle(iconColor)
61 }
62 }
63
64 private var primaryMessage: String {
65 if let snapshot {
66 return snapshot.hasDisruption ? snapshot.bannerSummary : snapshot.overallStatusText
67 }
68 if let errorMessage, !errorMessage.isEmpty {
69 return errorMessage
70 }
71 if isLoading {
72 return "Loading system status…"
73 }
74 return "System status is unavailable right now."
75 }
76
77 private var metadataMessage: String? {
78 if let snapshot {
79 if isShowingStaleData {
80 return "Updated \(snapshot.lastUpdated.relativeDescription) • Showing saved data"
81 }
82 return "Updated \(snapshot.lastUpdated.relativeDescription)"
83 }
84 if errorMessage != nil {
85 return "Open System Status to retry."
86 }
87 return nil
88 }
89
90 private var iconName: String {
91 if let snapshot {
92 return snapshot.hasDisruption ? "exclamationmark.triangle.fill" : "checkmark.circle.fill"
93 }
94 if errorMessage != nil {
95 return "exclamationmark.triangle"
96 }
97 return "server.rack"
98 }
99
100 private var iconColor: Color {
101 if let snapshot {
102 return snapshot.hasDisruption ? .orange : .green
103 }
104 if errorMessage != nil {
105 return .secondary
106 }
107 return .secondary
108 }
109
110 private var primaryMessageColor: Color {
111 if snapshot != nil {
112 return .secondary
113 }
114 if errorMessage != nil {
115 return .secondary
116 }
117 return .secondary
118 }
119}
Hutch/Views/SystemStatus/SystemStatusView.swift +8
@@ -31,6 +31,14 @@ struct SystemStatusView: View {
31 @ViewBuilder 31 @ViewBuilder
32 private func content(_ viewModel: SystemStatusViewModel) -> some View { 32 private func content(_ viewModel: SystemStatusViewModel) -> some View {
33 List { 33 List {
34 if viewModel.isShowingStaleData, let staleDataMessage = viewModel.staleDataMessage {
35 Section {
36 Label(staleDataMessage, systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
37 .font(.subheadline)
38 .foregroundStyle(.secondary)
39 }
40 }
41
34 if let snapshot = viewModel.snapshot { 42 if let snapshot = viewModel.snapshot {
35 summarySection(snapshot) 43 summarySection(snapshot)
36 servicesSection(snapshot) 44 servicesSection(snapshot)
Hutch/Views/SystemStatus/SystemStatusViewModel.swift +30 −4
@@ -8,6 +8,8 @@ final class SystemStatusViewModel {
8 private(set) var snapshot: SystemStatusSnapshot? 8 private(set) var snapshot: SystemStatusSnapshot?
9 private(set) var recentIncidents: [StatusIncident] = [] 9 private(set) var recentIncidents: [StatusIncident] = []
10 private(set) var isLoading = false 10 private(set) var isLoading = false
11 private(set) var isShowingStaleData = false
12 private(set) var staleDataMessage: String?
11 var errorMessage: String? 13 var errorMessage: String?
12 14
13 init(repository: SystemStatusRepository) { 15 init(repository: SystemStatusRepository) {
@@ -25,12 +27,24 @@ final class SystemStatusViewModel {
25 defer { isLoading = false } 27 defer { isLoading = false }
26 28
27 errorMessage = nil 29 errorMessage = nil
30 staleDataMessage = nil
31 isShowingStaleData = false
28 32
29 async let snapshotTask = repository.snapshot(forceRefresh: forceRefresh) 33 async let snapshotTask = repository.snapshotResult(forceRefresh: forceRefresh)
30 async let incidentsTask = repository.recentIncidents(forceRefresh: forceRefresh) 34 async let incidentsTask = repository.recentIncidentsResult(forceRefresh: forceRefresh)
35
36 var refreshWarnings: [String] = []
31 37
32 do { 38 do {
33 snapshot = try await snapshotTask 39 let result = try await snapshotTask
40 snapshot = result.value
41 if result.isStale {
42 isShowingStaleData = true
43 staleDataMessage = "Showing the last saved system status snapshot."
44 if let warning = result.refreshErrorMessage {
45 refreshWarnings.append(warning)
46 }
47 }
34 } catch { 48 } catch {
35 if snapshot == nil { 49 if snapshot == nil {
36 errorMessage = error.userFacingMessage 50 errorMessage = error.userFacingMessage
@@ -38,11 +52,23 @@ final class SystemStatusViewModel {
38 } 52 }
39 53
40 do { 54 do {
41 recentIncidents = try await incidentsTask 55 let result = try await incidentsTask
56 recentIncidents = result.value
57 if result.isStale {
58 isShowingStaleData = true
59 staleDataMessage = staleDataMessage ?? "Showing the last saved incident history."
60 if let warning = result.refreshErrorMessage {
61 refreshWarnings.append(warning)
62 }
63 }
42 } catch { 64 } catch {
43 if errorMessage == nil && recentIncidents.isEmpty { 65 if errorMessage == nil && recentIncidents.isEmpty {
44 errorMessage = error.userFacingMessage 66 errorMessage = error.userFacingMessage
45 } 67 }
46 } 68 }
69
70 if hasContent, let firstWarning = refreshWarnings.first {
71 errorMessage = "Showing cached system status. \(firstWarning)"
72 }
47 } 73 }
48} 74}
HutchTests/AppStateTests.swift +11
@@ -22,4 +22,15 @@ struct AppStateTests {
22 22
23 #expect(appState.deepLinkError == "The ticket could not be found or is inaccessible.") 23 #expect(appState.deepLinkError == "The ticket could not be found or is inaccessible.")
24 } 24 }
25
26 @Test
27 @MainActor
28 func openSystemStatusSelectsMoreTabAndQueuesNavigation() {
29 let appState = AppState()
30
31 appState.openSystemStatus()
32
33 #expect(appState.selectedTab == .more)
34 #expect(appState.pendingTabNavigation == .systemStatus)
35 }
25} 36}
HutchTests/SystemStatusRepositoryTests.swift added +147
@@ -0,0 +1,147 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct SystemStatusRepositoryTests {
6
7 @Test
8 func fallsBackToPersistedSnapshotWhenRefreshFails() async throws {
9 let defaultsName = "SystemStatusRepositoryTests-\(UUID().uuidString)"
10 let defaults = try #require(UserDefaults(suiteName: defaultsName))
11 defaults.removePersistentDomain(forName: defaultsName)
12 defer { defaults.removePersistentDomain(forName: defaultsName) }
13
14 let cachedSnapshot = SystemStatusSnapshot(
15 services: [
16 StatusServiceState(id: "git.sr.ht", name: "git.sr.ht", slug: "git.sr.ht", status: .degraded, description: nil)
17 ],
18 activeIncidents: [],
19 lastUpdated: Date(timeIntervalSince1970: 120)
20 )
21 let cacheStore = SystemStatusCacheStore(defaults: defaults)
22 let initialRepository = SystemStatusRepository(
23 service: TestSystemStatusService(
24 snapshotHTMLHandler: { Self.cachedSnapshotHTML },
25 incidentsDataHandler: { Data(Self.emptyRSS.utf8) }
26 ),
27 cacheStore: cacheStore,
28 now: { Date(timeIntervalSince1970: 120) }
29 )
30
31 _ = try await initialRepository.snapshotResult(forceRefresh: true)
32
33 let fallbackRepository = SystemStatusRepository(
34 service: TestSystemStatusService(
35 snapshotHTMLHandler: { throw SRHTError.httpError(503) },
36 incidentsDataHandler: { Data(Self.emptyRSS.utf8) }
37 ),
38 ttl: 0,
39 cacheStore: cacheStore,
40 now: { Date(timeIntervalSince1970: 180) }
41 )
42
43 let result = try await fallbackRepository.snapshotResult(forceRefresh: true)
44
45 #expect(result.value == cachedSnapshot)
46 #expect(result.isStale)
47 #expect(result.lastSuccessfulAt == Date(timeIntervalSince1970: 120))
48 #expect(result.refreshErrorMessage != nil)
49 }
50
51 @Test
52 func fallsBackToPersistedIncidentsWhenRefreshFails() async throws {
53 let defaultsName = "SystemStatusRepositoryTests-\(UUID().uuidString)"
54 let defaults = try #require(UserDefaults(suiteName: defaultsName))
55 defaults.removePersistentDomain(forName: defaultsName)
56 defer { defaults.removePersistentDomain(forName: defaultsName) }
57
58 let cachedIncidents = [
59 StatusIncident(
60 id: "incident-1",
61 title: "builds.sr.ht outage",
62 summary: "Builds are failing.",
63 url: nil,
64 publishedAt: Date(timeIntervalSince1970: 200),
65 updatedAt: nil,
66 isActive: true
67 )
68 ]
69 let cacheStore = SystemStatusCacheStore(defaults: defaults)
70 let initialRepository = SystemStatusRepository(
71 service: TestSystemStatusService(
72 snapshotHTMLHandler: { Self.cachedOperationalHTML },
73 incidentsDataHandler: { Data(Self.cachedIncidentRSS.utf8) }
74 ),
75 cacheStore: cacheStore,
76 now: { Date(timeIntervalSince1970: 220) }
77 )
78
79 _ = try await initialRepository.recentIncidentsResult(forceRefresh: true)
80
81 let fallbackRepository = SystemStatusRepository(
82 service: TestSystemStatusService(
83 snapshotHTMLHandler: { Self.cachedOperationalHTML },
84 incidentsDataHandler: { throw SRHTError.httpError(504) }
85 ),
86 ttl: 0,
87 cacheStore: cacheStore,
88 now: { Date(timeIntervalSince1970: 260) }
89 )
90
91 let result = try await fallbackRepository.recentIncidentsResult(forceRefresh: true)
92
93 #expect(result.value == cachedIncidents)
94 #expect(result.isStale)
95 #expect(result.lastSuccessfulAt == Date(timeIntervalSince1970: 220))
96 #expect(result.refreshErrorMessage != nil)
97 }
98}
99
100private struct TestSystemStatusService: SystemStatusServing {
101 let snapshotHTMLHandler: @Sendable () async throws -> String
102 let incidentsDataHandler: @Sendable () async throws -> Data
103
104 func fetchSnapshotHTML() async throws -> String {
105 try await snapshotHTMLHandler()
106 }
107
108 func fetchIncidentFeedData() async throws -> Data {
109 try await incidentsDataHandler()
110 }
111}
112
113private extension SystemStatusRepositoryTests {
114 static let cachedSnapshotHTML = #"""
115 <div class="component" data-status="disrupted">
116 <a href="/affected/git.sr.ht/">git.sr.ht</a>
117 <span class="component-status">Disrupted</span>
118 </div>
119 """#
120
121 static let cachedOperationalHTML = #"""
122 <div class="component" data-status="ok">
123 <a href="/affected/meta.sr.ht/">meta.sr.ht</a>
124 <span class="component-status">Operational</span>
125 </div>
126 """#
127
128 static let cachedIncidentRSS = #"""
129 <rss version="2.0">
130 <channel>
131 <item>
132 <title>builds.sr.ht outage</title>
133 <link>https://status.sr.ht/issues/1/</link>
134 <pubDate>Thu, 01 Jan 1970 00:03:20 +0000</pubDate>
135 <guid>incident-1</guid>
136 <description>&lt;p&gt;Builds are failing.&lt;/p&gt;</description>
137 </item>
138 </channel>
139 </rss>
140 """#
141
142 static let emptyRSS = #"""
143 <rss version="2.0">
144 <channel></channel>
145 </rss>
146 """#
147}
HutchTests/SystemStatusServiceTests.swift +63
@@ -19,6 +19,18 @@ struct SystemStatusServiceTests {
19 #expect(snapshot.activeIncidents[0].url?.absoluteString == "https://status.sr.ht/issues/2026-04-06-ddos-attack/") 19 #expect(snapshot.activeIncidents[0].url?.absoluteString == "https://status.sr.ht/issues/2026-04-06-ddos-attack/")
20 } 20 }
21 21
22 @Test
23 func parsesStatusHTMLWithClassOrderChangesAndTimeElements() throws {
24 let snapshot = try SystemStatusService.parseSnapshotHTML(Self.variantHTML, fetchedAt: .now)
25
26 #expect(snapshot.services.count == 2)
27 #expect(snapshot.services[0].status == .operational)
28 #expect(snapshot.services[1].status == .majorOutage)
29 #expect(snapshot.activeIncidents.count == 1)
30 #expect(snapshot.activeIncidents[0].title == "builds.sr.ht outage")
31 #expect(snapshot.activeIncidents[0].publishedAt == ISO8601DateFormatter().date(from: "2026-04-07T12:00:00Z"))
32 }
33
22 @Test 34 @Test
23 func parsesIncidentFeedRSS() async throws { 35 func parsesIncidentFeedRSS() async throws {
24 let incidents = try await SystemStatusService.parseIncidentFeedXML(Data(Self.sampleRSS.utf8)) 36 let incidents = try await SystemStatusService.parseIncidentFeedXML(Data(Self.sampleRSS.utf8))
@@ -32,6 +44,15 @@ struct SystemStatusServiceTests {
32 #expect(incidents[1].updatedAt != nil) 44 #expect(incidents[1].updatedAt != nil)
33 } 45 }
34 46
47 @Test
48 func parsesIncidentFeedWithISO8601Dates() async throws {
49 let incidents = try await SystemStatusService.parseIncidentFeedXML(Data(Self.variantRSS.utf8))
50
51 #expect(incidents.count == 1)
52 #expect(incidents[0].title == "Status feed moved")
53 #expect(incidents[0].publishedAt == ISO8601DateFormatter().date(from: "2026-04-07T15:30:00Z"))
54 }
55
35 @Test 56 @Test
36 func bannerSummaryPrefersSpecificServiceThenCount() { 57 func bannerSummaryPrefersSpecificServiceThenCount() {
37 let operational = SystemStatusSnapshot( 58 let operational = SystemStatusSnapshot(
@@ -102,6 +123,32 @@ struct SystemStatusServiceTests {
102 </html> 123 </html>
103 """# 124 """#
104 125
126 private static let variantHTML = #"""
127 <html>
128 <body>
129 <div class="component extra" data-status="ok">
130 <a class="no-underline" href="/affected/meta.sr.ht/">meta.sr.ht</a>
131 <small class="component-status secondary">All systems operational</small>
132 </div>
133 <div data-status="down" class="extra component">
134 <a href="/affected/builds.sr.ht/" class="link">builds.sr.ht</a>
135 <div class="component-status badge">Outage</div>
136 </div>
137 <div class="announcement-box">
138 <div class="padding">
139 <p><a href="/issues/2026-04-07-builds-outage/"><strong>builds.sr.ht outage</strong></a></p>
140 <p><strong>Build jobs are currently failing.</strong></p>
141 </div>
142 </div>
143 <a class="issue no-underline urgent" href="/issues/2026-04-07-builds-outage/">
144 <time class="date" datetime="2026-04-07T12:00:00Z">Apr 7</time>
145 <h4>builds.sr.ht outage</h4>
146 <span>Investigating elevated failures</span>
147 </a>
148 </body>
149 </html>
150 """#
151
105 private static let sampleRSS = #""" 152 private static let sampleRSS = #"""
106 <?xml version="1.0" encoding="utf-8" standalone="yes"?> 153 <?xml version="1.0" encoding="utf-8" standalone="yes"?>
107 <rss version="2.0"> 154 <rss version="2.0">
@@ -126,4 +173,20 @@ struct SystemStatusServiceTests {
126 </channel> 173 </channel>
127 </rss> 174 </rss>
128 """# 175 """#
176
177 private static let variantRSS = #"""
178 <?xml version="1.0" encoding="utf-8" standalone="yes"?>
179 <rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">
180 <channel>
181 <title>sr.ht status</title>
182 <item>
183 <title>Status feed moved</title>
184 <link>https://status.sr.ht/issues/2026-04-07-feed-moved/</link>
185 <dc:date>2026-04-07T15:30:00Z</dc:date>
186 <guid>https://status.sr.ht/issues/2026-04-07-feed-moved/</guid>
187 <description>&lt;p&gt;Use the new feed endpoint.&lt;/p&gt;</description>
188 </item>
189 </channel>
190 </rss>
191 """#
129} 192}