gitbay/Admin/AdminInstance.swift

36835f99e6d463f3e18c5c3fc3562bb92513f52b
gitbay-ios/gitbay/Admin/AdminInstance.swift history · blame · raw

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}