diff options
| author | Christian Cleberg <[email protected]> | 2026-04-11 23:36:35 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-11 23:36:35 -0500 |
| commit | 00cc231e9b419d27b412036d93457c2cbabe0b16 (patch) | |
| tree | 6b042b802fcddcd1e03f1ae0f666965620225485 | |
| parent | 0fde7529ceaebf1cd9baa01826d95d587023d001 (diff) | |
| download | hutch-00cc231e9b419d27b412036d93457c2cbabe0b16.tar.gz hutch-00cc231e9b419d27b412036d93457c2cbabe0b16.tar.bz2 hutch-00cc231e9b419d27b412036d93457c2cbabe0b16.zip | |
Add SourceHut system status screen and home disruption banner
| -rw-r--r-- | Hutch/App/AppState.swift | 14 | ||||
| -rw-r--r-- | Hutch/App/RootView.swift | 10 | ||||
| -rw-r--r-- | Hutch/Extensions/SRHTWebURL.swift | 3 | ||||
| -rw-r--r-- | Hutch/Models/SystemStatusModels.swift | 84 | ||||
| -rw-r--r-- | Hutch/Networking/SystemStatusRepository.swift | 57 | ||||
| -rw-r--r-- | Hutch/Networking/SystemStatusService.swift | 400 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeView.swift | 47 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 20 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/LookupView.swift | 2 | ||||
| -rw-r--r-- | Hutch/Views/More/MoreView.swift | 8 | ||||
| -rw-r--r-- | Hutch/Views/SystemStatus/SystemStatusView.swift | 227 | ||||
| -rw-r--r-- | Hutch/Views/SystemStatus/SystemStatusViewModel.swift | 48 | ||||
| -rw-r--r-- | HutchTests/SRHTWebURLTests.swift | 12 | ||||
| -rw-r--r-- | 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) @@ -76,6 +81,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 { Section { @@ -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><p><strong>SourceHut was disrupted by a DDoS attack</strong>.</p></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><p><strong>The maintenance is complete</strong>.</p></description> + </item> + </channel> + </rss> + """# +} |
