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