| @@ -27,14 +27,20 @@ private struct SubmittedJob: Decodable, Sendable { |
| 27 | 27 | @Observable |
| 28 | 28 | @MainActor |
| 29 | 29 | final class BuildDetailViewModel { |
| 30 | private static let autoRefreshInterval: Duration = .seconds(5) |
| 30 | 31 | |
| 31 | 32 | let jobId: Int |
| 32 | 33 | private let client: SRHTClient |
| 33 | 34 | |
| 35 | private var autoRefreshTask: Task<Void, Never>? |
| 34 | 36 | private(set) var job: JobDetail? |
| 35 | 37 | private(set) var isLoading = false |
| 38 | private(set) var buildLogText: String? |
| 39 | private(set) var isLoadingBuildLog = false |
| 36 | 40 | private(set) var taskLogs: [String: String] = [:] |
| 37 | 41 | private(set) var loadingTaskLogs: Set<String> = [] |
| 42 | private(set) var failedTaskLogs: Set<String> = [] |
| 43 | private var taskLogRetryCounts: [String: Int] = [:] |
| 38 | 44 | private(set) var isCancelling = false |
| 39 | 45 | private(set) var isRebuilding = false |
| 40 | 46 | private(set) var isSubmittingEditedBuild = false |
| @@ -108,7 +114,13 @@ final class BuildDetailViewModel { |
| 108 | 114 | loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in |
| 109 | 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 | 124 | } catch { |
| 113 | 125 | self.error = error.userFacingMessage |
| 114 | 126 | } |
| @@ -118,21 +130,82 @@ final class BuildDetailViewModel { |
| 118 | 130 | |
| 119 | 131 | func loadTaskLog(task: BuildTask) async { |
| 120 | 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 | 137 | guard let log = task.log, |
| 122 | 138 | let logURL = URL(string: log.fullURL), |
| 123 | | !loadingTaskLogs.contains(cacheKey), |
| 124 | | taskLogs[cacheKey] == nil else { return } |
| 139 | !loadingTaskLogs.contains(cacheKey) else { return } |
| 140 | guard jobIsTerminal else { return } |
| 141 | if jobIsTerminal, taskLogs[cacheKey] != nil { return } |
| 142 | failedTaskLogs.remove(cacheKey) |
| 125 | 143 | loadingTaskLogs.insert(cacheKey) |
| 126 | 144 | |
| 127 | 145 | do { |
| 128 | 146 | taskLogs[cacheKey] = try await client.fetchText(url: logURL) |
| 147 | failedTaskLogs.remove(cacheKey) |
| 129 | 148 | } catch { |
| 149 | failedTaskLogs.insert(cacheKey) |
| 130 | 150 | self.error = error.userFacingMessage |
| 131 | 151 | } |
| 132 | 152 | |
| 133 | 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 | 209 | func cancelJob() async { |
| 137 | 210 | guard let job, job.status.isCancellable, !isCancelling else { return } |
| 138 | 211 | isCancelling = true |
| @@ -237,4 +310,50 @@ final class BuildDetailViewModel { |
| 237 | 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 | } |