summaryrefslogtreecommitdiff
path: root/Hutch/Views
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-23 15:17:48 -0500
committerChristian Cleberg <[email protected]>2026-03-23 15:17:48 -0500
commitcf87c44f674e020c32fea70903f02c317b57da3b (patch)
tree88c09a27ef1ba30581a63c50f0f8d345477145c5 /Hutch/Views
parentb81657586244299fc9fe78e4f27ca1c8bca07060 (diff)
downloadhutch-cf87c44f674e020c32fea70903f02c317b57da3b.tar.gz
hutch-cf87c44f674e020c32fea70903f02c317b57da3b.tar.bz2
hutch-cf87c44f674e020c32fea70903f02c317b57da3b.zip
Add scoped auto-refresh to build detail
Diffstat (limited to 'Hutch/Views')
-rw-r--r--Hutch/Views/Builds/BuildDetailView.swift60
-rw-r--r--Hutch/Views/Builds/BuildDetailViewModel.swift125
2 files changed, 168 insertions, 17 deletions
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()
+ }
}