Commit 63dd5cadae
Unsigned
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 @@ | |||
| 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 | import Foundation | 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 | actor SystemStatusRepository { | 15 | actor 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 | ||
| 50 | private struct CacheEntry<Value: Sendable>: Sendable { | 186 | struct 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 | ||
| 73 | extension SystemStatusService: SystemStatusServing {} | ||
| 74 | |||
| 65 | extension SystemStatusService { | 75 | extension 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 | ||
| 265 | private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchecked Sendable { | 323 | private 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 | ||
| 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 | private struct HomeProjectRow: View { | 261 | private 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 @@ | |||
| 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 | @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 @@ | |||
| 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 | #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><p>Use the new feed endpoint.</p></description> | ||
| 188 | </item> | ||
| 189 | </channel> | ||
| 190 | </rss> | ||
| 191 | """# | ||
| 129 | } | 192 | } |