Commit 9e45c1d099

9e45c1d099ecffb5fc5c94542f5fb309f41d292f

parent: 7206ce5b1e

Unsigned

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

Add SourceHut system status screen and home disruption banner

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 @@
1import Foundation 1import Foundation
2 2
3enum SRHTWebURL { 3enum 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 @@
1import Foundation
2
3enum 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
35struct 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
43struct 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
53struct 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
81struct SystemStatusPageData: Sendable {
82 let snapshot: SystemStatusSnapshot
83 let recentIncidents: [StatusIncident]
84}
Hutch/Networking/SystemStatusRepository.swift added +57
@@ -0,0 +1,57 @@
1import Foundation
2
3actor 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
50private 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 @@
1import Foundation
2
3struct 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
65extension 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
265private 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
255private struct HomeSystemStatusBanner: View {
256 let title: String
257
258 var body: some View {
259 HStack(spacing: 12) {
260 Image(systemName: "exclamationmark.triangle.fill")
261 .foregroundStyle(.orange)
262 VStack(alignment: .leading, spacing: 2) {
263 Text("SourceHut service disruption")
264 .font(.subheadline.weight(.semibold))
265 .foregroundStyle(.primary)
266 Text(title)
267 .font(.caption)
268 .foregroundStyle(.secondary)
269 .lineLimit(1)
270 }
271 Spacer()
272 Image(systemName: "chevron.right")
273 .font(.caption.weight(.semibold))
274 .foregroundStyle(.tertiary)
275 }
276 .padding(.vertical, 4)
277 .contentShape(Rectangle())
278 }
279}
280
236private struct HomeProjectRow: View { 281private 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 @@
1import SwiftUI
2
3struct 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
157private 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
196private 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 @@
1import Foundation
2
3@Observable
4@MainActor
5final 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 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct 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 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct 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>&lt;p&gt;&lt;strong&gt;SourceHut was disrupted by a DDoS attack&lt;/strong&gt;.&lt;/p&gt;</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>&lt;p&gt;&lt;strong&gt;The maintenance is complete&lt;/strong&gt;.&lt;/p&gt;</description>
125 </item>
126 </channel>
127 </rss>
128 """#
129}