Commit 8b8a2634fc

8b8a2634fc2aea3c770b85c6bbe3d705aa4731c6

parent: 610ff1e5f9

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-22 22:48 UTC

admin: decode dashboard's queues into the web's six sections

Layout: unified · split

gitbay/Admin/AdminModels.swift added +181
@@ -0,0 +1,181 @@
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}
gitbayTests/AdminTests.swift added +105
@@ -0,0 +1,105 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15/// `dashboard` as an admin sees it, trimmed to the admin blocks, with
16/// one row in every queue that lists rows.
17private let adminDashboardJSON = """
18 {"protocol_version":1,"data":{"unread":0,\
19 "server":{"commit":"db7503f06f11"},\
20 "queues":{\
21 "webhooks":{"pending":2,"retrying":1,"failed":1,"oldest_pending":"2026-09-22T04:00:00Z","items":[\
22 {"id":9,"repo":"krz/gitbay","url":"https://ci.example.com/hook","attempts":5,\
23 "last_status":502,"last_error":"bad gateway","failed_at":"2026-09-22T05:00:00Z","created_at":"2026-09-22T03:00:00Z"}]},\
24 "mail":{"pending":0,"retrying":1,"failed":0,"items":[\
25 {"id":4,"recipient":"a@example.com","subject":"krz/gitbay#12","attempts":2,\
26 "last_error":"dial tcp: timeout","created_at":"2026-09-22T03:00:00Z"}]},\
27 "mirrors":{"dirty":3,"errors":1,"items":[\
28 {"id":1,"repo":"krz/solar","direction":"push","url":"https://github.com/x/solar",\
29 "last_error":"auth failed"}]},\
30 "builds":{"pending":1,"running":1,"items":[\
31 {"repo":"krz/gitbay","number":1480,"job":"test","status":"running",\
32 "created_at":"2026-09-22T04:40:00Z","started_at":"2026-09-22T04:41:00Z"},\
33 {"repo":"krz/gitbay","number":1481,"job":"build","status":"pending",\
34 "created_at":"2026-09-22T04:42:00Z","started_at":""}]},\
35 "deps":{"errors":0,"items":[]},\
36 "push":{"pending":0,"retrying":0,"failed":1,"items":[\
37 {"id":7,"device_id":3,"title":"krz/gitbay!450 merged","attempts":3,\
38 "last_error":"BadDeviceToken","failed_at":"2026-09-22T05:00:00Z","created_at":"2026-09-22T04:00:00Z"}]}\
39 }},"exit_code":0}
40 """
41
42nonisolated private struct Envelope: Decodable { let data: AdminDashboard }
43
44private func sections() throws -> [QueueSection] {
45 let dashboard = try GitbayClient.decoder()
46 .decode(Envelope.self, from: Data(adminDashboardJSON.utf8)).data
47 let queues = try #require(dashboard.queues)
48 // A fixed stand-in for relative time keeps the text deterministic.
49 return queues.sections(when: { "at \($0)" })
50}
51
52struct AdminQueueSectionTests {
53
54 @Test func sixSectionsInTheWebsOrder() throws {
55 let all = try sections()
56 #expect(all.map(\.heading) == [
57 "Webhook deliveries", "Mail", "Push", "Mirrors", "Builds", "Dependency checks",
58 ])
59 #expect(all.map(\.count) == [2, 0, 0, 1, 1, 0])
60 }
61
62 @Test func countsLinesUseTheWebsWords() throws {
63 let all = try sections()
64 #expect(all[0].summary == "2 pending · 1 retrying · 1 dead-lettered · oldest pending at 2026-09-22T04:00:00Z")
65 #expect(all[1].summary == "0 pending · 1 retrying · 0 failed")
66 #expect(all[3].summary == "3 waiting for a sync · 1 with a failed last sync")
67 #expect(all[4].summary == "1 pending · 1 running")
68 #expect(all[5].summary == nil)
69 }
70
71 @Test func rowsSayWhatTheWebsTableColumnsSay() throws {
72 let all = try sections()
73 #expect(all[0].rows.map(\.title) == ["krz/gitbay"])
74 #expect(all[0].rows[0].detail
75 == "https://ci.example.com/hook · 5 attempts · dead-lettered at 2026-09-22T05:00:00Z · 502 bad gateway")
76 #expect(all[1].rows[0].title == "a@example.com")
77 #expect(all[1].rows[0].detail == "krz/gitbay#12 · 2 attempts · retrying · dial tcp: timeout")
78 #expect(all[2].rows[0].title == "device 3")
79 #expect(all[2].rows[0].detail
80 == "krz/gitbay!450 merged · 3 attempts · failed at 2026-09-22T05:00:00Z · BadDeviceToken")
81 #expect(all[3].rows[0].detail == "push · https://github.com/x/solar · never synced · auth failed")
82 // A pending build has started_at "" and reports since it was queued.
83 #expect(all[4].rows.map(\.title) == ["krz/gitbay #1480", "krz/gitbay #1481"])
84 #expect(all[4].rows[0].detail == "test · running · since at 2026-09-22T04:41:00Z")
85 #expect(all[4].rows[1].detail == "build · pending · since at 2026-09-22T04:42:00Z")
86 }
87
88 @Test func anEmptyQueueSaysSoInTheWebsWords() throws {
89 let deps = try #require(try sections().last)
90 #expect(deps.rows.isEmpty)
91 #expect(deps.empty == "No check has failed")
92 }
93
94 /// Everyone else's dashboard omits both blocks.
95 @Test func aNonAdminDashboardHasNoQueues() throws {
96 let json = #"{"protocol_version":1,"data":{"unread":3},"exit_code":0}"#
97 let dashboard = try GitbayClient.decoder().decode(Envelope.self, from: Data(json.utf8)).data
98 #expect(dashboard.queues == nil)
99 #expect(dashboard.server == nil)
100 }
101
102 @Test func anUnparseableTimestampIsShownAsIs() {
103 #expect(AdminQueues.relative("soon") == "soon")
104 }
105}