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