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
a39e1d5iko
ParseableViewer/Models/LogStream.swift+8
@@ -110,6 +110,8 @@ struct StreamStats: Codable, Sendable {
110 self.size = s
110 self.size = s
111 } else if let n = try? container.decode(Int.self, forKey: .size) {
111 } else if let n = try? container.decode(Int.self, forKey: .size) {
112 self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
112 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)
113 } else {
115 } else {
114 self.size = nil
116 self.size = nil
115 }
117 }
@@ -117,6 +119,8 @@ struct StreamStats: Codable, Sendable {
117 self.lifetime_size = s
119 self.lifetime_size = s
118 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
120 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
119 self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
121 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)
120 } else {
124 } else {
121 self.lifetime_size = nil
125 self.lifetime_size = nil
122 }
126 }
@@ -139,6 +143,8 @@ struct StreamStats: Codable, Sendable {
139 self.size = s
143 self.size = s
140 } else if let n = try? container.decode(Int.self, forKey: .size) {
144 } else if let n = try? container.decode(Int.self, forKey: .size) {
141 self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
145 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)
142 } else {
148 } else {
143 self.size = nil
149 self.size = nil
144 }
150 }
@@ -146,6 +152,8 @@ struct StreamStats: Codable, Sendable {
146 self.lifetime_size = s
152 self.lifetime_size = s
147 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
153 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
148 self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
154 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)
149 } else {
157 } else {
150 self.lifetime_size = nil
158 self.lifetime_size = nil
151 }
159 }
ParseableViewer/Services/ParseableClient.swift+6−1
@@ -352,7 +352,12 @@ final class ParseableClient: Sendable {
352 return legacy
352 return legacy
353 }
353 }
354 }
354 }
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)
356 } catch let newEndpointError {
361 } catch let newEndpointError {
357 // An auth failure won't be fixed by hitting the legacy endpoint (it
362 // An auth failure won't be fixed by hitting the legacy endpoint (it
358 // would just 401 again), so surface it immediately rather than
363 // would just 401 again), so surface it immediately rather than
ParseableViewer/ViewModels/LiveTailViewModel.swift+14−5
@@ -278,7 +278,10 @@ final class LiveTailViewModel {
278 let toIndex = columnOrder.firstIndex(of: targetColumn),
278 let toIndex = columnOrder.firstIndex(of: targetColumn),
279 fromIndex != toIndex else { return }
279 fromIndex != toIndex else { return }
280 let item = columnOrder.remove(at: fromIndex)
280 let item = columnOrder.remove(at: fromIndex)
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
281− columnOrder.insert(item, at: toIndex)
284+ columnOrder.insert(item, at: adjusted)
282 saveColumnConfig()
285 saveColumnConfig()
283 }
286 }
284
287
@@ -569,7 +572,11 @@ final class LiveTailViewModel {
569 case .int(let i):
572 case .int(let i):
570 hashNumberBits(Double(i).bitPattern, h0: &h0, h1: &h1)
573 hashNumberBits(Double(i).bitPattern, h0: &h0, h1: &h1)
571 case .double(let d):
574 case .double(let d):
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)
572− hashNumberBits((d.isNaN ? Double.nan : d).bitPattern, h0: &h0, h1: &h1)
579+ hashNumberBits(canonical.bitPattern, h0: &h0, h1: &h1)
573 case .string(let s):
580 case .string(let s):
574 h0 = (h0 ^ 0x04) &* 1099511628211
581 h0 = (h0 ^ 0x04) &* 1099511628211
575 h1 = (h1 ^ 0x04) &* 6700417
582 h1 = (h1 ^ 0x04) &* 6700417
@@ -632,14 +639,16 @@ final class LiveTailViewModel {
632 }
639 }
633
640
634 if parts.isEmpty {
641 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.
635 let scalarFields = record
645 let scalarFields = record
636 .filter { $0.key != "p_timestamp" && $0.key != "p_tags" && $0.key != "p_metadata" }
646 .filter { $0.key != "p_timestamp" && $0.key != "p_tags" && $0.key != "p_metadata" }
647+ .filter { $0.value.isScalar }
637 .sorted { $0.key < $1.key }
648 .sorted { $0.key < $1.key }
638 .prefix(3)
649 .prefix(3)
639 for (key, value) in scalarFields {
650 for (key, value) in scalarFields {
640− if value.isScalar {
641− parts.append("\(key)=\(value.displayString)")
651+ parts.append("\(key)=\(value.displayString)")
642− }
643 }
652 }
644 }
653 }
645
654
ParseableViewer/ViewModels/QueryViewModel.swift+4−1
@@ -193,7 +193,10 @@ final class QueryViewModel {
193 let toIndex = columnOrder.firstIndex(of: targetColumn),
193 let toIndex = columnOrder.firstIndex(of: targetColumn),
194 fromIndex != toIndex else { return }
194 fromIndex != toIndex else { return }
195 let item = columnOrder.remove(at: fromIndex)
195 let item = columnOrder.remove(at: fromIndex)
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
196− columnOrder.insert(item, at: toIndex)
199+ columnOrder.insert(item, at: adjusted)
197 saveColumnConfig()
200 saveColumnConfig()
198 updateSQLColumns()
201 updateSQLColumns()
199 }
202 }
ParseableViewer/Views/SidebarView.swift+2−2
@@ -10,7 +10,7 @@ struct SidebarView: View {
10
10
11 /// Validates a stream name, returning an error message or nil if valid.
11 /// Validates a stream name, returning an error message or nil if valid.
12 static func validateStreamName(_ name: String) -> String? {
12 static func validateStreamName(_ name: String) -> String? {
13− if name.trimmingCharacters(in: .whitespaces).isEmpty {
13+ if name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
14 return "Stream name cannot be empty."
14 return "Stream name cannot be empty."
15 }
15 }
16 if name.count > 255 {
16 if name.count > 255 {
@@ -155,7 +155,7 @@ struct SidebarView: View {
155 .alert("Create Log Stream", isPresented: $showCreateStream) {
155 .alert("Create Log Stream", isPresented: $showCreateStream) {
156 TextField("Stream name", text: $newStreamName)
156 TextField("Stream name", text: $newStreamName)
157 Button("Create") {
157 Button("Create") {
158− let name = newStreamName.trimmingCharacters(in: .whitespaces)
158+ let name = newStreamName.trimmingCharacters(in: .whitespacesAndNewlines)
159 guard !name.isEmpty else { newStreamName = ""; return }
159 guard !name.isEmpty else { newStreamName = ""; return }
160 if let error = Self.validateStreamName(name) {
160 if let error = Self.validateStreamName(name) {
161 appState.showErrorMessage(error)
161 appState.showErrorMessage(error)