git.iko.soy/parseable-macos
on master help sign in
Fix 7 bugs: column reorder, unfiltered alerts, empty summary, dedup signed-zero, stream validation, stats fractional size
commita39e1d579ac66e9cc8cbc568e096f518f397aa7a authoriko <mail@iko.soy> dateSun 21 Jun 2026, 11:29 (3 months ago) parente36d73b
5 files changed+34−9
ParseableViewer/Models/LogStream.swift+8 ParseableViewer/Services/ParseableClient.swift+6−1 ParseableViewer/ViewModels/LiveTailViewModel.swift+14−5 ParseableViewer/ViewModels/QueryViewModel.swift+4−1 ParseableViewer/Views/SidebarView.swift+2−2
ParseableViewer/Models/LogStream.swift+8
@@ -110,6 +110,8 @@ struct StreamStats: Codable, Sendable {
110110 self.size = s
111111 } else if let n = try? container.decode(Int.self, forKey: .size) {
112112 self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
113+ } else if let d = try? container.decode(Double.self, forKey: .size) {
114+ self.size = ByteCountFormatter.string(fromByteCount: Int64(d), countStyle: .file)
113115 } else {
114116 self.size = nil
115117 }
@@ -117,6 +119,8 @@ struct StreamStats: Codable, Sendable {
117119 self.lifetime_size = s
118120 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
119121 self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
122+ } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
123+ self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(d), countStyle: .file)
120124 } else {
121125 self.lifetime_size = nil
122126 }
@@ -139,6 +143,8 @@ struct StreamStats: Codable, Sendable {
139143 self.size = s
140144 } else if let n = try? container.decode(Int.self, forKey: .size) {
141145 self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
146+ } else if let d = try? container.decode(Double.self, forKey: .size) {
147+ self.size = ByteCountFormatter.string(fromByteCount: Int64(d), countStyle: .file)
142148 } else {
143149 self.size = nil
144150 }
@@ -146,6 +152,8 @@ struct StreamStats: Codable, Sendable {
146152 self.lifetime_size = s
147153 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
148154 self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
155+ } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
156+ self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(d), countStyle: .file)
149157 } else {
150158 self.lifetime_size = nil
151159 }
ParseableViewer/Services/ParseableClient.swift+6−1
@@ -352,7 +352,12 @@ final class ParseableClient: Sendable {
352352 return legacy
353353 }
354354 }
355− return config
355+ // `/api/v1/alerts` is server-global; the legacy endpoint above is already
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
358+ // unfiltered global list.
359+ let scoped = config.alerts?.filter { $0.datasets?.contains(stream) ?? false }
360+ return AlertConfig(alerts: scoped, version: config.version)
356361 } catch let newEndpointError {
357362 // An auth failure won't be fixed by hitting the legacy endpoint (it
358363 // would just 401 again), so surface it immediately rather than
ParseableViewer/ViewModels/LiveTailViewModel.swift+14−5
@@ -278,7 +278,10 @@ final class LiveTailViewModel {
278278 let toIndex = columnOrder.firstIndex(of: targetColumn),
279279 fromIndex != toIndex else { return }
280280 let item = columnOrder.remove(at: fromIndex)
281− columnOrder.insert(item, at: toIndex)
281+ // Removing an element before the target shifts the target down by one, so
282+ // inserting at the original `toIndex` would land the column past its target.
283+ let adjusted = fromIndex < toIndex ? toIndex - 1 : toIndex
284+ columnOrder.insert(item, at: adjusted)
282285 saveColumnConfig()
283286 }
284287
@@ -569,7 +572,11 @@ final class LiveTailViewModel {
569572 case .int(let i):
570573 hashNumberBits(Double(i).bitPattern, h0: &h0, h1: &h1)
571574 case .double(let d):
572− hashNumberBits((d.isNaN ? Double.nan : d).bitPattern, h0: &h0, h1: &h1)
575+ // Canonicalize NaN (so all NaNs fold together) AND signed zero: JSONValue
576+ // equality treats `-0.0 == 0.0`, but their raw bit patterns differ, which
577+ // would otherwise let an identical record slip past dedup as a phantom row.
578+ let canonical: Double = d.isNaN ? .nan : (d == 0 ? 0.0 : d)
579+ hashNumberBits(canonical.bitPattern, h0: &h0, h1: &h1)
573580 case .string(let s):
574581 h0 = (h0 ^ 0x04) &* 1099511628211
575582 h1 = (h1 ^ 0x04) &* 6700417
@@ -632,14 +639,16 @@ final class LiveTailViewModel {
632639 }
633640
634641 if parts.isEmpty {
642+ // Filter to scalars BEFORE taking the prefix — otherwise three leading
643+ // non-scalar fields (alphabetically) would yield an empty summary even
644+ // when scalar fields exist later in the record.
635645 let scalarFields = record
636646 .filter { $0.key != "p_timestamp" && $0.key != "p_tags" && $0.key != "p_metadata" }
647+ .filter { $0.value.isScalar }
637648 .sorted { $0.key < $1.key }
638649 .prefix(3)
639650 for (key, value) in scalarFields {
640− if value.isScalar {
641− parts.append("\(key)=\(value.displayString)")
642− }
651+ parts.append("\(key)=\(value.displayString)")
643652 }
644653 }
645654
ParseableViewer/ViewModels/QueryViewModel.swift+4−1
@@ -193,7 +193,10 @@ final class QueryViewModel {
193193 let toIndex = columnOrder.firstIndex(of: targetColumn),
194194 fromIndex != toIndex else { return }
195195 let item = columnOrder.remove(at: fromIndex)
196− columnOrder.insert(item, at: toIndex)
196+ // Removing an element before the target shifts the target down by one, so
197+ // inserting at the original `toIndex` would land the column past its target.
198+ let adjusted = fromIndex < toIndex ? toIndex - 1 : toIndex
199+ columnOrder.insert(item, at: adjusted)
197200 saveColumnConfig()
198201 updateSQLColumns()
199202 }
ParseableViewer/Views/SidebarView.swift+2−2
@@ -10,7 +10,7 @@ struct SidebarView: View {
1010
1111 /// Validates a stream name, returning an error message or nil if valid.
1212 static func validateStreamName(_ name: String) -> String? {
13− if name.trimmingCharacters(in: .whitespaces).isEmpty {
13+ if name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
1414 return "Stream name cannot be empty."
1515 }
1616 if name.count > 255 {
@@ -155,7 +155,7 @@ struct SidebarView: View {
155155 .alert("Create Log Stream", isPresented: $showCreateStream) {
156156 TextField("Stream name", text: $newStreamName)
157157 Button("Create") {
158− let name = newStreamName.trimmingCharacters(in: .whitespaces)
158+ let name = newStreamName.trimmingCharacters(in: .whitespacesAndNewlines)
159159 guard !name.isEmpty else { newStreamName = ""; return }
160160 if let error = Self.validateStreamName(name) {
161161 appState.showErrorMessage(error)