gitbay/Admin/AdminModels.swift

v1.6.2
gitbay-ios/gitbay/Admin/AdminModels.swift history · blame · raw

239 lines · 10141 bytes

  1import Foundation
  2
  3/// The admin-only half of `dashboard`: the server build and every
  4/// background worker's backlog. The web's `/admin` page is this read.
  5/// Both are absent for anyone who is not an instance admin.
  6nonisolated struct AdminDashboard: Decodable, Sendable, Hashable {
  7    let queues: AdminQueues?
  8    let server: Server?
  9
 10    nonisolated struct Server: Decodable, Sendable, Hashable {
 11        let commit: String?
 12    }
 13}
 14
 15/// `dashboard`'s `queues` block.
 16nonisolated struct AdminQueues: Decodable, Sendable, Hashable {
 17    let webhooks: Queue
 18    let mail: Queue
 19    let push: Queue
 20    let mirrors: Queue
 21    let builds: Queue
 22    let deps: Queue
 23
 24    /// Each queue reports a different subset of these counts; the
 25    /// absent ones stay nil.
 26    nonisolated struct Queue: Decodable, Sendable, Hashable {
 27        let pending: Int?
 28        let retrying: Int?
 29        let failed: Int?
 30        let running: Int?
 31        let dirty: Int?
 32        let errors: Int?
 33        let oldestPending: String?
 34        let items: [Row]?
 35
 36        enum CodingKeys: String, CodingKey {
 37            case pending, retrying, failed, running, dirty, errors, items
 38            case oldestPending = "oldest_pending"
 39        }
 40
 41        var rows: [Row] { items ?? [] }
 42    }
 43
 44    /// A row of any queue; each kind fills its own fields. Timestamps
 45    /// stay strings: a pending build's `started_at` is `""`, which no
 46    /// date decoder accepts.
 47    nonisolated struct Row: Decodable, Sendable, Hashable {
 48        let repo: String?
 49        let url: String?
 50        let recipient: String?
 51        let subject: String?
 52        let title: String?
 53        let deviceID: Int64?
 54        let direction: String?
 55        let number: Int64?
 56        let job: String?
 57        let status: String?
 58        let attempts: Int64?
 59        let lastStatus: Int64?
 60        let lastError: String?
 61        let failedAt: String?
 62        let lastSync: String?
 63        let lastCheck: String?
 64        let startedAt: String?
 65        let createdAt: String?
 66
 67        enum CodingKeys: String, CodingKey {
 68            case repo, url, recipient, subject, title, direction, number, job, status, attempts
 69            case deviceID = "device_id"
 70            case lastStatus = "last_status"
 71            case lastError = "last_error"
 72            case failedAt = "failed_at"
 73            case lastSync = "last_sync"
 74            case lastCheck = "last_check"
 75            case startedAt = "started_at"
 76            case createdAt = "created_at"
 77        }
 78    }
 79
 80    /// The web's six sections, in its order and its words
 81    /// (`internal/web/templates/admin.html`). `when` renders a timestamp;
 82    /// tests pass a fixed one.
 83    func sections(when: (String) -> String = AdminQueues.relative) -> [QueueSection] {
 84        func oldest(_ queue: Queue) -> String {
 85            guard let at = queue.oldestPending, !at.isEmpty else { return "" }
 86            return " · oldest pending \(when(at))"
 87        }
 88        func n(_ value: Int?) -> Int { value ?? 0 }
 89        func attempts(_ row: Row) -> String? { row.attempts.map { "\($0) attempts" } }
 90        func state(_ row: Row, failed: String) -> String {
 91            guard let at = row.failedAt, !at.isEmpty else { return "retrying" }
 92            return "\(failed) \(when(at))"
 93        }
 94        func lines(_ queue: Queue, _ make: (Row) -> (String, [String?])) -> [QueueLine] {
 95            queue.rows.enumerated().map { index, row in
 96                let (title, parts) = make(row)
 97                let detail = parts.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · ")
 98                return QueueLine(id: index, title: title, detail: detail)
 99            }
100        }
101
102        return [
103            QueueSection(
104                id: "webhooks", heading: "Webhook deliveries", count: n(webhooks.pending),
105                summary: "\(n(webhooks.pending)) pending · \(n(webhooks.retrying)) retrying · \(n(webhooks.failed)) dead-lettered" + oldest(webhooks),
106                rows: lines(webhooks) { row in
107                    let error = [row.lastStatus.map(String.init), row.lastError]
108                        .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " ")
109                    return (row.repo ?? "", [row.url, attempts(row), state(row, failed: "dead-lettered"), error])
110                },
111                empty: "Nothing retrying or dead-lettered"),
112            QueueSection(
113                id: "mail", heading: "Mail", count: n(mail.pending),
114                summary: "\(n(mail.pending)) pending · \(n(mail.retrying)) retrying · \(n(mail.failed)) failed" + oldest(mail),
115                rows: lines(mail) { row in
116                    (row.recipient ?? "", [row.subject, attempts(row), state(row, failed: "failed"), row.lastError])
117                },
118                empty: "Nothing retrying or failed"),
119            // A push row names the device id, never the token.
120            QueueSection(
121                id: "push", heading: "Push", count: n(push.pending),
122                summary: "\(n(push.pending)) pending · \(n(push.retrying)) retrying · \(n(push.failed)) failed" + oldest(push),
123                rows: lines(push) { row in
124                    ("device \(row.deviceID.map(String.init) ?? "?")",
125                     [row.title, attempts(row), state(row, failed: "failed"), row.lastError])
126                },
127                empty: "Nothing retrying or failed"),
128            QueueSection(
129                id: "mirrors", heading: "Mirrors", count: n(mirrors.errors),
130                summary: "\(n(mirrors.dirty)) waiting for a sync · \(n(mirrors.errors)) with a failed last sync",
131                rows: lines(mirrors) { row in
132                    let sync = row.lastSync.flatMap { $0.isEmpty ? nil : "last sync \(when($0))" } ?? "never synced"
133                    return (row.repo ?? "", [row.direction, row.url, sync, row.lastError])
134                },
135                empty: "Every mirror's last sync succeeded"),
136            QueueSection(
137                id: "builds", heading: "Builds", count: n(builds.pending),
138                summary: "\(n(builds.pending)) pending · \(n(builds.running)) running" + oldest(builds),
139                rows: lines(builds) { row in
140                    let started = row.startedAt.flatMap { $0.isEmpty ? nil : $0 }
141                    let since = (started ?? row.createdAt).map { "since \(when($0))" }
142                    return ("\(row.repo ?? "") #\(row.number.map(String.init) ?? "?")", [row.job, row.status, since])
143                },
144                empty: "No build running or pending"),
145            QueueSection(
146                id: "deps", heading: "Dependency checks", count: n(deps.errors),
147                summary: nil,
148                rows: lines(deps) { row in
149                    let check = row.lastCheck.flatMap { $0.isEmpty ? nil : "last check \(when($0))" } ?? "never checked"
150                    return (row.repo ?? "", [check, row.lastError])
151                },
152                empty: "No check has failed"),
153        ]
154    }
155
156    /// "5 minutes ago", or the timestamp itself when it does not parse.
157    static func relative(_ timestamp: String) -> String {
158        let fractional = ISO8601DateFormatter()
159        fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
160        guard let date = fractional.date(from: timestamp) ?? ISO8601DateFormatter().date(from: timestamp)
161        else { return timestamp }
162        return date.formatted(.relative(presentation: .named))
163    }
164}
165
166/// One section of the admin screen: a heading with its count, the counts
167/// line, the rows, and what an empty queue says.
168nonisolated struct QueueSection: Sendable, Hashable, Identifiable {
169    let id: String
170    let heading: String
171    let count: Int
172    let summary: String?
173    let rows: [QueueLine]
174    let empty: String
175}
176
177nonisolated struct QueueLine: Sendable, Hashable, Identifiable {
178    let id: Int
179    let title: String
180    let detail: String
181}
182
183/// One row of `admin user list`.
184nonisolated struct AdminUser: Decodable, Sendable, Hashable, Identifiable {
185    let username: String
186    /// active, pending or disabled.
187    let state: String
188    let admin: Bool
189    let createdAt: Date
190    /// Absent for an account that has never signed in.
191    let lastSeen: Date?
192
193    enum CodingKeys: String, CodingKey {
194        case username, state, admin
195        case createdAt = "created_at"
196        case lastSeen = "last_seen"
197    }
198
199    var id: String { username }
200
201    /// The role button the web draws: Demote for an admin, Promote for
202    /// anyone else.
203    var roleAction: AdminAction { admin ? .demote : .promote }
204    /// Only an active account can be promoted; the web draws the button
205    /// disabled for the rest.
206    var canChangeRole: Bool { admin || state == "active" }
207    var accessAction: AdminAction { state == "disabled" ? .enable : .disable }
208}
209
210nonisolated enum AdminAction: String, Sendable, Hashable {
211    case promote, demote, disable, enable
212
213    /// Demote and disable take something away from the account, and the
214    /// web asks for the username typed before either.
215    var needsTypedName: Bool { self == .demote || self == .disable }
216
217    var title: String { rawValue.capitalized }
218
219    func argv(_ username: String) -> [String] { ["admin", "user", rawValue, username] }
220
221    func message(for username: String) -> String {
222        switch self {
223        case .promote: "\(username) becomes an instance admin."
224        case .demote: "\(username) stops being an instance admin. Type the username to confirm."
225        case .disable: "Signs \(username) out of the web and revokes their API tokens; SSH is refused until the account is enabled. Type the username to confirm."
226        case .enable: "\(username) can sign in again."
227        }
228    }
229}
230
231/// The web's five filters over `admin user list`.
232nonisolated enum AdminUserFilter: String, CaseIterable, Identifiable, Sendable {
233    case all, active, pending, disabled, admin
234
235    var id: String { rawValue }
236    var label: String { self == .admin ? "Admins" : rawValue.capitalized }
237
238    func flags() -> [String] { self == .all ? [] : ["--state", rawValue] }
239}