Commit 63dd5cadae
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -515,7 +515,7 @@ | ||
| 515 | 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 516 | 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 517 | 517 | CODE_SIGN_STYLE = Automatic; |
| 518 | CURRENT_PROJECT_VERSION = 29; | |
| 518 | CURRENT_PROJECT_VERSION = 30; | |
| 519 | 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 520 | 520 | ENABLE_PREVIEWS = YES; |
| 521 | 521 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -532,7 +532,7 @@ | ||
| 532 | 532 | "$(inherited)", |
| 533 | 533 | "@executable_path/Frameworks", |
| 534 | 534 | ); |
| 535 | MARKETING_VERSION = 2.13.0; | |
| 535 | MARKETING_VERSION = 2.13.1; | |
| 536 | 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 537 | 537 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 538 | 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -552,7 +552,7 @@ | ||
| 552 | 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 553 | 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 554 | 554 | CODE_SIGN_STYLE = Automatic; |
| 555 | CURRENT_PROJECT_VERSION = 29; | |
| 555 | CURRENT_PROJECT_VERSION = 30; | |
| 556 | 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 557 | 557 | ENABLE_PREVIEWS = YES; |
| 558 | 558 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -569,7 +569,7 @@ | ||
| 569 | 569 | "$(inherited)", |
| 570 | 570 | "@executable_path/Frameworks", |
| 571 | 571 | ); |
| 572 | MARKETING_VERSION = 2.13.0; | |
| 572 | MARKETING_VERSION = 2.13.1; | |
| 573 | 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 574 | 574 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 575 | 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -632,7 +632,7 @@ | ||
| 632 | 632 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 633 | 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 634 | 634 | CODE_SIGN_STYLE = Automatic; |
| 635 | CURRENT_PROJECT_VERSION = 29; | |
| 635 | CURRENT_PROJECT_VERSION = 30; | |
| 636 | 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 637 | 637 | GENERATE_INFOPLIST_FILE = NO; |
| 638 | 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -642,7 +642,7 @@ | ||
| 642 | 642 | "@executable_path/Frameworks", |
| 643 | 643 | "@executable_path/../../Frameworks", |
| 644 | 644 | ); |
| 645 | MARKETING_VERSION = 2.13.0; | |
| 645 | MARKETING_VERSION = 2.13.1; | |
| 646 | 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 647 | 647 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 648 | 648 | SKIP_INSTALL = YES; |
| @@ -661,7 +661,7 @@ | ||
| 661 | 661 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 662 | 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 663 | 663 | CODE_SIGN_STYLE = Automatic; |
| 664 | CURRENT_PROJECT_VERSION = 29; | |
| 664 | CURRENT_PROJECT_VERSION = 30; | |
| 665 | 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 666 | 666 | GENERATE_INFOPLIST_FILE = NO; |
| 667 | 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -671,7 +671,7 @@ | ||
| 671 | 671 | "@executable_path/Frameworks", |
| 672 | 672 | "@executable_path/../../Frameworks", |
| 673 | 673 | ); |
| 674 | MARKETING_VERSION = 2.13.0; | |
| 674 | MARKETING_VERSION = 2.13.1; | |
| 675 | 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 676 | 676 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 677 | 677 | SKIP_INSTALL = YES; |
Hutch/App/AppStorageKeys.swift +2
| @@ -6,4 +6,6 @@ enum AppStorageKeys { | ||
| 6 | 6 | static let wrapRepositoryFileLines = "wrapRepositoryFileLines" |
| 7 | 7 | static let lookupHistory = "lookupHistory" |
| 8 | 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 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | actor 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 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 3 | protocol SystemStatusServing: Sendable { | |
| 4 | func fetchSnapshotHTML() async throws -> String | |
| 5 | func fetchIncidentFeedData() async throws -> Data | |
| 6 | } | |
| 7 | ||
| 8 | struct CachedSystemStatusValue<Value: Sendable>: Sendable { | |
| 9 | let value: Value | |
| 10 | let lastSuccessfulAt: Date | |
| 11 | let isStale: Bool | |
| 12 | let refreshErrorMessage: String? | |
| 13 | } | |
| 14 | ||
| 3 | 15 | actor SystemStatusRepository { |
| 4 | private let service: SystemStatusService | |
| 16 | private let service: any SystemStatusServing | |
| 5 | 17 | private let ttl: TimeInterval |
| 18 | private let cacheStore: SystemStatusCacheStore | |
| 19 | private let now: @Sendable () -> Date | |
| 6 | 20 | |
| 7 | 21 | private var snapshotCache: CacheEntry<SystemStatusSnapshot>? |
| 8 | 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 | 31 | self.service = service |
| 12 | 32 | self.ttl = ttl |
| 33 | self.cacheStore = cacheStore | |
| 34 | self.now = now | |
| 13 | 35 | } |
| 14 | 36 | |
| 15 | 37 | func snapshot(forceRefresh: Bool = false) async throws -> SystemStatusSnapshot { |
| 16 | if let cached = snapshotCache, !forceRefresh, !cached.isExpired(ttl: ttl) { | |
| 17 | return cached.value | |
| 38 | try await snapshotResult(forceRefresh: forceRefresh).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 | 57 | do { |
| 21 | let snapshot = try await service.fetchSnapshot() | |
| 22 | snapshotCache = CacheEntry(value: snapshot, timestamp: Date()) | |
| 23 | return snapshot | |
| 58 | let html = try await service.fetchSnapshotHTML() | |
| 59 | let snapshot = try SystemStatusService.parseSnapshotHTML(html, fetchedAt: now()) | |
| 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 | 69 | } catch { |
| 25 | 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 | 78 | throw error |
| 29 | 79 | } |
| 30 | 80 | } |
| 31 | 81 | |
| 32 | func recentIncidents(forceRefresh: Bool = false) async throws -> [StatusIncident] { | |
| 33 | if let cached = incidentsCache, !forceRefresh, !cached.isExpired(ttl: ttl) { | |
| 34 | return cached.value | |
| 82 | func recentIncidentsResult(forceRefresh: Bool = false) async throws -> CachedSystemStatusValue<[StatusIncident]> { | |
| 83 | await loadPersistentCacheIfNeeded() | |
| 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 | 94 | do { |
| 38 | let incidents = try await service.fetchIncidentFeed() | |
| 39 | incidentsCache = CacheEntry(value: incidents, timestamp: Date()) | |
| 40 | return incidents | |
| 95 | let feedData = try await service.fetchIncidentFeedData() | |
| 96 | let incidents = try await SystemStatusService.parseIncidentFeedXML(feedData) | |
| 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 | 106 | } catch { |
| 42 | 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 | 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 | |
| 50 | private struct CacheEntry<Value: Sendable>: Sendable { | |
| 186 | struct CacheEntry<Value: Sendable>: Sendable { | |
| 51 | 187 | let value: Value |
| 52 | 188 | let timestamp: Date |
| 53 | 189 | |
| 54 | nonisolated func isExpired(ttl: TimeInterval) -> Bool { | |
| 55 | Date().timeIntervalSince(timestamp) > ttl | |
| 190 | nonisolated func isExpired(ttl: TimeInterval, now: @escaping @Sendable () -> Date = Date.init) -> Bool { | |
| 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 | 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 | 17 | return try Self.parseSnapshotHTML(html, fetchedAt: now()) |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | 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 | 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 | 33 | private func fetchText(from url: URL, accept: String) async throws -> String { |
| 26 | 34 | let data = try await fetchData(from: url, accept: accept) |
| 27 | 35 | guard let text = String(data: data, encoding: .utf8) else { |
| @@ -62,6 +70,8 @@ struct SystemStatusService: Sendable { | ||
| 62 | 70 | } |
| 63 | 71 | } |
| 64 | 72 | |
| 73 | extension SystemStatusService: SystemStatusServing {} | |
| 74 | ||
| 65 | 75 | extension SystemStatusService { |
| 66 | 76 | nonisolated static func parseSnapshotHTML(_ html: String, fetchedAt: Date) throws -> SystemStatusSnapshot { |
| 67 | 77 | let services = parseServices(in: html) |
| @@ -96,7 +106,7 @@ extension SystemStatusService { | ||
| 96 | 106 | nonisolated private static func parseServices(in html: String) -> [StatusServiceState] { |
| 97 | 107 | firstMatches( |
| 98 | 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 | 110 | ).compactMap { captures in |
| 101 | 111 | guard captures.count >= 2 else { return nil } |
| 102 | 112 | |
| @@ -104,16 +114,18 @@ extension SystemStatusService { | ||
| 104 | 114 | let content = captures[1] |
| 105 | 115 | guard let linkCaptures = firstMatches( |
| 106 | 116 | in: content, |
| 107 | pattern: #"<a[^>]*href="([^"]+)"[^>]*>\s*(.*?)\s*</a>"# | |
| 117 | pattern: #"<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)</a>"# | |
| 108 | 118 | ).first, |
| 109 | linkCaptures.count >= 2, | |
| 110 | let statusText = firstMatch(in: content, pattern: #"<span class="component-status">\s*(.*?)\s*</span>"#) else { | |
| 119 | linkCaptures.count >= 2 else { | |
| 111 | 120 | return nil |
| 112 | 121 | } |
| 113 | 122 | |
| 114 | 123 | let href = linkCaptures[0] |
| 115 | 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 | 129 | let level = statusLevel(fromHTMLStatus: rawStatus) |
| 118 | 130 | |
| 119 | 131 | return StatusServiceState( |
| @@ -129,19 +141,19 @@ extension SystemStatusService { | ||
| 129 | 141 | nonisolated private static func parseHTMLIncidentCards(in html: String) -> [StatusIncident] { |
| 130 | 142 | firstMatches( |
| 131 | 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 | 145 | ).compactMap { captures in |
| 134 | 146 | guard captures.count >= 2 else { return nil } |
| 135 | 147 | let href = captures[0] |
| 136 | 148 | let content = captures[1] |
| 137 | guard let titleHTML = firstMatch(in: content, pattern: #"<h3>\s*([\s\S]*?)\s*</h3>"#), | |
| 138 | let titleAttribute = firstMatch(in: content, pattern: #"<small class="date[^"]*" title="([^"]+)">"#), | |
| 139 | let publishedAt = htmlIssueDateFormatter.date(from: cleanText(titleAttribute)) else { | |
| 149 | guard let titleHTML = firstMatch(in: content, pattern: #"<h[1-6][^>]*>\s*([\s\S]*?)\s*</h[1-6]>"#) | |
| 150 | ?? firstMatch(in: content, pattern: #"<strong[^>]*>\s*([\s\S]*?)\s*</strong>"#) | |
| 151 | ?? firstMatch(in: content, pattern: #"<span[^>]*>\s*([\s\S]*?)\s*</span>"#), | |
| 152 | let publishedAt = publishedIncidentDate(in: content) else { | |
| 140 | 153 | return nil |
| 141 | 154 | } |
| 142 | 155 | |
| 143 | 156 | let url = URL(string: href, relativeTo: statusURL)?.absoluteURL |
| 144 | let isActive = content.localizedCaseInsensitiveContains("This issue is not resolved yet") | |
| 145 | 157 | return StatusIncident( |
| 146 | 158 | id: url?.absoluteString ?? cleanText(titleHTML), |
| 147 | 159 | title: cleanText(titleHTML), |
| @@ -149,7 +161,7 @@ extension SystemStatusService { | ||
| 149 | 161 | url: url, |
| 150 | 162 | publishedAt: publishedAt, |
| 151 | 163 | updatedAt: nil, |
| 152 | isActive: isActive | |
| 164 | isActive: isActiveIncidentCard(content) | |
| 153 | 165 | ) |
| 154 | 166 | } |
| 155 | 167 | } |
| @@ -157,12 +169,12 @@ extension SystemStatusService { | ||
| 157 | 169 | nonisolated private static func parseActiveIncidentSummaries(in html: String) -> [String: String] { |
| 158 | 170 | firstMatches( |
| 159 | 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 | 173 | ).reduce(into: [:]) { partialResult, captures in |
| 162 | 174 | guard let content = captures.first, |
| 163 | 175 | let titleLinkCaptures = firstMatches( |
| 164 | 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 | 178 | ).first, |
| 167 | 179 | let href = titleLinkCaptures.first else { |
| 168 | 180 | return |
| @@ -197,17 +209,22 @@ extension SystemStatusService { | ||
| 197 | 209 | } |
| 198 | 210 | |
| 199 | 211 | nonisolated private static func statusLevel(fromLabel label: String) -> StatusLevel { |
| 200 | switch label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { | |
| 201 | case "operational": | |
| 202 | .operational | |
| 203 | case "disrupted", "degraded": | |
| 204 | .degraded | |
| 205 | case "down", "major outage": | |
| 206 | .majorOutage | |
| 207 | case "maintenance": | |
| 208 | .maintenance | |
| 212 | let normalizedLabel = label | |
| 213 | .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 214 | .lowercased() | |
| 215 | .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) | |
| 216 | ||
| 217 | return switch normalizedLabel { | |
| 218 | case "operational", "all systems operational": | |
| 219 | StatusLevel.operational | |
| 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 | 226 | default: |
| 210 | .unknown | |
| 227 | StatusLevel.unknown | |
| 211 | 228 | } |
| 212 | 229 | } |
| 213 | 230 | |
| @@ -252,6 +269,47 @@ extension SystemStatusService { | ||
| 252 | 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 | 313 | nonisolated private static let htmlIssueDateFormatter: DateFormatter = { |
| 256 | 314 | let formatter = DateFormatter() |
| 257 | 315 | formatter.locale = Locale(identifier: "en_US_POSIX") |
| @@ -259,9 +317,9 @@ extension SystemStatusService { | ||
| 259 | 317 | formatter.dateFormat = "MMM d HH:mm:ss yyyy zzz" |
| 260 | 318 | return formatter |
| 261 | 319 | }() |
| 320 | ||
| 262 | 321 | } |
| 263 | 322 | |
| 264 | @MainActor | |
| 265 | 323 | private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchecked Sendable { |
| 266 | 324 | private var incidents: [StatusIncident] = [] |
| 267 | 325 | private var currentItem: FeedItem? |
| @@ -315,7 +373,7 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec | ||
| 315 | 373 | currentItem.guid = value |
| 316 | 374 | case "description": |
| 317 | 375 | currentItem.description = value |
| 318 | case "pubDate": | |
| 376 | case "pubDate", "dc:date": | |
| 319 | 377 | currentItem.pubDate = value |
| 320 | 378 | case "category": |
| 321 | 379 | currentItem.category = value |
| @@ -345,7 +403,7 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec | ||
| 345 | 403 | func makeIncident() -> StatusIncident? { |
| 346 | 404 | let cleanedTitle = title.replacingOccurrences(of: "[Resolved] ", with: "") |
| 347 | 405 | guard !cleanedTitle.isEmpty, |
| 348 | let publishedAt = SystemStatusFeedParser.pubDateFormatter.date(from: pubDate) else { | |
| 406 | let publishedAt = SystemStatusFeedParser.parsePubDate(pubDate) else { | |
| 349 | 407 | return nil |
| 350 | 408 | } |
| 351 | 409 | |
| @@ -382,6 +440,10 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec | ||
| 382 | 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 | 447 | nonisolated private static let pubDateFormatter: DateFormatter = { |
| 386 | 448 | let formatter = DateFormatter() |
| 387 | 449 | formatter.locale = Locale(identifier: "en_US_POSIX") |
Hutch/Views/Home/HomeView.swift +14 −34
| @@ -78,19 +78,25 @@ struct HomeView: View { | ||
| 78 | 78 | .refreshable { |
| 79 | 79 | await viewModel.loadDashboard() |
| 80 | 80 | } |
| 81 | .connectivityOverlay(hasContent: viewModel.hasDashboardContent) { | |
| 82 | await viewModel.loadDashboard() | |
| 83 | } | |
| 81 | 84 | } |
| 82 | 85 | |
| 83 | 86 | @ViewBuilder |
| 84 | 87 | private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View { |
| 85 | if let bannerTitle = viewModel.systemStatusBannerTitle { | |
| 86 | Section { | |
| 87 | NavigationLink { | |
| 88 | SystemStatusView() | |
| 89 | } label: { | |
| 90 | HomeSystemStatusBanner(title: bannerTitle) | |
| 91 | } | |
| 92 | .buttonStyle(.plain) | |
| 88 | Section { | |
| 89 | NavigationLink { | |
| 90 | SystemStatusView() | |
| 91 | } label: { | |
| 92 | SystemStatusSummaryRow( | |
| 93 | snapshot: viewModel.systemStatusSnapshot, | |
| 94 | isLoading: viewModel.isLoadingSystemStatus, | |
| 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 | |
| 255 | private 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 | ||
| 281 | 261 | private struct HomeProjectRow: View { |
| 282 | 262 | let project: Project |
| 283 | 263 | |
Hutch/Views/Home/HomeViewModel.swift +21 −7
| @@ -144,6 +144,9 @@ final class HomeViewModel { | ||
| 144 | 144 | var assignedTickets: [HomeAssignedTicket] = [] |
| 145 | 145 | var recentBuilds: [HomeBuildItem] = [] |
| 146 | 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 | 150 | private(set) var hasUnreadInboxThreads = false |
| 148 | 151 | private(set) var unreadInboxThreadCount: Int? |
| 149 | 152 | private(set) var isLoadingProjects = false |
| @@ -279,8 +282,11 @@ final class HomeViewModel { | ||
| 279 | 282 | isLoadingProjects = true |
| 280 | 283 | isLoadingAssignedTickets = true |
| 281 | 284 | isLoadingRecentBuilds = true |
| 285 | isLoadingSystemStatus = true | |
| 282 | 286 | assignedTicketsError = nil |
| 283 | 287 | recentBuildsError = nil |
| 288 | isShowingStaleSystemStatus = false | |
| 289 | systemStatusErrorMessage = nil | |
| 284 | 290 | |
| 285 | 291 | async let projectsTask = loadProjects() |
| 286 | 292 | async let jobsTask = loadRecentJobs() |
| @@ -324,13 +330,21 @@ final class HomeViewModel { | ||
| 324 | 330 | |
| 325 | 331 | unreadInboxThreadCount = await inboxUnreadTask |
| 326 | 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 | 343 | persistNeedsAttentionSnapshot() |
| 329 | 344 | } |
| 330 | 345 | |
| 331 | var systemStatusBannerTitle: String? { | |
| 332 | guard let systemStatusSnapshot, systemStatusSnapshot.hasDisruption else { return nil } | |
| 333 | return systemStatusSnapshot.bannerSummary | |
| 346 | var hasDashboardContent: Bool { | |
| 347 | !projects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || systemStatusSnapshot != nil | |
| 334 | 348 | } |
| 335 | 349 | |
| 336 | 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 | 448 | do { |
| 435 | return try await systemStatusRepository.snapshot() | |
| 449 | return .success(try await systemStatusRepository.snapshotResult()) | |
| 436 | 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 | 7 | ("chat.sr.ht", SRHTWebURL.chat) |
| 8 | 8 | ] |
| 9 | 9 | |
| 10 | @State private var viewModel: MoreViewModel? | |
| 10 | 11 | @State private var showAccountSwitcher = false |
| 11 | 12 | |
| 12 | 13 | var body: some View { |
| @@ -29,9 +30,14 @@ struct MoreView: View { | ||
| 29 | 30 | NavigationLink(value: MoreRoute.pastes) { |
| 30 | 31 | Label("Pastes", systemImage: "doc.on.clipboard") |
| 31 | 32 | } |
| 32 | ||
| 33 | ||
| 33 | 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 | 66 | .navigationTitle("More") |
| 67 | .refreshable { | |
| 68 | await ensureViewModel().loadSystemStatus(forceRefresh: true) | |
| 69 | } | |
| 70 | .task { | |
| 71 | await ensureViewModel().loadIfNeeded() | |
| 72 | } | |
| 61 | 73 | .toolbar { |
| 62 | 74 | ToolbarItem(placement: .topBarTrailing) { |
| 63 | 75 | Button { |
| @@ -71,4 +83,15 @@ struct MoreView: View { | ||
| 71 | 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 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | @Observable | |
| 4 | @MainActor | |
| 5 | final 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 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct 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 | 31 | @ViewBuilder |
| 32 | 32 | private func content(_ viewModel: SystemStatusViewModel) -> some View { |
| 33 | 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 | 42 | if let snapshot = viewModel.snapshot { |
| 35 | 43 | summarySection(snapshot) |
| 36 | 44 | servicesSection(snapshot) |
Hutch/Views/SystemStatus/SystemStatusViewModel.swift +30 −4
| @@ -8,6 +8,8 @@ final class SystemStatusViewModel { | ||
| 8 | 8 | private(set) var snapshot: SystemStatusSnapshot? |
| 9 | 9 | private(set) var recentIncidents: [StatusIncident] = [] |
| 10 | 10 | private(set) var isLoading = false |
| 11 | private(set) var isShowingStaleData = false | |
| 12 | private(set) var staleDataMessage: String? | |
| 11 | 13 | var errorMessage: String? |
| 12 | 14 | |
| 13 | 15 | init(repository: SystemStatusRepository) { |
| @@ -25,12 +27,24 @@ final class SystemStatusViewModel { | ||
| 25 | 27 | defer { isLoading = false } |
| 26 | 28 | |
| 27 | 29 | errorMessage = nil |
| 30 | staleDataMessage = nil | |
| 31 | isShowingStaleData = false | |
| 28 | 32 | |
| 29 | async let snapshotTask = repository.snapshot(forceRefresh: forceRefresh) | |
| 30 | async let incidentsTask = repository.recentIncidents(forceRefresh: forceRefresh) | |
| 33 | async let snapshotTask = repository.snapshotResult(forceRefresh: forceRefresh) | |
| 34 | async let incidentsTask = repository.recentIncidentsResult(forceRefresh: forceRefresh) | |
| 35 | ||
| 36 | var refreshWarnings: [String] = [] | |
| 31 | 37 | |
| 32 | 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 | 48 | } catch { |
| 35 | 49 | if snapshot == nil { |
| 36 | 50 | errorMessage = error.userFacingMessage |
| @@ -38,11 +52,23 @@ final class SystemStatusViewModel { | ||
| 38 | 52 | } |
| 39 | 53 | |
| 40 | 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 | 64 | } catch { |
| 43 | 65 | if errorMessage == nil && recentIncidents.isEmpty { |
| 44 | 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 | 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import Hutch | |
| 4 | ||
| 5 | struct 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 | ||
| 100 | private 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 | ||
| 113 | private 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><p>Builds are failing.</p></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 | 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 | 34 | @Test |
| 23 | 35 | func parsesIncidentFeedRSS() async throws { |
| 24 | 36 | let incidents = try await SystemStatusService.parseIncidentFeedXML(Data(Self.sampleRSS.utf8)) |
| @@ -32,6 +44,15 @@ struct SystemStatusServiceTests { | ||
| 32 | 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 | 56 | @Test |
| 36 | 57 | func bannerSummaryPrefersSpecificServiceThenCount() { |
| 37 | 58 | let operational = SystemStatusSnapshot( |
| @@ -102,6 +123,32 @@ struct SystemStatusServiceTests { | ||
| 102 | 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 | 152 | private static let sampleRSS = #""" |
| 106 | 153 | <?xml version="1.0" encoding="utf-8" standalone="yes"?> |
| 107 | 154 | <rss version="2.0"> |
| @@ -126,4 +173,20 @@ struct SystemStatusServiceTests { | ||
| 126 | 173 | </channel> |
| 127 | 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><p>Use the new feed endpoint.</p></description> | |
| 188 | </item> | |
| 189 | </channel> | |
| 190 | </rss> | |
| 191 | """# | |
| 129 | 192 | } |