git.iko.soy/parseable-macos
on 1c8b1da help sign in
5 files changed+13−101
since splitsnapshots
unifiedsplit
ParseableViewer/Views/AlertsView.swift+1−12 ParseableViewer/Views/LogTableView.swift+6−53 ParseableViewer/Views/ServerInfoView.swift+2−12 ParseableViewer/Views/StreamDetailView.swift+4−18 ParseableViewer/Views/UsersView.swift−6
ParseableViewer/Views/AlertsView.swift+1−12
@@ -5,10 +5,6 @@ struct AlertsView: View {
55 @State private var alertConfig: AlertConfig?
66 @State private var isLoading = false
77 @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
128
139 var body: some View {
1410 Group {
@@ -81,16 +77,12 @@ struct AlertsView: View {
8177 private func loadAlerts(stream: String) async {
8278 guard let client = appState.client else { return }
8379 let connID = appState.activeConnection?.id
84− loadGeneration &+= 1
85− let generation = loadGeneration
8680 isLoading = true
8781 errorMessage = nil
8882 alertConfig = nil
8983
9084 do {
9185 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 }
9486 // The manual Refresh/Retry buttons spawn detached Tasks not tied to
9587 // `.task(id: stream)` cancellation, so a slow refresh for the old stream
9688 // can resolve after a switch. Drop stale results. Check the connection
@@ -102,7 +94,6 @@ struct AlertsView: View {
10294 connID == appState.activeConnection?.id else { isLoading = false; return }
10395 alertConfig = config
10496 } catch {
105− guard generation == loadGeneration else { return }
10697 guard stream == appState.selectedStream,
10798 connID == appState.activeConnection?.id else { isLoading = false; return }
10899 errorMessage = ParseableError.userFriendlyMessage(for: error)
@@ -212,9 +203,7 @@ struct AlertRowView: View {
212203 private var stateColor: Color {
213204 switch alert.state?.lowercased() {
214205 case "triggered": return .red
215− case "disabled", "silenced": return .gray
216− // Parseable's default, healthy state (and 2.x's "resolved") — not a warning.
217− case "not-triggered", "resolved": return .green
206+ case "disabled": return .gray
218207 // A reported-but-unrecognized state is a genuine "needs attention" signal,
219208 // so tint it orange; a missing state is simply unknown and stays neutral so
220209 // 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 {
315315 }
316316 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
317317 .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− }
326318 .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− }
335319 recomputeColumnWidths(columns: columns, records: records)
336320 rebuildSort()
337321 }
@@ -417,9 +401,6 @@ struct LogTableView: View {
417401 }
418402
419403 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()
423404 let sevCols = buildSeverityColumnSet(columns: columns)
424405 let enumerated = Array(records.enumerated())
425406 let sorted: [(offset: Int, element: LogRecord)]
@@ -495,28 +476,12 @@ struct LogHeaderView: View {
495476
496477 @State private var draggedColumn: String?
497478 @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()
505− @State private var recordsGeneration = 0
506−
507− private final class UniqueValuesCache {
508− var generation = -1
509− var values: [String: [JSONValue]] = [:]
510− }
479+ @State private var uniqueValuesCache: [String: [JSONValue]] = [:]
511480
512481 private static let maxFilterValues = 20
513482
514483 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] {
520485 return cached
521486 }
522487 var seen = Set<JSONValue>()
@@ -528,7 +493,7 @@ struct LogHeaderView: View {
528493 }
529494 }
530495 let sorted = result.sorted()
531− uniqueValuesCache.values[column] = sorted
496+ uniqueValuesCache[column] = sorted
532497 return sorted
533498 }
534499
@@ -671,7 +636,7 @@ struct LogHeaderView: View {
671636 .background(Color(nsColor: .controlBackgroundColor))
672637 .border(Color.secondary.opacity(0.2), width: 0.5)
673638 .onChange(of: records) { _, _ in
674− recordsGeneration &+= 1
639+ uniqueValuesCache = [:]
675640 }
676641 }
677642}
@@ -704,26 +669,14 @@ private struct ColumnDropDelegate: DropDelegate {
704669 }
705670
706671 func performDrop(info: DropInfo) -> Bool {
707− guard let dragged = draggedColumn, dragged != column,
708− let provider = info.itemProviders(for: [UTType.plainText]).first else {
672+ guard let dragged = draggedColumn, dragged != column else {
709673 draggedColumn = nil
710674 dropTargetColumn = nil
711675 return false
712676 }
677+ onMoveColumn?(dragged, column)
713678 draggedColumn = nil
714679 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− }
727680 return true
728681 }
729682
ParseableViewer/Views/ServerInfoView.swift+2−12
@@ -9,9 +9,6 @@ struct ServerInfoView: View {
99 @State private var isLoading = true
1010 @State private var isHealthy = false
1111 @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
1512
1613 var body: some View {
1714 ScrollView {
@@ -22,7 +19,7 @@ struct ServerInfoView: View {
2219 .fontWeight(.semibold)
2320 Spacer()
2421 Button {
25− Task { await loadInfo(forceRefresh: true) }
22+ Task { await loadInfo() }
2623 } label: {
2724 Image(systemName: "arrow.clockwise")
2825 }
@@ -156,18 +153,12 @@ struct ServerInfoView: View {
156153 }
157154 }
158155
159− private func loadInfo(forceRefresh: Bool = false) async {
156+ private func loadInfo() async {
160157 // Clear isLoading on the no-client path: since isLoading now starts `true`
161158 // (to avoid a red "Unhealthy" flash before the first check), an early return
162159 // without resetting it would wedge the spinner permanently.
163160 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− }
168161 let connID = appState.activeConnection?.id
169− loadGeneration &+= 1
170− let generation = loadGeneration
171162 isLoading = true
172163 errorMessage = nil
173164 // Seed from the app-wide cache so first load isn't blank, but never fall
@@ -198,7 +189,6 @@ struct ServerInfoView: View {
198189 // load for the previous server can't overwrite the current one's state.
199190 // Still clear isLoading: this task owns the spinner it set, and leaving it
200191 // true on the stale path wedges the ProgressView and disables Refresh.
201− guard generation == loadGeneration else { return }
202192 guard connID == appState.activeConnection?.id else { isLoading = false; return }
203193 isHealthy = healthy
204194 about = aboutValue
ParseableViewer/Views/StreamDetailView.swift+4−18
@@ -8,9 +8,6 @@ struct StreamDetailView: View {
88 @State private var retention: [RetentionConfig] = []
99 @State private var isLoading = false
1010 @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
1411
1512 var body: some View {
1613 Group {
@@ -28,7 +25,7 @@ struct StreamDetailView: View {
2825 Spacer()
2926
3027 Button {
31− Task { await loadData(stream: stream, forceRefresh: true) }
28+ Task { await loadData(stream: stream) }
3229 } label: {
3330 Image(systemName: "arrow.clockwise")
3431 }
@@ -47,7 +44,7 @@ struct StreamDetailView: View {
4744 Text(error)
4845 Spacer()
4946 Button("Retry") {
50− Task { await loadData(stream: stream, forceRefresh: true) }
47+ Task { await loadData(stream: stream) }
5148 }
5249 .buttonStyle(.bordered)
5350 .controlSize(.small)
@@ -177,22 +174,13 @@ struct StreamDetailView: View {
177174 }
178175 }
179176
180− private func loadData(stream: String, forceRefresh: Bool = false) async {
177+ private func loadData(stream: String) async {
181178 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− }
189179 // Capture the connection this fetch belongs to. Two servers can expose a
190180 // same-named stream, so guarding on the stream name alone would let a
191181 // slow fetch write the previous server's data under the new server's
192182 // stream after a server switch.
193183 let connID = appState.activeConnection?.id
194− loadGeneration &+= 1
195− let generation = loadGeneration
196184 isLoading = true
197185 errorMessage = nil
198186 // Clear previous stream's data so it isn't shown under the new header
@@ -228,9 +216,7 @@ struct StreamDetailView: View {
228216 // resolve after the user switched. Drop stale results instead of writing
229217 // them under the new stream's header. Also check the connection so a
230218 // 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 —
232− // unless a newer load has started, which owns the spinner now.
233− guard generation == loadGeneration else { return }
219+ // data. Reset `isLoading` on the stale path or the spinner wedges forever.
234220 guard stream == appState.selectedStream, connID == appState.activeConnection?.id else {
235221 isLoading = false
236222 return
ParseableViewer/Views/UsersView.swift−6
@@ -5,9 +5,6 @@ struct UsersView: View {
55 @State private var users: [UserInfo] = []
66 @State private var isLoading = false
77 @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
118
129 var body: some View {
1310 VStack(spacing: 0) {
@@ -85,8 +82,6 @@ struct UsersView: View {
8582 private func loadUsers() async {
8683 guard let client = appState.client else { return }
8784 let connID = appState.activeConnection?.id
88− loadGeneration &+= 1
89− let generation = loadGeneration
9085 isLoading = true
9186 errorMessage = nil
9287 // Clear the previous server's list up front so it can't briefly show through
@@ -107,7 +102,6 @@ struct UsersView: View {
107102 // Drop results if the active connection changed while awaiting. Still clear
108103 // isLoading: this task set the spinner, so leaving it true on the stale path
109104 // wedges the ProgressView and disables Refresh.
110− guard generation == loadGeneration else { return }
111105 guard connID == appState.activeConnection?.id else { isLoading = false; return }
112106 if let loaded { users = loaded }
113107 errorMessage = loadError