krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.0.0: Hutch/Views/SystemStatus/SystemStatusView.swift · raw
1import SwiftUI
2
3struct SystemStatusView: View {
4 @Environment(AppState.self) private var appState
5 @State private var viewModel: SystemStatusViewModel?
6
7 var body: some View {
8 Group {
9 if let viewModel {
10 content(viewModel)
11 } else {
12 SRHTLoadingStateView(message: "Loading system status…")
13 }
14 }
15 .navigationTitle("System Status")
16 .navigationBarTitleDisplayMode(.inline)
17 .task {
18 let vm: SystemStatusViewModel
19 if let viewModel {
20 vm = viewModel
21 } else {
22 let newViewModel = SystemStatusViewModel(repository: appState.systemStatusRepository)
23 viewModel = newViewModel
24 vm = newViewModel
25 }
26
27 await vm.load()
28 }
29 }
30
31 @ViewBuilder
32 private func content(_ viewModel: SystemStatusViewModel) -> some View {
33 List {
34 if viewModel.isShowingStaleData, let staleDataMessage = viewModel.staleDataMessage {
35 Section {
36 Label(staleDataMessage, systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
37 .font(.subheadline)
38 .foregroundStyle(.secondary)
39 }
40 }
41
42 if let snapshot = viewModel.snapshot {
43 summarySection(snapshot)
44 servicesSection(snapshot)
45 activeIncidentsSection(snapshot.activeIncidents)
46 }
47
48 recentIncidentsSection(viewModel.recentIncidents)
49 }
50 .listStyle(.insetGrouped)
51 .refreshable {
52 await viewModel.load(forceRefresh: true)
53 }
54 .overlay {
55 if viewModel.isLoading && !viewModel.hasContent {
56 SRHTLoadingStateView(message: "Loading system status…")
57 } else if !viewModel.isLoading && !viewModel.hasContent, let errorMessage = viewModel.errorMessage {
58 SRHTErrorStateView(
59 title: "Couldn’t Load System Status",
60 message: errorMessage,
61 retryAction: { await viewModel.load(forceRefresh: true) }
62 )
63 } else if !viewModel.isLoading && !viewModel.hasContent {
64 ContentUnavailableView(
65 "No Status Data",
66 systemImage: "server.rack",
67 description: Text("System status information is not available right now.")
68 )
69 }
70 }
71 .connectivityOverlay(hasContent: viewModel.hasContent) {
72 await viewModel.load(forceRefresh: true)
73 }
74 .srhtErrorBanner(error: Binding(
75 get: { viewModel.errorMessage },
76 set: { viewModel.errorMessage = $0 }
77 ))
78 }
79
80 @ViewBuilder
81 private func summarySection(_ snapshot: SystemStatusSnapshot) -> some View {
82 Section {
83 VStack(alignment: .leading, spacing: 12) {
84 HStack(spacing: 10) {
85 Image(systemName: snapshot.hasDisruption ? "exclamationmark.triangle.fill" : "checkmark.circle.fill")
86 .foregroundStyle(snapshot.hasDisruption ? .orange : .green)
87 VStack(alignment: .leading, spacing: 4) {
88 Text(snapshot.overallStatusText)
89 .font(.headline)
90 Text("Updated \(snapshot.lastUpdated.relativeDescription)")
91 .font(.subheadline)
92 .foregroundStyle(.secondary)
93 }
94 }
95
96 Link(destination: SRHTWebURL.status) {
97 Label("Open status.sr.ht", systemImage: "safari")
98 }
99 .font(.subheadline.weight(.medium))
100 }
101 .padding(.vertical, 4)
102 }
103 }
104
105 @ViewBuilder
106 private func servicesSection(_ snapshot: SystemStatusSnapshot) -> some View {
107 Section("Services") {
108 ForEach(snapshot.services) { service in
109 HStack(spacing: 12) {
110 StatusLevelBadge(level: service.status)
111 VStack(alignment: .leading, spacing: 4) {
112 Text(service.name)
113 .font(.subheadline.weight(.medium))
114 Text(service.status.displayName)
115 .font(.caption)
116 .foregroundStyle(.secondary)
117 }
118 Spacer()
119 }
120 .padding(.vertical, 2)
121 }
122 }
123 }
124
125 @ViewBuilder
126 private func activeIncidentsSection(_ incidents: [StatusIncident]) -> some View {
127 if !incidents.isEmpty {
128 Section("Active Incidents") {
129 ForEach(incidents) { incident in
130 incidentRow(incident)
131 }
132 }
133 }
134 }
135
136 @ViewBuilder
137 private func recentIncidentsSection(_ incidents: [StatusIncident]) -> some View {
138 Section("Recent Incidents") {
139 if incidents.isEmpty {
140 ContentUnavailableView(
141 "No Recent Incidents",
142 systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
143 description: Text("The status feed didn’t return any recent incidents.")
144 )
145 } else {
146 ForEach(incidents) { incident in
147 incidentRow(incident)
148 }
149 }
150 }
151 }
152
153 @ViewBuilder
154 private func incidentRow(_ incident: StatusIncident) -> some View {
155 if let url = incident.url {
156 Link(destination: url) {
157 StatusIncidentRow(incident: incident)
158 }
159 } else {
160 StatusIncidentRow(incident: incident)
161 }
162 }
163}
164
165private struct StatusIncidentRow: View {
166 let incident: StatusIncident
167
168 var body: some View {
169 VStack(alignment: .leading, spacing: 6) {
170 HStack(alignment: .top, spacing: 8) {
171 Text(incident.title)
172 .font(.subheadline.weight(.medium))
173 .foregroundStyle(.primary)
174 Spacer(minLength: 8)
175 if incident.url != nil {
176 Image(systemName: "arrow.up.right.square")
177 .font(.caption)
178 .foregroundStyle(.secondary)
179 }
180 }
181
182 Text(timestampText)
183 .font(.caption)
184 .foregroundStyle(.secondary)
185
186 if let summary = incident.summary, !summary.isEmpty {
187 Text(summary)
188 .font(.caption)
189 .foregroundStyle(.secondary)
190 .lineLimit(3)
191 }
192 }
193 .padding(.vertical, 2)
194 }
195
196 private var timestampText: String {
197 if let updatedAt = incident.updatedAt {
198 return "Published \(incident.publishedAt.relativeDescription) • Updated \(updatedAt.relativeDescription)"
199 }
200 return "Published \(incident.publishedAt.relativeDescription)"
201 }
202}
203
204private struct StatusLevelBadge: View {
205 let level: StatusLevel
206
207 var body: some View {
208 HStack(spacing: 6) {
209 Circle()
210 .fill(color)
211 .frame(width: 8, height: 8)
212 Text(level.displayName)
213 .font(.caption.weight(.medium))
214 .foregroundStyle(.primary)
215 }
216 .padding(.horizontal, 10)
217 .padding(.vertical, 6)
218 .background(color.opacity(0.14), in: Capsule())
219 }
220
221 private var color: Color {
222 switch level {
223 case .operational:
224 .green
225 case .degraded:
226 .orange
227 case .majorOutage:
228 .red
229 case .maintenance:
230 .blue
231 case .unknown:
232 .gray
233 }
234 }
235}