Commit fdb2fc5d0f

fdb2fc5d0fce550befba99d12dc4778e057427b8

parent: 6416f6b00e

Unsigned

cmc <hello@cleberg.net> · 2026-03-23 20:17 UTC

Add scoped auto-refresh to build detail

Layout: unified · split

Hutch/Models/Builds.swift +11 −3
@@ -19,6 +19,14 @@ enum JobStatus: String, Codable, Sendable {
1919 default: false
2020 }
2121 }
22
23 /// Whether the job has reached a terminal state and no longer changes.
24 var isTerminal: Bool {
25 switch self {
26 case .success, .failed, .cancelled, .timeout: true
27 case .pending, .queued, .running: false
28 }
29 }
2230}
2331
2432/// Status of a single build task within a job.
@@ -33,7 +41,7 @@ enum TaskStatus: String, Codable, Sendable {
3341// MARK: - Build Task
3442
3543/// A single task within a build job.
36struct BuildTask: Codable, Sendable, Identifiable {
44struct BuildTask: Codable, Sendable, Identifiable, Equatable {
3745 private(set) var ordinal: Int?
3846 let name: String
3947 let status: TaskStatus
@@ -103,7 +111,7 @@ struct JobTaskSummary: Codable, Sendable, Hashable {
103111// MARK: - Job Detail (for detail view)
104112
105113/// Full job model with all fields for the detail view.
106struct JobDetail: Codable, Sendable {
114struct JobDetail: Codable, Sendable, Equatable {
107115 let id: Int
108116 let created: Date
109117 let updated: Date
@@ -119,7 +127,7 @@ struct JobDetail: Codable, Sendable {
119127}
120128
121129/// The log associated with a build job.
122struct BuildLog: Codable, Sendable {
130struct BuildLog: Codable, Sendable, Equatable {
123131 let fullURL: String
124132}
125133
Hutch/Views/Builds/BuildDetailView.swift +46 −14
@@ -6,10 +6,14 @@ struct BuildDetailView: View {
66 @Environment(AppState.self) private var appState
77 @State private var viewModel: BuildDetailViewModel?
88 @State private var rebuiltJobId: Int?
9 @State private var selectedTask: BuildTask?
9 @State private var selectedTaskName: String?
1010 @State private var showEditResubmitSheet = false
1111 @State private var showCancelConfirmation = false
1212
13 private var isPresentingLogSheet: Bool {
14 selectedTaskName != nil
15 }
16
1317 var body: some View {
1418 Group {
1519 if let viewModel {
@@ -42,18 +46,6 @@ struct BuildDetailView: View {
4246 BuildDetailView(jobId: rebuiltJobId)
4347 }
4448 }
45 .navigationDestination(isPresented: Binding(
46 get: { selectedTask != nil },
47 set: { isPresented in
48 if !isPresented {
49 selectedTask = nil
50 }
51 }
52 )) {
53 if let selectedTask, let viewModel {
54 BuildTaskLogView(task: selectedTask, viewModel: viewModel)
55 }
56 }
5749 .sheet(isPresented: $showEditResubmitSheet) {
5850 if let viewModel, let job = viewModel.job {
5951 EditResubmitBuildSheet(viewModel: viewModel, job: job) { jobId in
@@ -62,6 +54,38 @@ struct BuildDetailView: View {
6254 }
6355 }
6456 }
57 .sheet(isPresented: Binding(
58 get: { selectedTaskName != nil },
59 set: { isPresented in
60 if !isPresented {
61 selectedTaskName = nil
62 }
63 }
64 )) {
65 if let selectedTaskName, let viewModel {
66 NavigationStack {
67 BuildTaskLogView(taskName: selectedTaskName, viewModel: viewModel)
68 .toolbar {
69 ToolbarItem(placement: .cancellationAction) {
70 Button("Done") {
71 self.selectedTaskName = nil
72 }
73 }
74 }
75 }
76 } else {
77 NavigationStack {
78 SRHTLoadingStateView(message: "Loading…")
79 .toolbar {
80 ToolbarItem(placement: .cancellationAction) {
81 Button("Done") {
82 self.selectedTaskName = nil
83 }
84 }
85 }
86 }
87 }
88 }
6589 .alert("Cancel Build?", isPresented: $showCancelConfirmation) {
6690 Button("Keep Running", role: .cancel) {}
6791 Button("Cancel Build", role: .destructive) {
@@ -75,8 +99,16 @@ struct BuildDetailView: View {
7599 let vm = BuildDetailViewModel(jobId: jobId, client: appState.client)
76100 viewModel = vm
77101 await vm.loadJob()
102 vm.startAutoRefresh()
78103 }
79104 }
105 .onAppear {
106 viewModel?.startAutoRefresh()
107 }
108 .onDisappear {
109 guard !isPresentingLogSheet else { return }
110 viewModel?.stopAutoRefresh()
111 }
80112 }
81113
82114 @ViewBuilder
@@ -129,7 +161,7 @@ struct BuildDetailView: View {
129161 ForEach(job.tasks) { task in
130162 Section {
131163 Button {
132 selectedTask = task
164 selectedTaskName = task.name
133165 } label: {
134166 HStack {
135167 Text(task.status.rawValue.capitalized)
Hutch/Views/Builds/BuildDetailViewModel.swift +122 −3
@@ -27,14 +27,20 @@ private struct SubmittedJob: Decodable, Sendable {
2727@Observable
2828@MainActor
2929final class BuildDetailViewModel {
30 private static let autoRefreshInterval: Duration = .seconds(5)
3031
3132 let jobId: Int
3233 private let client: SRHTClient
3334
35 private var autoRefreshTask: Task<Void, Never>?
3436 private(set) var job: JobDetail?
3537 private(set) var isLoading = false
38 private(set) var buildLogText: String?
39 private(set) var isLoadingBuildLog = false
3640 private(set) var taskLogs: [String: String] = [:]
3741 private(set) var loadingTaskLogs: Set<String> = []
42 private(set) var failedTaskLogs: Set<String> = []
43 private var taskLogRetryCounts: [String: Int] = [:]
3844 private(set) var isCancelling = false
3945 private(set) var isRebuilding = false
4046 private(set) var isSubmittingEditedBuild = false
@@ -108,7 +114,13 @@ final class BuildDetailViewModel {
108114 loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in
109115 task.withOrdinal(index)
110116 }
111 job = loadedJob
117 if job != loadedJob {
118 job = loadedJob
119 }
120
121 if loadedJob.status.isTerminal {
122 stopAutoRefresh()
123 }
112124 } catch {
113125 self.error = error.userFacingMessage
114126 }
@@ -118,21 +130,82 @@ final class BuildDetailViewModel {
118130
119131 func loadTaskLog(task: BuildTask) async {
120132 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.
121137 guard let log = task.log,
122138 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)
125143 loadingTaskLogs.insert(cacheKey)
126144
127145 do {
128146 taskLogs[cacheKey] = try await client.fetchText(url: logURL)
147 failedTaskLogs.remove(cacheKey)
129148 } catch {
149 failedTaskLogs.insert(cacheKey)
130150 self.error = error.userFacingMessage
131151 }
132152
133153 loadingTaskLogs.remove(cacheKey)
134154 }
135155
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
136209 func cancelJob() async {
137210 guard let job, job.status.isCancellable, !isCancelling else { return }
138211 isCancelling = true
@@ -237,4 +310,50 @@ final class BuildDetailViewModel {
237310 return nil
238311 }
239312 }
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 }
240359}