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 {
1919 case repository(RepositorySummary)
2020 case tracker(TrackerSummary)
2121 case mailingList(InboxMailingListReference)
22 case systemStatus
2223 }
2324
2425 enum AuthPhase {
@@ -57,6 +58,7 @@ final class AppState {
5758
5859 let client: SRHTClient
5960 let configuration: AppConfiguration
61 let systemStatusRepository: SystemStatusRepository
6062
6163 // MARK: - Deep link pending navigation
6264
@@ -71,6 +73,7 @@ final class AppState {
7173 self.configuration = AppConfiguration()
7274 let token = KeychainHelper.loadToken()
7375 self.client = SRHTClient(token: token)
76 self.systemStatusRepository = SystemStatusRepository()
7477 }
7578
7679 // MARK: - Launch validation
@@ -273,6 +276,11 @@ final class AppState {
273276 selectedTab = .more
274277 }
275278
279 func openSystemStatus() {
280 pendingTabNavigation = .systemStatus
281 selectedTab = .more
282 }
283
276284 func presentRepositoryDeepLinkError() {
277285 deepLinkError = "The repository could not be found or is inaccessible."
278286 }
@@ -374,7 +382,11 @@ final class AppState {
374382 return
375383 }
376384
377 let viewModel = HomeViewModel(currentUser: currentUser, client: client)
385 let viewModel = HomeViewModel(
386 currentUser: currentUser,
387 client: client,
388 systemStatusRepository: systemStatusRepository
389 )
378390 await viewModel.loadDashboard()
379391 }
380392
Hutch/App/RootView.swift +10
@@ -207,6 +207,13 @@ struct RootView: View {
207207 morePath.append(MoreRoute.lists)
208208 morePath.append(MoreRoute.mailingList(mailingList))
209209 }
210 case .systemStatus:
211 morePath = NavigationPath()
212 appState.selectedTab = .more
213 Task {
214 await settleNavigationTransition()
215 morePath.append(MoreRoute.systemStatus)
216 }
210217 }
211218 }
212219
@@ -267,6 +274,7 @@ enum MoreRoute: Hashable {
267274 case lists
268275 case pastes
269276 case profile
277 case systemStatus
270278 case settings
271279 case mailingList(InboxMailingListReference)
272280 case thread(InboxThreadSummary)
@@ -287,6 +295,8 @@ private struct MoreNavigationRoot: View {
287295 PasteListView()
288296 case .profile:
289297 ProfileView()
298 case .systemStatus:
299 SystemStatusView()
290300 case .settings:
291301 SettingsView()
292302 case .mailingList(let mailingList):
Hutch/Extensions/SRHTWebURL.swift +3
@@ -1,6 +1,9 @@
11import Foundation
22
33enum SRHTWebURL {
4 static let chat = URL(string: "https://chat.sr.ht")!
5 static let status = URL(string: "https://status.sr.ht")!
6
47 static func repository(_ repository: RepositorySummary) -> URL? {
58 userScopedURL(
69 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 {
3333 if let viewModel {
3434 vm = viewModel
3535 } 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 )
3741 viewModel = newViewModel
3842 vm = newViewModel
3943 }
@@ -51,6 +55,7 @@ struct HomeView: View {
5155 @ViewBuilder
5256 private func content(_ viewModel: HomeViewModel) -> some View {
5357 List {
58 systemStatusBannerSection(viewModel)
5459 projectsSection(viewModel)
5560 assignedTicketsSection(viewModel)
5661 recentBuildsSection(viewModel)
@@ -75,6 +80,20 @@ struct HomeView: View {
7580 }
7681 }
7782
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
7897 @ViewBuilder
7998 private func projectsSection(_ viewModel: HomeViewModel) -> some View {
8099 if !viewModel.projects.isEmpty {
@@ -233,6 +252,32 @@ private struct HomeInboxToolbarIcon: View {
233252 }
234253}
235254
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
236281private struct HomeProjectRow: View {
237282 let project: Project
238283
Hutch/Views/Home/HomeViewModel.swift +19 −1
@@ -143,6 +143,7 @@ final class HomeViewModel {
143143 private(set) var projects: [Project] = []
144144 var assignedTickets: [HomeAssignedTicket] = []
145145 var recentBuilds: [HomeBuildItem] = []
146 private(set) var systemStatusSnapshot: SystemStatusSnapshot?
146147 private(set) var hasUnreadInboxThreads = false
147148 private(set) var unreadInboxThreadCount: Int?
148149 private(set) var isLoadingProjects = false
@@ -153,6 +154,7 @@ final class HomeViewModel {
153154
154155 private let currentUser: User
155156 private let client: SRHTClient
157 private let systemStatusRepository: SystemStatusRepository
156158 private let projectService: ProjectService
157159 private let ticketFetchConcurrencyLimit = 6
158160 private let inboxUnreadConcurrencyLimit = 4
@@ -266,9 +268,10 @@ final class HomeViewModel {
266268 }
267269 """
268270
269 init(currentUser: User, client: SRHTClient) {
271 init(currentUser: User, client: SRHTClient, systemStatusRepository: SystemStatusRepository) {
270272 self.currentUser = currentUser
271273 self.client = client
274 self.systemStatusRepository = systemStatusRepository
272275 self.projectService = ProjectService(client: client)
273276 }
274277
@@ -283,6 +286,7 @@ final class HomeViewModel {
283286 async let jobsTask = loadRecentJobs()
284287 async let assignedTicketsTask = loadAssignedTickets()
285288 async let inboxUnreadTask = loadInboxUnreadCount()
289 async let systemStatusTask = loadSystemStatusSnapshot()
286290
287291 let projectsResult = await projectsTask
288292 switch projectsResult {
@@ -320,9 +324,15 @@ final class HomeViewModel {
320324
321325 unreadInboxThreadCount = await inboxUnreadTask
322326 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
327 systemStatusSnapshot = await systemStatusTask
323328 persistNeedsAttentionSnapshot()
324329 }
325330
331 var systemStatusBannerTitle: String? {
332 guard let systemStatusSnapshot, systemStatusSnapshot.hasDisruption else { return nil }
333 return systemStatusSnapshot.bannerSummary
334 }
335
326336 func resolveTicket(_ ticket: HomeAssignedTicket) async {
327337 let input: [String: any Sendable] = [
328338 "status": TicketStatus.resolved.rawValue,
@@ -420,6 +430,14 @@ final class HomeViewModel {
420430 }
421431 }
422432
433 private func loadSystemStatusSnapshot() async -> SystemStatusSnapshot? {
434 do {
435 return try await systemStatusRepository.snapshot()
436 } catch {
437 return systemStatusSnapshot
438 }
439 }
440
423441 private func fetchUnreadInboxThreadCount() async throws -> Int {
424442 let mailingLists = try await fetchInboxMailingLists()
425443 guard !mailingLists.isEmpty else { return 0 }
Hutch/Views/Lookup/LookupView.swift +2
@@ -431,6 +431,8 @@ struct LookupView: View {
431431 PasteListView()
432432 case .profile:
433433 ProfileView()
434 case .systemStatus:
435 SystemStatusView()
434436 case .settings:
435437 SettingsView()
436438 case .mailingList(let mailingList):
Hutch/Views/More/MoreView.swift +6 −2
@@ -4,7 +4,7 @@ struct MoreView: View {
44 @Environment(AppState.self) private var appState
55
66 private let unsupportedLinks: [(title: String, url: URL)] = [
7 ("chat.sr.ht", URL(string: "https://chat.sr.ht")!)
7 ("chat.sr.ht", SRHTWebURL.chat)
88 ]
99
1010 @State private var showAccountSwitcher = false
@@ -29,6 +29,10 @@ struct MoreView: View {
2929 NavigationLink(value: MoreRoute.pastes) {
3030 Label("Pastes", systemImage: "doc.on.clipboard")
3131 }
32
33 NavigationLink(value: MoreRoute.systemStatus) {
34 Label("System Status", systemImage: "server.rack")
35 }
3236 }
3337
3438 Section("Meta") {
@@ -59,7 +63,7 @@ struct MoreView: View {
5963 Button {
6064 showAccountSwitcher = true
6165 } label: {
62 Image(systemName: "person.crop.circle")
66 Image(systemName: "person.crop.circle.badge.plus")
6367 }
6468 }
6569 }
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}