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
commitf47f680e03fee04bf7bb372cffd7c71959692830 authoriko <mail@iko.soy> dateSun 21 Jun 2026, 22:48 (3 months ago) parent0ebbd03
6 files changed+78−10
ParseableViewer/App/AppState.swift+12−1 ParseableViewer/Models/JSONValue.swift+8−1 ParseableViewer/Models/LogStream.swift+15−4 ParseableViewer/Services/ParseableClient.swift+1−1 ParseableViewer/Views/LiveTailView.swift+24−3 ParseableViewerTests/JSONValueTests.swift+18
ParseableViewer/App/AppState.swift+12−1
@@ -280,6 +280,7 @@ final class AppState {
280280
281281 @MainActor
282282 func updateConnection(_ connection: ServerConnection) {
283+ let previous = connections.first(where: { $0.id == connection.id })
283284 if let index = connections.firstIndex(where: { $0.id == connection.id }) {
284285 connections[index] = connection
285286 ConnectionStore.saveConnections(connections)
@@ -293,7 +294,17 @@ final class AppState {
293294 // connection id is unchanged. We must NOT also spawn a connect here: the
294295 // sole caller (ConnectionSheet.saveAndConnect) already connects right after,
295296 // and a second connect would run two full reconnects and flash the UI twice.
296− if activeConnection?.id == connection.id {
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
307+ if serverChanged, activeConnection?.id == connection.id {
297308 // Clear the stream-specific UI state so a leftover sidebar search filter
298309 // doesn't hide the new server's streams and a stream-specific tab
299310 // doesn't linger with no valid selection.
ParseableViewer/Models/JSONValue.swift+8−1
@@ -37,7 +37,14 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
3737 case .null: try container.encodeNil()
3838 case .bool(let v): try container.encode(v)
3939 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() }
4148 case .string(let v): try container.encode(v)
4249 case .array(let v): try container.encode(v)
4350 case .object(let v): try container.encode(v)
ParseableViewer/Models/LogStream.swift+15−4
@@ -103,6 +103,17 @@ private func formatByteSize(_ value: Int) -> String? {
103103 return ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file)
104104}
105105
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+
106117struct StreamStats: Codable, Sendable {
107118 let ingestion: IngestionStats?
108119 let storage: StorageStats?
@@ -123,7 +134,7 @@ struct StreamStats: Codable, Sendable {
123134 self.lifetime_count = try? container.decode(Int.self, forKey: .lifetime_count)
124135 // Handle size as string or number
125136 if let s = try? container.decode(String.self, forKey: .size) {
126− self.size = s
137+ self.size = formatByteSize(s)
127138 } else if let n = try? container.decode(Int.self, forKey: .size) {
128139 self.size = formatByteSize(n)
129140 } else if let d = try? container.decode(Double.self, forKey: .size) {
@@ -132,7 +143,7 @@ struct StreamStats: Codable, Sendable {
132143 self.size = nil
133144 }
134145 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
135− self.lifetime_size = s
146+ self.lifetime_size = formatByteSize(s)
136147 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
137148 self.lifetime_size = formatByteSize(n)
138149 } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
@@ -156,7 +167,7 @@ struct StreamStats: Codable, Sendable {
156167 let container = try decoder.container(keyedBy: CodingKeys.self)
157168 self.type = try? container.decode(String.self, forKey: .type)
158169 if let s = try? container.decode(String.self, forKey: .size) {
159− self.size = s
170+ self.size = formatByteSize(s)
160171 } else if let n = try? container.decode(Int.self, forKey: .size) {
161172 self.size = formatByteSize(n)
162173 } else if let d = try? container.decode(Double.self, forKey: .size) {
@@ -165,7 +176,7 @@ struct StreamStats: Codable, Sendable {
165176 self.size = nil
166177 }
167178 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
168− self.lifetime_size = s
179+ self.lifetime_size = formatByteSize(s)
169180 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
170181 self.lifetime_size = formatByteSize(n)
171182 } 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 {
9898
9999 private static nonisolated(unsafe) let jsonDecoder = JSONDecoder()
100100
101− private static let isoFormatter: ISO8601DateFormatter = {
101+ private static nonisolated(unsafe) let isoFormatter: ISO8601DateFormatter = {
102102 let f = ISO8601DateFormatter()
103103 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
104104 return f
ParseableViewer/Views/LiveTailView.swift+24−3
@@ -136,10 +136,21 @@ struct LiveTailView: View {
136136 Circle()
137137 .fill(viewModel.isPaused ? .yellow : .green)
138138 .frame(width: 8, height: 8)
139− .opacity(viewModel.isPaused ? 1.0 : pulseOpacity)
139+ .opacity(pulseOpacity)
140140 .accessibilityLabel(viewModel.isPaused ? "Status: Paused" : "Status: Streaming")
141− .animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: pulseOpacity)
142− .onAppear { pulseOpacity = 0.3 }
141+ // Drive the pulse with an explicit, re-triggerable animation.
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+ }
143154 .onDisappear { pulseOpacity = 1.0 }
144155 Text(viewModel.isPaused ? "Paused" : "Streaming")
145156 .font(.caption)
@@ -289,6 +300,16 @@ struct LiveTailView: View {
289300 /// fire `onChange(of: visibleColumns)`, which would otherwise leave widths
290301 /// empty (falling back to the default width) after `selectedStream` cleared
291302 /// 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+
292313 private func ensureColumnWidths() {
293314 let records = viewModel.cachedFilteredRecords
294315 for col in viewModel.visibleColumns where columnWidths[col] == nil {
ParseableViewerTests/JSONValueTests.swift+18
@@ -75,6 +75,24 @@ final class JSONValueTests: XCTestCase {
7575 XCTAssertEqual(original, decoded)
7676 }
7777
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+
7896 // MARK: - displayString
7997
8098 func testDisplayStringNull() {