krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

00cc231e9b419d27b412036d93457c2cbabe0b16

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-12T04:36:35Z

Add SourceHut system status screen and home disruption banner
 Hutch/App/AppState.swift                           |  14 +-
 Hutch/App/RootView.swift                           |  10 +
 Hutch/Extensions/SRHTWebURL.swift                  |   3 +
 Hutch/Models/SystemStatusModels.swift              |  84 +++++
 Hutch/Networking/SystemStatusRepository.swift      |  57 +++
 Hutch/Networking/SystemStatusService.swift         | 400 +++++++++++++++++++++
 Hutch/Views/Home/HomeView.swift                    |  47 ++-
 Hutch/Views/Home/HomeViewModel.swift               |  20 +-
 Hutch/Views/Lookup/LookupView.swift                |   2 +
 Hutch/Views/More/MoreView.swift                    |   8 +-
 Hutch/Views/SystemStatus/SystemStatusView.swift    | 227 ++++++++++++
 .../Views/SystemStatus/SystemStatusViewModel.swift |  48 +++
 HutchTests/SRHTWebURLTests.swift                   |  12 +
 HutchTests/SystemStatusServiceTests.swift          | 129 +++++++
 14 files changed, 1056 insertions(+), 5 deletions(-)

diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
index c519adc..95d9fa9 100644
--- a/Hutch/App/AppState.swift
+++ b/Hutch/App/AppState.swift
@@ -19,6 +19,7 @@ final class AppState {
         case repository(RepositorySummary)
         case tracker(TrackerSummary)
         case mailingList(InboxMailingListReference)
+        case systemStatus
     }
 
     enum AuthPhase {
@@ -57,6 +58,7 @@ final class AppState {
 
     let client: SRHTClient
     let configuration: AppConfiguration
+    let systemStatusRepository: SystemStatusRepository
 
     // MARK: - Deep link pending navigation
 
@@ -71,6 +73,7 @@ final class AppState {
         self.configuration = AppConfiguration()
         let token = KeychainHelper.loadToken()
         self.client = SRHTClient(token: token)
+        self.systemStatusRepository = SystemStatusRepository()
     }
 
     // MARK: - Launch validation
@@ -273,6 +276,11 @@ final class AppState {
         selectedTab = .more
     }
 
+    func openSystemStatus() {
+        pendingTabNavigation = .systemStatus
+        selectedTab = .more
+    }
+
     func presentRepositoryDeepLinkError() {
         deepLinkError = "The repository could not be found or is inaccessible."
     }
@@ -374,7 +382,11 @@ final class AppState {
             return
         }
 
-        let viewModel = HomeViewModel(currentUser: currentUser, client: client)
+        let viewModel = HomeViewModel(
+            currentUser: currentUser,
+            client: client,
+            systemStatusRepository: systemStatusRepository
+        )
         await viewModel.loadDashboard()
     }
 
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index f3e7159..c210d9d 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -207,6 +207,13 @@ struct RootView: View {
                 morePath.append(MoreRoute.lists)
                 morePath.append(MoreRoute.mailingList(mailingList))
             }
+        case .systemStatus:
+            morePath = NavigationPath()
+            appState.selectedTab = .more
+            Task {
+                await settleNavigationTransition()
+                morePath.append(MoreRoute.systemStatus)
+            }
         }
     }
 
@@ -267,6 +274,7 @@ enum MoreRoute: Hashable {
     case lists
     case pastes
     case profile
+    case systemStatus
     case settings
     case mailingList(InboxMailingListReference)
     case thread(InboxThreadSummary)
@@ -287,6 +295,8 @@ private struct MoreNavigationRoot: View {
                     PasteListView()
                 case .profile:
                     ProfileView()
+                case .systemStatus:
+                    SystemStatusView()
                 case .settings:
                     SettingsView()
                 case .mailingList(let mailingList):
diff --git a/Hutch/Extensions/SRHTWebURL.swift b/Hutch/Extensions/SRHTWebURL.swift
index 35d250d..9bc1552 100644
--- a/Hutch/Extensions/SRHTWebURL.swift
+++ b/Hutch/Extensions/SRHTWebURL.swift
@@ -1,6 +1,9 @@
 import Foundation
 
 enum SRHTWebURL {
+    static let chat = URL(string: "https://chat.sr.ht")!
+    static let status = URL(string: "https://status.sr.ht")!
+
     static func repository(_ repository: RepositorySummary) -> URL? {
         userScopedURL(
             host: "\(repository.service.rawValue).sr.ht",
diff --git a/Hutch/Models/SystemStatusModels.swift b/Hutch/Models/SystemStatusModels.swift
new file mode 100644
index 0000000..9a664b0
--- /dev/null
+++ b/Hutch/Models/SystemStatusModels.swift
@@ -0,0 +1,84 @@
+import Foundation
+
+enum StatusLevel: String, Codable, Sendable {
+    case operational
+    case degraded
+    case majorOutage
+    case maintenance
+    case unknown
+
+    var displayName: String {
+        switch self {
+        case .operational:
+            "Operational"
+        case .degraded:
+            "Degraded"
+        case .majorOutage:
+            "Major outage"
+        case .maintenance:
+            "Maintenance"
+        case .unknown:
+            "Unknown"
+        }
+    }
+
+    var requiresAttention: Bool {
+        switch self {
+        case .degraded, .majorOutage, .maintenance:
+            true
+        case .operational, .unknown:
+            false
+        }
+    }
+}
+
+struct StatusServiceState: Identifiable, Hashable, Codable, Sendable {
+    let id: String
+    let name: String
+    let slug: String?
+    let status: StatusLevel
+    let description: String?
+}
+
+struct StatusIncident: Identifiable, Hashable, Codable, Sendable {
+    let id: String
+    let title: String
+    let summary: String?
+    let url: URL?
+    let publishedAt: Date
+    let updatedAt: Date?
+    let isActive: Bool?
+}
+
+struct SystemStatusSnapshot: Hashable, Codable, Sendable {
+    let services: [StatusServiceState]
+    let activeIncidents: [StatusIncident]
+    let lastUpdated: Date
+
+    var disruptedServices: [StatusServiceState] {
+        services.filter { $0.status.requiresAttention }
+    }
+
+    var hasDisruption: Bool {
+        !disruptedServices.isEmpty
+    }
+
+    var overallStatusText: String {
+        hasDisruption ? "Experiencing disruptions" : "All monitored services operational"
+    }
+
+    var bannerSummary: String {
+        if disruptedServices.count == 1, let service = disruptedServices.first {
+            return "\(service.name) disrupted"
+        }
+        if disruptedServices.count > 1 {
+            return "\(disruptedServices.count) services disrupted"
+        }
+        return "SourceHut service disruption"
+    }
+}
+
+struct SystemStatusPageData: Sendable {
+    let snapshot: SystemStatusSnapshot
+    let recentIncidents: [StatusIncident]
+}
diff --git a/Hutch/Networking/SystemStatusRepository.swift b/Hutch/Networking/SystemStatusRepository.swift
new file mode 100644
index 0000000..48c5b16
--- /dev/null
+++ b/Hutch/Networking/SystemStatusRepository.swift
@@ -0,0 +1,57 @@
+import Foundation
+
+actor SystemStatusRepository {
+    private let service: SystemStatusService
+    private let ttl: TimeInterval
+
+    private var snapshotCache: CacheEntry<SystemStatusSnapshot>?
+    private var incidentsCache: CacheEntry<[StatusIncident]>?
+
+    init(service: SystemStatusService = SystemStatusService(), ttl: TimeInterval = 10 * 60) {
+        self.service = service
+        self.ttl = ttl
+    }
+
+    func snapshot(forceRefresh: Bool = false) async throws -> SystemStatusSnapshot {
+        if let cached = snapshotCache, !forceRefresh, !cached.isExpired(ttl: ttl) {
+            return cached.value
+        }
+
+        do {
+            let snapshot = try await service.fetchSnapshot()
+            snapshotCache = CacheEntry(value: snapshot, timestamp: Date())
+            return snapshot
+        } catch {
+            if let cached = snapshotCache {
+                return cached.value
+            }
+            throw error
+        }
+    }
+
+    func recentIncidents(forceRefresh: Bool = false) async throws -> [StatusIncident] {
+        if let cached = incidentsCache, !forceRefresh, !cached.isExpired(ttl: ttl) {
+            return cached.value
+        }
+
+        do {
+            let incidents = try await service.fetchIncidentFeed()
+            incidentsCache = CacheEntry(value: incidents, timestamp: Date())
+            return incidents
+        } catch {
+            if let cached = incidentsCache {
+                return cached.value
+            }
+            throw error
+        }
+    }
+}
+
+private struct CacheEntry<Value: Sendable>: Sendable {
+    let value: Value
+    let timestamp: Date
+
+    nonisolated func isExpired(ttl: TimeInterval) -> Bool {
+        Date().timeIntervalSince(timestamp) > ttl
+    }
+}
diff --git a/Hutch/Networking/SystemStatusService.swift b/Hutch/Networking/SystemStatusService.swift
new file mode 100644
index 0000000..fd38fd3
--- /dev/null
+++ b/Hutch/Networking/SystemStatusService.swift
@@ -0,0 +1,400 @@
+import Foundation
+
+struct SystemStatusService: Sendable {
+    nonisolated static let statusURL = URL(string: "https://status.sr.ht/")!
+    nonisolated static let feedURL = URL(string: "https://status.sr.ht/index.xml")!
+
+    private let session: URLSession
+    private let now: @Sendable () -> Date
+
+    nonisolated init(session: URLSession = .shared, now: @escaping @Sendable () -> Date = Date.init) {
+        self.session = session
+        self.now = now
+    }
+
+    func fetchSnapshot() async throws -> SystemStatusSnapshot {
+        let html = try await fetchText(from: Self.statusURL, accept: "text/html,application/xhtml+xml")
+        return try Self.parseSnapshotHTML(html, fetchedAt: now())
+    }
+
+    func fetchIncidentFeed() async throws -> [StatusIncident] {
+        let data = try await fetchData(from: Self.feedURL, accept: "application/rss+xml,application/xml,text/xml")
+        return try await Self.parseIncidentFeedXML(data)
+    }
+
+    private func fetchText(from url: URL, accept: String) async throws -> String {
+        let data = try await fetchData(from: url, accept: accept)
+        guard let text = String(data: data, encoding: .utf8) else {
+            throw SRHTError.decodingError(
+                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not UTF-8 text"))
+            )
+        }
+        return text
+    }
+
+    private func fetchData(from url: URL, accept: String) async throws -> Data {
+        var request = URLRequest(url: url)
+        request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
+        request.setValue(accept, forHTTPHeaderField: "Accept")
+
+        let (data, response): (Data, URLResponse)
+        do {
+            (data, response) = try await session.data(for: request)
+        } catch {
+            throw SRHTError.networkError(error)
+        }
+
+        if let http = response as? HTTPURLResponse,
+           !(200...299).contains(http.statusCode) {
+            throw SRHTError.httpError(http.statusCode)
+        }
+
+        return data
+    }
+
+    private var userAgent: String {
+        let bundle = Bundle.main
+        let name = (bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
+            ?? (bundle.object(forInfoDictionaryKey: "CFBundleName") as? String)
+            ?? "Hutch"
+        let version = (bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) ?? "dev"
+        return "\(name)/\(version) (System Status)"
+    }
+}
+
+extension SystemStatusService {
+    nonisolated static func parseSnapshotHTML(_ html: String, fetchedAt: Date) throws -> SystemStatusSnapshot {
+        let services = parseServices(in: html)
+        let incidents = parseHTMLIncidentCards(in: html)
+        let summaries = parseActiveIncidentSummaries(in: html)
+
+        let activeIncidents = incidents
+            .filter { $0.isActive == true }
+            .map { incident in
+                let summary = incident.url.flatMap { summaries[$0.absoluteString] } ?? incident.summary
+                return StatusIncident(
+                    id: incident.id,
+                    title: incident.title,
+                    summary: summary,
+                    url: incident.url,
+                    publishedAt: incident.publishedAt,
+                    updatedAt: incident.updatedAt,
+                    isActive: incident.isActive
+                )
+            }
+
+        return SystemStatusSnapshot(services: services, activeIncidents: activeIncidents, lastUpdated: fetchedAt)
+    }
+
+    nonisolated static func parseIncidentFeedXML(_ data: Data) async throws -> [StatusIncident] {
+        try await MainActor.run {
+            let parser = SystemStatusFeedParser()
+            return try parser.parse(data: data)
+        }
+    }
+
+    nonisolated private static func parseServices(in html: String) -> [StatusServiceState] {
+        firstMatches(
+            in: html,
+            pattern: #"<div class="component" data-status="([^"]+)">([\s\S]*?)</div>"#
+        ).compactMap { captures in
+            guard captures.count >= 2 else { return nil }
+
+            let rawStatus = captures[0]
+            let content = captures[1]
+            guard let linkCaptures = firstMatches(
+                in: content,
+                pattern: #"<a[^>]*href="([^"]+)"[^>]*>\s*(.*?)\s*</a>"#
+            ).first,
+                  linkCaptures.count >= 2,
+                  let statusText = firstMatch(in: content, pattern: #"<span class="component-status">\s*(.*?)\s*</span>"#) else {
+                return nil
+            }
+
+            let href = linkCaptures[0]
+            let cleanedName = cleanText(linkCaptures[1])
+            let readableStatus = cleanText(statusText)
+            let level = statusLevel(fromHTMLStatus: rawStatus)
+
+            return StatusServiceState(
+                id: normalizedSlug(from: href, fallback: cleanedName) ?? cleanedName,
+                name: cleanedName,
+                slug: normalizedSlug(from: href, fallback: cleanedName),
+                status: level == .unknown ? statusLevel(fromLabel: readableStatus) : level,
+                description: nil
+            )
+        }
+    }
+
+    nonisolated private static func parseHTMLIncidentCards(in html: String) -> [StatusIncident] {
+        firstMatches(
+            in: html,
+            pattern: #"<a href="([^"]+)" class="issue no-underline">([\s\S]*?)</a>"#
+        ).compactMap { captures in
+            guard captures.count >= 2 else { return nil }
+            let href = captures[0]
+            let content = captures[1]
+            guard let titleHTML = firstMatch(in: content, pattern: #"<h3>\s*([\s\S]*?)\s*</h3>"#),
+                  let titleAttribute = firstMatch(in: content, pattern: #"<small class="date[^"]*" title="([^"]+)">"#),
+                  let publishedAt = htmlIssueDateFormatter.date(from: cleanText(titleAttribute)) else {
+                return nil
+            }
+
+            let url = URL(string: href, relativeTo: statusURL)?.absoluteURL
+            let isActive = content.localizedCaseInsensitiveContains("This issue is not resolved yet")
+            return StatusIncident(
+                id: url?.absoluteString ?? cleanText(titleHTML),
+                title: cleanText(titleHTML),
+                summary: nil,
+                url: url,
+                publishedAt: publishedAt,
+                updatedAt: nil,
+                isActive: isActive
+            )
+        }
+    }
+
+    nonisolated private static func parseActiveIncidentSummaries(in html: String) -> [String: String] {
+        firstMatches(
+            in: html,
+            pattern: #"<div class="announcement-box"[\s\S]*?<div class="padding">([\s\S]*?)</div>\s*<hr class="clean announcement-box">"#
+        ).reduce(into: [:]) { partialResult, captures in
+            guard let content = captures.first,
+                  let titleLinkCaptures = firstMatches(
+                    in: content,
+                    pattern: #"<a href="([^"]+)"><strong class="bold">([\s\S]*?)</strong></a>"#
+                  ).first,
+                  let href = titleLinkCaptures.first else {
+                return
+            }
+
+            let paragraphs = firstMatches(in: content, pattern: #"<p>([\s\S]*?)</p>"#)
+                .compactMap(\.first)
+                .map(cleanText)
+                .filter { !$0.isEmpty }
+
+            let summary = paragraphs.dropFirst(2).first ?? paragraphs.dropFirst().first
+            guard let summary, !summary.isEmpty else { return }
+            if let url = URL(string: href, relativeTo: statusURL)?.absoluteURL {
+                partialResult[url.absoluteString] = summary
+            }
+        }
+    }
+
+    nonisolated private static func statusLevel(fromHTMLStatus status: String) -> StatusLevel {
+        switch status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
+        case "ok":
+            .operational
+        case "disrupted":
+            .degraded
+        case "down":
+            .majorOutage
+        case "notice":
+            .maintenance
+        default:
+            .unknown
+        }
+    }
+
+    nonisolated private static func statusLevel(fromLabel label: String) -> StatusLevel {
+        switch label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
+        case "operational":
+            .operational
+        case "disrupted", "degraded":
+            .degraded
+        case "down", "major outage":
+            .majorOutage
+        case "maintenance":
+            .maintenance
+        default:
+            .unknown
+        }
+    }
+
+    nonisolated private static func normalizedSlug(from href: String, fallback name: String) -> String? {
+        if href.contains("/affected/") {
+            let trimmed = href.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+            if let slug = trimmed.split(separator: "/").last {
+                return String(slug)
+            }
+        }
+        return name.isEmpty ? nil : name
+    }
+
+    nonisolated private static func firstMatch(in text: String, pattern: String) -> String? {
+        firstMatches(in: text, pattern: pattern).first?.first
+    }
+
+    nonisolated private static func firstMatches(in text: String, pattern: String) -> [[String]] {
+        guard let regex = try? NSRegularExpression(
+            pattern: pattern,
+            options: [.caseInsensitive, .dotMatchesLineSeparators]
+        ) else {
+            return []
+        }
+
+        let range = NSRange(text.startIndex..., in: text)
+        return regex.matches(in: text, range: range).map { match in
+            (1..<match.numberOfRanges).compactMap { captureIndex in
+                guard let captureRange = Range(match.range(at: captureIndex), in: text) else { return nil }
+                return String(text[captureRange])
+            }
+        }
+    }
+
+    nonisolated private static func cleanText(_ text: String) -> String {
+        let stripped = text.replacingOccurrences(of: #"<[^>]+>"#, with: " ", options: .regularExpression)
+        let decoded = decodeHTMLEntities(stripped)
+        return decoded
+            .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+            .replacingOccurrences(of: #"\s+([.,!?;:])"#, with: "$1", options: .regularExpression)
+            .replacingOccurrences(of: "→", with: "")
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+    }
+
+    nonisolated private static let htmlIssueDateFormatter: DateFormatter = {
+        let formatter = DateFormatter()
+        formatter.locale = Locale(identifier: "en_US_POSIX")
+        formatter.timeZone = TimeZone(identifier: "UTC")
+        formatter.dateFormat = "MMM d HH:mm:ss yyyy zzz"
+        return formatter
+    }()
+}
+
+@MainActor
+private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchecked Sendable {
+    private var incidents: [StatusIncident] = []
+    private var currentItem: FeedItem?
+    private var textBuffer = ""
+
+    func parse(data: Data) throws -> [StatusIncident] {
+        incidents = []
+        currentItem = nil
+        textBuffer = ""
+
+        let parser = XMLParser(data: data)
+        parser.delegate = self
+        guard parser.parse() else {
+            throw parser.parserError ?? SRHTError.decodingError(
+                DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Failed to parse status feed"))
+            )
+        }
+        return incidents.sorted { $0.publishedAt > $1.publishedAt }
+    }
+
+    func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) {
+        textBuffer = ""
+        if elementName == "item" {
+            currentItem = FeedItem()
+        }
+    }
+
+    func parser(_ parser: XMLParser, foundCharacters string: String) {
+        textBuffer += string
+    }
+
+    func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) {
+        if let string = String(data: CDATABlock, encoding: .utf8) {
+            textBuffer += string
+        }
+    }
+
+    func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
+        guard var currentItem else {
+            textBuffer = ""
+            return
+        }
+
+        let value = textBuffer.trimmingCharacters(in: .whitespacesAndNewlines)
+        switch elementName {
+        case "title":
+            currentItem.title = value
+        case "link":
+            currentItem.link = value
+        case "guid":
+            currentItem.guid = value
+        case "description":
+            currentItem.description = value
+        case "pubDate":
+            currentItem.pubDate = value
+        case "category":
+            currentItem.category = value
+        case "item":
+            if let incident = currentItem.makeIncident() {
+                incidents.append(incident)
+            }
+            self.currentItem = nil
+        default:
+            self.currentItem = currentItem
+        }
+
+        if elementName != "item" {
+            self.currentItem = currentItem
+        }
+        textBuffer = ""
+    }
+
+    private struct FeedItem {
+        var title = ""
+        var link = ""
+        var guid = ""
+        var description = ""
+        var pubDate = ""
+        var category = ""
+
+        func makeIncident() -> StatusIncident? {
+            let cleanedTitle = title.replacingOccurrences(of: "[Resolved] ", with: "")
+            guard !cleanedTitle.isEmpty,
+                  let publishedAt = SystemStatusFeedParser.pubDateFormatter.date(from: pubDate) else {
+                return nil
+            }
+
+            let url = URL(string: link)
+            let updatedAt = category.isEmpty ? nil : SystemStatusFeedParser.updatedDateFormatter.date(from: category)
+
+            return StatusIncident(
+                id: guid.isEmpty ? (url?.absoluteString ?? cleanedTitle) : guid,
+                title: cleanedTitle,
+                summary: SystemStatusFeedParser.summary(from: description),
+                url: url,
+                publishedAt: publishedAt,
+                updatedAt: updatedAt,
+                isActive: category.isEmpty
+            )
+        }
+    }
+
+    nonisolated private static func summary(from html: String) -> String? {
+        html
+            .components(separatedBy: "</p>")
+            .map { $0.replacingOccurrences(of: "<p>", with: "") }
+            .map(stripHTML)
+            .map {
+                $0.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+                    .replacingOccurrences(of: #"\s+([.,!?;:])"#, with: "$1", options: .regularExpression)
+                    .trimmingCharacters(in: .whitespacesAndNewlines)
+            }
+            .first { !$0.isEmpty }
+    }
+
+    nonisolated private static func stripHTML(_ text: String) -> String {
+        let stripped = text.replacingOccurrences(of: #"<[^>]+>"#, with: " ", options: .regularExpression)
+        return decodeHTMLEntities(stripped)
+    }
+
+    nonisolated private static let pubDateFormatter: DateFormatter = {
+        let formatter = DateFormatter()
+        formatter.locale = Locale(identifier: "en_US_POSIX")
+        formatter.timeZone = TimeZone(identifier: "UTC")
+        formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
+        return formatter
+    }()
+
+    nonisolated private static let updatedDateFormatter: DateFormatter = {
+        let formatter = DateFormatter()
+        formatter.locale = Locale(identifier: "en_US_POSIX")
+        formatter.timeZone = TimeZone(identifier: "UTC")
+        formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
+        return formatter
+    }()
+}
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index f9d3de5..695a31d 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -33,7 +33,11 @@ struct HomeView: View {
             if let viewModel {
                 vm = viewModel
             } else {
-                let newViewModel = HomeViewModel(currentUser: currentUser, client: appState.client)
+                let newViewModel = HomeViewModel(
+                    currentUser: currentUser,
+                    client: appState.client,
+                    systemStatusRepository: appState.systemStatusRepository
+                )
                 viewModel = newViewModel
                 vm = newViewModel
             }
@@ -51,6 +55,7 @@ struct HomeView: View {
     @ViewBuilder
     private func content(_ viewModel: HomeViewModel) -> some View {
         List {
+            systemStatusBannerSection(viewModel)
             projectsSection(viewModel)
             assignedTicketsSection(viewModel)
             recentBuildsSection(viewModel)
@@ -75,6 +80,20 @@ struct HomeView: View {
         }
     }
 
+    @ViewBuilder
+    private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View {
+        if let bannerTitle = viewModel.systemStatusBannerTitle {
+            Section {
+                Button {
+                    appState.openSystemStatus()
+                } label: {
+                    HomeSystemStatusBanner(title: bannerTitle)
+                }
+                .buttonStyle(.plain)
+            }
+        }
+    }
+
     @ViewBuilder
     private func projectsSection(_ viewModel: HomeViewModel) -> some View {
         if !viewModel.projects.isEmpty {
@@ -233,6 +252,32 @@ private struct HomeInboxToolbarIcon: View {
     }
 }
 
+private struct HomeSystemStatusBanner: View {
+    let title: String
+
+    var body: some View {
+        HStack(spacing: 12) {
+            Image(systemName: "exclamationmark.triangle.fill")
+                .foregroundStyle(.orange)
+            VStack(alignment: .leading, spacing: 2) {
+                Text("SourceHut service disruption")
+                    .font(.subheadline.weight(.semibold))
+                    .foregroundStyle(.primary)
+                Text(title)
+                    .font(.caption)
+                    .foregroundStyle(.secondary)
+                    .lineLimit(1)
+            }
+            Spacer()
+            Image(systemName: "chevron.right")
+                .font(.caption.weight(.semibold))
+                .foregroundStyle(.tertiary)
+        }
+        .padding(.vertical, 4)
+        .contentShape(Rectangle())
+    }
+}
+
 private struct HomeProjectRow: View {
     let project: Project
 
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index 8b4e7aa..a06df20 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -143,6 +143,7 @@ final class HomeViewModel {
     private(set) var projects: [Project] = []
     var assignedTickets: [HomeAssignedTicket] = []
     var recentBuilds: [HomeBuildItem] = []
+    private(set) var systemStatusSnapshot: SystemStatusSnapshot?
     private(set) var hasUnreadInboxThreads = false
     private(set) var unreadInboxThreadCount: Int?
     private(set) var isLoadingProjects = false
@@ -153,6 +154,7 @@ final class HomeViewModel {
 
     private let currentUser: User
     private let client: SRHTClient
+    private let systemStatusRepository: SystemStatusRepository
     private let projectService: ProjectService
     private let ticketFetchConcurrencyLimit = 6
     private let inboxUnreadConcurrencyLimit = 4
@@ -266,9 +268,10 @@ final class HomeViewModel {
     }
     """
 
-    init(currentUser: User, client: SRHTClient) {
+    init(currentUser: User, client: SRHTClient, systemStatusRepository: SystemStatusRepository) {
         self.currentUser = currentUser
         self.client = client
+        self.systemStatusRepository = systemStatusRepository
         self.projectService = ProjectService(client: client)
     }
 
@@ -283,6 +286,7 @@ final class HomeViewModel {
         async let jobsTask = loadRecentJobs()
         async let assignedTicketsTask = loadAssignedTickets()
         async let inboxUnreadTask = loadInboxUnreadCount()
+        async let systemStatusTask = loadSystemStatusSnapshot()
 
         let projectsResult = await projectsTask
         switch projectsResult {
@@ -320,9 +324,15 @@ final class HomeViewModel {
 
         unreadInboxThreadCount = await inboxUnreadTask
         hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
+        systemStatusSnapshot = await systemStatusTask
         persistNeedsAttentionSnapshot()
     }
 
+    var systemStatusBannerTitle: String? {
+        guard let systemStatusSnapshot, systemStatusSnapshot.hasDisruption else { return nil }
+        return systemStatusSnapshot.bannerSummary
+    }
+
     func resolveTicket(_ ticket: HomeAssignedTicket) async {
         let input: [String: any Sendable] = [
             "status": TicketStatus.resolved.rawValue,
@@ -420,6 +430,14 @@ final class HomeViewModel {
         }
     }
 
+    private func loadSystemStatusSnapshot() async -> SystemStatusSnapshot? {
+        do {
+            return try await systemStatusRepository.snapshot()
+        } catch {
+            return systemStatusSnapshot
+        }
+    }
+
     private func fetchUnreadInboxThreadCount() async throws -> Int {
         let mailingLists = try await fetchInboxMailingLists()
         guard !mailingLists.isEmpty else { return 0 }
diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift
index 2753ea3..e571140 100644
--- a/Hutch/Views/Lookup/LookupView.swift
+++ b/Hutch/Views/Lookup/LookupView.swift
@@ -431,6 +431,8 @@ struct LookupView: View {
                     PasteListView()
                 case .profile:
                     ProfileView()
+                case .systemStatus:
+                    SystemStatusView()
                 case .settings:
                     SettingsView()
                 case .mailingList(let mailingList):
diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift
index 09aa37a..284b1ad 100644
--- a/Hutch/Views/More/MoreView.swift
+++ b/Hutch/Views/More/MoreView.swift
@@ -4,7 +4,7 @@ struct MoreView: View {
     @Environment(AppState.self) private var appState
 
     private let unsupportedLinks: [(title: String, url: URL)] = [
-        ("chat.sr.ht", URL(string: "https://chat.sr.ht")!)
+        ("chat.sr.ht", SRHTWebURL.chat)
     ]
 
     @State private var showAccountSwitcher = false
@@ -29,6 +29,10 @@ struct MoreView: View {
                 NavigationLink(value: MoreRoute.pastes) {
                     Label("Pastes", systemImage: "doc.on.clipboard")
                 }
+                
+                NavigationLink(value: MoreRoute.systemStatus) {
+                    Label("System Status", systemImage: "server.rack")
+                }
             }
 
             Section("Meta") {
@@ -59,7 +63,7 @@ struct MoreView: View {
                 Button {
                     showAccountSwitcher = true
                 } label: {
-                    Image(systemName: "person.crop.circle")
+                    Image(systemName: "person.crop.circle.badge.plus")
                 }
             }
         }
diff --git a/Hutch/Views/SystemStatus/SystemStatusView.swift b/Hutch/Views/SystemStatus/SystemStatusView.swift
new file mode 100644
index 0000000..053210e
--- /dev/null
+++ b/Hutch/Views/SystemStatus/SystemStatusView.swift
@@ -0,0 +1,227 @@
+import SwiftUI
+
+struct SystemStatusView: View {
+    @Environment(AppState.self) private var appState
+    @State private var viewModel: SystemStatusViewModel?
+
+    var body: some View {
+        Group {
+            if let viewModel {
+                content(viewModel)
+            } else {
+                SRHTLoadingStateView(message: "Loading system status…")
+            }
+        }
+        .navigationTitle("System Status")
+        .navigationBarTitleDisplayMode(.inline)
+        .task {
+            let vm: SystemStatusViewModel
+            if let viewModel {
+                vm = viewModel
+            } else {
+                let newViewModel = SystemStatusViewModel(repository: appState.systemStatusRepository)
+                viewModel = newViewModel
+                vm = newViewModel
+            }
+
+            await vm.load()
+        }
+    }
+
+    @ViewBuilder
+    private func content(_ viewModel: SystemStatusViewModel) -> some View {
+        List {
+            if let snapshot = viewModel.snapshot {
+                summarySection(snapshot)
+                servicesSection(snapshot)
+                activeIncidentsSection(snapshot.activeIncidents)
+            }
+
+            recentIncidentsSection(viewModel.recentIncidents)
+        }
+        .listStyle(.insetGrouped)
+        .refreshable {
+            await viewModel.load(forceRefresh: true)
+        }
+        .overlay {
+            if viewModel.isLoading && !viewModel.hasContent {
+                SRHTLoadingStateView(message: "Loading system status…")
+            } else if !viewModel.isLoading && !viewModel.hasContent, let errorMessage = viewModel.errorMessage {
+                SRHTErrorStateView(
+                    title: "Couldn’t Load System Status",
+                    message: errorMessage,
+                    retryAction: { await viewModel.load(forceRefresh: true) }
+                )
+            } else if !viewModel.isLoading && !viewModel.hasContent {
+                ContentUnavailableView(
+                    "No Status Data",
+                    systemImage: "server.rack",
+                    description: Text("System status information is not available right now.")
+                )
+            }
+        }
+        .connectivityOverlay(hasContent: viewModel.hasContent) {
+            await viewModel.load(forceRefresh: true)
+        }
+        .srhtErrorBanner(error: Binding(
+            get: { viewModel.errorMessage },
+            set: { viewModel.errorMessage = $0 }
+        ))
+    }
+
+    @ViewBuilder
+    private func summarySection(_ snapshot: SystemStatusSnapshot) -> some View {
+        Section {
+            VStack(alignment: .leading, spacing: 12) {
+                HStack(spacing: 10) {
+                    Image(systemName: snapshot.hasDisruption ? "exclamationmark.triangle.fill" : "checkmark.circle.fill")
+                        .foregroundStyle(snapshot.hasDisruption ? .orange : .green)
+                    VStack(alignment: .leading, spacing: 4) {
+                        Text(snapshot.overallStatusText)
+                            .font(.headline)
+                        Text("Updated \(snapshot.lastUpdated.relativeDescription)")
+                            .font(.subheadline)
+                            .foregroundStyle(.secondary)
+                    }
+                }
+
+                Link(destination: SRHTWebURL.status) {
+                    Label("Open status.sr.ht", systemImage: "safari")
+                }
+                .font(.subheadline.weight(.medium))
+            }
+            .padding(.vertical, 4)
+        }
+    }
+
+    @ViewBuilder
+    private func servicesSection(_ snapshot: SystemStatusSnapshot) -> some View {
+        Section("Services") {
+            ForEach(snapshot.services) { service in
+                HStack(spacing: 12) {
+                    StatusLevelBadge(level: service.status)
+                    VStack(alignment: .leading, spacing: 4) {
+                        Text(service.name)
+                            .font(.subheadline.weight(.medium))
+                        Text(service.status.displayName)
+                            .font(.caption)
+                            .foregroundStyle(.secondary)
+                    }
+                    Spacer()
+                }
+                .padding(.vertical, 2)
+            }
+        }
+    }
+
+    @ViewBuilder
+    private func activeIncidentsSection(_ incidents: [StatusIncident]) -> some View {
+        if !incidents.isEmpty {
+            Section("Active Incidents") {
+                ForEach(incidents) { incident in
+                    incidentRow(incident)
+                }
+            }
+        }
+    }
+
+    @ViewBuilder
+    private func recentIncidentsSection(_ incidents: [StatusIncident]) -> some View {
+        Section("Recent Incidents") {
+            if incidents.isEmpty {
+                ContentUnavailableView(
+                    "No Recent Incidents",
+                    systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
+                    description: Text("The status feed didn’t return any recent incidents.")
+                )
+            } else {
+                ForEach(incidents) { incident in
+                    incidentRow(incident)
+                }
+            }
+        }
+    }
+
+    @ViewBuilder
+    private func incidentRow(_ incident: StatusIncident) -> some View {
+        if let url = incident.url {
+            Link(destination: url) {
+                StatusIncidentRow(incident: incident)
+            }
+        } else {
+            StatusIncidentRow(incident: incident)
+        }
+    }
+}
+
+private struct StatusIncidentRow: View {
+    let incident: StatusIncident
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 6) {
+            HStack(alignment: .top, spacing: 8) {
+                Text(incident.title)
+                    .font(.subheadline.weight(.medium))
+                    .foregroundStyle(.primary)
+                Spacer(minLength: 8)
+                if incident.url != nil {
+                    Image(systemName: "arrow.up.right.square")
+                        .font(.caption)
+                        .foregroundStyle(.secondary)
+                }
+            }
+
+            Text(timestampText)
+                .font(.caption)
+                .foregroundStyle(.secondary)
+
+            if let summary = incident.summary, !summary.isEmpty {
+                Text(summary)
+                    .font(.caption)
+                    .foregroundStyle(.secondary)
+                    .lineLimit(3)
+            }
+        }
+        .padding(.vertical, 2)
+    }
+
+    private var timestampText: String {
+        if let updatedAt = incident.updatedAt {
+            return "Published \(incident.publishedAt.relativeDescription) • Updated \(updatedAt.relativeDescription)"
+        }
+        return "Published \(incident.publishedAt.relativeDescription)"
+    }
+}
+
+private struct StatusLevelBadge: View {
+    let level: StatusLevel
+
+    var body: some View {
+        HStack(spacing: 6) {
+            Circle()
+                .fill(color)
+                .frame(width: 8, height: 8)
+            Text(level.displayName)
+                .font(.caption.weight(.medium))
+                .foregroundStyle(.primary)
+        }
+        .padding(.horizontal, 10)
+        .padding(.vertical, 6)
+        .background(color.opacity(0.14), in: Capsule())
+    }
+
+    private var color: Color {
+        switch level {
+        case .operational:
+            .green
+        case .degraded:
+            .orange
+        case .majorOutage:
+            .red
+        case .maintenance:
+            .blue
+        case .unknown:
+            .gray
+        }
+    }
+}
diff --git a/Hutch/Views/SystemStatus/SystemStatusViewModel.swift b/Hutch/Views/SystemStatus/SystemStatusViewModel.swift
new file mode 100644
index 0000000..646d7ca
--- /dev/null
+++ b/Hutch/Views/SystemStatus/SystemStatusViewModel.swift
@@ -0,0 +1,48 @@
+import Foundation
+
+@Observable
+@MainActor
+final class SystemStatusViewModel {
+    private let repository: SystemStatusRepository
+
+    private(set) var snapshot: SystemStatusSnapshot?
+    private(set) var recentIncidents: [StatusIncident] = []
+    private(set) var isLoading = false
+    var errorMessage: String?
+
+    init(repository: SystemStatusRepository) {
+        self.repository = repository
+    }
+
+    var hasContent: Bool {
+        snapshot != nil || !recentIncidents.isEmpty
+    }
+
+    func load(forceRefresh: Bool = false) async {
+        if !hasContent {
+            isLoading = true
+        }
+        defer { isLoading = false }
+
+        errorMessage = nil
+
+        async let snapshotTask = repository.snapshot(forceRefresh: forceRefresh)
+        async let incidentsTask = repository.recentIncidents(forceRefresh: forceRefresh)
+
+        do {
+            snapshot = try await snapshotTask
+        } catch {
+            if snapshot == nil {
+                errorMessage = error.userFacingMessage
+            }
+        }
+
+        do {
+            recentIncidents = try await incidentsTask
+        } catch {
+            if errorMessage == nil && recentIncidents.isEmpty {
+                errorMessage = error.userFacingMessage
+            }
+        }
+    }
+}
diff --git a/HutchTests/SRHTWebURLTests.swift b/HutchTests/SRHTWebURLTests.swift
new file mode 100644
index 0000000..15f242d
--- /dev/null
+++ b/HutchTests/SRHTWebURLTests.swift
@@ -0,0 +1,12 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct SRHTWebURLTests {
+
+    @Test
+    func browserOnlyServiceURLsUseCanonicalHosts() {
+        #expect(SRHTWebURL.chat.absoluteString == "https://chat.sr.ht")
+        #expect(SRHTWebURL.status.absoluteString == "https://status.sr.ht")
+    }
+}
diff --git a/HutchTests/SystemStatusServiceTests.swift b/HutchTests/SystemStatusServiceTests.swift
new file mode 100644
index 0000000..58ace33
--- /dev/null
+++ b/HutchTests/SystemStatusServiceTests.swift
@@ -0,0 +1,129 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct SystemStatusServiceTests {
+
+    @Test
+    func parsesCurrentStatusHTMLIntoServicesAndActiveIncidents() throws {
+        let snapshot = try SystemStatusService.parseSnapshotHTML(Self.sampleHTML, fetchedAt: Date(timeIntervalSince1970: 100))
+
+        #expect(snapshot.services.count == 3)
+        #expect(snapshot.services[0].name == "git.sr.ht")
+        #expect(snapshot.services[0].status == .degraded)
+        #expect(snapshot.services[1].status == .operational)
+        #expect(snapshot.hasDisruption)
+        #expect(snapshot.activeIncidents.count == 1)
+        #expect(snapshot.activeIncidents[0].title == "SourceHut disrupted due to DDoS attack")
+        #expect(snapshot.activeIncidents[0].summary == "SourceHut was disrupted by a DDoS attack.")
+        #expect(snapshot.activeIncidents[0].url?.absoluteString == "https://status.sr.ht/issues/2026-04-06-ddos-attack/")
+    }
+
+    @Test
+    func parsesIncidentFeedRSS() async throws {
+        let incidents = try await SystemStatusService.parseIncidentFeedXML(Data(Self.sampleRSS.utf8))
+
+        #expect(incidents.count == 2)
+        #expect(incidents[0].title == "SourceHut disrupted due to DDoS attack")
+        #expect(incidents[0].isActive == true)
+        #expect(incidents[0].summary == "SourceHut was disrupted by a DDoS attack.")
+        #expect(incidents[1].title == "Planned maintenance on all services")
+        #expect(incidents[1].isActive == false)
+        #expect(incidents[1].updatedAt != nil)
+    }
+
+    @Test
+    func bannerSummaryPrefersSpecificServiceThenCount() {
+        let operational = SystemStatusSnapshot(
+            services: [
+                StatusServiceState(id: "git.sr.ht", name: "git.sr.ht", slug: "git.sr.ht", status: .operational, description: nil)
+            ],
+            activeIncidents: [],
+            lastUpdated: .now
+        )
+
+        let oneDisrupted = SystemStatusSnapshot(
+            services: [
+                StatusServiceState(id: "git.sr.ht", name: "git.sr.ht", slug: "git.sr.ht", status: .degraded, description: nil),
+                StatusServiceState(id: "hg.sr.ht", name: "hg.sr.ht", slug: "hg.sr.ht", status: .operational, description: nil)
+            ],
+            activeIncidents: [],
+            lastUpdated: .now
+        )
+
+        let multipleDisrupted = SystemStatusSnapshot(
+            services: [
+                StatusServiceState(id: "git.sr.ht", name: "git.sr.ht", slug: "git.sr.ht", status: .degraded, description: nil),
+                StatusServiceState(id: "builds.sr.ht", name: "builds.sr.ht", slug: "builds.sr.ht", status: .majorOutage, description: nil)
+            ],
+            activeIncidents: [],
+            lastUpdated: .now
+        )
+
+        #expect(operational.hasDisruption == false)
+        #expect(oneDisrupted.bannerSummary == "git.sr.ht disrupted")
+        #expect(multipleDisrupted.bannerSummary == "2 services disrupted")
+    }
+
+    private static let sampleHTML = #"""
+    <!DOCTYPE html>
+    <html>
+    <body class="status-homepage status-disrupted">
+    <div class="announcement-box" style="border-bottom: 0">
+      <div class="padding">
+        <p>
+          <a href="/issues/2026-04-06-ddos-attack/"><strong class="bold">SourceHut disrupted due to DDoS attack →</strong></a>
+        </p>
+        <p><small><a href="/affected/git.sr.ht/" class="tag no-underline">git.sr.ht</a></small></p>
+        <p><strong>SourceHut was disrupted by a DDoS attack</strong>.</p>
+      </div>
+      <hr class="clean announcement-box">
+    </div>
+    <div class="components">
+      <div class="component" data-status="disrupted">
+        <a href="/affected/git.sr.ht/" class="no-underline">git.sr.ht</a>
+        <span class="component-status">Disrupted</span>
+      </div>
+      <div class="component" data-status="ok">
+        <a href="/affected/hg.sr.ht/" class="no-underline">hg.sr.ht</a>
+        <span class="component-status">Operational</span>
+      </div>
+      <div class="component" data-status="notice">
+        <a href="/affected/man.sr.ht/" class="no-underline">man.sr.ht</a>
+        <span class="component-status">Maintenance</span>
+      </div>
+    </div>
+    <a href="https://status.sr.ht/issues/2026-04-06-ddos-attack/" class="issue no-underline">
+      <small class="date float-right " title="Apr 5 10:00:00 2026 UTC">April 5, 2026 at 10:00 AM UTC</small>
+      <h3>SourceHut disrupted due to DDoS attack</h3>
+      <strong class="error">▲ This issue is not resolved yet</strong>
+    </a>
+    </body>
+    </html>
+    """#
+
+    private static let sampleRSS = #"""
+    <?xml version="1.0" encoding="utf-8" standalone="yes"?>
+    <rss version="2.0">
+      <channel>
+        <title>sr.ht status</title>
+        <item>
+          <title>SourceHut disrupted due to DDoS attack</title>
+          <link>https://status.sr.ht/issues/2026-04-06-ddos-attack/</link>
+          <pubDate>Sun, 05 Apr 2026 10:00:00 +0000</pubDate>
+          <guid>https://status.sr.ht/issues/2026-04-06-ddos-attack/</guid>
+          <category></category>
+          <description>&lt;p&gt;&lt;strong&gt;SourceHut was disrupted by a DDoS attack&lt;/strong&gt;.&lt;/p&gt;</description>
+        </item>
+        <item>
+          <title>[Resolved] Planned maintenance on all services</title>
+          <link>https://status.sr.ht/issues/2025-10-22-planned-maintenance/</link>
+          <pubDate>Wed, 22 Oct 2025 11:00:00 +0000</pubDate>
+          <guid>https://status.sr.ht/issues/2025-10-22-planned-maintenance/</guid>
+          <category>2025-10-22 12:25:00</category>
+          <description>&lt;p&gt;&lt;strong&gt;The maintenance is complete&lt;/strong&gt;.&lt;/p&gt;</description>
+        </item>
+      </channel>
+    </rss>
+    """#
+}