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
4b452faiko
ParseableViewer/App/AppState.swift+8−7
@@ -266,14 +266,15 @@ final class AppState {
266 connections[index] = connection
266 connections[index] = connection
267 ConnectionStore.saveConnections(connections)
267 ConnectionStore.saveConnections(connections)
268 }
268 }
269− // If the active connection was edited, refresh the displayed metadata and
269+ // If the active connection was edited, its URL or credentials may now point
270− // rebuild the live client so subsequent requests use the new URL/credentials
270+ // at a different server, so swapping the client alone isn't enough — the
271− // instead of the stale ones captured when the client was first created.
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.
272 if activeConnection?.id == connection.id {
275 if activeConnection?.id == connection.id {
273− activeConnection = connection
276+ selectedStream = nil
274− if let newClient = try? ParseableClient(connection: connection) {
277+ Task { await connect(to: connection) }
275− client = newClient
276− }
277 }
278 }
278 }
279 }
279
280
ParseableViewer/Models/LogStream.swift+13−4
@@ -87,6 +87,15 @@ struct SchemaField: Identifiable, Codable, Hashable, Sendable {
87 }
87 }
88}
88}
89
89
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+
90struct StreamStats: Codable, Sendable {
99struct StreamStats: Codable, Sendable {
91 let ingestion: IngestionStats?
100 let ingestion: IngestionStats?
92 let storage: StorageStats?
101 let storage: StorageStats?
@@ -111,7 +120,7 @@ struct StreamStats: Codable, Sendable {
111 } else if let n = try? container.decode(Int.self, forKey: .size) {
120 } else if let n = try? container.decode(Int.self, forKey: .size) {
112 self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
121 self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
113 } else if let d = try? container.decode(Double.self, forKey: .size) {
122 } 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)
115 } else {
124 } else {
116 self.size = nil
125 self.size = nil
117 }
126 }
@@ -120,7 +129,7 @@ struct StreamStats: Codable, Sendable {
120 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
129 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
121 self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
130 self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
122 } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
131 } 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)
124 } else {
133 } else {
125 self.lifetime_size = nil
134 self.lifetime_size = nil
126 }
135 }
@@ -144,7 +153,7 @@ struct StreamStats: Codable, Sendable {
144 } else if let n = try? container.decode(Int.self, forKey: .size) {
153 } else if let n = try? container.decode(Int.self, forKey: .size) {
145 self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
154 self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
146 } else if let d = try? container.decode(Double.self, forKey: .size) {
155 } 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)
148 } else {
157 } else {
149 self.size = nil
158 self.size = nil
150 }
159 }
@@ -153,7 +162,7 @@ struct StreamStats: Codable, Sendable {
153 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
162 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
154 self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
163 self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
155 } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
164 } 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)
157 } else {
166 } else {
158 self.lifetime_size = nil
167 self.lifetime_size = nil
159 }
168 }
ParseableViewer/Services/ParseableClient.swift+8−1
@@ -356,7 +356,14 @@ final class ParseableClient: Sendable {
356 // per-stream. Scope the modern result to the requested stream via each
356 // per-stream. Scope the modern result to the requested stream via each
357 // alert's `datasets` membership so every stream doesn't show the same
357 // alert's `datasets` membership so every stream doesn't show the same
358 // unfiltered global list.
358 // 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+ }
360 return AlertConfig(alerts: scoped, version: config.version)
367 return AlertConfig(alerts: scoped, version: config.version)
361 } catch let newEndpointError {
368 } catch let newEndpointError {
362 // An auth failure won't be fixed by hitting the legacy endpoint (it
369 // 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 {
584 filterTask?.cancel()
584 filterTask?.cancel()
585 filterTask = nil
585 filterTask = nil
586
586
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+
587 results = []
593 results = []
588 columns = []
594 columns = []
589 columnOrder = []
595 columnOrder = []
@@ -639,10 +645,19 @@ final class QueryViewModel {
639 }
645 }
640
646
641 if let whereEnd {
647 if let whereEnd {
642− if let tailStart, tailStart > whereEnd {
648+ // Parenthesize the existing WHERE body before AND-ing the new
643− sql.insert(contentsOf: "AND \(condition) ", at: tailStart)
649+ // condition, otherwise an existing top-level OR silently changes
644− } else {
650+ // meaning: `WHERE a = 1 OR b = 2` would become
645− sql += " AND \(condition)"
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)"
646 }
661 }
647 } else if let tailStart {
662 } else if let tailStart {
648 sql.insert(contentsOf: "WHERE \(condition) ", at: tailStart)
663 sql.insert(contentsOf: "WHERE \(condition) ", at: tailStart)
ParseableViewer/Views/LogTableView.swift+8−2
@@ -717,11 +717,17 @@ struct LogRowView: View {
717 }
717 }
718 if let onCellFilter {
718 if let onCellFilter {
719 Divider()
719 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
720 Button("Filter by This Value") {
726 Button("Filter by This Value") {
721− onCellFilter(column, value, false)
727+ onCellFilter(column, filterValue, false)
722 }
728 }
723 Button("Exclude This Value") {
729 Button("Exclude This Value") {
724− onCellFilter(column, value, true)
730+ onCellFilter(column, filterValue, true)
725 }
731 }
726 }
732 }
727 }
733 }
ParseableViewer/Views/SQLAutoComplete.swift+11−2
@@ -100,13 +100,22 @@ enum SQLCompletionProvider {
100 }
100 }
101
101
102 let prefix = nsText.substring(with: NSRange(location: wordStart, length: cursorPosition - wordStart))
102 let prefix = nsText.substring(with: NSRange(location: wordStart, length: cursorPosition - wordStart))
103− let range = NSRange(location: wordStart, length: wordEnd - wordStart)
104
103
105 guard !prefix.isEmpty else {
104 guard !prefix.isEmpty else {
106− return ([], "", range)
105+ return ([], "", NSRange(location: wordStart, length: wordEnd - wordStart))
107 }
106 }
108
107
109 let context = determineContext(text: text, position: wordStart)
108 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)
110 let uppercasePrefix = prefix.uppercased()
119 let uppercasePrefix = prefix.uppercased()
111
120
112 var items: [SQLCompletionItem] = []
121 var items: [SQLCompletionItem] = []
ParseableViewer/Views/SQLEditorView.swift+8
@@ -215,6 +215,14 @@ struct SQLEditorView: NSViewRepresentable {
215 }
215 }
216 }
216 }
217
217
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+
218 let replaceRange = NSRange(location: wordStart, length: wordEnd - wordStart)
226 let replaceRange = NSRange(location: wordStart, length: wordEnd - wordStart)
219
227
220 isInsertingCompletion = true
228 isInsertingCompletion = true
ParseableViewer/Views/SidebarView.swift+13−4
@@ -58,11 +58,18 @@ struct SidebarView: View {
58 }
58 }
59 Divider()
59 Divider()
60 Button("Delete...", role: .destructive) {
60 Button("Delete...", role: .destructive) {
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.
61− streamToDelete = stream.name
67+ let target = stream.name
68+ streamToDelete = target
62 deleteStreamDetail = nil
69 deleteStreamDetail = nil
63 Task {
70 Task {
64 if let client = appState.client,
71 if let client = appState.client,
65− let stats = try? await client.getStreamStats(stream: stream.name) {
72+ let stats = try? await client.getStreamStats(stream: target) {
66 var parts: [String] = []
73 var parts: [String] = []
67 if let count = stats.ingestion?.lifetime_count {
74 if let count = stats.ingestion?.lifetime_count {
68 parts.append("\(count) total records ingested")
75 parts.append("\(count) total records ingested")
@@ -70,11 +77,13 @@ struct SidebarView: View {
70 if let size = stats.storage?.lifetime_size ?? stats.storage?.size {
77 if let size = stats.storage?.lifetime_size ?? stats.storage?.size {
71 parts.append("\(size) storage used")
78 parts.append("\(size) storage used")
72 }
79 }
73− if !parts.isEmpty {
80+ if !parts.isEmpty, streamToDelete == target {
74 deleteStreamDetail = parts.joined(separator: ", ")
81 deleteStreamDetail = parts.joined(separator: ", ")
75 }
82 }
76 }
83 }
84+ if streamToDelete == target {
77− showDeleteConfirm = true
85+ showDeleteConfirm = true
86+ }
78 }
87 }
79 }
88 }
80 }
89 }