import Foundation import Observation /// `admin stats`: counts, and disk use by repository. nonisolated struct AdminStats: Decodable, Sendable, Hashable { nonisolated struct Counts: Decodable, Sendable, Hashable { let users: Int let orgs: Int let repos: Int let issues: Int let openIssues: Int let mrs: Int let openMRs: Int enum CodingKeys: String, CodingKey { case users, orgs, repos, issues, mrs case openIssues = "open_issues" case openMRs = "open_mrs" } } nonisolated struct RepoSize: Decodable, Sendable, Hashable, Identifiable { let path: String let bytes: Int64 var id: String { path } } let counts: Counts let dbBytes: Int64 let repoBytes: Int64 let lfsBytes: Int64 let repos: [RepoSize] enum CodingKeys: String, CodingKey { case counts case dbBytes = "db_bytes" case repoBytes = "repo_bytes" case lfsBytes = "lfs_bytes" case repos } init(from decoder: any Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) counts = try c.decode(Counts.self, forKey: .counts) dbBytes = try c.decodeIfPresent(Int64.self, forKey: .dbBytes) ?? 0 repoBytes = try c.decodeIfPresent(Int64.self, forKey: .repoBytes) ?? 0 lfsBytes = try c.decodeIfPresent(Int64.self, forKey: .lfsBytes) ?? 0 repos = try c.decodeIfPresent([RepoSize].self, forKey: .repos) ?? [] } } /// One `audit` row. `data` is the event's JSON as the server stored it. nonisolated struct AuditEntry: Decodable, Sendable, Hashable, Identifiable { let id: Int64 let actor: String? let action: String let data: String? let createdAt: Date enum CodingKeys: String, CodingKey { case id, actor, action, data case createdAt = "created_at" } } /// `admin runners`: the build queue and each key that polls for builds. nonisolated struct AdminRunners: Decodable, Sendable, Hashable { nonisolated struct Queue: Decodable, Sendable, Hashable { let pending: Int let claimed24h: Int let claimWaitAvgS: Int64 let claimWaitMaxS: Int64 let reaped24h: Int enum CodingKeys: String, CodingKey { case pending case claimed24h = "claimed_24h" case claimWaitAvgS = "claim_wait_avg_s" case claimWaitMaxS = "claim_wait_max_s" case reaped24h = "reaped_24h" } } nonisolated struct Runner: Decodable, Sendable, Hashable, Identifiable { let username: String let fingerprint: String let lastSeen: Date /// What the key may claim: repositories, "none", or absent for any. let scope: String? let buildRepo: String? let buildNumber: Int64? let buildJob: String? var id: String { fingerprint } enum CodingKeys: String, CodingKey { case username, fingerprint, scope case lastSeen = "last_seen" case buildRepo = "build_repo" case buildNumber = "build_number" case buildJob = "build_job" } } let queue: Queue let runners: [Runner] } /// `admin mail inbound check`: the reply mailbox, read-only. nonisolated struct InboundCheck: Decodable, Sendable, Hashable { let enabled: Bool let server: String? let mailbox: String? let messages: Int let unseen: Int let requireDKIM: Bool let warning: String? enum CodingKeys: String, CodingKey { case enabled, server, mailbox, messages, unseen, warning case requireDKIM = "require_dkim" } } /// The instance pages under Admin: one read each, and the runner removal. @Observable @MainActor final class AdminInstanceViewModel { private(set) var stats: LoadState = .loading private(set) var audit: LoadState<[AuditEntry]> = .loading private(set) var runners: LoadState = .loading private(set) var inbound: LoadState = .loading private(set) var actionError: String? private(set) var working = false /// `audit` filters: an actor (or "-" for the system), an action prefix, /// and how far back, all typed. var auditActor = "" var auditAction = "" var auditSince = "" private let client: GitbayClient init(client: GitbayClient) { self.client = client } func loadStats() async { do { stats = .loaded(try await client.read(["admin", "stats"], as: AdminStats.self)) } catch { stats = .from(error) } } var auditArgv: [String] { var argv = ["audit"] for (flag, text) in [("--actor", auditActor), ("--action", auditAction), ("--since", auditSince)] { let text = text.trimmingCharacters(in: .whitespaces) if !text.isEmpty { argv += [flag, text] } } return argv + ["--limit", "200"] } func loadAudit() async { do { let rows = try await client.readList(auditArgv, of: AuditEntry.self) audit = rows.isEmpty ? .empty("Nothing in the audit log matches.") : .loaded(rows) } catch let error as GitbayError { // The filters are typed: a usage refusal is about them. audit = error.isEmptyState ? .empty(error.userFacingMessage) : .failed(error.messageForTypedInput) } catch { audit = .from(error) } } func loadRunners() async { do { runners = .loaded(try await client.read(["admin", "runners"], as: AdminRunners.self)) } catch { runners = .from(error) } } /// `admin runners remove `: drops the key's heartbeat row, /// say for a key that polled once by mistake. The key itself stays. func removeRunner(_ runner: AdminRunners.Runner) async { working = true actionError = nil defer { working = false } do { try await client.run(["admin", "runners", "remove", runner.fingerprint]) } catch let error as GitbayError { actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } // Either way: a refusal may mean the row went already. await loadRunners() } func checkInbound() async { inbound = .loading do { inbound = .loaded(try await client.read(["admin", "mail", "inbound", "check"], as: InboundCheck.self)) } catch { inbound = .from(error) } } }