gitbay/Views/Admin/AdminInstanceViews.swift

36835f99e6d463f3e18c5c3fc3562bb92513f52b
gitbay-ios/gitbay/Views/Admin/AdminInstanceViews.swift history · blame · raw

234 lines · 10172 bytes

6 symbols in this file
  1import SwiftUI
  2
  3private func bytes(_ n: Int64) -> String { n.formatted(.byteCount(style: .file, spellsOutZero: false)) }
  4
  5/// `admin stats`: how big the instance is, and where its disk goes.
  6struct AdminStatsView: View {
  7
  8    @State private var model: AdminInstanceViewModel
  9
 10    init(client: GitbayClient) {
 11        _model = State(initialValue: AdminInstanceViewModel(client: client))
 12    }
 13
 14    var body: some View {
 15        List {
 16            if let s = model.stats.value {
 17                Section("Counts") {
 18                    LabeledContent("Accounts", value: String(s.counts.users))
 19                    LabeledContent("Organizations", value: String(s.counts.orgs))
 20                    LabeledContent("Repositories", value: String(s.counts.repos))
 21                    LabeledContent("Issues", value: "\(s.counts.openIssues) open of \(s.counts.issues)")
 22                    LabeledContent("Merge requests", value: "\(s.counts.openMRs) open of \(s.counts.mrs)")
 23                }
 24                Section("Disk") {
 25                    LabeledContent("Database", value: bytes(s.dbBytes))
 26                    LabeledContent("Repositories", value: bytes(s.repoBytes))
 27                    LabeledContent("LFS", value: bytes(s.lfsBytes))
 28                }
 29                if !s.repos.isEmpty {
 30                    Section("By repository") {
 31                        ForEach(s.repos) { repo in
 32                            LabeledContent(repo.path, value: bytes(repo.bytes))
 33                        }
 34                    }
 35                }
 36            }
 37        }
 38        .font(.gbSans(.subheadline))
 39        .overlay { LoadStateOverlay(state: model.stats) }
 40        .navigationTitle("Statistics")
 41        .navigationBarTitleDisplayMode(.inline)
 42        .task { await model.loadStats() }
 43        .refreshable { await model.loadStats() }
 44    }
 45}
 46
 47/// `audit`: the instance's record of administrative and command events,
 48/// newest first, narrowed by actor, action and age.
 49struct AdminAuditView: View {
 50
 51    @State private var model: AdminInstanceViewModel
 52
 53    init(client: GitbayClient) {
 54        _model = State(initialValue: AdminInstanceViewModel(client: client))
 55    }
 56
 57    var body: some View {
 58        List {
 59            Section {
 60                filter("Actor", "username, or - for the system", Bindable(model).auditActor, id: "audit-actor")
 61                filter("Action", "starts with, e.g. cmd admin", Bindable(model).auditAction, id: "audit-action")
 62                filter("Since", "24h, 7d or 2026-10-01", Bindable(model).auditSince, id: "audit-since")
 63            } footer: {
 64                Text("The latest 200 that match.")
 65            }
 66            ForEach(model.audit.value ?? []) { entry in
 67                VStack(alignment: .leading, spacing: 2) {
 68                    Text(entry.action)
 69                        .font(.gbMono(.caption).weight(.medium))
 70                    HStack(spacing: 4) {
 71                        Text(entry.actor ?? "system")
 72                        Text("·")
 73                        Text(entry.createdAt.formatted(date: .abbreviated, time: .shortened))
 74                    }
 75                    .font(.gbSans(.caption2))
 76                    .foregroundStyle(.secondary)
 77                    if let data = entry.data, data != "{}" {
 78                        Text(data)
 79                            .font(.gbMono(.caption2))
 80                            .foregroundStyle(.secondary)
 81                            .lineLimit(3)
 82                            .textSelection(.enabled)
 83                    }
 84                }
 85                .accessibilityElement(children: .combine)
 86            }
 87        }
 88        .overlay {
 89            if model.audit.value == nil { LoadStateOverlay(state: model.audit) }
 90        }
 91        .navigationTitle("Audit Log")
 92        .navigationBarTitleDisplayMode(.inline)
 93        .task { await model.loadAudit() }
 94        .refreshable { await model.loadAudit() }
 95    }
 96
 97    private func filter(_ title: String, _ prompt: String, _ text: Binding<String>, id: String) -> some View {
 98        LabeledContent(title) {
 99            TextField(prompt, text: text)
100                .multilineTextAlignment(.trailing)
101                .autocorrectionDisabled()
102                .textInputAutocapitalization(.never)
103                .onSubmit { Task { await model.loadAudit() } }
104                .accessibilityIdentifier(id)
105        }
106        .font(.gbSans(.subheadline))
107    }
108}
109
110/// `admin runners`: the build queue, and each key polling for builds with
111/// what it may claim and the build it holds.
112struct AdminRunnersView: View {
113
114    @State private var model: AdminInstanceViewModel
115    @State private var removing: AdminRunners.Runner?
116
117    init(client: GitbayClient) {
118        _model = State(initialValue: AdminInstanceViewModel(client: client))
119    }
120
121    var body: some View {
122        List {
123            if let error = model.actionError {
124                Section { GBNotice(error, .gbWarn) }
125            }
126            if let r = model.runners.value {
127                Section("Queue") {
128                    LabeledContent("Waiting", value: String(r.queue.pending))
129                    LabeledContent("Claimed in 24h", value: String(r.queue.claimed24h))
130                    LabeledContent("Wait", value: "\(r.queue.claimWaitAvgS)s average, \(r.queue.claimWaitMaxS)s longest")
131                    LabeledContent("Reaped in 24h", value: String(r.queue.reaped24h))
132                }
133                .font(.gbSans(.subheadline))
134                Section {
135                    if r.runners.isEmpty {
136                        Text("No key has polled for builds.")
137                            .font(.gbSans(.subheadline))
138                            .foregroundStyle(.secondary)
139                    }
140                    ForEach(r.runners) { runner in
141                        VStack(alignment: .leading, spacing: 2) {
142                            Text(runner.username).font(.gbSans(.subheadline).weight(.medium))
143                            Text(runner.fingerprint)
144                                .font(.gbMono(.caption))
145                                .lineLimit(1)
146                                .truncationMode(.middle)
147                            Text("polled \(runner.lastSeen.formatted(.relative(presentation: .named))) · claims \(runner.scope ?? "any repository")")
148                                .font(.gbSans(.caption2))
149                                .foregroundStyle(.secondary)
150                            if let repo = runner.buildRepo, let n = runner.buildNumber {
151                                Text("holds \(repo) #\(n)\(runner.buildJob.map { " \($0)" } ?? "")")
152                                    .font(.gbSans(.caption2))
153                                    .foregroundStyle(Color.gbWarn)
154                            }
155                        }
156                        .accessibilityElement(children: .combine)
157                        .swipeActions {
158                            Button("Remove", role: .destructive) { removing = runner }
159                        }
160                    }
161                } header: {
162                    Text("Runners")
163                } footer: {
164                    Text("Removing drops a key's heartbeat row, say one that polled once by mistake. The key stays.")
165                }
166            }
167        }
168        .overlay { LoadStateOverlay(state: model.runners) }
169        .navigationTitle("Runners")
170        .navigationBarTitleDisplayMode(.inline)
171        .confirmationDialog(
172            removing.map { "Remove \($0.username)'s runner row?" } ?? "",
173            isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
174            titleVisibility: .visible,
175            presenting: removing
176        ) { runner in
177            Button("Remove", role: .destructive) { Task { await model.removeRunner(runner) } }
178            Button("Cancel", role: .cancel) {}
179        }
180        .task { await model.loadRunners() }
181        .refreshable { await model.loadRunners() }
182    }
183}
184
185/// `admin mail inbound check`: whether the reply mailbox answers, and
186/// what waits in it.
187struct AdminInboundView: View {
188
189    @State private var model: AdminInstanceViewModel
190
191    init(client: GitbayClient) {
192        _model = State(initialValue: AdminInstanceViewModel(client: client))
193    }
194
195    var body: some View {
196        List {
197            if let check = model.inbound.value {
198                Section {
199                    LabeledContent("Reading replies", value: check.enabled ? "on" : "off")
200                        .accessibilityIdentifier("inbound-enabled")
201                    if check.enabled {
202                        if let server = check.server { LabeledContent("Server", value: server) }
203                        if let mailbox = check.mailbox { LabeledContent("Mailbox", value: mailbox) }
204                        LabeledContent("Messages", value: "\(check.messages), \(check.unseen) unseen")
205                        LabeledContent("DKIM required", value: check.requireDKIM ? "yes" : "no")
206                    }
207                } footer: {
208                    if let warning = check.warning {
209                        Text(warning).foregroundStyle(Color.gbWarn)
210                    } else if !check.enabled {
211                        Text("[mail.inbound] is off, so replies to notification mail are not read.")
212                    }
213                }
214                .font(.gbSans(.subheadline))
215            }
216        }
217        .overlay {
218            if case .loading = model.inbound {
219                // A live connection to the mailbox; one that does not answer
220                // takes its timeout, twice with the retry.
221                ProgressView("Connecting to the mailbox. This can take a minute when it does not answer.")
222                    .font(.gbSans(.subheadline))
223                    .multilineTextAlignment(.center)
224                    .padding()
225            } else {
226                LoadStateOverlay(state: model.inbound)
227            }
228        }
229        .navigationTitle("Inbound Mail")
230        .navigationBarTitleDisplayMode(.inline)
231        .task { await model.checkInbound() }
232        .refreshable { await model.checkInbound() }
233    }
234}