import Foundation /// The admin-only half of `dashboard`: the server build and every /// background worker's backlog. The web's `/admin` page is this read. /// Both are absent for anyone who is not an instance admin. nonisolated struct AdminDashboard: Decodable, Sendable, Hashable { let queues: AdminQueues? let server: Server? nonisolated struct Server: Decodable, Sendable, Hashable { let commit: String? } } /// `dashboard`'s `queues` block. nonisolated struct AdminQueues: Decodable, Sendable, Hashable { let webhooks: Queue let mail: Queue let push: Queue let mirrors: Queue let builds: Queue let deps: Queue /// Each queue reports a different subset of these counts; the /// absent ones stay nil. nonisolated struct Queue: Decodable, Sendable, Hashable { let pending: Int? let retrying: Int? let failed: Int? let running: Int? let dirty: Int? let errors: Int? let oldestPending: String? let items: [Row]? enum CodingKeys: String, CodingKey { case pending, retrying, failed, running, dirty, errors, items case oldestPending = "oldest_pending" } var rows: [Row] { items ?? [] } } /// A row of any queue; each kind fills its own fields. Timestamps /// stay strings: a pending build's `started_at` is `""`, which no /// date decoder accepts. nonisolated struct Row: Decodable, Sendable, Hashable { let repo: String? let url: String? let recipient: String? let subject: String? let title: String? let deviceID: Int64? let direction: String? let number: Int64? let job: String? let status: String? let attempts: Int64? let lastStatus: Int64? let lastError: String? let failedAt: String? let lastSync: String? let lastCheck: String? let startedAt: String? let createdAt: String? enum CodingKeys: String, CodingKey { case repo, url, recipient, subject, title, direction, number, job, status, attempts case deviceID = "device_id" case lastStatus = "last_status" case lastError = "last_error" case failedAt = "failed_at" case lastSync = "last_sync" case lastCheck = "last_check" case startedAt = "started_at" case createdAt = "created_at" } } /// The web's six sections, in its order and its words /// (`internal/web/templates/admin.html`). `when` renders a timestamp; /// tests pass a fixed one. func sections(when: (String) -> String = AdminQueues.relative) -> [QueueSection] { func oldest(_ queue: Queue) -> String { guard let at = queue.oldestPending, !at.isEmpty else { return "" } return " · oldest pending \(when(at))" } func n(_ value: Int?) -> Int { value ?? 0 } func attempts(_ row: Row) -> String? { row.attempts.map { "\($0) attempts" } } func state(_ row: Row, failed: String) -> String { guard let at = row.failedAt, !at.isEmpty else { return "retrying" } return "\(failed) \(when(at))" } func lines(_ queue: Queue, _ make: (Row) -> (String, [String?])) -> [QueueLine] { queue.rows.enumerated().map { index, row in let (title, parts) = make(row) let detail = parts.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · ") return QueueLine(id: index, title: title, detail: detail) } } return [ QueueSection( id: "webhooks", heading: "Webhook deliveries", count: n(webhooks.pending), summary: "\(n(webhooks.pending)) pending · \(n(webhooks.retrying)) retrying · \(n(webhooks.failed)) dead-lettered" + oldest(webhooks), rows: lines(webhooks) { row in let error = [row.lastStatus.map(String.init), row.lastError] .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " ") return (row.repo ?? "", [row.url, attempts(row), state(row, failed: "dead-lettered"), error]) }, empty: "Nothing retrying or dead-lettered"), QueueSection( id: "mail", heading: "Mail", count: n(mail.pending), summary: "\(n(mail.pending)) pending · \(n(mail.retrying)) retrying · \(n(mail.failed)) failed" + oldest(mail), rows: lines(mail) { row in (row.recipient ?? "", [row.subject, attempts(row), state(row, failed: "failed"), row.lastError]) }, empty: "Nothing retrying or failed"), // A push row names the device id, never the token. QueueSection( id: "push", heading: "Push", count: n(push.pending), summary: "\(n(push.pending)) pending · \(n(push.retrying)) retrying · \(n(push.failed)) failed" + oldest(push), rows: lines(push) { row in ("device \(row.deviceID.map(String.init) ?? "?")", [row.title, attempts(row), state(row, failed: "failed"), row.lastError]) }, empty: "Nothing retrying or failed"), QueueSection( id: "mirrors", heading: "Mirrors", count: n(mirrors.errors), summary: "\(n(mirrors.dirty)) waiting for a sync · \(n(mirrors.errors)) with a failed last sync", rows: lines(mirrors) { row in let sync = row.lastSync.flatMap { $0.isEmpty ? nil : "last sync \(when($0))" } ?? "never synced" return (row.repo ?? "", [row.direction, row.url, sync, row.lastError]) }, empty: "Every mirror's last sync succeeded"), QueueSection( id: "builds", heading: "Builds", count: n(builds.pending), summary: "\(n(builds.pending)) pending · \(n(builds.running)) running" + oldest(builds), rows: lines(builds) { row in let started = row.startedAt.flatMap { $0.isEmpty ? nil : $0 } let since = (started ?? row.createdAt).map { "since \(when($0))" } return ("\(row.repo ?? "") #\(row.number.map(String.init) ?? "?")", [row.job, row.status, since]) }, empty: "No build running or pending"), QueueSection( id: "deps", heading: "Dependency checks", count: n(deps.errors), summary: nil, rows: lines(deps) { row in let check = row.lastCheck.flatMap { $0.isEmpty ? nil : "last check \(when($0))" } ?? "never checked" return (row.repo ?? "", [check, row.lastError]) }, empty: "No check has failed"), ] } /// "5 minutes ago", or the timestamp itself when it does not parse. static func relative(_ timestamp: String) -> String { let fractional = ISO8601DateFormatter() fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] guard let date = fractional.date(from: timestamp) ?? ISO8601DateFormatter().date(from: timestamp) else { return timestamp } return date.formatted(.relative(presentation: .named)) } } /// One section of the admin screen: a heading with its count, the counts /// line, the rows, and what an empty queue says. nonisolated struct QueueSection: Sendable, Hashable, Identifiable { let id: String let heading: String let count: Int let summary: String? let rows: [QueueLine] let empty: String } nonisolated struct QueueLine: Sendable, Hashable, Identifiable { let id: Int let title: String let detail: String } /// One row of `admin user list`. nonisolated struct AdminUser: Decodable, Sendable, Hashable, Identifiable { let username: String /// active, pending or disabled. let state: String let admin: Bool let createdAt: Date /// Absent for an account that has never signed in. let lastSeen: Date? enum CodingKeys: String, CodingKey { case username, state, admin case createdAt = "created_at" case lastSeen = "last_seen" } var id: String { username } /// The role button the web draws: Demote for an admin, Promote for /// anyone else. var roleAction: AdminAction { admin ? .demote : .promote } /// Only an active account can be promoted; the web draws the button /// disabled for the rest. var canChangeRole: Bool { admin || state == "active" } var accessAction: AdminAction { state == "disabled" ? .enable : .disable } } nonisolated enum AdminAction: String, Sendable, Hashable { case promote, demote, disable, enable /// Demote and disable take something away from the account, and the /// web asks for the username typed before either. var needsTypedName: Bool { self == .demote || self == .disable } var title: String { rawValue.capitalized } func argv(_ username: String) -> [String] { ["admin", "user", rawValue, username] } func message(for username: String) -> String { switch self { case .promote: "\(username) becomes an instance admin." case .demote: "\(username) stops being an instance admin. Type the username to confirm." 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." case .enable: "\(username) can sign in again." } } } /// The web's five filters over `admin user list`. nonisolated enum AdminUserFilter: String, CaseIterable, Identifiable, Sendable { case all, active, pending, disabled, admin var id: String { rawValue } var label: String { self == .admin ? "Admins" : rawValue.capitalized } func flags() -> [String] { self == .all ? [] : ["--state", rawValue] } }