git.iko.soy/parseable-macos
on master help sign in
Fix 5 bugs: JSON export data loss on non-finite doubles, unformatted byte sizes when server sends quoted strings, frozen streaming pulse after pause/resume, connection edit bouncing user off stream selection, isoFormatter Sendable hygiene
f47f680iko
ParseableViewer/App/AppState.swift+12−1
@@ -280,6 +280,7 @@ final class AppState {
280
280
281 @MainActor
281 @MainActor
282 func updateConnection(_ connection: ServerConnection) {
282 func updateConnection(_ connection: ServerConnection) {
283+ let previous = connections.first(where: { $0.id == connection.id })
283 if let index = connections.firstIndex(where: { $0.id == connection.id }) {
284 if let index = connections.firstIndex(where: { $0.id == connection.id }) {
284 connections[index] = connection
285 connections[index] = connection
285 ConnectionStore.saveConnections(connections)
286 ConnectionStore.saveConnections(connections)
@@ -293,7 +294,17 @@ final class AppState {
293 // connection id is unchanged. We must NOT also spawn a connect here: the
294 // connection id is unchanged. We must NOT also spawn a connect here: the
294 // sole caller (ConnectionSheet.saveAndConnect) already connects right after,
295 // sole caller (ConnectionSheet.saveAndConnect) already connects right after,
295 // and a second connect would run two full reconnects and flash the UI twice.
296 // and a second connect would run two full reconnects and flash the UI twice.
297+ //
298+ // Only the URL/username/password identify the *server*; a cosmetic edit (e.g.
299+ // renaming the connection) points at the same server, so wiping the stream
300+ // selection/tab/streams would needlessly kick the user out of their current
301+ // view. Restrict the reset to edits that actually change the server target.
302+ let serverChanged = previous.map {
303+ $0.url != connection.url
304+ || $0.username != connection.username
305+ || $0.password != connection.password
306+ } ?? true
296− if activeConnection?.id == connection.id {
307+ if serverChanged, activeConnection?.id == connection.id {
297 // Clear the stream-specific UI state so a leftover sidebar search filter
308 // Clear the stream-specific UI state so a leftover sidebar search filter
298 // doesn't hide the new server's streams and a stream-specific tab
309 // doesn't hide the new server's streams and a stream-specific tab
299 // doesn't linger with no valid selection.
310 // doesn't linger with no valid selection.
ParseableViewer/Models/JSONValue.swift+8−1
@@ -37,7 +37,14 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
37 case .null: try container.encodeNil()
37 case .null: try container.encodeNil()
38 case .bool(let v): try container.encode(v)
38 case .bool(let v): try container.encode(v)
39 case .int(let v): try container.encode(v)
39 case .int(let v): try container.encode(v)
40− case .double(let v): try container.encode(v)
40+ case .double(let v):
41+ // JSON has no representation for infinity/NaN, and JSONEncoder's
42+ // default `.throw` strategy would abort the *entire* encode — turning
43+ // a single non-finite value into a total export failure (`exportAsJSON`
44+ // collapses to "[]"). Encode it as null to match displayString,
45+ // compactJSON/jsonNumber, and the CSV export, which all render
46+ // non-finite doubles as "null".
47+ if v.isFinite { try container.encode(v) } else { try container.encodeNil() }
41 case .string(let v): try container.encode(v)
48 case .string(let v): try container.encode(v)
42 case .array(let v): try container.encode(v)
49 case .array(let v): try container.encode(v)
43 case .object(let v): try container.encode(v)
50 case .object(let v): try container.encode(v)
ParseableViewer/Models/LogStream.swift+15−4
@@ -103,6 +103,17 @@ private func formatByteSize(_ value: Int) -> String? {
103 return ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file)
103 return ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file)
104}
104}
105
105
106+/// Normalizes a byte count that arrived as a JSON *string*. A bare numeric string
107+/// (e.g. "1048576") is a raw byte count and is formatted like the numeric branches
108+/// so identical sizes render consistently ("1 MB") regardless of whether the server
109+/// quoted the value; an already-formatted string (e.g. "1.2 GB") is passed through
110+/// unchanged.
111+private func formatByteSize(_ value: String) -> String {
112+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
113+ if let n = Int(trimmed) { return formatByteSize(n) ?? value }
114+ return value
115+}
116+
106struct StreamStats: Codable, Sendable {
117struct StreamStats: Codable, Sendable {
107 let ingestion: IngestionStats?
118 let ingestion: IngestionStats?
108 let storage: StorageStats?
119 let storage: StorageStats?
@@ -123,7 +134,7 @@ struct StreamStats: Codable, Sendable {
123 self.lifetime_count = try? container.decode(Int.self, forKey: .lifetime_count)
134 self.lifetime_count = try? container.decode(Int.self, forKey: .lifetime_count)
124 // Handle size as string or number
135 // Handle size as string or number
125 if let s = try? container.decode(String.self, forKey: .size) {
136 if let s = try? container.decode(String.self, forKey: .size) {
126− self.size = s
137+ self.size = formatByteSize(s)
127 } else if let n = try? container.decode(Int.self, forKey: .size) {
138 } else if let n = try? container.decode(Int.self, forKey: .size) {
128 self.size = formatByteSize(n)
139 self.size = formatByteSize(n)
129 } else if let d = try? container.decode(Double.self, forKey: .size) {
140 } else if let d = try? container.decode(Double.self, forKey: .size) {
@@ -132,7 +143,7 @@ struct StreamStats: Codable, Sendable {
132 self.size = nil
143 self.size = nil
133 }
144 }
134 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
145 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
135− self.lifetime_size = s
146+ self.lifetime_size = formatByteSize(s)
136 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
147 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
137 self.lifetime_size = formatByteSize(n)
148 self.lifetime_size = formatByteSize(n)
138 } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
149 } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
@@ -156,7 +167,7 @@ struct StreamStats: Codable, Sendable {
156 let container = try decoder.container(keyedBy: CodingKeys.self)
167 let container = try decoder.container(keyedBy: CodingKeys.self)
157 self.type = try? container.decode(String.self, forKey: .type)
168 self.type = try? container.decode(String.self, forKey: .type)
158 if let s = try? container.decode(String.self, forKey: .size) {
169 if let s = try? container.decode(String.self, forKey: .size) {
159− self.size = s
170+ self.size = formatByteSize(s)
160 } else if let n = try? container.decode(Int.self, forKey: .size) {
171 } else if let n = try? container.decode(Int.self, forKey: .size) {
161 self.size = formatByteSize(n)
172 self.size = formatByteSize(n)
162 } else if let d = try? container.decode(Double.self, forKey: .size) {
173 } else if let d = try? container.decode(Double.self, forKey: .size) {
@@ -165,7 +176,7 @@ struct StreamStats: Codable, Sendable {
165 self.size = nil
176 self.size = nil
166 }
177 }
167 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
178 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
168− self.lifetime_size = s
179+ self.lifetime_size = formatByteSize(s)
169 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
180 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
170 self.lifetime_size = formatByteSize(n)
181 self.lifetime_size = formatByteSize(n)
171 } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
182 } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
ParseableViewer/Services/ParseableClient.swift+1−1
@@ -98,7 +98,7 @@ final class ParseableClient: Sendable {
98
98
99 private static nonisolated(unsafe) let jsonDecoder = JSONDecoder()
99 private static nonisolated(unsafe) let jsonDecoder = JSONDecoder()
100
100
101− private static let isoFormatter: ISO8601DateFormatter = {
101+ private static nonisolated(unsafe) let isoFormatter: ISO8601DateFormatter = {
102 let f = ISO8601DateFormatter()
102 let f = ISO8601DateFormatter()
103 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
103 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
104 return f
104 return f
ParseableViewer/Views/LiveTailView.swift+24−3
@@ -136,10 +136,21 @@ struct LiveTailView: View {
136 Circle()
136 Circle()
137 .fill(viewModel.isPaused ? .yellow : .green)
137 .fill(viewModel.isPaused ? .yellow : .green)
138 .frame(width: 8, height: 8)
138 .frame(width: 8, height: 8)
139− .opacity(viewModel.isPaused ? 1.0 : pulseOpacity)
139+ .opacity(pulseOpacity)
140 .accessibilityLabel(viewModel.isPaused ? "Status: Paused" : "Status: Streaming")
140 .accessibilityLabel(viewModel.isPaused ? "Status: Paused" : "Status: Streaming")
141− .animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: pulseOpacity)
141+ // Drive the pulse with an explicit, re-triggerable animation.
142− .onAppear { pulseOpacity = 0.3 }
142+ // The previous one-shot `pulseOpacity = 0.3` only changed the
143+ // value once, so the implicit `animation(value:)` never
144+ // restarted after a pause/resume and the dot froze. Pausing
145+ // settles the dot to solid; resuming restarts the pulse.
146+ .onAppear { startPulsing() }
147+ .onChange(of: viewModel.isPaused) { _, paused in
148+ if paused {
149+ withAnimation(.easeInOut(duration: 0.3)) { pulseOpacity = 1.0 }
150+ } else {
151+ startPulsing()
152+ }
153+ }
143 .onDisappear { pulseOpacity = 1.0 }
154 .onDisappear { pulseOpacity = 1.0 }
144 Text(viewModel.isPaused ? "Paused" : "Streaming")
155 Text(viewModel.isPaused ? "Paused" : "Streaming")
145 .font(.caption)
156 .font(.caption)
@@ -289,6 +300,16 @@ struct LiveTailView: View {
289 /// fire `onChange(of: visibleColumns)`, which would otherwise leave widths
300 /// fire `onChange(of: visibleColumns)`, which would otherwise leave widths
290 /// empty (falling back to the default width) after `selectedStream` cleared
301 /// empty (falling back to the default width) after `selectedStream` cleared
291 /// them.
302 /// them.
303+ /// Starts (or restarts) the streaming-status pulse. Resetting `pulseOpacity`
304+ /// to 1.0 before animating to 0.3 guarantees a value delta so the repeating
305+ /// animation re-triggers even when called after a pause/resume cycle.
306+ private func startPulsing() {
307+ pulseOpacity = 1.0
308+ withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
309+ pulseOpacity = 0.3
310+ }
311+ }
312+
292 private func ensureColumnWidths() {
313 private func ensureColumnWidths() {
293 let records = viewModel.cachedFilteredRecords
314 let records = viewModel.cachedFilteredRecords
294 for col in viewModel.visibleColumns where columnWidths[col] == nil {
315 for col in viewModel.visibleColumns where columnWidths[col] == nil {
ParseableViewerTests/JSONValueTests.swift+18
@@ -75,6 +75,24 @@ final class JSONValueTests: XCTestCase {
75 XCTAssertEqual(original, decoded)
75 XCTAssertEqual(original, decoded)
76 }
76 }
77
77
78+ func testEncodeNonFiniteDoubleAsNull() throws {
79+ // A non-finite double must encode as JSON null rather than throwing — a
80+ // single infinity/NaN otherwise aborts the whole JSONEncoder pass and
81+ // collapses exportAsJSON() to "[]" (total silent data loss).
82+ let encoder = JSONEncoder()
83+ for value: Double in [.infinity, -.infinity, .nan] {
84+ let data = try encoder.encode(JSONValue.double(value))
85+ XCTAssertEqual(String(data: data, encoding: .utf8), "null")
86+ }
87+ }
88+
89+ func testEncodeArrayWithNonFiniteDoubleDoesNotThrow() throws {
90+ // Mirrors the export path: one non-finite value must not poison the rest.
91+ let original: JSONValue = .array([.int(1), .double(.infinity), .string("ok")])
92+ let data = try JSONEncoder().encode(original)
93+ XCTAssertEqual(String(data: data, encoding: .utf8), "[1,null,\"ok\"]")
94+ }
95+
78 // MARK: - displayString
96 // MARK: - displayString
79
97
80 func testDisplayStringNull() {
98 func testDisplayStringNull() {