diff options
| author | Christian Cleberg <[email protected]> | 2026-03-23 15:17:48 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-23 15:17:48 -0500 |
| commit | cf87c44f674e020c32fea70903f02c317b57da3b (patch) | |
| tree | 88c09a27ef1ba30581a63c50f0f8d345477145c5 /Hutch/Views/Builds/BuildDetailViewModel.swift | |
| parent | b81657586244299fc9fe78e4f27ca1c8bca07060 (diff) | |
| download | hutch-cf87c44f674e020c32fea70903f02c317b57da3b.tar.gz hutch-cf87c44f674e020c32fea70903f02c317b57da3b.tar.bz2 hutch-cf87c44f674e020c32fea70903f02c317b57da3b.zip | |
Add scoped auto-refresh to build detail
Diffstat (limited to 'Hutch/Views/Builds/BuildDetailViewModel.swift')
| -rw-r--r-- | Hutch/Views/Builds/BuildDetailViewModel.swift | 125 |
1 files changed, 122 insertions, 3 deletions
diff --git a/Hutch/Views/Builds/BuildDetailViewModel.swift b/Hutch/Views/Builds/BuildDetailViewModel.swift index 2b91a90..ec4d265 100644 --- a/Hutch/Views/Builds/BuildDetailViewModel.swift +++ b/Hutch/Views/Builds/BuildDetailViewModel.swift @@ -27,14 +27,20 @@ private struct SubmittedJob: Decodable, Sendable { @Observable @MainActor final class BuildDetailViewModel { + private static let autoRefreshInterval: Duration = .seconds(5) let jobId: Int private let client: SRHTClient + private var autoRefreshTask: Task<Void, Never>? private(set) var job: JobDetail? private(set) var isLoading = false + private(set) var buildLogText: String? + private(set) var isLoadingBuildLog = false private(set) var taskLogs: [String: String] = [:] private(set) var loadingTaskLogs: Set<String> = [] + private(set) var failedTaskLogs: Set<String> = [] + private var taskLogRetryCounts: [String: Int] = [:] private(set) var isCancelling = false private(set) var isRebuilding = false private(set) var isSubmittingEditedBuild = false @@ -108,7 +114,13 @@ final class BuildDetailViewModel { loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in task.withOrdinal(index) } - job = loadedJob + if job != loadedJob { + job = loadedJob + } + + if loadedJob.status.isTerminal { + stopAutoRefresh() + } } catch { self.error = error.userFacingMessage } @@ -118,21 +130,82 @@ final class BuildDetailViewModel { func loadTaskLog(task: BuildTask) async { let cacheKey = task.logCacheKey + let jobIsTerminal = job?.status.isTerminal ?? false + + // Task-specific logs are only fetched after the job reaches a terminal + // state. While the build is active, the UI shows the shared live build log. guard let log = task.log, let logURL = URL(string: log.fullURL), - !loadingTaskLogs.contains(cacheKey), - taskLogs[cacheKey] == nil else { return } + !loadingTaskLogs.contains(cacheKey) else { return } + guard jobIsTerminal else { return } + if jobIsTerminal, taskLogs[cacheKey] != nil { return } + failedTaskLogs.remove(cacheKey) loadingTaskLogs.insert(cacheKey) do { taskLogs[cacheKey] = try await client.fetchText(url: logURL) + failedTaskLogs.remove(cacheKey) } catch { + failedTaskLogs.insert(cacheKey) self.error = error.userFacingMessage } loadingTaskLogs.remove(cacheKey) } + func loadBuildLog() async { + guard let log = job?.log, + let logURL = URL(string: log.fullURL), + !isLoadingBuildLog else { return } + + let jobIsTerminal = job?.status.isTerminal ?? false + if jobIsTerminal, buildLogText != nil { return } + + isLoadingBuildLog = true + + do { + buildLogText = try await client.fetchText(url: logURL) + } catch { + self.error = error.userFacingMessage + } + + isLoadingBuildLog = false + } + + func retryTaskLog(task: BuildTask) async { + let cacheKey = task.logCacheKey + failedTaskLogs.remove(cacheKey) + taskLogRetryCounts[cacheKey, default: 0] += 1 + await loadTaskLog(task: task) + } + + func displayedLogText(for task: BuildTask?) -> String? { + guard let task else { return nil } + guard let job else { return nil } + + if !job.status.isTerminal { + return buildLogText + } + + return taskLogs[task.logCacheKey] ?? buildLogText + } + + func isShowingBuildLogFallback(for task: BuildTask?) -> Bool { + guard let task, let job else { return false } + if !job.status.isTerminal { + return buildLogText != nil + } + + return taskLogs[task.logCacheKey] == nil && buildLogText != nil + } + + func taskLogTrigger(for task: BuildTask?) -> String? { + guard let task, let logURL = task.log?.fullURL else { return nil } + let retryCount = taskLogRetryCounts[task.logCacheKey, default: 0] + let isTerminal = job?.status.isTerminal ?? false + return "\(logURL)#\(retryCount)#\(isTerminal)" + } + func cancelJob() async { guard let job, job.status.isCancellable, !isCancelling else { return } isCancelling = true @@ -237,4 +310,50 @@ final class BuildDetailViewModel { return nil } } + + func startAutoRefresh() { + guard autoRefreshTask == nil else { return } + guard shouldAutoRefresh else { return } + + print("Build auto-refresh started") + + autoRefreshTask = Task { [weak self] in + while !Task.isCancelled { + do { + try await Task.sleep(for: Self.autoRefreshInterval) + } catch { + break + } + + guard let self else { return } + await self.performAutoRefreshTick() + } + } + } + + func stopAutoRefresh() { + guard let autoRefreshTask else { return } + + autoRefreshTask.cancel() + self.autoRefreshTask = nil + print("Build auto-refresh stopped") + } + + private var shouldAutoRefresh: Bool { + guard let job else { return true } + return !job.status.isTerminal + } + + private func performAutoRefreshTick() async { + guard !Task.isCancelled, shouldAutoRefresh, !isLoading else { + if !shouldAutoRefresh { + stopAutoRefresh() + } + return + } + + print("Build refresh tick") + await loadJob() + await loadBuildLog() + } } |
