gitbay/Admin/AdminInstance.swift
190 lines · 6605 bytes
12 symbols in this file
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}