Admin: statistics, audit log, runners and the inbound mail check !174

merged merged by cmc on 2026-10-14 08:50 UTC · krz/gitbay-ios:admin-instance into main

6 files changed, +549 −0

Layout: unified · split

gitbay/Admin/AdminInstance.swift added +190
@@ -0,0 +1,190 @@
1import Foundation
2import Observation
3
4/// `admin stats`: counts, and disk use by repository.
5nonisolated struct AdminStats: Decodable, Sendable, Hashable {
6 nonisolated struct Counts: Decodable, Sendable, Hashable {
7 let users: Int
8 let orgs: Int
9 let repos: Int
10 let issues: Int
11 let openIssues: Int
12 let mrs: Int
13 let openMRs: Int
14 enum CodingKeys: String, CodingKey {
15 case users, orgs, repos, issues, mrs
16 case openIssues = "open_issues"
17 case openMRs = "open_mrs"
18 }
19 }
20 nonisolated struct RepoSize: Decodable, Sendable, Hashable, Identifiable {
21 let path: String
22 let bytes: Int64
23 var id: String { path }
24 }
25 let counts: Counts
26 let dbBytes: Int64
27 let repoBytes: Int64
28 let lfsBytes: Int64
29 let repos: [RepoSize]
30 enum CodingKeys: String, CodingKey {
31 case counts
32 case dbBytes = "db_bytes"
33 case repoBytes = "repo_bytes"
34 case lfsBytes = "lfs_bytes"
35 case repos
36 }
37 init(from decoder: any Decoder) throws {
38 let c = try decoder.container(keyedBy: CodingKeys.self)
39 counts = try c.decode(Counts.self, forKey: .counts)
40 dbBytes = try c.decodeIfPresent(Int64.self, forKey: .dbBytes) ?? 0
41 repoBytes = try c.decodeIfPresent(Int64.self, forKey: .repoBytes) ?? 0
42 lfsBytes = try c.decodeIfPresent(Int64.self, forKey: .lfsBytes) ?? 0
43 repos = try c.decodeIfPresent([RepoSize].self, forKey: .repos) ?? []
44 }
45}
46
47/// One `audit` row. `data` is the event's JSON as the server stored it.
48nonisolated struct AuditEntry: Decodable, Sendable, Hashable, Identifiable {
49 let id: Int64
50 let actor: String?
51 let action: String
52 let data: String?
53 let createdAt: Date
54 enum CodingKeys: String, CodingKey {
55 case id, actor, action, data
56 case createdAt = "created_at"
57 }
58}
59
60/// `admin runners`: the build queue and each key that polls for builds.
61nonisolated struct AdminRunners: Decodable, Sendable, Hashable {
62 nonisolated struct Queue: Decodable, Sendable, Hashable {
63 let pending: Int
64 let claimed24h: Int
65 let claimWaitAvgS: Int64
66 let claimWaitMaxS: Int64
67 let reaped24h: Int
68 enum CodingKeys: String, CodingKey {
69 case pending
70 case claimed24h = "claimed_24h"
71 case claimWaitAvgS = "claim_wait_avg_s"
72 case claimWaitMaxS = "claim_wait_max_s"
73 case reaped24h = "reaped_24h"
74 }
75 }
76 nonisolated struct Runner: Decodable, Sendable, Hashable, Identifiable {
77 let username: String
78 let fingerprint: String
79 let lastSeen: Date
80 /// What the key may claim: repositories, "none", or absent for any.
81 let scope: String?
82 let buildRepo: String?
83 let buildNumber: Int64?
84 let buildJob: String?
85 var id: String { fingerprint }
86 enum CodingKeys: String, CodingKey {
87 case username, fingerprint, scope
88 case lastSeen = "last_seen"
89 case buildRepo = "build_repo"
90 case buildNumber = "build_number"
91 case buildJob = "build_job"
92 }
93 }
94 let queue: Queue
95 let runners: [Runner]
96}
97
98/// `admin mail inbound check`: the reply mailbox, read-only.
99nonisolated struct InboundCheck: Decodable, Sendable, Hashable {
100 let enabled: Bool
101 let server: String?
102 let mailbox: String?
103 let messages: Int
104 let unseen: Int
105 let requireDKIM: Bool
106 let warning: String?
107 enum CodingKeys: String, CodingKey {
108 case enabled, server, mailbox, messages, unseen, warning
109 case requireDKIM = "require_dkim"
110 }
111}
112
113/// The instance pages under Admin: one read each, and the runner removal.
114@Observable
115@MainActor
116final class AdminInstanceViewModel {
117
118 private(set) var stats: LoadState<AdminStats> = .loading
119 private(set) var audit: LoadState<[AuditEntry]> = .loading
120 private(set) var runners: LoadState<AdminRunners> = .loading
121 private(set) var inbound: LoadState<InboundCheck> = .loading
122 private(set) var actionError: String?
123 private(set) var working = false
124
125 /// `audit` filters: an actor (or "-" for the system), an action prefix,
126 /// and how far back, all typed.
127 var auditActor = ""
128 var auditAction = ""
129 var auditSince = ""
130
131 private let client: GitbayClient
132
133 init(client: GitbayClient) {
134 self.client = client
135 }
136
137 func loadStats() async {
138 do { stats = .loaded(try await client.read(["admin", "stats"], as: AdminStats.self)) }
139 catch { stats = .from(error) }
140 }
141
142 var auditArgv: [String] {
143 var argv = ["audit"]
144 for (flag, text) in [("--actor", auditActor), ("--action", auditAction), ("--since", auditSince)] {
145 let text = text.trimmingCharacters(in: .whitespaces)
146 if !text.isEmpty { argv += [flag, text] }
147 }
148 return argv + ["--limit", "200"]
149 }
150
151 func loadAudit() async {
152 do {
153 let rows = try await client.readList(auditArgv, of: AuditEntry.self)
154 audit = rows.isEmpty ? .empty("Nothing in the audit log matches.") : .loaded(rows)
155 } catch let error as GitbayError {
156 // The filters are typed: a usage refusal is about them.
157 audit = error.isEmptyState ? .empty(error.userFacingMessage) : .failed(error.messageForTypedInput)
158 } catch {
159 audit = .from(error)
160 }
161 }
162
163 func loadRunners() async {
164 do { runners = .loaded(try await client.read(["admin", "runners"], as: AdminRunners.self)) }
165 catch { runners = .from(error) }
166 }
167
168 /// `admin runners remove <fingerprint>`: drops the key's heartbeat row,
169 /// say for a key that polled once by mistake. The key itself stays.
170 func removeRunner(_ runner: AdminRunners.Runner) async {
171 working = true
172 actionError = nil
173 defer { working = false }
174 do {
175 try await client.run(["admin", "runners", "remove", runner.fingerprint])
176 } catch let error as GitbayError {
177 actionError = error.userFacingMessage
178 } catch {
179 actionError = GitbayError.transport(error).userFacingMessage
180 }
181 // Either way: a refusal may mean the row went already.
182 await loadRunners()
183 }
184
185 func checkInbound() async {
186 inbound = .loading
187 do { inbound = .loaded(try await client.read(["admin", "mail", "inbound", "check"], as: InboundCheck.self)) }
188 catch { inbound = .from(error) }
189 }
190}
gitbay/ContentView.swift +8
@@ -349,6 +349,14 @@ private struct RouteDestinations: ViewModifier {
349349 AdminUsersView(client: client)
350350 case .adminUser(let name):
351351 AdminAccountView(client: client, username: name)
352 case .adminStats:
353 AdminStatsView(client: client)
354 case .adminAudit:
355 AdminAuditView(client: client)
356 case .adminRunners:
357 AdminRunnersView(client: client)
358 case .adminInbound:
359 AdminInboundView(client: client)
352360 }
353361 }
354362
gitbay/Views/Admin/AdminInstanceViews.swift added +234
@@ -0,0 +1,234 @@
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}
gitbay/Views/Admin/AdminView.swift +16
@@ -18,6 +18,22 @@ struct AdminView: View {
1818 Label("Accounts", systemImage: "person.2")
1919 }
2020 .accessibilityIdentifier("admin-accounts")
21 NavigationLink(value: RepoRoute.adminStats) {
22 Label("Statistics", systemImage: "chart.bar")
23 }
24 .accessibilityIdentifier("admin-stats")
25 NavigationLink(value: RepoRoute.adminAudit) {
26 Label("Audit log", systemImage: "list.bullet.rectangle")
27 }
28 .accessibilityIdentifier("admin-audit")
29 NavigationLink(value: RepoRoute.adminRunners) {
30 Label("Runners", systemImage: "hammer")
31 }
32 .accessibilityIdentifier("admin-runners")
33 NavigationLink(value: RepoRoute.adminInbound) {
34 Label("Inbound mail", systemImage: "tray.and.arrow.down")
35 }
36 .accessibilityIdentifier("admin-inbound")
2137 } footer: {
2238 if let commit = overview.commit {
2339 Text("Server build \(commit).")
gitbay/Views/Repos/RepoRoute.swift +4
@@ -33,4 +33,8 @@ nonisolated enum RepoRoute: Hashable {
3333 case admin
3434 case adminUsers
3535 case adminUser(String)
36 case adminStats
37 case adminAudit
38 case adminRunners
39 case adminInbound
3640}
gitbayTests/AdminInstanceTests.swift added +97
@@ -0,0 +1,97 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func argv(_ url: URL) -> [String] {
16 URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.compactMap(\.value) ?? []
17}
18
19@MainActor
20struct AdminInstanceTests {
21
22 @Test func statsDecode() async throws {
23 let (client, stub) = try makeClient()
24 stub.enqueue(.init(status: 200, json: """
25 {"protocol_version":1,"data":{"counts":{"users":3,"orgs":1,"repos":1,"issues":1,"open_issues":1,\
26 "mrs":4,"open_mrs":2},"db_bytes":876544,"repo_bytes":38159,"lfs_bytes":0,\
27 "repos":[{"path":"sim/app","bytes":38159}]}}
28 """))
29 let model = AdminInstanceViewModel(client: client)
30 await model.loadStats()
31 let stats = try #require(model.stats.value)
32 #expect(stats.counts.openMRs == 2)
33 #expect(stats.repos.first?.bytes == 38159)
34 #expect(argv(try #require(stub.seen.first).url) == ["admin", "stats"])
35 }
36
37 @Test func auditTakesTheTypedFilters() async throws {
38 let (client, stub) = try makeClient()
39 stub.enqueue(.init(status: 200, json: """
40 {"protocol_version":1,"data":[{"id":172,"actor":"sim","action":"cmd admin org limits",\
41 "data":"{}","created_at":"2026-10-14T08:09:23.929Z"}]}
42 """))
43 let model = AdminInstanceViewModel(client: client)
44 model.auditActor = " sim "
45 model.auditAction = "cmd admin"
46 model.auditSince = ""
47
48 await model.loadAudit()
49
50 #expect(argv(try #require(stub.seen.first).url) == ["audit", "--actor", "sim", "--action", "cmd admin", "--limit", "200"])
51 #expect(model.audit.value?.first?.action == "cmd admin org limits")
52 }
53
54 @Test func aBadSinceSaysWhy() async throws {
55 let (client, stub) = try makeClient()
56 stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"--since takes a duration like 24h or a date","exit_code":2}"#))
57 let model = AdminInstanceViewModel(client: client)
58 model.auditSince = "yesterday"
59
60 await model.loadAudit()
61
62 guard case .failed(let message) = model.audit else { Issue.record("not failed"); return }
63 #expect(message == "--since takes a duration like 24h or a date")
64 }
65
66 @Test func runnersDecodeAndRemoveByFingerprint() async throws {
67 let (client, stub) = try makeClient()
68 let json = """
69 {"protocol_version":1,"data":{"queue":{"pending":3,"claimed_24h":1,"claim_wait_avg_s":1,\
70 "claim_wait_max_s":1,"reaped_24h":0},"runners":[{"username":"ci","fingerprint":"SHA256:r",\
71 "last_seen":"2026-10-14T05:36:28.062Z","scope":"krz/gitbay","build_repo":"krz/gitbay",\
72 "build_number":9,"build_job":"test"}]}}
73 """
74 stub.enqueue(.init(status: 200, json: json, match: "argv=runners"))
75 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd"))
76 stub.enqueue(.init(status: 200, json: json, match: "argv=runners"))
77 let model = AdminInstanceViewModel(client: client)
78 await model.loadRunners()
79 let runner = try #require(model.runners.value?.runners.first)
80 #expect(runner.buildNumber == 9)
81 #expect(model.runners.value?.queue.pending == 3)
82
83 await model.removeRunner(runner)
84
85 let post = try #require(stub.seen.first { $0.method == "POST" })
86 let body = try #require(try JSONSerialization.jsonObject(with: post.body) as? [String: Any])
87 #expect(body["argv"] as? [String] == ["admin", "runners", "remove", "SHA256:r"])
88 }
89
90 @Test func inboundCheckDecodesOff() async throws {
91 let (client, stub) = try makeClient()
92 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"enabled":false,"messages":0,"unseen":0,"require_dkim":false}}"#))
93 let model = AdminInstanceViewModel(client: client)
94 await model.checkInbound()
95 #expect(model.inbound.value?.enabled == false)
96 }
97}