Commit 9e45c1d099
Unsigned
Layout: unified · split
Hutch/App/AppState.swift +13 −1
| @@ -19,6 +19,7 @@ final class AppState { | |||
| 19 | case repository(RepositorySummary) | 19 | case repository(RepositorySummary) |
| 20 | case tracker(TrackerSummary) | 20 | case tracker(TrackerSummary) |
| 21 | case mailingList(InboxMailingListReference) | 21 | case mailingList(InboxMailingListReference) |
| 22 | case systemStatus | ||
| 22 | } | 23 | } |
| 23 | 24 | ||
| 24 | enum AuthPhase { | 25 | enum AuthPhase { |
| @@ -57,6 +58,7 @@ final class AppState { | |||
| 57 | 58 | ||
| 58 | let client: SRHTClient | 59 | let client: SRHTClient |
| 59 | let configuration: AppConfiguration | 60 | let configuration: AppConfiguration |
| 61 | let systemStatusRepository: SystemStatusRepository | ||
| 60 | 62 | ||
| 61 | // MARK: - Deep link pending navigation | 63 | // MARK: - Deep link pending navigation |
| 62 | 64 | ||
| @@ -71,6 +73,7 @@ final class AppState { | |||
| 71 | self.configuration = AppConfiguration() | 73 | self.configuration = AppConfiguration() |
| 72 | let token = KeychainHelper.loadToken() | 74 | let token = KeychainHelper.loadToken() |
| 73 | self.client = SRHTClient(token: token) | 75 | self.client = SRHTClient(token: token) |
| 76 | self.systemStatusRepository = SystemStatusRepository() | ||
| 74 | } | 77 | } |
| 75 | 78 | ||
| 76 | // MARK: - Launch validation | 79 | // MARK: - Launch validation |
| @@ -273,6 +276,11 @@ final class AppState { | |||
| 273 | selectedTab = .more | 276 | selectedTab = .more |
| 274 | } | 277 | } |
| 275 | 278 | ||
| 279 | func openSystemStatus() { | ||
| 280 | pendingTabNavigation = .systemStatus | ||
| 281 | selectedTab = .more | ||
| 282 | } | ||
| 283 | |||
| 276 | func presentRepositoryDeepLinkError() { | 284 | func presentRepositoryDeepLinkError() { |
| 277 | deepLinkError = "The repository could not be found or is inaccessible." | 285 | deepLinkError = "The repository could not be found or is inaccessible." |
| 278 | } | 286 | } |
| @@ -374,7 +382,11 @@ final class AppState { | |||
| 374 | return | 382 | return |
| 375 | } | 383 | } |
| 376 | 384 | ||
| 377 | let viewModel = HomeViewModel(currentUser: currentUser, client: client) | 385 | let viewModel = HomeViewModel( |
| 386 | currentUser: currentUser, | ||
| 387 | client: client, | ||
| 388 | systemStatusRepository: systemStatusRepository | ||
| 389 | ) | ||
| 378 | await viewModel.loadDashboard() | 390 | await viewModel.loadDashboard() |
| 379 | } | 391 | } |
| 380 | 392 | ||
Hutch/App/RootView.swift +10
| @@ -207,6 +207,13 @@ struct RootView: View { | |||
| 207 | morePath.append(MoreRoute.lists) | 207 | morePath.append(MoreRoute.lists) |
| 208 | morePath.append(MoreRoute.mailingList(mailingList)) | 208 | morePath.append(MoreRoute.mailingList(mailingList)) |
| 209 | } | 209 | } |
| 210 | case .systemStatus: | ||
| 211 | morePath = NavigationPath() | ||
| 212 | appState.selectedTab = .more | ||
| 213 | Task { | ||
| 214 | await settleNavigationTransition() | ||
| 215 | morePath.append(MoreRoute.systemStatus) | ||
| 216 | } | ||
| 210 | } | 217 | } |
| 211 | } | 218 | } |
| 212 | 219 | ||
| @@ -267,6 +274,7 @@ enum MoreRoute: Hashable { | |||
| 267 | case lists | 274 | case lists |
| 268 | case pastes | 275 | case pastes |
| 269 | case profile | 276 | case profile |
| 277 | case systemStatus | ||
| 270 | case settings | 278 | case settings |
| 271 | case mailingList(InboxMailingListReference) | 279 | case mailingList(InboxMailingListReference) |
| 272 | case thread(InboxThreadSummary) | 280 | case thread(InboxThreadSummary) |
| @@ -287,6 +295,8 @@ private struct MoreNavigationRoot: View { | |||
| 287 | PasteListView() | 295 | PasteListView() |
| 288 | case .profile: | 296 | case .profile: |
| 289 | ProfileView() | 297 | ProfileView() |
| 298 | case .systemStatus: | ||
| 299 | SystemStatusView() | ||
| 290 | case .settings: | 300 | case .settings: |
| 291 | SettingsView() | 301 | SettingsView() |
| 292 | case .mailingList(let mailingList): | 302 | case .mailingList(let mailingList): |
Hutch/Extensions/SRHTWebURL.swift +3
| @@ -1,6 +1,9 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | 2 | ||
| 3 | enum SRHTWebURL { | 3 | enum SRHTWebURL { |
| 4 | static let chat = URL(string: "https://chat.sr.ht")! | ||
| 5 | static let status = URL(string: "https://status.sr.ht")! | ||
| 6 | |||
| 4 | static func repository(_ repository: RepositorySummary) -> URL? { | 7 | static func repository(_ repository: RepositorySummary) -> URL? { |
| 5 | userScopedURL( | 8 | userScopedURL( |
| 6 | host: "\(repository.service.rawValue).sr.ht", | 9 | host: "\(repository.service.rawValue).sr.ht", |
Hutch/Models/SystemStatusModels.swift added +84
| @@ -0,0 +1,84 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | enum StatusLevel: String, Codable, Sendable { | ||
| 4 | case operational | ||
| 5 | case degraded | ||
| 6 | case majorOutage | ||
| 7 | case maintenance | ||
| 8 | case unknown | ||
| 9 | |||
| 10 | var displayName: String { | ||
| 11 | switch self { | ||
| 12 | case .operational: | ||
| 13 | "Operational" | ||
| 14 | case .degraded: | ||
| 15 | "Degraded" | ||
| 16 | case .majorOutage: | ||
| 17 | "Major outage" | ||
| 18 | case .maintenance: | ||
| 19 | "Maintenance" | ||
| 20 | case .unknown: | ||
| 21 | "Unknown" | ||
| 22 | } | ||
| 23 | } | ||
| 24 | |||
| 25 | var requiresAttention: Bool { | ||
| 26 | switch self { | ||
| 27 | case .degraded, .majorOutage, .maintenance: | ||
| 28 | true | ||
| 29 | case .operational, .unknown: | ||
| 30 | false | ||
| 31 | } | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | struct StatusServiceState: Identifiable, Hashable, Codable, Sendable { | ||
| 36 | let id: String | ||
| 37 | let name: String | ||
| 38 | let slug: String? | ||
| 39 | let status: StatusLevel | ||
| 40 | let description: String? | ||
| 41 | } | ||
| 42 | |||
| 43 | struct StatusIncident: Identifiable, Hashable, Codable, Sendable { | ||
| 44 | let id: String | ||
| 45 | let title: String | ||
| 46 | let summary: String? | ||
| 47 | let url: URL? | ||
| 48 | let publishedAt: Date | ||
| 49 | let updatedAt: Date? | ||
| 50 | let isActive: Bool? | ||
| 51 | } | ||
| 52 | |||
| 53 | struct SystemStatusSnapshot: Hashable, Codable, Sendable { | ||
| 54 | let services: [StatusServiceState] | ||
| 55 | let activeIncidents: [StatusIncident] | ||
| 56 | let lastUpdated: Date | ||
| 57 | |||
| 58 | var disruptedServices: [StatusServiceState] { | ||
| 59 | services.filter { $0.status.requiresAttention } | ||
| 60 | } | ||
| 61 | |||
| 62 | var hasDisruption: Bool { | ||
| 63 | !disruptedServices.isEmpty | ||
| 64 | } | ||
| 65 | |||
| 66 | var overallStatusText: String { | ||
| 67 | hasDisruption ? "Experiencing disruptions" : "All monitored services operational" | ||
| 68 | } | ||
| 69 | |||
| 70 | var bannerSummary: String { | ||
| 71 | if disruptedServices.count == 1, let service = disruptedServices.first { | ||
| 72 | return "\(service.name) disrupted" | ||
| 73 | } | ||
| 74 | if disruptedServices.count > 1 { | ||
| 75 | return "\(disruptedServices.count) services disrupted" | ||
| 76 | } | ||
| 77 | return "SourceHut service disruption" | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | struct SystemStatusPageData: Sendable { | ||
| 82 | let snapshot: SystemStatusSnapshot | ||
| 83 | let recentIncidents: [StatusIncident] | ||
| 84 | } | ||
Hutch/Networking/SystemStatusRepository.swift added +57
| @@ -0,0 +1,57 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | actor SystemStatusRepository { | ||
| 4 | private let service: SystemStatusService | ||
| 5 | private let ttl: TimeInterval | ||
| 6 | |||
| 7 | private var snapshotCache: CacheEntry<SystemStatusSnapshot>? | ||
| 8 | private var incidentsCache: CacheEntry<[StatusIncident]>? | ||
| 9 | |||
| 10 | init(service: SystemStatusService = SystemStatusService(), ttl: TimeInterval = 10 * 60) { | ||
| 11 | self.service = service | ||
| 12 | self.ttl = ttl | ||
| 13 | } | ||
| 14 | |||
| 15 | func snapshot(forceRefresh: Bool = false) async throws -> SystemStatusSnapshot { | ||
| 16 | if let cached = snapshotCache, !forceRefresh, !cached.isExpired(ttl: ttl) { | ||
| 17 | return cached.value | ||
| 18 | } | ||
| 19 | |||
| 20 | do { | ||
| 21 | let snapshot = try await service.fetchSnapshot() | ||
| 22 | snapshotCache = CacheEntry(value: snapshot, timestamp: Date()) | ||
| 23 | return snapshot | ||
| 24 | } catch { | ||
| 25 | if let cached = snapshotCache { | ||
| 26 | return cached.value | ||
| 27 | } | ||
| 28 | throw error | ||
| 29 | } | ||
| 30 | } | ||
| 31 | |||
| 32 | func recentIncidents(forceRefresh: Bool = false) async throws -> [StatusIncident] { | ||
| 33 | if let cached = incidentsCache, !forceRefresh, !cached.isExpired(ttl: ttl) { | ||
| 34 | return cached.value | ||
| 35 | } | ||
| 36 | |||
| 37 | do { | ||
| 38 | let incidents = try await service.fetchIncidentFeed() | ||
| 39 | incidentsCache = CacheEntry(value: incidents, timestamp: Date()) | ||
| 40 | return incidents | ||
| 41 | } catch { | ||
| 42 | if let cached = incidentsCache { | ||
| 43 | return cached.value | ||
| 44 | } | ||
| 45 | throw error | ||
| 46 | } | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | private struct CacheEntry<Value: Sendable>: Sendable { | ||
| 51 | let value: Value | ||
| 52 | let timestamp: Date | ||
| 53 | |||
| 54 | nonisolated func isExpired(ttl: TimeInterval) -> Bool { | ||
| 55 | Date().timeIntervalSince(timestamp) > ttl | ||
| 56 | } | ||
| 57 | } | ||
Hutch/Networking/SystemStatusService.swift added +400
| @@ -0,0 +1,400 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | struct SystemStatusService: Sendable { | ||
| 4 | nonisolated static let statusURL = URL(string: "https://status.sr.ht/")! | ||
| 5 | nonisolated static let feedURL = URL(string: "https://status.sr.ht/index.xml")! | ||
| 6 | |||
| 7 | private let session: URLSession | ||
| 8 | private let now: @Sendable () -> Date | ||
| 9 | |||
| 10 | nonisolated init(session: URLSession = .shared, now: @escaping @Sendable () -> Date = Date.init) { | ||
| 11 | self.session = session | ||
| 12 | self.now = now | ||
| 13 | } | ||
| 14 | |||
| 15 | func fetchSnapshot() async throws -> SystemStatusSnapshot { | ||
| 16 | let html = try await fetchText(from: Self.statusURL, accept: "text/html,application/xhtml+xml") | ||
| 17 | return try Self.parseSnapshotHTML(html, fetchedAt: now()) | ||
| 18 | } | ||
| 19 | |||
| 20 | func fetchIncidentFeed() async throws -> [StatusIncident] { | ||
| 21 | let data = try await fetchData(from: Self.feedURL, accept: "application/rss+xml,application/xml,text/xml") | ||
| 22 | return try await Self.parseIncidentFeedXML(data) | ||
| 23 | } | ||
| 24 | |||
| 25 | private func fetchText(from url: URL, accept: String) async throws -> String { | ||
| 26 | let data = try await fetchData(from: url, accept: accept) | ||
| 27 | guard let text = String(data: data, encoding: .utf8) else { | ||
| 28 | throw SRHTError.decodingError( | ||
| 29 | DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not UTF-8 text")) | ||
| 30 | ) | ||
| 31 | } | ||
| 32 | return text | ||
| 33 | } | ||
| 34 | |||
| 35 | private func fetchData(from url: URL, accept: String) async throws -> Data { | ||
| 36 | var request = URLRequest(url: url) | ||
| 37 | request.setValue(userAgent, forHTTPHeaderField: "User-Agent") | ||
| 38 | request.setValue(accept, forHTTPHeaderField: "Accept") | ||
| 39 | |||
| 40 | let (data, response): (Data, URLResponse) | ||
| 41 | do { | ||
| 42 | (data, response) = try await session.data(for: request) | ||
| 43 | } catch { | ||
| 44 | throw SRHTError.networkError(error) | ||
| 45 | } | ||
| 46 | |||
| 47 | if let http = response as? HTTPURLResponse, | ||
| 48 | !(200...299).contains(http.statusCode) { | ||
| 49 | throw SRHTError.httpError(http.statusCode) | ||
| 50 | } | ||
| 51 | |||
| 52 | return data | ||
| 53 | } | ||
| 54 | |||
| 55 | private var userAgent: String { | ||
| 56 | let bundle = Bundle.main | ||
| 57 | let name = (bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) | ||
| 58 | ?? (bundle.object(forInfoDictionaryKey: "CFBundleName") as? String) | ||
| 59 | ?? "Hutch" | ||
| 60 | let version = (bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) ?? "dev" | ||
| 61 | return "\(name)/\(version) (System Status)" | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | extension SystemStatusService { | ||
| 66 | nonisolated static func parseSnapshotHTML(_ html: String, fetchedAt: Date) throws -> SystemStatusSnapshot { | ||
| 67 | let services = parseServices(in: html) | ||
| 68 | let incidents = parseHTMLIncidentCards(in: html) | ||
| 69 | let summaries = parseActiveIncidentSummaries(in: html) | ||
| 70 | |||
| 71 | let activeIncidents = incidents | ||
| 72 | .filter { $0.isActive == true } | ||
| 73 | .map { incident in | ||
| 74 | let summary = incident.url.flatMap { summaries[$0.absoluteString] } ?? incident.summary | ||
| 75 | return StatusIncident( | ||
| 76 | id: incident.id, | ||
| 77 | title: incident.title, | ||
| 78 | summary: summary, | ||
| 79 | url: incident.url, | ||
| 80 | publishedAt: incident.publishedAt, | ||
| 81 | updatedAt: incident.updatedAt, | ||
| 82 | isActive: incident.isActive | ||
| 83 | ) | ||
| 84 | } | ||
| 85 | |||
| 86 | return SystemStatusSnapshot(services: services, activeIncidents: activeIncidents, lastUpdated: fetchedAt) | ||
| 87 | } | ||
| 88 | |||
| 89 | nonisolated static func parseIncidentFeedXML(_ data: Data) async throws -> [StatusIncident] { | ||
| 90 | try await MainActor.run { | ||
| 91 | let parser = SystemStatusFeedParser() | ||
| 92 | return try parser.parse(data: data) | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | nonisolated private static func parseServices(in html: String) -> [StatusServiceState] { | ||
| 97 | firstMatches( | ||
| 98 | in: html, | ||
| 99 | pattern: #"<div class="component" data-status="([^"]+)">([\s\S]*?)</div>"# | ||
| 100 | ).compactMap { captures in | ||
| 101 | guard captures.count >= 2 else { return nil } | ||
| 102 | |||
| 103 | let rawStatus = captures[0] | ||
| 104 | let content = captures[1] | ||
| 105 | guard let linkCaptures = firstMatches( | ||
| 106 | in: content, | ||
| 107 | pattern: #"<a[^>]*href="([^"]+)"[^>]*>\s*(.*?)\s*</a>"# | ||
| 108 | ).first, | ||
| 109 | linkCaptures.count >= 2, | ||
| 110 | let statusText = firstMatch(in: content, pattern: #"<span class="component-status">\s*(.*?)\s*</span>"#) else { | ||
| 111 | return nil | ||
| 112 | } | ||
| 113 | |||
| 114 | let href = linkCaptures[0] | ||
| 115 | let cleanedName = cleanText(linkCaptures[1]) | ||
| 116 | let readableStatus = cleanText(statusText) | ||
| 117 | let level = statusLevel(fromHTMLStatus: rawStatus) | ||
| 118 | |||
| 119 | return StatusServiceState( | ||
| 120 | id: normalizedSlug(from: href, fallback: cleanedName) ?? cleanedName, | ||
| 121 | name: cleanedName, | ||
| 122 | slug: normalizedSlug(from: href, fallback: cleanedName), | ||
| 123 | status: level == .unknown ? statusLevel(fromLabel: readableStatus) : level, | ||
| 124 | description: nil | ||
| 125 | ) | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | nonisolated private static func parseHTMLIncidentCards(in html: String) -> [StatusIncident] { | ||
| 130 | firstMatches( | ||
| 131 | in: html, | ||
| 132 | pattern: #"<a href="([^"]+)" class="issue no-underline">([\s\S]*?)</a>"# | ||
| 133 | ).compactMap { captures in | ||
| 134 | guard captures.count >= 2 else { return nil } | ||
| 135 | let href = captures[0] | ||
| 136 | 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 { | ||
| 140 | return nil | ||
| 141 | } | ||
| 142 | |||
| 143 | let url = URL(string: href, relativeTo: statusURL)?.absoluteURL | ||
| 144 | let isActive = content.localizedCaseInsensitiveContains("This issue is not resolved yet") | ||
| 145 | return StatusIncident( | ||
| 146 | id: url?.absoluteString ?? cleanText(titleHTML), | ||
| 147 | title: cleanText(titleHTML), | ||
| 148 | summary: nil, | ||
| 149 | url: url, | ||
| 150 | publishedAt: publishedAt, | ||
| 151 | updatedAt: nil, | ||
| 152 | isActive: isActive | ||
| 153 | ) | ||
| 154 | } | ||
| 155 | } | ||
| 156 | |||
| 157 | nonisolated private static func parseActiveIncidentSummaries(in html: String) -> [String: String] { | ||
| 158 | firstMatches( | ||
| 159 | in: html, | ||
| 160 | pattern: #"<div class="announcement-box"[\s\S]*?<div class="padding">([\s\S]*?)</div>\s*<hr class="clean announcement-box">"# | ||
| 161 | ).reduce(into: [:]) { partialResult, captures in | ||
| 162 | guard let content = captures.first, | ||
| 163 | let titleLinkCaptures = firstMatches( | ||
| 164 | in: content, | ||
| 165 | pattern: #"<a href="([^"]+)"><strong class="bold">([\s\S]*?)</strong></a>"# | ||
| 166 | ).first, | ||
| 167 | let href = titleLinkCaptures.first else { | ||
| 168 | return | ||
| 169 | } | ||
| 170 | |||
| 171 | let paragraphs = firstMatches(in: content, pattern: #"<p>([\s\S]*?)</p>"#) | ||
| 172 | .compactMap(\.first) | ||
| 173 | .map(cleanText) | ||
| 174 | .filter { !$0.isEmpty } | ||
| 175 | |||
| 176 | let summary = paragraphs.dropFirst(2).first ?? paragraphs.dropFirst().first | ||
| 177 | guard let summary, !summary.isEmpty else { return } | ||
| 178 | if let url = URL(string: href, relativeTo: statusURL)?.absoluteURL { | ||
| 179 | partialResult[url.absoluteString] = summary | ||
| 180 | } | ||
| 181 | } | ||
| 182 | } | ||
| 183 | |||
| 184 | nonisolated private static func statusLevel(fromHTMLStatus status: String) -> StatusLevel { | ||
| 185 | switch status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { | ||
| 186 | case "ok": | ||
| 187 | .operational | ||
| 188 | case "disrupted": | ||
| 189 | .degraded | ||
| 190 | case "down": | ||
| 191 | .majorOutage | ||
| 192 | case "notice": | ||
| 193 | .maintenance | ||
| 194 | default: | ||
| 195 | .unknown | ||
| 196 | } | ||
| 197 | } | ||
| 198 | |||
| 199 | 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 | ||
| 209 | default: | ||
| 210 | .unknown | ||
| 211 | } | ||
| 212 | } | ||
| 213 | |||
| 214 | nonisolated private static func normalizedSlug(from href: String, fallback name: String) -> String? { | ||
| 215 | if href.contains("/affected/") { | ||
| 216 | let trimmed = href.trimmingCharacters(in: CharacterSet(charactersIn: "/")) | ||
| 217 | if let slug = trimmed.split(separator: "/").last { | ||
| 218 | return String(slug) | ||
| 219 | } | ||
| 220 | } | ||
| 221 | return name.isEmpty ? nil : name | ||
| 222 | } | ||
| 223 | |||
| 224 | nonisolated private static func firstMatch(in text: String, pattern: String) -> String? { | ||
| 225 | firstMatches(in: text, pattern: pattern).first?.first | ||
| 226 | } | ||
| 227 | |||
| 228 | nonisolated private static func firstMatches(in text: String, pattern: String) -> [[String]] { | ||
| 229 | guard let regex = try? NSRegularExpression( | ||
| 230 | pattern: pattern, | ||
| 231 | options: [.caseInsensitive, .dotMatchesLineSeparators] | ||
| 232 | ) else { | ||
| 233 | return [] | ||
| 234 | } | ||
| 235 | |||
| 236 | let range = NSRange(text.startIndex..., in: text) | ||
| 237 | return regex.matches(in: text, range: range).map { match in | ||
| 238 | (1..<match.numberOfRanges).compactMap { captureIndex in | ||
| 239 | guard let captureRange = Range(match.range(at: captureIndex), in: text) else { return nil } | ||
| 240 | return String(text[captureRange]) | ||
| 241 | } | ||
| 242 | } | ||
| 243 | } | ||
| 244 | |||
| 245 | nonisolated private static func cleanText(_ text: String) -> String { | ||
| 246 | let stripped = text.replacingOccurrences(of: #"<[^>]+>"#, with: " ", options: .regularExpression) | ||
| 247 | let decoded = decodeHTMLEntities(stripped) | ||
| 248 | return decoded | ||
| 249 | .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) | ||
| 250 | .replacingOccurrences(of: #"\s+([.,!?;:])"#, with: "$1", options: .regularExpression) | ||
| 251 | .replacingOccurrences(of: "→", with: "") | ||
| 252 | .trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 253 | } | ||
| 254 | |||
| 255 | nonisolated private static let htmlIssueDateFormatter: DateFormatter = { | ||
| 256 | let formatter = DateFormatter() | ||
| 257 | formatter.locale = Locale(identifier: "en_US_POSIX") | ||
| 258 | formatter.timeZone = TimeZone(identifier: "UTC") | ||
| 259 | formatter.dateFormat = "MMM d HH:mm:ss yyyy zzz" | ||
| 260 | return formatter | ||
| 261 | }() | ||
| 262 | } | ||
| 263 | |||
| 264 | @MainActor | ||
| 265 | private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchecked Sendable { | ||
| 266 | private var incidents: [StatusIncident] = [] | ||
| 267 | private var currentItem: FeedItem? | ||
| 268 | private var textBuffer = "" | ||
| 269 | |||
| 270 | func parse(data: Data) throws -> [StatusIncident] { | ||
| 271 | incidents = [] | ||
| 272 | currentItem = nil | ||
| 273 | textBuffer = "" | ||
| 274 | |||
| 275 | let parser = XMLParser(data: data) | ||
| 276 | parser.delegate = self | ||
| 277 | guard parser.parse() else { | ||
| 278 | throw parser.parserError ?? SRHTError.decodingError( | ||
| 279 | DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Failed to parse status feed")) | ||
| 280 | ) | ||
| 281 | } | ||
| 282 | return incidents.sorted { $0.publishedAt > $1.publishedAt } | ||
| 283 | } | ||
| 284 | |||
| 285 | func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) { | ||
| 286 | textBuffer = "" | ||
| 287 | if elementName == "item" { | ||
| 288 | currentItem = FeedItem() | ||
| 289 | } | ||
| 290 | } | ||
| 291 | |||
| 292 | func parser(_ parser: XMLParser, foundCharacters string: String) { | ||
| 293 | textBuffer += string | ||
| 294 | } | ||
| 295 | |||
| 296 | func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) { | ||
| 297 | if let string = String(data: CDATABlock, encoding: .utf8) { | ||
| 298 | textBuffer += string | ||
| 299 | } | ||
| 300 | } | ||
| 301 | |||
| 302 | func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { | ||
| 303 | guard var currentItem else { | ||
| 304 | textBuffer = "" | ||
| 305 | return | ||
| 306 | } | ||
| 307 | |||
| 308 | let value = textBuffer.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 309 | switch elementName { | ||
| 310 | case "title": | ||
| 311 | currentItem.title = value | ||
| 312 | case "link": | ||
| 313 | currentItem.link = value | ||
| 314 | case "guid": | ||
| 315 | currentItem.guid = value | ||
| 316 | case "description": | ||
| 317 | currentItem.description = value | ||
| 318 | case "pubDate": | ||
| 319 | currentItem.pubDate = value | ||
| 320 | case "category": | ||
| 321 | currentItem.category = value | ||
| 322 | case "item": | ||
| 323 | if let incident = currentItem.makeIncident() { | ||
| 324 | incidents.append(incident) | ||
| 325 | } | ||
| 326 | self.currentItem = nil | ||
| 327 | default: | ||
| 328 | self.currentItem = currentItem | ||
| 329 | } | ||
| 330 | |||
| 331 | if elementName != "item" { | ||
| 332 | self.currentItem = currentItem | ||
| 333 | } | ||
| 334 | textBuffer = "" | ||
| 335 | } | ||
| 336 | |||
| 337 | private struct FeedItem { | ||
| 338 | var title = "" | ||
| 339 | var link = "" | ||
| 340 | var guid = "" | ||
| 341 | var description = "" | ||
| 342 | var pubDate = "" | ||
| 343 | var category = "" | ||
| 344 | |||
| 345 | func makeIncident() -> StatusIncident? { | ||
| 346 | let cleanedTitle = title.replacingOccurrences(of: "[Resolved] ", with: "") | ||
| 347 | guard !cleanedTitle.isEmpty, | ||
| 348 | let publishedAt = SystemStatusFeedParser.pubDateFormatter.date(from: pubDate) else { | ||
| 349 | return nil | ||
| 350 | } | ||
| 351 | |||
| 352 | let url = URL(string: link) | ||
| 353 | let updatedAt = category.isEmpty ? nil : SystemStatusFeedParser.updatedDateFormatter.date(from: category) | ||
| 354 | |||
| 355 | return StatusIncident( | ||
| 356 | id: guid.isEmpty ? (url?.absoluteString ?? cleanedTitle) : guid, | ||
| 357 | title: cleanedTitle, | ||
| 358 | summary: SystemStatusFeedParser.summary(from: description), | ||
| 359 | url: url, | ||
| 360 | publishedAt: publishedAt, | ||
| 361 | updatedAt: updatedAt, | ||
| 362 | isActive: category.isEmpty | ||
| 363 | ) | ||
| 364 | } | ||
| 365 | } | ||
| 366 | |||
| 367 | nonisolated private static func summary(from html: String) -> String? { | ||
| 368 | html | ||
| 369 | .components(separatedBy: "</p>") | ||
| 370 | .map { $0.replacingOccurrences(of: "<p>", with: "") } | ||
| 371 | .map(stripHTML) | ||
| 372 | .map { | ||
| 373 | $0.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) | ||
| 374 | .replacingOccurrences(of: #"\s+([.,!?;:])"#, with: "$1", options: .regularExpression) | ||
| 375 | .trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 376 | } | ||
| 377 | .first { !$0.isEmpty } | ||
| 378 | } | ||
| 379 | |||
| 380 | nonisolated private static func stripHTML(_ text: String) -> String { | ||
| 381 | let stripped = text.replacingOccurrences(of: #"<[^>]+>"#, with: " ", options: .regularExpression) | ||
| 382 | return decodeHTMLEntities(stripped) | ||
| 383 | } | ||
| 384 | |||
| 385 | nonisolated private static let pubDateFormatter: DateFormatter = { | ||
| 386 | let formatter = DateFormatter() | ||
| 387 | formatter.locale = Locale(identifier: "en_US_POSIX") | ||
| 388 | formatter.timeZone = TimeZone(identifier: "UTC") | ||
| 389 | formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" | ||
| 390 | return formatter | ||
| 391 | }() | ||
| 392 | |||
| 393 | nonisolated private static let updatedDateFormatter: DateFormatter = { | ||
| 394 | let formatter = DateFormatter() | ||
| 395 | formatter.locale = Locale(identifier: "en_US_POSIX") | ||
| 396 | formatter.timeZone = TimeZone(identifier: "UTC") | ||
| 397 | formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" | ||
| 398 | return formatter | ||
| 399 | }() | ||
| 400 | } | ||
Hutch/Views/Home/HomeView.swift +46 −1
| @@ -33,7 +33,11 @@ struct HomeView: View { | |||
| 33 | if let viewModel { | 33 | if let viewModel { |
| 34 | vm = viewModel | 34 | vm = viewModel |
| 35 | } else { | 35 | } else { |
| 36 | let newViewModel = HomeViewModel(currentUser: currentUser, client: appState.client) | 36 | let newViewModel = HomeViewModel( |
| 37 | currentUser: currentUser, | ||
| 38 | client: appState.client, | ||
| 39 | systemStatusRepository: appState.systemStatusRepository | ||
| 40 | ) | ||
| 37 | viewModel = newViewModel | 41 | viewModel = newViewModel |
| 38 | vm = newViewModel | 42 | vm = newViewModel |
| 39 | } | 43 | } |
| @@ -51,6 +55,7 @@ struct HomeView: View { | |||
| 51 | @ViewBuilder | 55 | @ViewBuilder |
| 52 | private func content(_ viewModel: HomeViewModel) -> some View { | 56 | private func content(_ viewModel: HomeViewModel) -> some View { |
| 53 | List { | 57 | List { |
| 58 | systemStatusBannerSection(viewModel) | ||
| 54 | projectsSection(viewModel) | 59 | projectsSection(viewModel) |
| 55 | assignedTicketsSection(viewModel) | 60 | assignedTicketsSection(viewModel) |
| 56 | recentBuildsSection(viewModel) | 61 | recentBuildsSection(viewModel) |
| @@ -75,6 +80,20 @@ struct HomeView: View { | |||
| 75 | } | 80 | } |
| 76 | } | 81 | } |
| 77 | 82 | ||
| 83 | @ViewBuilder | ||
| 84 | private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View { | ||
| 85 | if let bannerTitle = viewModel.systemStatusBannerTitle { | ||
| 86 | Section { | ||
| 87 | Button { | ||
| 88 | appState.openSystemStatus() | ||
| 89 | } label: { | ||
| 90 | HomeSystemStatusBanner(title: bannerTitle) | ||
| 91 | } | ||
| 92 | .buttonStyle(.plain) | ||
| 93 | } | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 78 | @ViewBuilder | 97 | @ViewBuilder |
| 79 | private func projectsSection(_ viewModel: HomeViewModel) -> some View { | 98 | private func projectsSection(_ viewModel: HomeViewModel) -> some View { |
| 80 | if !viewModel.projects.isEmpty { | 99 | if !viewModel.projects.isEmpty { |
| @@ -233,6 +252,32 @@ private struct HomeInboxToolbarIcon: View { | |||
| 233 | } | 252 | } |
| 234 | } | 253 | } |
| 235 | 254 | ||
| 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 | |||
| 236 | private struct HomeProjectRow: View { | 281 | private struct HomeProjectRow: View { |
| 237 | let project: Project | 282 | let project: Project |
| 238 | 283 | ||
Hutch/Views/Home/HomeViewModel.swift +19 −1
| @@ -143,6 +143,7 @@ final class HomeViewModel { | |||
| 143 | private(set) var projects: [Project] = [] | 143 | private(set) var projects: [Project] = [] |
| 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 hasUnreadInboxThreads = false | 147 | private(set) var hasUnreadInboxThreads = false |
| 147 | private(set) var unreadInboxThreadCount: Int? | 148 | private(set) var unreadInboxThreadCount: Int? |
| 148 | private(set) var isLoadingProjects = false | 149 | private(set) var isLoadingProjects = false |
| @@ -153,6 +154,7 @@ final class HomeViewModel { | |||
| 153 | 154 | ||
| 154 | private let currentUser: User | 155 | private let currentUser: User |
| 155 | private let client: SRHTClient | 156 | private let client: SRHTClient |
| 157 | private let systemStatusRepository: SystemStatusRepository | ||
| 156 | private let projectService: ProjectService | 158 | private let projectService: ProjectService |
| 157 | private let ticketFetchConcurrencyLimit = 6 | 159 | private let ticketFetchConcurrencyLimit = 6 |
| 158 | private let inboxUnreadConcurrencyLimit = 4 | 160 | private let inboxUnreadConcurrencyLimit = 4 |
| @@ -266,9 +268,10 @@ final class HomeViewModel { | |||
| 266 | } | 268 | } |
| 267 | """ | 269 | """ |
| 268 | 270 | ||
| 269 | init(currentUser: User, client: SRHTClient) { | 271 | init(currentUser: User, client: SRHTClient, systemStatusRepository: SystemStatusRepository) { |
| 270 | self.currentUser = currentUser | 272 | self.currentUser = currentUser |
| 271 | self.client = client | 273 | self.client = client |
| 274 | self.systemStatusRepository = systemStatusRepository | ||
| 272 | self.projectService = ProjectService(client: client) | 275 | self.projectService = ProjectService(client: client) |
| 273 | } | 276 | } |
| 274 | 277 | ||
| @@ -283,6 +286,7 @@ final class HomeViewModel { | |||
| 283 | async let jobsTask = loadRecentJobs() | 286 | async let jobsTask = loadRecentJobs() |
| 284 | async let assignedTicketsTask = loadAssignedTickets() | 287 | async let assignedTicketsTask = loadAssignedTickets() |
| 285 | async let inboxUnreadTask = loadInboxUnreadCount() | 288 | async let inboxUnreadTask = loadInboxUnreadCount() |
| 289 | async let systemStatusTask = loadSystemStatusSnapshot() | ||
| 286 | 290 | ||
| 287 | let projectsResult = await projectsTask | 291 | let projectsResult = await projectsTask |
| 288 | switch projectsResult { | 292 | switch projectsResult { |
| @@ -320,9 +324,15 @@ final class HomeViewModel { | |||
| 320 | 324 | ||
| 321 | unreadInboxThreadCount = await inboxUnreadTask | 325 | unreadInboxThreadCount = await inboxUnreadTask |
| 322 | hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 | 326 | hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 |
| 327 | systemStatusSnapshot = await systemStatusTask | ||
| 323 | persistNeedsAttentionSnapshot() | 328 | persistNeedsAttentionSnapshot() |
| 324 | } | 329 | } |
| 325 | 330 | ||
| 331 | var systemStatusBannerTitle: String? { | ||
| 332 | guard let systemStatusSnapshot, systemStatusSnapshot.hasDisruption else { return nil } | ||
| 333 | return systemStatusSnapshot.bannerSummary | ||
| 334 | } | ||
| 335 | |||
| 326 | func resolveTicket(_ ticket: HomeAssignedTicket) async { | 336 | func resolveTicket(_ ticket: HomeAssignedTicket) async { |
| 327 | let input: [String: any Sendable] = [ | 337 | let input: [String: any Sendable] = [ |
| 328 | "status": TicketStatus.resolved.rawValue, | 338 | "status": TicketStatus.resolved.rawValue, |
| @@ -420,6 +430,14 @@ final class HomeViewModel { | |||
| 420 | } | 430 | } |
| 421 | } | 431 | } |
| 422 | 432 | ||
| 433 | private func loadSystemStatusSnapshot() async -> SystemStatusSnapshot? { | ||
| 434 | do { | ||
| 435 | return try await systemStatusRepository.snapshot() | ||
| 436 | } catch { | ||
| 437 | return systemStatusSnapshot | ||
| 438 | } | ||
| 439 | } | ||
| 440 | |||
| 423 | private func fetchUnreadInboxThreadCount() async throws -> Int { | 441 | private func fetchUnreadInboxThreadCount() async throws -> Int { |
| 424 | let mailingLists = try await fetchInboxMailingLists() | 442 | let mailingLists = try await fetchInboxMailingLists() |
| 425 | guard !mailingLists.isEmpty else { return 0 } | 443 | guard !mailingLists.isEmpty else { return 0 } |
Hutch/Views/Lookup/LookupView.swift +2
| @@ -431,6 +431,8 @@ struct LookupView: View { | |||
| 431 | PasteListView() | 431 | PasteListView() |
| 432 | case .profile: | 432 | case .profile: |
| 433 | ProfileView() | 433 | ProfileView() |
| 434 | case .systemStatus: | ||
| 435 | SystemStatusView() | ||
| 434 | case .settings: | 436 | case .settings: |
| 435 | SettingsView() | 437 | SettingsView() |
| 436 | case .mailingList(let mailingList): | 438 | case .mailingList(let mailingList): |
Hutch/Views/More/MoreView.swift +6 −2
| @@ -4,7 +4,7 @@ struct MoreView: View { | |||
| 4 | @Environment(AppState.self) private var appState | 4 | @Environment(AppState.self) private var appState |
| 5 | 5 | ||
| 6 | private let unsupportedLinks: [(title: String, url: URL)] = [ | 6 | private let unsupportedLinks: [(title: String, url: URL)] = [ |
| 7 | ("chat.sr.ht", URL(string: "https://chat.sr.ht")!) | 7 | ("chat.sr.ht", SRHTWebURL.chat) |
| 8 | ] | 8 | ] |
| 9 | 9 | ||
| 10 | @State private var showAccountSwitcher = false | 10 | @State private var showAccountSwitcher = false |
| @@ -29,6 +29,10 @@ struct MoreView: View { | |||
| 29 | NavigationLink(value: MoreRoute.pastes) { | 29 | NavigationLink(value: MoreRoute.pastes) { |
| 30 | Label("Pastes", systemImage: "doc.on.clipboard") | 30 | Label("Pastes", systemImage: "doc.on.clipboard") |
| 31 | } | 31 | } |
| 32 | |||
| 33 | NavigationLink(value: MoreRoute.systemStatus) { | ||
| 34 | Label("System Status", systemImage: "server.rack") | ||
| 35 | } | ||
| 32 | } | 36 | } |
| 33 | 37 | ||
| 34 | Section("Meta") { | 38 | Section("Meta") { |
| @@ -59,7 +63,7 @@ struct MoreView: View { | |||
| 59 | Button { | 63 | Button { |
| 60 | showAccountSwitcher = true | 64 | showAccountSwitcher = true |
| 61 | } label: { | 65 | } label: { |
| 62 | Image(systemName: "person.crop.circle") | 66 | Image(systemName: "person.crop.circle.badge.plus") |
| 63 | } | 67 | } |
| 64 | } | 68 | } |
| 65 | } | 69 | } |
Hutch/Views/SystemStatus/SystemStatusView.swift added +227
| @@ -0,0 +1,227 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct SystemStatusView: View { | ||
| 4 | @Environment(AppState.self) private var appState | ||
| 5 | @State private var viewModel: SystemStatusViewModel? | ||
| 6 | |||
| 7 | var body: some View { | ||
| 8 | Group { | ||
| 9 | if let viewModel { | ||
| 10 | content(viewModel) | ||
| 11 | } else { | ||
| 12 | SRHTLoadingStateView(message: "Loading system status…") | ||
| 13 | } | ||
| 14 | } | ||
| 15 | .navigationTitle("System Status") | ||
| 16 | .navigationBarTitleDisplayMode(.inline) | ||
| 17 | .task { | ||
| 18 | let vm: SystemStatusViewModel | ||
| 19 | if let viewModel { | ||
| 20 | vm = viewModel | ||
| 21 | } else { | ||
| 22 | let newViewModel = SystemStatusViewModel(repository: appState.systemStatusRepository) | ||
| 23 | viewModel = newViewModel | ||
| 24 | vm = newViewModel | ||
| 25 | } | ||
| 26 | |||
| 27 | await vm.load() | ||
| 28 | } | ||
| 29 | } | ||
| 30 | |||
| 31 | @ViewBuilder | ||
| 32 | private func content(_ viewModel: SystemStatusViewModel) -> some View { | ||
| 33 | List { | ||
| 34 | if let snapshot = viewModel.snapshot { | ||
| 35 | summarySection(snapshot) | ||
| 36 | servicesSection(snapshot) | ||
| 37 | activeIncidentsSection(snapshot.activeIncidents) | ||
| 38 | } | ||
| 39 | |||
| 40 | recentIncidentsSection(viewModel.recentIncidents) | ||
| 41 | } | ||
| 42 | .listStyle(.insetGrouped) | ||
| 43 | .refreshable { | ||
| 44 | await viewModel.load(forceRefresh: true) | ||
| 45 | } | ||
| 46 | .overlay { | ||
| 47 | if viewModel.isLoading && !viewModel.hasContent { | ||
| 48 | SRHTLoadingStateView(message: "Loading system status…") | ||
| 49 | } else if !viewModel.isLoading && !viewModel.hasContent, let errorMessage = viewModel.errorMessage { | ||
| 50 | SRHTErrorStateView( | ||
| 51 | title: "Couldn’t Load System Status", | ||
| 52 | message: errorMessage, | ||
| 53 | retryAction: { await viewModel.load(forceRefresh: true) } | ||
| 54 | ) | ||
| 55 | } else if !viewModel.isLoading && !viewModel.hasContent { | ||
| 56 | ContentUnavailableView( | ||
| 57 | "No Status Data", | ||
| 58 | systemImage: "server.rack", | ||
| 59 | description: Text("System status information is not available right now.") | ||
| 60 | ) | ||
| 61 | } | ||
| 62 | } | ||
| 63 | .connectivityOverlay(hasContent: viewModel.hasContent) { | ||
| 64 | await viewModel.load(forceRefresh: true) | ||
| 65 | } | ||
| 66 | .srhtErrorBanner(error: Binding( | ||
| 67 | get: { viewModel.errorMessage }, | ||
| 68 | set: { viewModel.errorMessage = $0 } | ||
| 69 | )) | ||
| 70 | } | ||
| 71 | |||
| 72 | @ViewBuilder | ||
| 73 | private func summarySection(_ snapshot: SystemStatusSnapshot) -> some View { | ||
| 74 | Section { | ||
| 75 | VStack(alignment: .leading, spacing: 12) { | ||
| 76 | HStack(spacing: 10) { | ||
| 77 | Image(systemName: snapshot.hasDisruption ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") | ||
| 78 | .foregroundStyle(snapshot.hasDisruption ? .orange : .green) | ||
| 79 | VStack(alignment: .leading, spacing: 4) { | ||
| 80 | Text(snapshot.overallStatusText) | ||
| 81 | .font(.headline) | ||
| 82 | Text("Updated \(snapshot.lastUpdated.relativeDescription)") | ||
| 83 | .font(.subheadline) | ||
| 84 | .foregroundStyle(.secondary) | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | Link(destination: SRHTWebURL.status) { | ||
| 89 | Label("Open status.sr.ht", systemImage: "safari") | ||
| 90 | } | ||
| 91 | .font(.subheadline.weight(.medium)) | ||
| 92 | } | ||
| 93 | .padding(.vertical, 4) | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | @ViewBuilder | ||
| 98 | private func servicesSection(_ snapshot: SystemStatusSnapshot) -> some View { | ||
| 99 | Section("Services") { | ||
| 100 | ForEach(snapshot.services) { service in | ||
| 101 | HStack(spacing: 12) { | ||
| 102 | StatusLevelBadge(level: service.status) | ||
| 103 | VStack(alignment: .leading, spacing: 4) { | ||
| 104 | Text(service.name) | ||
| 105 | .font(.subheadline.weight(.medium)) | ||
| 106 | Text(service.status.displayName) | ||
| 107 | .font(.caption) | ||
| 108 | .foregroundStyle(.secondary) | ||
| 109 | } | ||
| 110 | Spacer() | ||
| 111 | } | ||
| 112 | .padding(.vertical, 2) | ||
| 113 | } | ||
| 114 | } | ||
| 115 | } | ||
| 116 | |||
| 117 | @ViewBuilder | ||
| 118 | private func activeIncidentsSection(_ incidents: [StatusIncident]) -> some View { | ||
| 119 | if !incidents.isEmpty { | ||
| 120 | Section("Active Incidents") { | ||
| 121 | ForEach(incidents) { incident in | ||
| 122 | incidentRow(incident) | ||
| 123 | } | ||
| 124 | } | ||
| 125 | } | ||
| 126 | } | ||
| 127 | |||
| 128 | @ViewBuilder | ||
| 129 | private func recentIncidentsSection(_ incidents: [StatusIncident]) -> some View { | ||
| 130 | Section("Recent Incidents") { | ||
| 131 | if incidents.isEmpty { | ||
| 132 | ContentUnavailableView( | ||
| 133 | "No Recent Incidents", | ||
| 134 | systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90", | ||
| 135 | description: Text("The status feed didn’t return any recent incidents.") | ||
| 136 | ) | ||
| 137 | } else { | ||
| 138 | ForEach(incidents) { incident in | ||
| 139 | incidentRow(incident) | ||
| 140 | } | ||
| 141 | } | ||
| 142 | } | ||
| 143 | } | ||
| 144 | |||
| 145 | @ViewBuilder | ||
| 146 | private func incidentRow(_ incident: StatusIncident) -> some View { | ||
| 147 | if let url = incident.url { | ||
| 148 | Link(destination: url) { | ||
| 149 | StatusIncidentRow(incident: incident) | ||
| 150 | } | ||
| 151 | } else { | ||
| 152 | StatusIncidentRow(incident: incident) | ||
| 153 | } | ||
| 154 | } | ||
| 155 | } | ||
| 156 | |||
| 157 | private struct StatusIncidentRow: View { | ||
| 158 | let incident: StatusIncident | ||
| 159 | |||
| 160 | var body: some View { | ||
| 161 | VStack(alignment: .leading, spacing: 6) { | ||
| 162 | HStack(alignment: .top, spacing: 8) { | ||
| 163 | Text(incident.title) | ||
| 164 | .font(.subheadline.weight(.medium)) | ||
| 165 | .foregroundStyle(.primary) | ||
| 166 | Spacer(minLength: 8) | ||
| 167 | if incident.url != nil { | ||
| 168 | Image(systemName: "arrow.up.right.square") | ||
| 169 | .font(.caption) | ||
| 170 | .foregroundStyle(.secondary) | ||
| 171 | } | ||
| 172 | } | ||
| 173 | |||
| 174 | Text(timestampText) | ||
| 175 | .font(.caption) | ||
| 176 | .foregroundStyle(.secondary) | ||
| 177 | |||
| 178 | if let summary = incident.summary, !summary.isEmpty { | ||
| 179 | Text(summary) | ||
| 180 | .font(.caption) | ||
| 181 | .foregroundStyle(.secondary) | ||
| 182 | .lineLimit(3) | ||
| 183 | } | ||
| 184 | } | ||
| 185 | .padding(.vertical, 2) | ||
| 186 | } | ||
| 187 | |||
| 188 | private var timestampText: String { | ||
| 189 | if let updatedAt = incident.updatedAt { | ||
| 190 | return "Published \(incident.publishedAt.relativeDescription) • Updated \(updatedAt.relativeDescription)" | ||
| 191 | } | ||
| 192 | return "Published \(incident.publishedAt.relativeDescription)" | ||
| 193 | } | ||
| 194 | } | ||
| 195 | |||
| 196 | private struct StatusLevelBadge: View { | ||
| 197 | let level: StatusLevel | ||
| 198 | |||
| 199 | var body: some View { | ||
| 200 | HStack(spacing: 6) { | ||
| 201 | Circle() | ||
| 202 | .fill(color) | ||
| 203 | .frame(width: 8, height: 8) | ||
| 204 | Text(level.displayName) | ||
| 205 | .font(.caption.weight(.medium)) | ||
| 206 | .foregroundStyle(.primary) | ||
| 207 | } | ||
| 208 | .padding(.horizontal, 10) | ||
| 209 | .padding(.vertical, 6) | ||
| 210 | .background(color.opacity(0.14), in: Capsule()) | ||
| 211 | } | ||
| 212 | |||
| 213 | private var color: Color { | ||
| 214 | switch level { | ||
| 215 | case .operational: | ||
| 216 | .green | ||
| 217 | case .degraded: | ||
| 218 | .orange | ||
| 219 | case .majorOutage: | ||
| 220 | .red | ||
| 221 | case .maintenance: | ||
| 222 | .blue | ||
| 223 | case .unknown: | ||
| 224 | .gray | ||
| 225 | } | ||
| 226 | } | ||
| 227 | } | ||
Hutch/Views/SystemStatus/SystemStatusViewModel.swift added +48
| @@ -0,0 +1,48 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | @Observable | ||
| 4 | @MainActor | ||
| 5 | final class SystemStatusViewModel { | ||
| 6 | private let repository: SystemStatusRepository | ||
| 7 | |||
| 8 | private(set) var snapshot: SystemStatusSnapshot? | ||
| 9 | private(set) var recentIncidents: [StatusIncident] = [] | ||
| 10 | private(set) var isLoading = false | ||
| 11 | var errorMessage: String? | ||
| 12 | |||
| 13 | init(repository: SystemStatusRepository) { | ||
| 14 | self.repository = repository | ||
| 15 | } | ||
| 16 | |||
| 17 | var hasContent: Bool { | ||
| 18 | snapshot != nil || !recentIncidents.isEmpty | ||
| 19 | } | ||
| 20 | |||
| 21 | func load(forceRefresh: Bool = false) async { | ||
| 22 | if !hasContent { | ||
| 23 | isLoading = true | ||
| 24 | } | ||
| 25 | defer { isLoading = false } | ||
| 26 | |||
| 27 | errorMessage = nil | ||
| 28 | |||
| 29 | async let snapshotTask = repository.snapshot(forceRefresh: forceRefresh) | ||
| 30 | async let incidentsTask = repository.recentIncidents(forceRefresh: forceRefresh) | ||
| 31 | |||
| 32 | do { | ||
| 33 | snapshot = try await snapshotTask | ||
| 34 | } catch { | ||
| 35 | if snapshot == nil { | ||
| 36 | errorMessage = error.userFacingMessage | ||
| 37 | } | ||
| 38 | } | ||
| 39 | |||
| 40 | do { | ||
| 41 | recentIncidents = try await incidentsTask | ||
| 42 | } catch { | ||
| 43 | if errorMessage == nil && recentIncidents.isEmpty { | ||
| 44 | errorMessage = error.userFacingMessage | ||
| 45 | } | ||
| 46 | } | ||
| 47 | } | ||
| 48 | } | ||
HutchTests/SRHTWebURLTests.swift added +12
| @@ -0,0 +1,12 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Hutch | ||
| 4 | |||
| 5 | struct SRHTWebURLTests { | ||
| 6 | |||
| 7 | @Test | ||
| 8 | func browserOnlyServiceURLsUseCanonicalHosts() { | ||
| 9 | #expect(SRHTWebURL.chat.absoluteString == "https://chat.sr.ht") | ||
| 10 | #expect(SRHTWebURL.status.absoluteString == "https://status.sr.ht") | ||
| 11 | } | ||
| 12 | } | ||
HutchTests/SystemStatusServiceTests.swift added +129
| @@ -0,0 +1,129 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Hutch | ||
| 4 | |||
| 5 | struct SystemStatusServiceTests { | ||
| 6 | |||
| 7 | @Test | ||
| 8 | func parsesCurrentStatusHTMLIntoServicesAndActiveIncidents() throws { | ||
| 9 | let snapshot = try SystemStatusService.parseSnapshotHTML(Self.sampleHTML, fetchedAt: Date(timeIntervalSince1970: 100)) | ||
| 10 | |||
| 11 | #expect(snapshot.services.count == 3) | ||
| 12 | #expect(snapshot.services[0].name == "git.sr.ht") | ||
| 13 | #expect(snapshot.services[0].status == .degraded) | ||
| 14 | #expect(snapshot.services[1].status == .operational) | ||
| 15 | #expect(snapshot.hasDisruption) | ||
| 16 | #expect(snapshot.activeIncidents.count == 1) | ||
| 17 | #expect(snapshot.activeIncidents[0].title == "SourceHut disrupted due to DDoS attack") | ||
| 18 | #expect(snapshot.activeIncidents[0].summary == "SourceHut was disrupted by a DDoS attack.") | ||
| 19 | #expect(snapshot.activeIncidents[0].url?.absoluteString == "https://status.sr.ht/issues/2026-04-06-ddos-attack/") | ||
| 20 | } | ||
| 21 | |||
| 22 | @Test | ||
| 23 | func parsesIncidentFeedRSS() async throws { | ||
| 24 | let incidents = try await SystemStatusService.parseIncidentFeedXML(Data(Self.sampleRSS.utf8)) | ||
| 25 | |||
| 26 | #expect(incidents.count == 2) | ||
| 27 | #expect(incidents[0].title == "SourceHut disrupted due to DDoS attack") | ||
| 28 | #expect(incidents[0].isActive == true) | ||
| 29 | #expect(incidents[0].summary == "SourceHut was disrupted by a DDoS attack.") | ||
| 30 | #expect(incidents[1].title == "Planned maintenance on all services") | ||
| 31 | #expect(incidents[1].isActive == false) | ||
| 32 | #expect(incidents[1].updatedAt != nil) | ||
| 33 | } | ||
| 34 | |||
| 35 | @Test | ||
| 36 | func bannerSummaryPrefersSpecificServiceThenCount() { | ||
| 37 | let operational = SystemStatusSnapshot( | ||
| 38 | services: [ | ||
| 39 | StatusServiceState(id: "git.sr.ht", name: "git.sr.ht", slug: "git.sr.ht", status: .operational, description: nil) | ||
| 40 | ], | ||
| 41 | activeIncidents: [], | ||
| 42 | lastUpdated: .now | ||
| 43 | ) | ||
| 44 | |||
| 45 | let oneDisrupted = SystemStatusSnapshot( | ||
| 46 | services: [ | ||
| 47 | StatusServiceState(id: "git.sr.ht", name: "git.sr.ht", slug: "git.sr.ht", status: .degraded, description: nil), | ||
| 48 | StatusServiceState(id: "hg.sr.ht", name: "hg.sr.ht", slug: "hg.sr.ht", status: .operational, description: nil) | ||
| 49 | ], | ||
| 50 | activeIncidents: [], | ||
| 51 | lastUpdated: .now | ||
| 52 | ) | ||
| 53 | |||
| 54 | let multipleDisrupted = SystemStatusSnapshot( | ||
| 55 | services: [ | ||
| 56 | StatusServiceState(id: "git.sr.ht", name: "git.sr.ht", slug: "git.sr.ht", status: .degraded, description: nil), | ||
| 57 | StatusServiceState(id: "builds.sr.ht", name: "builds.sr.ht", slug: "builds.sr.ht", status: .majorOutage, description: nil) | ||
| 58 | ], | ||
| 59 | activeIncidents: [], | ||
| 60 | lastUpdated: .now | ||
| 61 | ) | ||
| 62 | |||
| 63 | #expect(operational.hasDisruption == false) | ||
| 64 | #expect(oneDisrupted.bannerSummary == "git.sr.ht disrupted") | ||
| 65 | #expect(multipleDisrupted.bannerSummary == "2 services disrupted") | ||
| 66 | } | ||
| 67 | |||
| 68 | private static let sampleHTML = #""" | ||
| 69 | <!DOCTYPE html> | ||
| 70 | <html> | ||
| 71 | <body class="status-homepage status-disrupted"> | ||
| 72 | <div class="announcement-box" style="border-bottom: 0"> | ||
| 73 | <div class="padding"> | ||
| 74 | <p> | ||
| 75 | <a href="/issues/2026-04-06-ddos-attack/"><strong class="bold">SourceHut disrupted due to DDoS attack →</strong></a> | ||
| 76 | </p> | ||
| 77 | <p><small><a href="/affected/git.sr.ht/" class="tag no-underline">git.sr.ht</a></small></p> | ||
| 78 | <p><strong>SourceHut was disrupted by a DDoS attack</strong>.</p> | ||
| 79 | </div> | ||
| 80 | <hr class="clean announcement-box"> | ||
| 81 | </div> | ||
| 82 | <div class="components"> | ||
| 83 | <div class="component" data-status="disrupted"> | ||
| 84 | <a href="/affected/git.sr.ht/" class="no-underline">git.sr.ht</a> | ||
| 85 | <span class="component-status">Disrupted</span> | ||
| 86 | </div> | ||
| 87 | <div class="component" data-status="ok"> | ||
| 88 | <a href="/affected/hg.sr.ht/" class="no-underline">hg.sr.ht</a> | ||
| 89 | <span class="component-status">Operational</span> | ||
| 90 | </div> | ||
| 91 | <div class="component" data-status="notice"> | ||
| 92 | <a href="/affected/man.sr.ht/" class="no-underline">man.sr.ht</a> | ||
| 93 | <span class="component-status">Maintenance</span> | ||
| 94 | </div> | ||
| 95 | </div> | ||
| 96 | <a href="https://status.sr.ht/issues/2026-04-06-ddos-attack/" class="issue no-underline"> | ||
| 97 | <small class="date float-right " title="Apr 5 10:00:00 2026 UTC">April 5, 2026 at 10:00 AM UTC</small> | ||
| 98 | <h3>SourceHut disrupted due to DDoS attack</h3> | ||
| 99 | <strong class="error">▲ This issue is not resolved yet</strong> | ||
| 100 | </a> | ||
| 101 | </body> | ||
| 102 | </html> | ||
| 103 | """# | ||
| 104 | |||
| 105 | private static let sampleRSS = #""" | ||
| 106 | <?xml version="1.0" encoding="utf-8" standalone="yes"?> | ||
| 107 | <rss version="2.0"> | ||
| 108 | <channel> | ||
| 109 | <title>sr.ht status</title> | ||
| 110 | <item> | ||
| 111 | <title>SourceHut disrupted due to DDoS attack</title> | ||
| 112 | <link>https://status.sr.ht/issues/2026-04-06-ddos-attack/</link> | ||
| 113 | <pubDate>Sun, 05 Apr 2026 10:00:00 +0000</pubDate> | ||
| 114 | <guid>https://status.sr.ht/issues/2026-04-06-ddos-attack/</guid> | ||
| 115 | <category></category> | ||
| 116 | <description><p><strong>SourceHut was disrupted by a DDoS attack</strong>.</p></description> | ||
| 117 | </item> | ||
| 118 | <item> | ||
| 119 | <title>[Resolved] Planned maintenance on all services</title> | ||
| 120 | <link>https://status.sr.ht/issues/2025-10-22-planned-maintenance/</link> | ||
| 121 | <pubDate>Wed, 22 Oct 2025 11:00:00 +0000</pubDate> | ||
| 122 | <guid>https://status.sr.ht/issues/2025-10-22-planned-maintenance/</guid> | ||
| 123 | <category>2025-10-22 12:25:00</category> | ||
| 124 | <description><p><strong>The maintenance is complete</strong>.</p></description> | ||
| 125 | </item> | ||
| 126 | </channel> | ||
| 127 | </rss> | ||
| 128 | """# | ||
| 129 | } | ||