krz/hutch

an ios client for sourcehut

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

v2.16.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}