From 00cc231e9b419d27b412036d93457c2cbabe0b16 Mon Sep 17 00:00:00 2001
From: Christian Cleberg ([\s\S]*?)\s*([\s\S]*?)\s*
"#),
+ let titleAttribute = firstMatch(in: content, pattern: #""#),
+ 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: #"
"#
+ ).reduce(into: [:]) { partialResult, captures in
+ guard let content = captures.first,
+ let titleLinkCaptures = firstMatches(
+ in: content,
+ pattern: #"([\s\S]*?)"#
+ ).first,
+ let href = titleLinkCaptures.first else {
+ return
+ }
+
+ let paragraphs = firstMatches(in: content, pattern: #"
", 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 = #""" + + +
++ SourceHut disrupted due to DDoS attack → +
+ +SourceHut was disrupted by a DDoS attack.
+