| @@ -27,14 +27,20 @@ private struct SubmittedJob: Decodable, Sendable { |
| 27 | @Observable |
27 | @Observable |
| 28 | @MainActor |
28 | @MainActor |
| 29 | final class BuildDetailViewModel { |
29 | final class BuildDetailViewModel { |
| |
30 | private static let autoRefreshInterval: Duration = .seconds(5) |
| 30 | |
31 | |
| 31 | let jobId: Int |
32 | let jobId: Int |
| 32 | private let client: SRHTClient |
33 | private let client: SRHTClient |
| 33 | |
34 | |
| |
35 | private var autoRefreshTask: Task<Void, Never>? |
| 34 | private(set) var job: JobDetail? |
36 | private(set) var job: JobDetail? |
| 35 | private(set) var isLoading = false |
37 | private(set) var isLoading = false |
| |
38 | private(set) var buildLogText: String? |
| |
39 | private(set) var isLoadingBuildLog = false |
| 36 | private(set) var taskLogs: [String: String] = [:] |
40 | private(set) var taskLogs: [String: String] = [:] |
| 37 | private(set) var loadingTaskLogs: Set<String> = [] |
41 | private(set) var loadingTaskLogs: Set<String> = [] |
| |
42 | private(set) var failedTaskLogs: Set<String> = [] |
| |
43 | private var taskLogRetryCounts: [String: Int] = [:] |
| 38 | private(set) var isCancelling = false |
44 | private(set) var isCancelling = false |
| 39 | private(set) var isRebuilding = false |
45 | private(set) var isRebuilding = false |
| 40 | private(set) var isSubmittingEditedBuild = false |
46 | private(set) var isSubmittingEditedBuild = false |
| @@ -108,7 +114,13 @@ final class BuildDetailViewModel { |
| 108 | loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in |
114 | loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in |
| 109 | task.withOrdinal(index) |
115 | task.withOrdinal(index) |
| 110 | } |
116 | } |
| 111 | job = loadedJob |
117 | if job != loadedJob { |
| |
118 | job = loadedJob |
| |
119 | } |
| |
120 | |
| |
121 | if loadedJob.status.isTerminal { |
| |
122 | stopAutoRefresh() |
| |
123 | } |
| 112 | } catch { |
124 | } catch { |
| 113 | self.error = error.userFacingMessage |
125 | self.error = error.userFacingMessage |
| 114 | } |
126 | } |
| @@ -118,21 +130,82 @@ final class BuildDetailViewModel { |
| 118 | |
130 | |
| 119 | func loadTaskLog(task: BuildTask) async { |
131 | func loadTaskLog(task: BuildTask) async { |
| 120 | let cacheKey = task.logCacheKey |
132 | let cacheKey = task.logCacheKey |
| |
133 | let jobIsTerminal = job?.status.isTerminal ?? false |
| |
134 | |
| |
135 | // Task-specific logs are only fetched after the job reaches a terminal |
| |
136 | // state. While the build is active, the UI shows the shared live build log. |
| 121 | guard let log = task.log, |
137 | guard let log = task.log, |
| 122 | let logURL = URL(string: log.fullURL), |
138 | let logURL = URL(string: log.fullURL), |
| 123 | !loadingTaskLogs.contains(cacheKey), |
139 | !loadingTaskLogs.contains(cacheKey) else { return } |
| 124 | taskLogs[cacheKey] == nil else { return } |
140 | guard jobIsTerminal else { return } |
| |
141 | if jobIsTerminal, taskLogs[cacheKey] != nil { return } |
| |
142 | failedTaskLogs.remove(cacheKey) |
| 125 | loadingTaskLogs.insert(cacheKey) |
143 | loadingTaskLogs.insert(cacheKey) |
| 126 | |
144 | |
| 127 | do { |
145 | do { |
| 128 | taskLogs[cacheKey] = try await client.fetchText(url: logURL) |
146 | taskLogs[cacheKey] = try await client.fetchText(url: logURL) |
| |
147 | failedTaskLogs.remove(cacheKey) |
| 129 | } catch { |
148 | } catch { |
| |
149 | failedTaskLogs.insert(cacheKey) |
| 130 | self.error = error.userFacingMessage |
150 | self.error = error.userFacingMessage |
| 131 | } |
151 | } |
| 132 | |
152 | |
| 133 | loadingTaskLogs.remove(cacheKey) |
153 | loadingTaskLogs.remove(cacheKey) |
| 134 | } |
154 | } |
| 135 | |
155 | |
| |
156 | func loadBuildLog() async { |
| |
157 | guard let log = job?.log, |
| |
158 | let logURL = URL(string: log.fullURL), |
| |
159 | !isLoadingBuildLog else { return } |
| |
160 | |
| |
161 | let jobIsTerminal = job?.status.isTerminal ?? false |
| |
162 | if jobIsTerminal, buildLogText != nil { return } |
| |
163 | |
| |
164 | isLoadingBuildLog = true |
| |
165 | |
| |
166 | do { |
| |
167 | buildLogText = try await client.fetchText(url: logURL) |
| |
168 | } catch { |
| |
169 | self.error = error.userFacingMessage |
| |
170 | } |
| |
171 | |
| |
172 | isLoadingBuildLog = false |
| |
173 | } |
| |
174 | |
| |
175 | func retryTaskLog(task: BuildTask) async { |
| |
176 | let cacheKey = task.logCacheKey |
| |
177 | failedTaskLogs.remove(cacheKey) |
| |
178 | taskLogRetryCounts[cacheKey, default: 0] += 1 |
| |
179 | await loadTaskLog(task: task) |
| |
180 | } |
| |
181 | |
| |
182 | func displayedLogText(for task: BuildTask?) -> String? { |
| |
183 | guard let task else { return nil } |
| |
184 | guard let job else { return nil } |
| |
185 | |
| |
186 | if !job.status.isTerminal { |
| |
187 | return buildLogText |
| |
188 | } |
| |
189 | |
| |
190 | return taskLogs[task.logCacheKey] ?? buildLogText |
| |
191 | } |
| |
192 | |
| |
193 | func isShowingBuildLogFallback(for task: BuildTask?) -> Bool { |
| |
194 | guard let task, let job else { return false } |
| |
195 | if !job.status.isTerminal { |
| |
196 | return buildLogText != nil |
| |
197 | } |
| |
198 | |
| |
199 | return taskLogs[task.logCacheKey] == nil && buildLogText != nil |
| |
200 | } |
| |
201 | |
| |
202 | func taskLogTrigger(for task: BuildTask?) -> String? { |
| |
203 | guard let task, let logURL = task.log?.fullURL else { return nil } |
| |
204 | let retryCount = taskLogRetryCounts[task.logCacheKey, default: 0] |
| |
205 | let isTerminal = job?.status.isTerminal ?? false |
| |
206 | return "\(logURL)#\(retryCount)#\(isTerminal)" |
| |
207 | } |
| |
208 | |
| 136 | func cancelJob() async { |
209 | func cancelJob() async { |
| 137 | guard let job, job.status.isCancellable, !isCancelling else { return } |
210 | guard let job, job.status.isCancellable, !isCancelling else { return } |
| 138 | isCancelling = true |
211 | isCancelling = true |
| @@ -237,4 +310,50 @@ final class BuildDetailViewModel { |
| 237 | return nil |
310 | return nil |
| 238 | } |
311 | } |
| 239 | } |
312 | } |
| |
313 | |
| |
314 | func startAutoRefresh() { |
| |
315 | guard autoRefreshTask == nil else { return } |
| |
316 | guard shouldAutoRefresh else { return } |
| |
317 | |
| |
318 | print("Build auto-refresh started") |
| |
319 | |
| |
320 | autoRefreshTask = Task { [weak self] in |
| |
321 | while !Task.isCancelled { |
| |
322 | do { |
| |
323 | try await Task.sleep(for: Self.autoRefreshInterval) |
| |
324 | } catch { |
| |
325 | break |
| |
326 | } |
| |
327 | |
| |
328 | guard let self else { return } |
| |
329 | await self.performAutoRefreshTick() |
| |
330 | } |
| |
331 | } |
| |
332 | } |
| |
333 | |
| |
334 | func stopAutoRefresh() { |
| |
335 | guard let autoRefreshTask else { return } |
| |
336 | |
| |
337 | autoRefreshTask.cancel() |
| |
338 | self.autoRefreshTask = nil |
| |
339 | print("Build auto-refresh stopped") |
| |
340 | } |
| |
341 | |
| |
342 | private var shouldAutoRefresh: Bool { |
| |
343 | guard let job else { return true } |
| |
344 | return !job.status.isTerminal |
| |
345 | } |
| |
346 | |
| |
347 | private func performAutoRefreshTick() async { |
| |
348 | guard !Task.isCancelled, shouldAutoRefresh, !isLoading else { |
| |
349 | if !shouldAutoRefresh { |
| |
350 | stopAutoRefresh() |
| |
351 | } |
| |
352 | return |
| |
353 | } |
| |
354 | |
| |
355 | print("Build refresh tick") |
| |
356 | await loadJob() |
| |
357 | await loadBuildLog() |
| |
358 | } |
| 240 | } |
359 | } |