git.iko.soy/parseable-macos
on master help sign in
Fix 7 bugs: live-tail polling, string comparison contract, stale connection state, reconnect loop, zero-length time range, negative byte sizes
8ac672aiko
ParseableViewer/App/AppState.swift+14
@@ -231,6 +231,13 @@ final class AppState {
231 selectedStream = nil
231 selectedStream = nil
232 serverAbout = nil
232 serverAbout = nil
233 filters = []
233 filters = []
234+ // Clear transient stream/UI state too, otherwise a leftover stream-load
235+ // error keeps rendering in the sidebar after disconnect and a stale search
236+ // filter / tab carries into the next connection.
237+ streamLoadError = nil
238+ isLoadingStreams = false
239+ streamSearchText = ""
240+ currentTab = .query
234 ConnectionStore.saveActiveConnectionID(nil)
241 ConnectionStore.saveActiveConnectionID(nil)
235 }
242 }
236
243
@@ -277,7 +284,14 @@ final class AppState {
277 // about). Clear the stream selection first so a now-missing stream doesn't
284 // about). Clear the stream selection first so a now-missing stream doesn't
278 // keep driving detail-tab requests during the reconnect.
285 // keep driving detail-tab requests during the reconnect.
279 if activeConnection?.id == connection.id {
286 if activeConnection?.id == connection.id {
287+ // `performConnect` skips its stale-state cleanup because the id is
288+ // unchanged, but the edited URL/credentials may point at a different
289+ // server. Clear the stream-specific UI state here so a leftover sidebar
290+ // search filter doesn't hide the new server's streams and a
291+ // stream-specific tab doesn't linger with no valid selection.
280 selectedStream = nil
292 selectedStream = nil
293+ streamSearchText = ""
294+ currentTab = .query
281 Task { await connect(to: connection) }
295 Task { await connect(to: connection) }
282 }
296 }
283 }
297 }
ParseableViewer/Models/JSONValue.swift+7−1
@@ -211,7 +211,13 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
211 case (.int(let a), .double(let b)): return intLessThanDouble(a, b)
211 case (.int(let a), .double(let b)): return intLessThanDouble(a, b)
212 case (.double(let a), .int(let b)): return doubleLessThanInt(a, b)
212 case (.double(let a), .int(let b)): return doubleLessThanInt(a, b)
213 case (.string(let a), .string(let b)):
213 case (.string(let a), .string(let b)):
214+ // Two distinct strings can fold to `.orderedSame` under
215+ // `localizedStandardCompare` (e.g. full-width vs half-width forms that
216+ // Swift's `==` still treats as different). Break that tie with the raw
217+ // `<` so order-equivalent values stay `==`, as `Comparable` requires —
218+ // the same fallback `LogStream.<` already applies.
219+ let order = a.localizedStandardCompare(b)
214− return a.localizedStandardCompare(b) == .orderedAscending
220+ return order == .orderedAscending || (order == .orderedSame && a < b)
215 case (.array, .array), (.object, .object):
221 case (.array, .array), (.object, .object):
216 // Same rank, structured: equal values must not be ordered; otherwise
222 // Same rank, structured: equal values must not be ordered; otherwise
217 // use a deterministic serialization tiebreaker.
223 // use a deterministic serialization tiebreaker.
ParseableViewer/Models/LogStream.swift+11−4
@@ -96,6 +96,13 @@ private func formatByteSize(_ value: Double) -> String? {
96 return ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file)
96 return ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file)
97}
97}
98
98
99+private func formatByteSize(_ value: Int) -> String? {
100+ // A negative byte count isn't meaningful; `ByteCountFormatter` would render it
101+ // as a bogus "-1 KB"/"Zero KB" string, so drop it like the `Double` path does.
102+ guard value >= 0 else { return nil }
103+ return ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file)
104+}
105+
99struct StreamStats: Codable, Sendable {
106struct StreamStats: Codable, Sendable {
100 let ingestion: IngestionStats?
107 let ingestion: IngestionStats?
101 let storage: StorageStats?
108 let storage: StorageStats?
@@ -118,7 +125,7 @@ struct StreamStats: Codable, Sendable {
118 if let s = try? container.decode(String.self, forKey: .size) {
125 if let s = try? container.decode(String.self, forKey: .size) {
119 self.size = s
126 self.size = s
120 } else if let n = try? container.decode(Int.self, forKey: .size) {
127 } else if let n = try? container.decode(Int.self, forKey: .size) {
121− self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
128+ self.size = formatByteSize(n)
122 } else if let d = try? container.decode(Double.self, forKey: .size) {
129 } else if let d = try? container.decode(Double.self, forKey: .size) {
123 self.size = formatByteSize(d)
130 self.size = formatByteSize(d)
124 } else {
131 } else {
@@ -127,7 +134,7 @@ struct StreamStats: Codable, Sendable {
127 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
134 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
128 self.lifetime_size = s
135 self.lifetime_size = s
129 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
136 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
130− self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
137+ self.lifetime_size = formatByteSize(n)
131 } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
138 } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
132 self.lifetime_size = formatByteSize(d)
139 self.lifetime_size = formatByteSize(d)
133 } else {
140 } else {
@@ -151,7 +158,7 @@ struct StreamStats: Codable, Sendable {
151 if let s = try? container.decode(String.self, forKey: .size) {
158 if let s = try? container.decode(String.self, forKey: .size) {
152 self.size = s
159 self.size = s
153 } else if let n = try? container.decode(Int.self, forKey: .size) {
160 } else if let n = try? container.decode(Int.self, forKey: .size) {
154− self.size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
161+ self.size = formatByteSize(n)
155 } else if let d = try? container.decode(Double.self, forKey: .size) {
162 } else if let d = try? container.decode(Double.self, forKey: .size) {
156 self.size = formatByteSize(d)
163 self.size = formatByteSize(d)
157 } else {
164 } else {
@@ -160,7 +167,7 @@ struct StreamStats: Codable, Sendable {
160 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
167 if let s = try? container.decode(String.self, forKey: .lifetime_size) {
161 self.lifetime_size = s
168 self.lifetime_size = s
162 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
169 } else if let n = try? container.decode(Int.self, forKey: .lifetime_size) {
163− self.lifetime_size = ByteCountFormatter.string(fromByteCount: Int64(n), countStyle: .file)
170+ self.lifetime_size = formatByteSize(n)
164 } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
171 } else if let d = try? container.decode(Double.self, forKey: .lifetime_size) {
165 self.lifetime_size = formatByteSize(d)
172 self.lifetime_size = formatByteSize(d)
166 } else {
173 } else {
ParseableViewer/ViewModels/LiveTailViewModel.swift+8−1
@@ -198,12 +198,19 @@ final class LiveTailViewModel {
198 currentStream = stream
198 currentStream = stream
199 rebuildFilteredEntries()
199 rebuildFilteredEntries()
200
200
201+ // Install the timer in `.common` modes so polling keeps firing while the
202+ // main run loop is in event-tracking mode (scrolling, dragging a column,
203+ // an open menu/popover). A `.default`-only timer — what
204+ // `scheduledTimer(withTimeInterval:)` installs — silently pauses during
205+ // those interactions, freezing the "live" tail until the user lets go.
201− timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
206+ let pollTimer = Timer(timeInterval: pollInterval, repeats: true) { [weak self] _ in
202 Task { @MainActor [weak self] in
207 Task { @MainActor [weak self] in
203 guard let self, !self.isPaused else { return }
208 guard let self, !self.isPaused else { return }
204 await self.poll(client: client, stream: stream)
209 await self.poll(client: client, stream: stream)
205 }
210 }
206 }
211 }
212+ RunLoop.main.add(pollTimer, forMode: .common)
213+ timer = pollTimer
207 }
214 }
208
215
209 func stop() {
216 func stop() {
ParseableViewer/Views/SidebarView.swift+6−1
@@ -225,7 +225,12 @@ struct ConnectionStatusView: View {
225 if newID == ConnectionStatusView.addConnectionID {
225 if newID == ConnectionStatusView.addConnectionID {
226 appState.editingConnection = nil
226 appState.editingConnection = nil
227 appState.showConnectionSheet = true
227 appState.showConnectionSheet = true
228+ } else if newID != connection.id,
228− } else if let conn = appState.connections.first(where: { $0.id == newID }) {
229+ let conn = appState.connections.first(where: { $0.id == newID }) {
230+ // Only reconnect when a *different* connection is
231+ // picked. Re-selecting the active one would tear
232+ // down and rebuild the client for no reason (a full
233+ // health-check + stream reload, with a visible flicker).
229 Task { await appState.connect(to: conn) }
234 Task { await appState.connect(to: conn) }
230 }
235 }
231 }
236 }
ParseableViewer/Views/TimeRangePicker.swift+2−2
@@ -41,7 +41,7 @@ struct TimeRangePicker: View {
41 DatePicker("End:", selection: $customEnd)
41 DatePicker("End:", selection: $customEnd)
42 .datePickerStyle(.field)
42 .datePickerStyle(.field)
43
43
44− if customEnd < customStart {
44+ if customEnd <= customStart {
45 HStack(spacing: 4) {
45 HStack(spacing: 4) {
46 Image(systemName: "exclamationmark.triangle.fill")
46 Image(systemName: "exclamationmark.triangle.fill")
47 Text("End date must be after start date")
47 Text("End date must be after start date")
@@ -82,7 +82,7 @@ struct TimeRangePicker: View {
82 onCommit?()
82 onCommit?()
83 }
83 }
84 .buttonStyle(.borderedProminent)
84 .buttonStyle(.borderedProminent)
85− .disabled(customEnd < customStart)
85+ .disabled(customEnd <= customStart)
86 }
86 }
87 }
87 }
88 .padding()
88 .padding()