Commit cde7fbf640
Verified · cmc
Layout: unified · split
gitbay/Admin/AdminInstance.swift added +189
| @@ -0,0 +1,189 @@ | ||
| 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 | await loadRunners() | |
| 177 | } catch let error as GitbayError { | |
| 178 | actionError = error.userFacingMessage | |
| 179 | } catch { | |
| 180 | actionError = GitbayError.transport(error).userFacingMessage | |
| 181 | } | |
| 182 | } | |
| 183 | ||
| 184 | func checkInbound() async { | |
| 185 | inbound = .loading | |
| 186 | do { inbound = .loaded(try await client.read(["admin", "mail", "inbound", "check"], as: InboundCheck.self)) } | |
| 187 | catch { inbound = .from(error) } | |
| 188 | } | |
| 189 | } | |
gitbay/ContentView.swift +8
| @@ -349,6 +349,14 @@ private struct RouteDestinations: ViewModifier { | ||
| 349 | 349 | AdminUsersView(client: client) |
| 350 | 350 | case .adminUser(let name): |
| 351 | 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 +223
| @@ -0,0 +1,223 @@ | ||
| 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 { LoadStateOverlay(state: model.inbound) } | |
| 218 | .navigationTitle("Inbound Mail") | |
| 219 | .navigationBarTitleDisplayMode(.inline) | |
| 220 | .task { await model.checkInbound() } | |
| 221 | .refreshable { await model.checkInbound() } | |
| 222 | } | |
| 223 | } | |
gitbay/Views/Admin/AdminView.swift +16
| @@ -18,6 +18,22 @@ struct AdminView: View { | ||
| 18 | 18 | Label("Accounts", systemImage: "person.2") |
| 19 | 19 | } |
| 20 | 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 | 37 | } footer: { |
| 22 | 38 | if let commit = overview.commit { |
| 23 | 39 | Text("Server build \(commit).") |
gitbay/Views/Repos/RepoRoute.swift +4
| @@ -33,4 +33,8 @@ nonisolated enum RepoRoute: Hashable { | ||
| 33 | 33 | case admin |
| 34 | 34 | case adminUsers |
| 35 | 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 | } | |