gitbay/Views/Admin/AdminInstanceViews.swift
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}