git.iko.soy/parseable-macos
on master help sign in
Fix 13 bugs: selection corruption, data loss, query race, JSON equality, dedup, completion, severity, validation, and alerts
commit5247b428a5edd1648ef48748403114bc273acfc3 authoriko <mail@iko.soy> dateSat 20 Jun 2026, 23:51 (3 months ago) parent09b478e
10 files changed+110−20
ParseableViewer/App/AppState.swift+5 ParseableViewer/Models/JSONValue.swift+5−1 ParseableViewer/Services/ParseableClient.swift+19−3 ParseableViewer/ViewModels/LiveTailViewModel.swift+18−2 ParseableViewer/ViewModels/QueryViewModel.swift+11 ParseableViewer/Views/ConnectionSheet.swift+4 ParseableViewer/Views/LiveTailView.swift+18−12 ParseableViewer/Views/LogTableView.swift+19−2 ParseableViewer/Views/SQLEditorView.swift+7 ParseableViewer/Views/SidebarView.swift+4
ParseableViewer/App/AppState.swift+5
@@ -113,6 +113,11 @@ final class AppState {
113113 if activeConnection?.id != connection.id {
114114 selectedStream = nil
115115 streamLoadError = nil
116+ // Reset the sidebar search filter and tab too: a stale filter would
117+ // silently hide the new server's streams, and a stream-specific tab
118+ // would have no valid selection behind it.
119+ streamSearchText = ""
120+ currentTab = .query
116121 }
117122
118123 do {
ParseableViewer/Models/JSONValue.swift+5−1
@@ -272,7 +272,11 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
272272 case (.null, .null): return true
273273 case (.bool(let a), .bool(let b)): return a == b
274274 case (.int(let a), .int(let b)): return a == b
275− case (.double(let a), .double(let b)): return a == b
275+ // NaN must compare equal to itself here: `<` (via doubleLessThan) orders
276+ // two NaNs as equivalent, and Comparable requires order-equivalent values
277+ // to be ==. Raw `a == b` (NaN != NaN) would break reflexivity, so e.g.
278+ // `.array([.double(.nan)])` would not equal itself.
279+ case (.double(let a), .double(let b)): return a == b || (a.isNaN && b.isNaN)
276280 case (.int(let a), .double(let b)): return Self.intEqualsDouble(a, b)
277281 case (.double(let a), .int(let b)): return Self.intEqualsDouble(b, a)
278282 case (.string(let a), .string(let b)): return a == b
ParseableViewer/Services/ParseableClient.swift+19−3
@@ -322,13 +322,29 @@ final class ParseableClient: Sendable {
322322 // a 405, or a 200 carrying a differently-shaped body (a DecodingError,
323323 // which is not a ParseableError and would otherwise escape this method).
324324 let encoded = try Self.encodePathComponent(stream)
325+
326+ func legacyAlerts() async throws -> AlertConfig {
327+ let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/alert")
328+ return try Self.jsonDecoder.decode(AlertConfig.self, from: data)
329+ }
330+
325331 do {
326332 let data = try await performRequest(method: "GET", path: "/api/v1/alerts")
327− return try Self.jsonDecoder.decode(AlertConfig.self, from: data)
333+ let config = try Self.jsonDecoder.decode(AlertConfig.self, from: data)
334+ // AlertConfig decodes leniently (every field via try?), so a 200 with
335+ // an unexpected body yields an empty, non-throwing config rather than
336+ // a DecodingError. Treat an empty result as "new endpoint unsupported"
337+ // and fall through to the legacy per-stream endpoint instead of
338+ // silently reporting no alerts.
339+ if config.alerts?.isEmpty ?? true {
340+ if let legacy = try? await legacyAlerts(), !(legacy.alerts?.isEmpty ?? true) {
341+ return legacy
342+ }
343+ }
344+ return config
328345 } catch let newEndpointError {
329346 do {
330− let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/alert")
331− return try Self.jsonDecoder.decode(AlertConfig.self, from: data)
347+ return try await legacyAlerts()
332348 } catch {
333349 // Legacy also failed — surface the original (more informative) error.
334350 throw newEndpointError
ParseableViewer/ViewModels/LiveTailViewModel.swift+18−2
@@ -28,6 +28,12 @@ final class LiveTailViewModel {
2828 nonisolated(unsafe) private var timer: Timer?
2929 private var lastTimestamp: Date?
3030 private var seenFingerprints: Set<String> = []
31+ /// Upper bound on the dedup fingerprint set. Kept well above `maxEntries` so
32+ /// fingerprints of recently-evicted entries survive long enough to suppress
33+ /// duplicates that reappear inside the next poll's look-back window, while
34+ /// still bounding memory. Only when this is exceeded do we fall back to the
35+ /// retained-entry fingerprints.
36+ private var maxSeenFingerprints: Int { maxEntries * 4 }
3137 private var allKnownKeys: Set<String> = []
3238 private var consecutiveErrors = 0
3339 private static let maxConsecutiveErrors = 5
@@ -372,8 +378,18 @@ final class LiveTailViewModel {
372378 entries.sort { $0.timestamp < $1.timestamp }
373379 entries = Array(entries.suffix(maxEntries))
374380
375− // Rebuild fingerprint set from stored values (no re-hashing)
376− seenFingerprints = Set(entries.map { $0.fingerprint })
381+ // Do NOT rebuild the fingerprint set from the retained
382+ // entries: an evicted record can still fall inside the next
383+ // poll's look-back window, and dropping its fingerprint here
384+ // would let it pass the dedup check and be re-appended as a
385+ // duplicate. Keep the full seen-set so dedup stays correct;
386+ // it is bounded below to avoid unbounded growth.
387+ if seenFingerprints.count > maxSeenFingerprints {
388+ // Retain the fingerprints of everything still buffered
389+ // (those are the ones that matter for dedup) and discard
390+ // the older surplus.
391+ seenFingerprints = Set(entries.map { $0.fingerprint })
392+ }
377393 }
378394
379395 // Only scan new records for new keys
ParseableViewer/ViewModels/QueryViewModel.swift+11
@@ -525,6 +525,17 @@ final class QueryViewModel {
525525 }
526526
527527 func clearResults() {
528+ // Cancel any in-flight work first. Without this, a query still running
529+ // when the user switches streams or disconnects would pass its post-await
530+ // cancellation check (it was never cancelled) and write its results +
531+ // persist a column config for the *old* stream after this reset.
532+ queryTask?.cancel()
533+ queryTask = nil
534+ searchTextTask?.cancel()
535+ searchTextTask = nil
536+ filterTask?.cancel()
537+ filterTask = nil
538+
528539 results = []
529540 columns = []
530541 columnOrder = []
ParseableViewer/Views/ConnectionSheet.swift+4
@@ -135,6 +135,10 @@ struct ConnectionSheet: View {
135135 username = connection.username
136136 password = connection.password
137137 }
138+ // Validate the pre-filled URL on open. onChange(of:url) doesn't fire
139+ // for the initial assignment above, so without this an invalid stored
140+ // URL would show no error and pass the Save guard.
141+ urlValidationError = Self.validateURL(url)
138142 }
139143 }
140144
ParseableViewer/Views/LiveTailView.swift+18−12
@@ -3,7 +3,10 @@ import SwiftUI
33struct LiveTailView: View {
44 @Environment(AppState.self) private var appState
55 @State private var viewModel = LiveTailViewModel()
6− @State private var selectedRecord: LogRecord?
6+ /// Selection is tracked by entry id, not by record value: live-tail rows
7+ /// frequently share identical content (repeated log lines), so comparing by
8+ /// record value would select/toggle every matching row at once.
9+ @State private var selectedEntryID: UUID?
710 @State private var autoScroll = true
811 @State private var pulseOpacity: Double = 1.0
912 @State private var showColumnPopover = false
@@ -112,7 +115,7 @@ struct LiveTailView: View {
112115
113116 Button {
114117 viewModel.clear()
115− selectedRecord = nil
118+ selectedEntryID = nil
116119 sortColumn = nil
117120 columnWidths = [:]
118121 cachedSorted = []
@@ -250,8 +253,9 @@ struct LiveTailView: View {
250253 .frame(minWidth: 400)
251254
252255 // Detail pane
253− if let record = selectedRecord {
254− LogDetailView(record: record)
256+ if let id = selectedEntryID,
257+ let entry = cachedSorted.first(where: { $0.id == id }) {
258+ LogDetailView(record: entry.record)
255259 .frame(minWidth: 280, idealWidth: 320)
256260 }
257261 }
@@ -263,7 +267,7 @@ struct LiveTailView: View {
263267 viewModel.stop()
264268 }
265269 viewModel.clear()
266− selectedRecord = nil
270+ selectedEntryID = nil
267271 sortColumn = nil
268272 columnWidths = [:]
269273 cachedSorted = []
@@ -337,7 +341,7 @@ struct LiveTailView: View {
337341 record: entry.record,
338342 columns: viewModel.visibleColumns,
339343 columnWidths: columnWidths,
340− isSelected: selectedRecord == entry.record,
344+ isSelected: selectedEntryID == entry.id,
341345 isAlternate: index % 2 == 1,
342346 severity: severityByID[entry.id] ?? .unknown,
343347 severityColumns: severityColumnSet,
@@ -347,10 +351,10 @@ struct LiveTailView: View {
347351 }
348352 )
349353 .onTapGesture {
350− if selectedRecord == entry.record {
351− selectedRecord = nil
354+ if selectedEntryID == entry.id {
355+ selectedEntryID = nil
352356 } else {
353− selectedRecord = entry.record
357+ selectedEntryID = entry.id
354358 }
355359 }
356360 }
@@ -381,9 +385,11 @@ struct LiveTailView: View {
381385 rebuildSortedEntries()
382386 }
383387 // Scroll when the rendered list (`cachedSorted`) changes, targeting
384− // its last element. Keying off `entries.count` could fire before the
385− // rebuild, scrolling to an id not yet present (a silent no-op).
386− .onChange(of: cachedSorted.count) { _, _ in
388+ // its last element. Key off the last element's id rather than the
389+ // count: once the buffer is pinned at `maxEntries`, the count stops
390+ // changing while new rows keep arriving, so a count-based trigger
391+ // would silently stall auto-scroll.
392+ .onChange(of: cachedSorted.last?.id) { _, _ in
387393 guard autoScroll, sortColumn == nil else { return }
388394 if let target = cachedSorted.last {
389395 proxy.scrollTo(target.id, anchor: .bottom)
ParseableViewer/Views/LogTableView.swift+19−2
@@ -139,12 +139,19 @@ func parseOTelSeverityNumber(_ num: Int) -> SeverityLevel {
139139
140140/// Extracts the severity level from a log record by checking only known severity columns.
141141func extractSeverity(from record: LogRecord, severityColumns: Set<String>) -> SeverityLevel {
142− for col in severityColumns {
142+ // Iterate in a deterministic (sorted) order so that, when a record carries
143+ // more than one severity-like column with conflicting values, the "winning"
144+ // column is stable across runs rather than dependent on Set iteration order.
145+ for col in severityColumns.sorted() {
143146 guard let value = record[col] else { continue }
144147 let str: String
145148 switch value {
146149 case .string(let s): str = s
147150 case .int(let i): str = String(i)
151+ // Numeric severities (e.g. OTel severity_number) sometimes decode as a
152+ // double; stringify the integral value so they still parse.
153+ case .double(let d) where d == d.rounded() && abs(d) < 1e15:
154+ str = String(format: "%.0f", d)
148155 default: continue
149156 }
150157 // OpenTelemetry severity columns use a 1–24 scale that collides with
@@ -297,6 +304,13 @@ struct LogTableView: View {
297304 // their natural order.
298305 if let sortColumn, !newColumns.contains(sortColumn) {
299306 self.sortColumn = nil
307+ // The sortColumn change triggers its own rebuild below.
308+ } else {
309+ // Columns changed without dropping the sort column (e.g.
310+ // hiding/showing a severity column via the Column Manager).
311+ // Rebuild so the severity column set — and therefore row
312+ // tinting — reflects the new columns.
313+ debouncedRebuildSort()
300314 }
301315 }
302316 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
@@ -674,7 +688,10 @@ struct LogRowView: View {
674688 .foregroundStyle(colorForValue(column: column, value: value))
675689 .contextMenu {
676690 Button("Copy Value") {
677− let text = value?.displayString ?? ""
691+ // Use exportString, not displayString: the latter
692+ // renders nested arrays/objects as placeholders like
693+ // "[3 items]", so copying would lose the real data.
694+ let text = value?.exportString ?? ""
678695 NSPasteboard.general.clearContents()
679696 _ = NSPasteboard.general.setString(text, forType: .string)
680697 }
ParseableViewer/Views/SQLEditorView.swift+7
@@ -147,6 +147,13 @@ struct SQLEditorView: NSViewRepresentable {
147147 // MARK: - Completions
148148
149149 private func updateCompletions(for textView: NSTextView) {
150+ // Capture the text view that the popup is being presented for. The
151+ // popup's accept handler (e.g. a double-click in the borderless popup
152+ // window) doesn't necessarily route through the begin-editing /
153+ // selection-change delegate callbacks first, so relying on those
154+ // alone to record the target can drop an accepted completion.
155+ lastTextView = textView
156+
150157 let cursorPosition = textView.selectedRange().location
151158 let result = SQLCompletionProvider.completions(
152159 for: textView.string,
ParseableViewer/Views/SidebarView.swift+4
@@ -280,6 +280,10 @@ struct FilterRow: View {
280280
281281 var body: some View {
282282 Button {
283+ // Don't navigate to a stream that no longer exists: it would drive
284+ // detail views to query a deleted stream (surfacing 404s) and leave
285+ // the sidebar selection pointing at no row.
286+ guard streamExists else { return }
283287 appState.selectedStream = filter.streamName
284288 if let sql = filter.query.filterQuery {
285289 appState.pendingFilterSQL = sql