git.iko.soy/parseable-macos
on 1c8b1da help sign in
ParseableViewer/Views/AlertsView.swift+1−12
since splitsnapshots
unifiedsplit
@@ -5,10 +5,6 @@ struct AlertsView: View {
5 @State private var alertConfig: AlertConfig?
5 @State private var alertConfig: AlertConfig?
6 @State private var isLoading = false
6 @State private var isLoading = false
7 @State private var errorMessage: String?
7 @State private var errorMessage: String?
8− /// Bumped by every load. SwiftUI cancels the previous `.task(id:)` load on a
9− /// stream switch, but that load still resumes (with a cancellation error) —
10− /// and must not touch `isLoading`, which now belongs to the newer load.
11− @State private var loadGeneration = 0
12
8
13 var body: some View {
9 var body: some View {
14 Group {
10 Group {
@@ -81,16 +77,12 @@ struct AlertsView: View {
81 private func loadAlerts(stream: String) async {
77 private func loadAlerts(stream: String) async {
82 guard let client = appState.client else { return }
78 guard let client = appState.client else { return }
83 let connID = appState.activeConnection?.id
79 let connID = appState.activeConnection?.id
84− loadGeneration &+= 1
85− let generation = loadGeneration
86 isLoading = true
80 isLoading = true
87 errorMessage = nil
81 errorMessage = nil
88 alertConfig = nil
82 alertConfig = nil
89
83
90 do {
84 do {
91 let config = try await client.getAlerts(stream: stream)
85 let config = try await client.getAlerts(stream: stream)
92− // Superseded by a newer load: leave its spinner and state alone.
93− guard generation == loadGeneration else { return }
94 // The manual Refresh/Retry buttons spawn detached Tasks not tied to
86 // The manual Refresh/Retry buttons spawn detached Tasks not tied to
95 // `.task(id: stream)` cancellation, so a slow refresh for the old stream
87 // `.task(id: stream)` cancellation, so a slow refresh for the old stream
96 // can resolve after a switch. Drop stale results. Check the connection
88 // can resolve after a switch. Drop stale results. Check the connection
@@ -102,7 +94,6 @@ struct AlertsView: View {
102 connID == appState.activeConnection?.id else { isLoading = false; return }
94 connID == appState.activeConnection?.id else { isLoading = false; return }
103 alertConfig = config
95 alertConfig = config
104 } catch {
96 } catch {
105− guard generation == loadGeneration else { return }
106 guard stream == appState.selectedStream,
97 guard stream == appState.selectedStream,
107 connID == appState.activeConnection?.id else { isLoading = false; return }
98 connID == appState.activeConnection?.id else { isLoading = false; return }
108 errorMessage = ParseableError.userFriendlyMessage(for: error)
99 errorMessage = ParseableError.userFriendlyMessage(for: error)
@@ -212,9 +203,7 @@ struct AlertRowView: View {
212 private var stateColor: Color {
203 private var stateColor: Color {
213 switch alert.state?.lowercased() {
204 switch alert.state?.lowercased() {
214 case "triggered": return .red
205 case "triggered": return .red
215− case "disabled", "silenced": return .gray
206+ case "disabled": return .gray
216− // Parseable's default, healthy state (and 2.x's "resolved") — not a warning.
217− case "not-triggered", "resolved": return .green
218 // A reported-but-unrecognized state is a genuine "needs attention" signal,
207 // A reported-but-unrecognized state is a genuine "needs attention" signal,
219 // so tint it orange; a missing state is simply unknown and stays neutral so
208 // so tint it orange; a missing state is simply unknown and stays neutral so
220 // the bell icon isn't painted as a warning for every stateless alert.
209 // the bell icon isn't painted as a warning for every stateless alert.
ParseableViewer/Views/LogTableView.swift+6−53
@@ -315,23 +315,7 @@ struct LogTableView: View {
315 }
315 }
316 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
316 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
317 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
317 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
318− .onChange(of: selectedRecord) { _, newValue in
319− // The owner can drop the selection on its own (QueryViewModel
320− // clears it after every query). When the new result set equals the
321− // old one, `onChange(of: records)` doesn't fire, so nothing would
322− // reconcile the row highlight: the row stayed highlighted with no
323− // detail pane, and the next click on it merely "deselected" it.
324− if newValue == nil { selectedIndex = nil }
325− }
326 .onAppear {
318 .onAppear {
327− // This branch is unmounted while results are empty (e.g. across a
328− // stream switch, which clears them first), so `onChange(of: columns)`
329− // never sees the switch. Drop a sort column the new columns don't
330− // have — or hide — before sorting, or rows would be ordered by an
331− // invisible column with no header showing the sort.
332− if let sortColumn, !columns.contains(sortColumn) {
333− self.sortColumn = nil
334− }
335 recomputeColumnWidths(columns: columns, records: records)
319 recomputeColumnWidths(columns: columns, records: records)
336 rebuildSort()
320 rebuildSort()
337 }
321 }
@@ -417,9 +401,6 @@ struct LogTableView: View {
417 }
401 }
418
402
419 private func rebuildSort() {
403 private func rebuildSort() {
420− // A debounced rebuild scheduled before the table unmounted must not land
421− // after (and overwrite) this synchronous one.
422− sortTask?.cancel()
423 let sevCols = buildSeverityColumnSet(columns: columns)
404 let sevCols = buildSeverityColumnSet(columns: columns)
424 let enumerated = Array(records.enumerated())
405 let enumerated = Array(records.enumerated())
425 let sorted: [(offset: Int, element: LogRecord)]
406 let sorted: [(offset: Int, element: LogRecord)]
@@ -495,28 +476,12 @@ struct LogHeaderView: View {
495
476
496 @State private var draggedColumn: String?
477 @State private var draggedColumn: String?
497 @State private var dropTargetColumn: String?
478 @State private var dropTargetColumn: String?
498− /// `.contextMenu`'s builder runs during body evaluation, so the filter menus
499− /// compute their values while the view is being rendered. Writing an `@State`
500− /// dictionary from there is "modifying state during view update" (undefined
501− /// behavior, and it re-invalidates the header). The cache is therefore a plain
502− /// reference that isn't observed; `recordsGeneration` — bumped outside body
503− /// evaluation — is what invalidates it and triggers the re-render.
504− @State private var uniqueValuesCache = UniqueValuesCache()
479+ @State private var uniqueValuesCache: [String: [JSONValue]] = [:]
505− @State private var recordsGeneration = 0
506−
507− private final class UniqueValuesCache {
508− var generation = -1
509− var values: [String: [JSONValue]] = [:]
510− }
511
480
512 private static let maxFilterValues = 20
481 private static let maxFilterValues = 20
513
482
514 private func uniqueValues(for column: String) -> [JSONValue] {
483 private func uniqueValues(for column: String) -> [JSONValue] {
515− if uniqueValuesCache.generation != recordsGeneration {
516− uniqueValuesCache.generation = recordsGeneration
517− uniqueValuesCache.values = [:]
518− }
519− if let cached = uniqueValuesCache.values[column] {
484+ if let cached = uniqueValuesCache[column] {
520 return cached
485 return cached
521 }
486 }
522 var seen = Set<JSONValue>()
487 var seen = Set<JSONValue>()
@@ -528,7 +493,7 @@ struct LogHeaderView: View {
528 }
493 }
529 }
494 }
530 let sorted = result.sorted()
495 let sorted = result.sorted()
531− uniqueValuesCache.values[column] = sorted
496+ uniqueValuesCache[column] = sorted
532 return sorted
497 return sorted
533 }
498 }
534
499
@@ -671,7 +636,7 @@ struct LogHeaderView: View {
671 .background(Color(nsColor: .controlBackgroundColor))
636 .background(Color(nsColor: .controlBackgroundColor))
672 .border(Color.secondary.opacity(0.2), width: 0.5)
637 .border(Color.secondary.opacity(0.2), width: 0.5)
673 .onChange(of: records) { _, _ in
638 .onChange(of: records) { _, _ in
674− recordsGeneration &+= 1
639+ uniqueValuesCache = [:]
675 }
640 }
676 }
641 }
677}
642}
@@ -704,26 +669,14 @@ private struct ColumnDropDelegate: DropDelegate {
704 }
669 }
705
670
706 func performDrop(info: DropInfo) -> Bool {
671 func performDrop(info: DropInfo) -> Bool {
707− guard let dragged = draggedColumn, dragged != column,
672+ guard let dragged = draggedColumn, dragged != column else {
708− let provider = info.itemProviders(for: [UTType.plainText]).first else {
709 draggedColumn = nil
673 draggedColumn = nil
710 dropTargetColumn = nil
674 dropTargetColumn = nil
711 return false
675 return false
712 }
676 }
677+ onMoveColumn?(dragged, column)
713 draggedColumn = nil
678 draggedColumn = nil
714 dropTargetColumn = nil
679 dropTargetColumn = nil
715− // `draggedColumn` is only cleared by a drop on a header, so a header drag
716− // that was cancelled (or dropped elsewhere) leaves it set, and any later
717− // plain-text drag onto a header would move that stale column. Only move
718− // when the dropped payload really is the column header being dragged.
719− let target = column
720− let move = onMoveColumn
721− _ = provider.loadObject(ofClass: NSString.self) { object, _ in
722− guard let payload = object as? NSString, payload as String == dragged else { return }
723− DispatchQueue.main.async {
724− move?(dragged, target)
725− }
726− }
727 return true
680 return true
728 }
681 }
729
682
ParseableViewer/Views/ServerInfoView.swift+2−12
@@ -9,9 +9,6 @@ struct ServerInfoView: View {
9 @State private var isLoading = true
9 @State private var isLoading = true
10 @State private var isHealthy = false
10 @State private var isHealthy = false
11 @State private var errorMessage: String?
11 @State private var errorMessage: String?
12− /// Bumped by every load so a superseded (cancelled) load can't clear the
13− /// newer load's spinner or overwrite its results.
14− @State private var loadGeneration = 0
15
12
16 var body: some View {
13 var body: some View {
17 ScrollView {
14 ScrollView {
@@ -22,7 +19,7 @@ struct ServerInfoView: View {
22 .fontWeight(.semibold)
19 .fontWeight(.semibold)
23 Spacer()
20 Spacer()
24 Button {
21 Button {
25− Task { await loadInfo(forceRefresh: true) }
22+ Task { await loadInfo() }
26 } label: {
23 } label: {
27 Image(systemName: "arrow.clockwise")
24 Image(systemName: "arrow.clockwise")
28 }
25 }
@@ -156,18 +153,12 @@ struct ServerInfoView: View {
156 }
153 }
157 }
154 }
158
155
159− private func loadInfo(forceRefresh: Bool = false) async {
156+ private func loadInfo() async {
160 // Clear isLoading on the no-client path: since isLoading now starts `true`
157 // Clear isLoading on the no-client path: since isLoading now starts `true`
161 // (to avoid a red "Unhealthy" flash before the first check), an early return
158 // (to avoid a red "Unhealthy" flash before the first check), an early return
162 // without resetting it would wedge the spinner permanently.
159 // without resetting it would wedge the spinner permanently.
163 guard let client = appState.client else { isLoading = false; return }
160 guard let client = appState.client else { isLoading = false; return }
164− // `getAbout()` is cached for 60 s; the Refresh button must bypass that.
165− if forceRefresh {
166− await client.invalidateCache()
167− }
168 let connID = appState.activeConnection?.id
161 let connID = appState.activeConnection?.id
169− loadGeneration &+= 1
170− let generation = loadGeneration
171 isLoading = true
162 isLoading = true
172 errorMessage = nil
163 errorMessage = nil
173 // Seed from the app-wide cache so first load isn't blank, but never fall
164 // Seed from the app-wide cache so first load isn't blank, but never fall
@@ -198,7 +189,6 @@ struct ServerInfoView: View {
198 // load for the previous server can't overwrite the current one's state.
189 // load for the previous server can't overwrite the current one's state.
199 // Still clear isLoading: this task owns the spinner it set, and leaving it
190 // Still clear isLoading: this task owns the spinner it set, and leaving it
200 // true on the stale path wedges the ProgressView and disables Refresh.
191 // true on the stale path wedges the ProgressView and disables Refresh.
201− guard generation == loadGeneration else { return }
202 guard connID == appState.activeConnection?.id else { isLoading = false; return }
192 guard connID == appState.activeConnection?.id else { isLoading = false; return }
203 isHealthy = healthy
193 isHealthy = healthy
204 about = aboutValue
194 about = aboutValue
ParseableViewer/Views/StreamDetailView.swift+4−18
@@ -8,9 +8,6 @@ struct StreamDetailView: View {
8 @State private var retention: [RetentionConfig] = []
8 @State private var retention: [RetentionConfig] = []
9 @State private var isLoading = false
9 @State private var isLoading = false
10 @State private var errorMessage: String?
10 @State private var errorMessage: String?
11− /// Bumped by every load. A superseded load (its `.task(id:)` cancelled by a
12− /// stream switch) still resumes, and must not clear the newer load's spinner.
13− @State private var loadGeneration = 0
14
11
15 var body: some View {
12 var body: some View {
16 Group {
13 Group {
@@ -28,7 +25,7 @@ struct StreamDetailView: View {
28 Spacer()
25 Spacer()
29
26
30 Button {
27 Button {
31− Task { await loadData(stream: stream, forceRefresh: true) }
28+ Task { await loadData(stream: stream) }
32 } label: {
29 } label: {
33 Image(systemName: "arrow.clockwise")
30 Image(systemName: "arrow.clockwise")
34 }
31 }
@@ -47,7 +44,7 @@ struct StreamDetailView: View {
47 Text(error)
44 Text(error)
48 Spacer()
45 Spacer()
49 Button("Retry") {
46 Button("Retry") {
50− Task { await loadData(stream: stream, forceRefresh: true) }
47+ Task { await loadData(stream: stream) }
51 }
48 }
52 .buttonStyle(.bordered)
49 .buttonStyle(.bordered)
53 .controlSize(.small)
50 .controlSize(.small)
@@ -177,22 +174,13 @@ struct StreamDetailView: View {
177 }
174 }
178 }
175 }
179
176
180− private func loadData(stream: String, forceRefresh: Bool = false) async {
177+ private func loadData(stream: String) async {
181 guard let client = appState.client else { return }
178 guard let client = appState.client else { return }
182− // The client caches schema/stats/info for 60 s. That's right for the
183− // automatic load on appear, but an explicit Refresh/Retry must hit the
184− // server — otherwise it repaints the same cached numbers and looks like a
185− // refresh that found nothing new.
186− if forceRefresh {
187− await client.invalidateCache()
188− }
189 // Capture the connection this fetch belongs to. Two servers can expose a
179 // Capture the connection this fetch belongs to. Two servers can expose a
190 // same-named stream, so guarding on the stream name alone would let a
180 // same-named stream, so guarding on the stream name alone would let a
191 // slow fetch write the previous server's data under the new server's
181 // slow fetch write the previous server's data under the new server's
192 // stream after a server switch.
182 // stream after a server switch.
193 let connID = appState.activeConnection?.id
183 let connID = appState.activeConnection?.id
194− loadGeneration &+= 1
195− let generation = loadGeneration
196 isLoading = true
184 isLoading = true
197 errorMessage = nil
185 errorMessage = nil
198 // Clear previous stream's data so it isn't shown under the new header
186 // Clear previous stream's data so it isn't shown under the new header
@@ -228,9 +216,7 @@ struct StreamDetailView: View {
228 // resolve after the user switched. Drop stale results instead of writing
216 // resolve after the user switched. Drop stale results instead of writing
229 // them under the new stream's header. Also check the connection so a
217 // them under the new stream's header. Also check the connection so a
230 // same-named stream on a different server doesn't accept the old server's
218 // same-named stream on a different server doesn't accept the old server's
231− // data. Reset `isLoading` on the stale path or the spinner wedges forever —
219+ // data. Reset `isLoading` on the stale path or the spinner wedges forever.
232− // unless a newer load has started, which owns the spinner now.
233− guard generation == loadGeneration else { return }
234 guard stream == appState.selectedStream, connID == appState.activeConnection?.id else {
220 guard stream == appState.selectedStream, connID == appState.activeConnection?.id else {
235 isLoading = false
221 isLoading = false
236 return
222 return
ParseableViewer/Views/UsersView.swift−6
@@ -5,9 +5,6 @@ struct UsersView: View {
5 @State private var users: [UserInfo] = []
5 @State private var users: [UserInfo] = []
6 @State private var isLoading = false
6 @State private var isLoading = false
7 @State private var errorMessage: String?
7 @State private var errorMessage: String?
8− /// Bumped by every load so a superseded (cancelled) load can't clear the
9− /// newer load's spinner.
10− @State private var loadGeneration = 0
11
8
12 var body: some View {
9 var body: some View {
13 VStack(spacing: 0) {
10 VStack(spacing: 0) {
@@ -85,8 +82,6 @@ struct UsersView: View {
85 private func loadUsers() async {
82 private func loadUsers() async {
86 guard let client = appState.client else { return }
83 guard let client = appState.client else { return }
87 let connID = appState.activeConnection?.id
84 let connID = appState.activeConnection?.id
88− loadGeneration &+= 1
89− let generation = loadGeneration
90 isLoading = true
85 isLoading = true
91 errorMessage = nil
86 errorMessage = nil
92 // Clear the previous server's list up front so it can't briefly show through
87 // Clear the previous server's list up front so it can't briefly show through
@@ -107,7 +102,6 @@ struct UsersView: View {
107 // Drop results if the active connection changed while awaiting. Still clear
102 // Drop results if the active connection changed while awaiting. Still clear
108 // isLoading: this task set the spinner, so leaving it true on the stale path
103 // isLoading: this task set the spinner, so leaving it true on the stale path
109 // wedges the ProgressView and disables Refresh.
104 // wedges the ProgressView and disables Refresh.
110− guard generation == loadGeneration else { return }
111 guard connID == appState.activeConnection?.id else { isLoading = false; return }
105 guard connID == appState.activeConnection?.id else { isLoading = false; return }
112 if let loaded { users = loaded }
106 if let loaded { users = loaded }
113 errorMessage = loadError
107 errorMessage = loadError