git.iko.soy/parseable-macos
on master help sign in
Fix 8 bugs: alert scoping, Int64 trap, isLoading stuck, OR precedence, delete stats, quote completion, stale connection, cell filter
commit4b452fa0228630327534a377efa44ecf5e751eeb authoriko <mail@iko.soy> dateSun 21 Jun 2026, 11:38 (3 months ago) parenta39e1d5
8 files changed+88−24
ParseableViewer/App/AppState.swift+8−7 ParseableViewer/Models/LogStream.swift+13−4 ParseableViewer/Services/ParseableClient.swift+8−1 ParseableViewer/ViewModels/QueryViewModel.swift+19−4 ParseableViewer/Views/LogTableView.swift+8−2 ParseableViewer/Views/SQLAutoComplete.swift+11−2 ParseableViewer/Views/SQLEditorView.swift+8 ParseableViewer/Views/SidebarView.swift+13−4
ParseableViewer/App/AppState.swift+8−7
@@ -266,14 +266,15 @@ final class AppState {
266266 connections[index] = connection
267267 ConnectionStore.saveConnections(connections)
268268 }
269− // If the active connection was edited, refresh the displayed metadata and
270− // rebuild the live client so subsequent requests use the new URL/credentials
271− // instead of the stale ones captured when the client was first created.
269+ // If the active connection was edited, its URL or credentials may now point
270+ // at a different server, so swapping the client alone isn't enough — the
271+ // displayed streams/selection/server-info would be stale and queries would
272+ // 404. Reconnect end to end (re-validate health, reload streams/filters/
273+ // about). Clear the stream selection first so a now-missing stream doesn't
274+ // keep driving detail-tab requests during the reconnect.
272275 if activeConnection?.id == connection.id {
273− activeConnection = connection
274− if let newClient = try? ParseableClient(connection: connection) {
275− client = newClient
276− }
276+ selectedStream = nil
277+ Task { await connect(to: connection) }
277278 }
278279 }
279280
ParseableViewer/Models/LogStream.swift+13−4
@@ -87,6 +87,15 @@ struct SchemaField: Identifiable, Codable, Hashable, Sendable {
8787 }
8888}
8989
90+/// Formats a byte count that may arrive from the server as a fractional or
91+/// out-of-range Double. `Int64(_:)` from a `Double` is a *trapping* conversion —
92+/// it crashes on NaN, infinity, or values outside `Int64`'s range — so a valid
93+/// but huge JSON number like `1e30` would crash the whole decode. Guard first.
94+private func formatByteSize(_ value: Double) -> String? {
95+ guard value.isFinite, value >= 0, value < 9.223372036854776e18 else { return nil }
96+ return ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file)
97+}
98+
9099struct StreamStats: Codable, Sendable {
91100 let ingestion: IngestionStats?
92101 let storage: StorageStats?
@@ -111,7 +120,7 @@ struct StreamStats: Codable, Sendable {
111120 } else if let n = try? container.decode(Int.self, forKey: .size) {
112121 self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
113122 } else if let d = try? container.decode(Double.self, forKey: .size) {
114− self.size = ByteCountFormatter.string(fromByteCount: Int64(d), countStyle: .file)
123+ self.size = formatByteSize(d)
115124 } else {
116125 self.size = nil
117126 }
@@ -120,7 +129,7 @@ struct StreamStats: Codable, Sendable {
120129 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
121130 self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
122131 } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
123− self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(d), countStyle: .file)
132+ self.lifetime_size = formatByteSize(d)
124133 } else {
125134 self.lifetime_size = nil
126135 }
@@ -144,7 +153,7 @@ struct StreamStats: Codable, Sendable {
144153 } else if let n = try? container.decode(Int.self, forKey: .size) {
145154 self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
146155 } else if let d = try? container.decode(Double.self, forKey: .size) {
147− self.size = ByteCountFormatter.string(fromByteCount: Int64(d), countStyle: .file)
156+ self.size = formatByteSize(d)
148157 } else {
149158 self.size = nil
150159 }
@@ -153,7 +162,7 @@ struct StreamStats: Codable, Sendable {
153162 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
154163 self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
155164 } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
156− self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(d), countStyle: .file)
165+ self.lifetime_size = formatByteSize(d)
157166 } else {
158167 self.lifetime_size = nil
159168 }
ParseableViewer/Services/ParseableClient.swift+8−1
@@ -356,7 +356,14 @@ final class ParseableClient: Sendable {
356356 // per-stream. Scope the modern result to the requested stream via each
357357 // alert's `datasets` membership so every stream doesn't show the same
358358 // unfiltered global list.
359− let scoped = config.alerts?.filter { $0.datasets?.contains(stream) ?? false }
359+ let scoped = config.alerts?.filter { rule in
360+ // A missing or empty `datasets` means the alert isn't scoped to
361+ // specific streams (the summary endpoint frequently omits it), so
362+ // treat it as global and surface it for every stream. Only exclude
363+ // an alert when it explicitly lists datasets, none of which match.
364+ guard let datasets = rule.datasets, !datasets.isEmpty else { return true }
365+ return datasets.contains(stream)
366+ }
360367 return AlertConfig(alerts: scoped, version: config.version)
361368 } catch let newEndpointError {
362369 // An auth failure won't be fixed by hitting the legacy endpoint (it
ParseableViewer/ViewModels/QueryViewModel.swift+19−4
@@ -584,6 +584,12 @@ final class QueryViewModel {
584584 filterTask?.cancel()
585585 filterTask = nil
586586
587+ // The in-flight query was cancelled above, so its task returns via the
588+ // CancellationError path and never reaches `isLoading = false`. Reset it
589+ // here, otherwise deselecting the stream / disconnecting (which calls
590+ // clearResults with no follow-up query) leaves the spinner stuck on.
591+ isLoading = false
592+
587593 results = []
588594 columns = []
589595 columnOrder = []
@@ -639,10 +645,19 @@ final class QueryViewModel {
639645 }
640646
641647 if let whereEnd {
642− if let tailStart, tailStart > whereEnd {
643− sql.insert(contentsOf: "AND \(condition) ", at: tailStart)
644− } else {
645− sql += " AND \(condition)"
648+ // Parenthesize the existing WHERE body before AND-ing the new
649+ // condition, otherwise an existing top-level OR silently changes
650+ // meaning: `WHERE a = 1 OR b = 2` would become
651+ // `WHERE a = 1 OR b = 2 AND c = 3`, which SQL binds as
652+ // `a = 1 OR (b = 2 AND c = 3)` rather than the intended
653+ // `(a = 1 OR b = 2) AND c = 3`.
654+ let bodyEnd = tailStart ?? sql.endIndex
655+ let head = sql[..<whereEnd]
656+ let body = sql[whereEnd..<bodyEnd].trimmingCharacters(in: .whitespacesAndNewlines)
657+ let tail = sql[bodyEnd...].trimmingCharacters(in: .whitespacesAndNewlines)
658+ sql = "\(head) (\(body)) AND \(condition)"
659+ if !tail.isEmpty {
660+ sql += " \(tail)"
646661 }
647662 } else if let tailStart {
648663 sql.insert(contentsOf: "WHERE \(condition) ", at: tailStart)
ParseableViewer/Views/LogTableView.swift+8−2
@@ -717,11 +717,17 @@ struct LogRowView: View {
717717 }
718718 if let onCellFilter {
719719 Divider()
720+ // Normalize a null cell to `nil` so this produces an
721+ // IS NULL / IS NOT NULL filter, matching the header
722+ // "Filter by Value" path. Passing `.null` straight
723+ // through would build an `= null` value filter, which
724+ // the live-tail in-memory matcher treats differently.
725+ let filterValue: JSONValue? = value == .null ? nil : value
720726 Button("Filter by This Value") {
721− onCellFilter(column, value, false)
727+ onCellFilter(column, filterValue, false)
722728 }
723729 Button("Exclude This Value") {
724− onCellFilter(column, value, true)
730+ onCellFilter(column, filterValue, true)
725731 }
726732 }
727733 }
ParseableViewer/Views/SQLAutoComplete.swift+11−2
@@ -100,13 +100,22 @@ enum SQLCompletionProvider {
100100 }
101101
102102 let prefix = nsText.substring(with: NSRange(location: wordStart, length: cursorPosition - wordStart))
103− let range = NSRange(location: wordStart, length: wordEnd - wordStart)
104103
105104 guard !prefix.isEmpty else {
106− return ([], "", range)
105+ return ([], "", NSRange(location: wordStart, length: wordEnd - wordStart))
107106 }
108107
109108 let context = determineContext(text: text, position: wordStart)
109+
110+ // A table completion supplies its own surrounding quotes. If the user has
111+ // already typed an opening double-quote (common for stream names with
112+ // spaces, e.g. `FROM "my`), absorb it into the replacement range so the
113+ // inserted `"name"` replaces the stray quote instead of producing `""name"`.
114+ var replaceStart = wordStart
115+ if case .tableRef = context, wordStart > 0, nsText.character(at: wordStart - 1) == 0x22 {
116+ replaceStart -= 1
117+ }
118+ let range = NSRange(location: replaceStart, length: wordEnd - replaceStart)
110119 let uppercasePrefix = prefix.uppercased()
111120
112121 var items: [SQLCompletionItem] = []
ParseableViewer/Views/SQLEditorView.swift+8
@@ -215,6 +215,14 @@ struct SQLEditorView: NSViewRepresentable {
215215 }
216216 }
217217
218+ // A table completion carries its own quotes; absorb a directly
219+ // preceding opening quote the user typed (e.g. `FROM "my`) so the
220+ // inserted `"name"` replaces it instead of producing `""name"`.
221+ if item.kind == .table, wordStart > 0,
222+ nsText.character(at: wordStart - 1) == 0x22 {
223+ wordStart -= 1
224+ }
225+
218226 let replaceRange = NSRange(location: wordStart, length: wordEnd - wordStart)
219227
220228 isInsertingCompletion = true
ParseableViewer/Views/SidebarView.swift+13−4
@@ -58,11 +58,18 @@ struct SidebarView: View {
5858 }
5959 Divider()
6060 Button("Delete...", role: .destructive) {
61− streamToDelete = stream.name
61+ // Capture the target so a slower stats fetch
62+ // for a *previously* clicked stream can't write
63+ // its detail (or raise the confirm) against the
64+ // stream the user has since selected — which
65+ // would show the wrong stats on a destructive
66+ // confirmation.
67+ let target = stream.name
68+ streamToDelete = target
6269 deleteStreamDetail = nil
6370 Task {
6471 if let client = appState.client,
65− let stats = try? await client.getStreamStats(stream: stream.name) {
72+ let stats = try? await client.getStreamStats(stream: target) {
6673 var parts: [String] = []
6774 if let count = stats.ingestion?.lifetime_count {
6875 parts.append("\(count) total records ingested")
@@ -70,11 +77,13 @@ struct SidebarView: View {
7077 if let size = stats.storage?.lifetime_size ?? stats.storage?.size {
7178 parts.append("\(size) storage used")
7279 }
73− if !parts.isEmpty {
80+ if !parts.isEmpty, streamToDelete == target {
7481 deleteStreamDetail = parts.joined(separator: ", ")
7582 }
7683 }
77− showDeleteConfirm = true
84+ if streamToDelete == target {
85+ showDeleteConfirm = true
86+ }
7887 }
7988 }
8089 }