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}