gitbay/Admin/AdminModels.swift
239 lines · 10141 bytes
13 symbols in this file
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}