Admin: statistics, audit log, runners and the inbound mail check !174
6 files changed, +549 −0
Layout: unified · split
gitbay/Admin/AdminInstance.swift added +190
| @@ -0,0 +1,190 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | |||
| 4 | /// `admin stats`: counts, and disk use by repository. | ||
| 5 | nonisolated 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. | ||
| 48 | nonisolated 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. | ||
| 61 | nonisolated 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. | ||
| 99 | nonisolated 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 | ||
| 116 | final 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 { | |||
| 349 | AdminUsersView(client: client) | 349 | AdminUsersView(client: client) |
| 350 | case .adminUser(let name): | 350 | case .adminUser(let name): |
| 351 | AdminAccountView(client: client, username: name) | 351 | 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) | ||
| 352 | } | 360 | } |
| 353 | } | 361 | } |
| 354 | 362 | ||
gitbay/Views/Admin/AdminInstanceViews.swift added +234
| @@ -0,0 +1,234 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | private 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. | ||
| 6 | struct 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. | ||
| 49 | struct 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. | ||
| 112 | struct 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. | ||
| 187 | struct 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 { | |||
| 18 | Label("Accounts", systemImage: "person.2") | 18 | Label("Accounts", systemImage: "person.2") |
| 19 | } | 19 | } |
| 20 | .accessibilityIdentifier("admin-accounts") | 20 | .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") | ||
| 21 | } footer: { | 37 | } footer: { |
| 22 | if let commit = overview.commit { | 38 | if let commit = overview.commit { |
| 23 | Text("Server build \(commit).") | 39 | Text("Server build \(commit).") |
gitbay/Views/Repos/RepoRoute.swift +4
| @@ -33,4 +33,8 @@ nonisolated enum RepoRoute: Hashable { | |||
| 33 | case admin | 33 | case admin |
| 34 | case adminUsers | 34 | case adminUsers |
| 35 | case adminUser(String) | 35 | case adminUser(String) |
| 36 | case adminStats | ||
| 37 | case adminAudit | ||
| 38 | case adminRunners | ||
| 39 | case adminInbound | ||
| 36 | } | 40 | } |
gitbayTests/AdminInstanceTests.swift added +97
| @@ -0,0 +1,97 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | private 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 | |||
| 15 | private func argv(_ url: URL) -> [String] { | ||
| 16 | URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.compactMap(\.value) ?? [] | ||
| 17 | } | ||
| 18 | |||
| 19 | @MainActor | ||
| 20 | struct 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 | } | ||