diff options
| -rw-r--r-- | Hutch/Models/Builds.swift | 14 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildDetailView.swift | 60 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildDetailViewModel.swift | 125 |
3 files changed, 179 insertions, 20 deletions
diff --git a/Hutch/Models/Builds.swift b/Hutch/Models/Builds.swift index b0c0e8d..8de4235 100644 --- a/Hutch/Models/Builds.swift +++ b/Hutch/Models/Builds.swift @@ -19,6 +19,14 @@ enum JobStatus: String, Codable, Sendable { default: false } } + + /// Whether the job has reached a terminal state and no longer changes. + var isTerminal: Bool { + switch self { + case .success, .failed, .cancelled, .timeout: true + case .pending, .queued, .running: false + } + } } /// Status of a single build task within a job. @@ -33,7 +41,7 @@ enum TaskStatus: String, Codable, Sendable { // MARK: - Build Task /// A single task within a build job. -struct BuildTask: Codable, Sendable, Identifiable { +struct BuildTask: Codable, Sendable, Identifiable, Equatable { private(set) var ordinal: Int? let name: String let status: TaskStatus @@ -103,7 +111,7 @@ struct JobTaskSummary: Codable, Sendable, Hashable { // MARK: - Job Detail (for detail view) /// Full job model with all fields for the detail view. -struct JobDetail: Codable, Sendable { +struct JobDetail: Codable, Sendable, Equatable { let id: Int let created: Date let updated: Date @@ -119,7 +127,7 @@ struct JobDetail: Codable, Sendable { } /// The log associated with a build job. -struct BuildLog: Codable, Sendable { +struct BuildLog: Codable, Sendable, Equatable { let fullURL: String } diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift index cd2a972..104b961 100644 --- a/Hutch/Views/Builds/BuildDetailView.swift +++ b/Hutch/Views/Builds/BuildDetailView.swift @@ -6,10 +6,14 @@ struct BuildDetailView: View { @Environment(AppState.self) private var appState @State private var viewModel: BuildDetailViewModel? @State private var rebuiltJobId: Int? - @State private var selectedTask: BuildTask? + @State private var selectedTaskName: String? @State private var showEditResubmitSheet = false @State private var showCancelConfirmation = false + private var isPresentingLogSheet: Bool { + selectedTaskName != nil + } + var body: some View { Group { if let viewModel { @@ -42,18 +46,6 @@ struct BuildDetailView: View { BuildDetailView(jobId: rebuiltJobId) } } - .navigationDestination(isPresented: Binding( - get: { selectedTask != nil }, - set: { isPresented in - if !isPresented { - selectedTask = nil - } - } - )) { - if let selectedTask, let viewModel { - BuildTaskLogView(task: selectedTask, viewModel: viewModel) - } - } .sheet(isPresented: $showEditResubmitSheet) { if let viewModel, let job = viewModel.job { EditResubmitBuildSheet(viewModel: viewModel, job: job) { jobId in @@ -62,6 +54,38 @@ struct BuildDetailView: View { } } } + .sheet(isPresented: Binding( + get: { selectedTaskName != nil }, + set: { isPresented in + if !isPresented { + selectedTaskName = nil + } + } + )) { + if let selectedTaskName, let viewModel { + NavigationStack { + BuildTaskLogView(taskName: selectedTaskName, viewModel: viewModel) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + self.selectedTaskName = nil + } + } + } + } + } else { + NavigationStack { + SRHTLoadingStateView(message: "Loading…") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + self.selectedTaskName = nil + } + } + } + } + } + } .alert("Cancel Build?", isPresented: $showCancelConfirmation) { Button("Keep Running", role: .cancel) {} Button("Cancel Build", role: .destructive) { @@ -75,8 +99,16 @@ struct BuildDetailView: View { let vm = BuildDetailViewModel(jobId: jobId, client: appState.client) viewModel = vm await vm.loadJob() + vm.startAutoRefresh() } } + .onAppear { + viewModel?.startAutoRefresh() + } + .onDisappear { + guard !isPresentingLogSheet else { return } + viewModel?.stopAutoRefresh() + } } @ViewBuilder @@ -129,7 +161,7 @@ struct BuildDetailView: View { ForEach(job.tasks) { task in Section { Button { - selectedTask = task + selectedTaskName = task.name } label: { HStack { Text(task.status.rawValue.capitalized) 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() + } } |
