summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-11 23:36:35 -0500
committerChristian Cleberg <[email protected]>2026-04-11 23:36:35 -0500
commit00cc231e9b419d27b412036d93457c2cbabe0b16 (patch)
tree6b042b802fcddcd1e03f1ae0f666965620225485 /Hutch
parent0fde7529ceaebf1cd9baa01826d95d587023d001 (diff)
downloadhutch-00cc231e9b419d27b412036d93457c2cbabe0b16.tar.gz
hutch-00cc231e9b419d27b412036d93457c2cbabe0b16.tar.bz2
hutch-00cc231e9b419d27b412036d93457c2cbabe0b16.zip
Add SourceHut system status screen and home disruption banner
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/App/AppState.swift14
-rw-r--r--Hutch/App/RootView.swift10
-rw-r--r--Hutch/Extensions/SRHTWebURL.swift3
-rw-r--r--Hutch/Models/SystemStatusModels.swift84
-rw-r--r--Hutch/Networking/SystemStatusRepository.swift57
-rw-r--r--Hutch/Networking/SystemStatusService.swift400
-rw-r--r--Hutch/Views/Home/HomeView.swift47
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift20
-rw-r--r--Hutch/Views/Lookup/LookupView.swift2
-rw-r--r--Hutch/Views/More/MoreView.swift8
-rw-r--r--Hutch/Views/SystemStatus/SystemStatusView.swift227
-rw-r--r--Hutch/Views/SystemStatus/SystemStatusViewModel.swift48
12 files changed, 915 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
+ }
+ }
+ }
+}