krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.1.3: 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}