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 {
19 default: false 19 default: false
20 } 20 }
21 } 21 }
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 }
22} 30}
23 31
24/// Status of a single build task within a job. 32/// Status of a single build task within a job.
@@ -33,7 +41,7 @@ enum TaskStatus: String, Codable, Sendable {
33// MARK: - Build Task 41// MARK: - Build Task
34 42
35/// A single task within a build job. 43/// A single task within a build job.
36struct BuildTask: Codable, Sendable, Identifiable { 44struct BuildTask: Codable, Sendable, Identifiable, Equatable {
37 private(set) var ordinal: Int? 45 private(set) var ordinal: Int?
38 let name: String 46 let name: String
39 let status: TaskStatus 47 let status: TaskStatus
@@ -103,7 +111,7 @@ struct JobTaskSummary: Codable, Sendable, Hashable {
103// MARK: - Job Detail (for detail view) 111// MARK: - Job Detail (for detail view)
104 112
105/// Full job model with all fields for the detail view. 113/// Full job model with all fields for the detail view.
106struct JobDetail: Codable, Sendable { 114struct JobDetail: Codable, Sendable, Equatable {
107 let id: Int 115 let id: Int
108 let created: Date 116 let created: Date
109 let updated: Date 117 let updated: Date
@@ -119,7 +127,7 @@ struct JobDetail: Codable, Sendable {
119} 127}
120 128
121/// The log associated with a build job. 129/// The log associated with a build job.
122struct BuildLog: Codable, Sendable { 130struct BuildLog: Codable, Sendable, Equatable {
123 let fullURL: String 131 let fullURL: String
124} 132}
125 133
Hutch/Views/Builds/BuildDetailView.swift +46 −14
@@ -6,10 +6,14 @@ struct BuildDetailView: View {
6 @Environment(AppState.self) private var appState 6 @Environment(AppState.self) private var appState
7 @State private var viewModel: BuildDetailViewModel? 7 @State private var viewModel: BuildDetailViewModel?
8 @State private var rebuiltJobId: Int? 8 @State private var rebuiltJobId: Int?
9 @State private var selectedTask: BuildTask? 9 @State private var selectedTaskName: String?
10 @State private var showEditResubmitSheet = false 10 @State private var showEditResubmitSheet = false
11 @State private var showCancelConfirmation = false 11 @State private var showCancelConfirmation = false
12 12
13 private var isPresentingLogSheet: Bool {
14 selectedTaskName != nil
15 }
16
13 var body: some View { 17 var body: some View {
14 Group { 18 Group {
15 if let viewModel { 19 if let viewModel {
@@ -42,18 +46,6 @@ struct BuildDetailView: View {
42 BuildDetailView(jobId: rebuiltJobId) 46 BuildDetailView(jobId: rebuiltJobId)
43 } 47 }
44 } 48 }
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 }
57 .sheet(isPresented: $showEditResubmitSheet) { 49 .sheet(isPresented: $showEditResubmitSheet) {
58 if let viewModel, let job = viewModel.job { 50 if let viewModel, let job = viewModel.job {
59 EditResubmitBuildSheet(viewModel: viewModel, job: job) { jobId in 51 EditResubmitBuildSheet(viewModel: viewModel, job: job) { jobId in
@@ -62,6 +54,38 @@ struct BuildDetailView: View {
62 } 54 }
63 } 55 }
64 } 56 }
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 }
65 .alert("Cancel Build?", isPresented: $showCancelConfirmation) { 89 .alert("Cancel Build?", isPresented: $showCancelConfirmation) {
66 Button("Keep Running", role: .cancel) {} 90 Button("Keep Running", role: .cancel) {}
67 Button("Cancel Build", role: .destructive) { 91 Button("Cancel Build", role: .destructive) {
@@ -75,8 +99,16 @@ struct BuildDetailView: View {
75 let vm = BuildDetailViewModel(jobId: jobId, client: appState.client) 99 let vm = BuildDetailViewModel(jobId: jobId, client: appState.client)
76 viewModel = vm 100 viewModel = vm
77 await vm.loadJob() 101 await vm.loadJob()
102 vm.startAutoRefresh()
78 } 103 }
79 } 104 }
105 .onAppear {
106 viewModel?.startAutoRefresh()
107 }
108 .onDisappear {
109 guard !isPresentingLogSheet else { return }
110 viewModel?.stopAutoRefresh()
111 }
80 } 112 }
81 113
82 @ViewBuilder 114 @ViewBuilder
@@ -129,7 +161,7 @@ struct BuildDetailView: View {
129 ForEach(job.tasks) { task in 161 ForEach(job.tasks) { task in
130 Section { 162 Section {
131 Button { 163 Button {
132 selectedTask = task 164 selectedTaskName = task.name
133 } label: { 165 } label: {
134 HStack { 166 HStack {
135 Text(task.status.rawValue.capitalized) 167 Text(task.status.rawValue.capitalized)
Hutch/Views/Builds/BuildDetailViewModel.swift +122 −3
@@ -27,14 +27,20 @@ private struct SubmittedJob: Decodable, Sendable {
27@Observable 27@Observable
28@MainActor 28@MainActor
29final class BuildDetailViewModel { 29final class BuildDetailViewModel {
30 private static let autoRefreshInterval: Duration = .seconds(5)
30 31
31 let jobId: Int 32 let jobId: Int
32 private let client: SRHTClient 33 private let client: SRHTClient
33 34
35 private var autoRefreshTask: Task<Void, Never>?
34 private(set) var job: JobDetail? 36 private(set) var job: JobDetail?
35 private(set) var isLoading = false 37 private(set) var isLoading = false
38 private(set) var buildLogText: String?
39 private(set) var isLoadingBuildLog = false
36 private(set) var taskLogs: [String: String] = [:] 40 private(set) var taskLogs: [String: String] = [:]
37 private(set) var loadingTaskLogs: Set<String> = [] 41 private(set) var loadingTaskLogs: Set<String> = []
42 private(set) var failedTaskLogs: Set<String> = []
43 private var taskLogRetryCounts: [String: Int] = [:]
38 private(set) var isCancelling = false 44 private(set) var isCancelling = false
39 private(set) var isRebuilding = false 45 private(set) var isRebuilding = false
40 private(set) var isSubmittingEditedBuild = false 46 private(set) var isSubmittingEditedBuild = false
@@ -108,7 +114,13 @@ final class BuildDetailViewModel {
108 loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in 114 loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in
109 task.withOrdinal(index) 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 } catch { 124 } catch {
113 self.error = error.userFacingMessage 125 self.error = error.userFacingMessage
114 } 126 }
@@ -118,21 +130,82 @@ final class BuildDetailViewModel {
118 130
119 func loadTaskLog(task: BuildTask) async { 131 func loadTaskLog(task: BuildTask) async {
120 let cacheKey = task.logCacheKey 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 guard let log = task.log, 137 guard let log = task.log,
122 let logURL = URL(string: log.fullURL), 138 let logURL = URL(string: log.fullURL),
123 !loadingTaskLogs.contains(cacheKey), 139 !loadingTaskLogs.contains(cacheKey) else { return }
124 taskLogs[cacheKey] == nil else { return } 140 guard jobIsTerminal else { return }
141 if jobIsTerminal, taskLogs[cacheKey] != nil { return }
142 failedTaskLogs.remove(cacheKey)
125 loadingTaskLogs.insert(cacheKey) 143 loadingTaskLogs.insert(cacheKey)
126 144
127 do { 145 do {
128 taskLogs[cacheKey] = try await client.fetchText(url: logURL) 146 taskLogs[cacheKey] = try await client.fetchText(url: logURL)
147 failedTaskLogs.remove(cacheKey)
129 } catch { 148 } catch {
149 failedTaskLogs.insert(cacheKey)
130 self.error = error.userFacingMessage 150 self.error = error.userFacingMessage
131 } 151 }
132 152
133 loadingTaskLogs.remove(cacheKey) 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 func cancelJob() async { 209 func cancelJob() async {
137 guard let job, job.status.isCancellable, !isCancelling else { return } 210 guard let job, job.status.isCancellable, !isCancelling else { return }
138 isCancelling = true 211 isCancelling = true
@@ -237,4 +310,50 @@ final class BuildDetailViewModel {
237 return nil 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}