git.iko.soy/parseable-macos
on master help sign in
Fix 14 bugs: Comparable contract, JSON serialization, alerts fallback, live-tail data loss, and UI issues
09b478eiko
ParseableViewer/App/AppState.swift+7
@@ -151,6 +151,13 @@ final class AppState {
151 self.isConnected = false
151 self.isConnected = false
152 self.client = nil
152 self.client = nil
153 self.activeConnection = nil
153 self.activeConnection = nil
154+ // Clear stream-specific state so the sidebar doesn't keep showing
155+ // the previous server's streams/selection after a failed connect.
156+ self.streams = []
157+ self.selectedStream = nil
158+ self.streamLoadError = nil
159+ self.serverAbout = nil
160+ self.filters = []
154 }
161 }
155 }
162 }
156
163
ParseableViewer/Models/JSONValue.swift+61−11
@@ -66,7 +66,7 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
66 case .null: return "null"
66 case .null: return "null"
67 case .bool(let v): return v ? "true" : "false"
67 case .bool(let v): return v ? "true" : "false"
68 case .int(let v): return String(v)
68 case .int(let v): return String(v)
69− case .double(let v): return String(v)
69+ case .double(let v): return Self.jsonNumber(v)
70 case .string(let v): return "\"\(Self.escapeJSONString(v))\""
70 case .string(let v): return "\"\(Self.escapeJSONString(v))\""
71 case .array(let items):
71 case .array(let items):
72 return "[" + items.map { $0.compactJSON }.joined(separator: ",") + "]"
72 return "[" + items.map { $0.compactJSON }.joined(separator: ",") + "]"
@@ -96,7 +96,7 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
96 case .null: return "NULL"
96 case .null: return "NULL"
97 case .bool(let v): return v ? "true" : "false"
97 case .bool(let v): return v ? "true" : "false"
98 case .int(let v): return String(v)
98 case .int(let v): return String(v)
99− case .double(let v): return String(v)
99+ case .double(let v): return v.isFinite ? String(v) : "NULL"
100 case .string(let v): return "'\(v.replacingOccurrences(of: "'", with: "''"))'"
100 case .string(let v): return "'\(v.replacingOccurrences(of: "'", with: "''"))'"
101 case .array, .object:
101 case .array, .object:
102 return "'\(exportString.replacingOccurrences(of: "'", with: "''"))'"
102 return "'\(exportString.replacingOccurrences(of: "'", with: "''"))'"
@@ -137,7 +137,7 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
137 case .null: return "null"
137 case .null: return "null"
138 case .bool(let v): return v ? "true" : "false"
138 case .bool(let v): return v ? "true" : "false"
139 case .int(let v): return String(v)
139 case .int(let v): return String(v)
140− case .double(let v): return String(v)
140+ case .double(let v): return Self.jsonNumber(v)
141 case .string(let v): return "\"\(Self.escapeJSONString(v))\""
141 case .string(let v): return "\"\(Self.escapeJSONString(v))\""
142 case .array(let items):
142 case .array(let items):
143 if items.isEmpty { return "[]" }
143 if items.isEmpty { return "[]" }
@@ -182,23 +182,43 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
182 return result
182 return result
183 }
183 }
184
184
185+ /// Stable ordering bucket per case so cross-type comparisons form a total
186+ /// order. Integers and doubles share a bucket so they compare numerically.
187+ private var typeRank: Int {
188+ switch self {
189+ case .null: return 0
190+ case .bool: return 1
191+ case .int, .double: return 2
192+ case .string: return 3
193+ case .array: return 4
194+ case .object: return 5
195+ }
196+ }
197+
185 /// Type-aware comparison: numbers compare numerically, strings
198 /// Type-aware comparison: numbers compare numerically, strings
186 /// lexicographically, nulls sort first, bools sort false < true.
199 /// lexicographically, nulls sort first, bools sort false < true.
187− /// Cross-type comparisons fall back to displayString.
200+ /// Different types are ordered by a stable type rank so that `<` is a strict
201+ /// weak ordering consistent with `==` (the `Comparable` contract requires
202+ /// that order-equivalent values are equal — a `displayString` fallback
203+ /// violated this, e.g. `.int(5)` vs `.string("5")` both render "5").
188 static func < (lhs: JSONValue, rhs: JSONValue) -> Bool {
204 static func < (lhs: JSONValue, rhs: JSONValue) -> Bool {
205+ if lhs.typeRank != rhs.typeRank { return lhs.typeRank < rhs.typeRank }
189 switch (lhs, rhs) {
206 switch (lhs, rhs) {
190 case (.null, .null): return false
207 case (.null, .null): return false
191− case (.null, _): return true
192− case (_, .null): return false
193 case (.bool(let a), .bool(let b)): return !a && b
208 case (.bool(let a), .bool(let b)): return !a && b
194 case (.int(let a), .int(let b)): return a < b
209 case (.int(let a), .int(let b)): return a < b
195 case (.double(let a), .double(let b)): return doubleLessThan(a, b)
210 case (.double(let a), .double(let b)): return doubleLessThan(a, b)
196− case (.int(let a), .double(let b)): return doubleLessThan(Double(a), b)
211+ case (.int(let a), .double(let b)): return intLessThanDouble(a, b)
197− case (.double(let a), .int(let b)): return doubleLessThan(a, Double(b))
212+ case (.double(let a), .int(let b)): return doubleLessThanInt(a, b)
198 case (.string(let a), .string(let b)):
213 case (.string(let a), .string(let b)):
199 return a.localizedStandardCompare(b) == .orderedAscending
214 return a.localizedStandardCompare(b) == .orderedAscending
215+ case (.array, .array), (.object, .object):
216+ // Same rank, structured: equal values must not be ordered; otherwise
217+ // use a deterministic serialization tiebreaker.
218+ if lhs == rhs { return false }
219+ return lhs.compactJSON < rhs.compactJSON
200 default:
220 default:
201− return lhs.displayString < rhs.displayString
221+ return false
202 }
222 }
203 }
223 }
204
224
@@ -211,6 +231,36 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
211 return a < b
231 return a < b
212 }
232 }
213
233
234+ /// Exact `Int < Double` comparison that avoids the precision loss of
235+ /// `Double(Int)` for magnitudes beyond 2^53. NaN sorts after all numbers.
236+ private static func intLessThanDouble(_ a: Int, _ b: Double) -> Bool {
237+ if b.isNaN { return true }
238+ if let bi = Int(exactly: b) { return a < bi } // b is a whole, in-range number
239+ return Double(a) < b // b is fractional / infinite / out of range
240+ }
241+
242+ /// Exact `Double < Int` comparison, the mirror of `intLessThanDouble`.
243+ /// NaN sorts after all numbers, so `NaN < anyInt` is false.
244+ private static func doubleLessThanInt(_ a: Double, _ b: Int) -> Bool {
245+ if a.isNaN { return false }
246+ if let ai = Int(exactly: a) { return ai < b } // a is a whole, in-range number
247+ return a < Double(b) // a is fractional / infinite / out of range
248+ }
249+
250+ /// Exact `Int == Double` test (true only when the double represents exactly
251+ /// the same integer — `Int(exactly:)` is nil for fractional/out-of-range b).
252+ private static func intEqualsDouble(_ a: Int, _ b: Double) -> Bool {
253+ if let bi = Int(exactly: b) { return a == bi }
254+ return false
255+ }
256+
257+ /// JSON-safe rendering of a double: JSON has no NaN/Infinity literals, so
258+ /// non-finite values serialize as `null` rather than the invalid `nan`/`inf`
259+ /// that `String(_:)` would emit.
260+ static func jsonNumber(_ v: Double) -> String {
261+ v.isFinite ? String(v) : "null"
262+ }
263+
214 // MARK: - Equatable / Hashable
264 // MARK: - Equatable / Hashable
215
265
216 /// Equality treats numerically-equal integers and doubles as equal
266 /// Equality treats numerically-equal integers and doubles as equal
@@ -223,8 +273,8 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
223 case (.bool(let a), .bool(let b)): return a == b
273 case (.bool(let a), .bool(let b)): return a == b
224 case (.int(let a), .int(let b)): return a == b
274 case (.int(let a), .int(let b)): return a == b
225 case (.double(let a), .double(let b)): return a == b
275 case (.double(let a), .double(let b)): return a == b
226− case (.int(let a), .double(let b)): return Double(a) == b
276+ case (.int(let a), .double(let b)): return Self.intEqualsDouble(a, b)
227− case (.double(let a), .int(let b)): return a == Double(b)
277+ case (.double(let a), .int(let b)): return Self.intEqualsDouble(b, a)
228 case (.string(let a), .string(let b)): return a == b
278 case (.string(let a), .string(let b)): return a == b
229 case (.array(let a), .array(let b)): return a == b
279 case (.array(let a), .array(let b)): return a == b
230 case (.object(let a), .object(let b)): return a == b
280 case (.object(let a), .object(let b)): return a == b
ParseableViewer/Models/SQLTokenizer.swift+10−5
@@ -267,13 +267,18 @@ struct SQLTokenizer: Sendable {
267 }
267 }
268 return c.isNumber
268 return c.isNumber
269 })
269 })
270− // Exponent
270+ // Exponent — only consume `e`/`E` (and an optional sign) when actual
271+ // exponent digits follow, so `1e` or `1e+` aren't swallowed whole and
272+ // instead tokenize as a number followed by an identifier/operator.
271 if i < sql.endIndex, sql[i] == "e" || sql[i] == "E" {
273 if i < sql.endIndex, sql[i] == "e" || sql[i] == "E" {
272− sql.formIndex(after: &i)
274+ var lookahead = sql.index(after: i)
273− if i < sql.endIndex, sql[i] == "+" || sql[i] == "-" {
275+ if lookahead < sql.endIndex, sql[lookahead] == "+" || sql[lookahead] == "-" {
274− sql.formIndex(after: &i)
276+ sql.formIndex(after: &lookahead)
277+ }
278+ if lookahead < sql.endIndex, sql[lookahead].isNumber {
279+ i = lookahead
280+ advance(&i, in: sql, while: { $0.isNumber })
275 }
281 }
276− advance(&i, in: sql, while: { $0.isNumber })
277 }
282 }
278 }
283 }
279
284
ParseableViewer/Services/ParseableClient.swift+14−10
@@ -193,7 +193,8 @@ final class ParseableClient: Sendable {
193 if httpResponse.statusCode == 401 {
193 if httpResponse.statusCode == 401 {
194 throw ParseableError.unauthorized
194 throw ParseableError.unauthorized
195 }
195 }
196+ // Accept any 2xx — a HEAD /liveness can legitimately return 204, etc.
196− guard httpResponse.statusCode == 200 else {
197+ guard (200...299).contains(httpResponse.statusCode) else {
197 throw ParseableError.serverError(httpResponse.statusCode, "Health check failed")
198 throw ParseableError.serverError(httpResponse.statusCode, "Health check failed")
198 }
199 }
199 }
200 }
@@ -316,20 +317,23 @@ final class ParseableClient: Sendable {
316 // MARK: - Alerts
317 // MARK: - Alerts
317
318
318 func getAlerts(stream: String) async throws -> AlertConfig {
319 func getAlerts(stream: String) async throws -> AlertConfig {
319− // Try new API first; only fall back to legacy per-stream endpoint on 404
320+ // Try the new API first. Fall back to the legacy per-stream endpoint on
321+ // ANY failure — a server without the new endpoint may answer with a 404,
322+ // a 405, or a 200 carrying a differently-shaped body (a DecodingError,
323+ // which is not a ParseableError and would otherwise escape this method).
324+ let encoded = try Self.encodePathComponent(stream)
320 do {
325 do {
321 let data = try await performRequest(method: "GET", path: "/api/v1/alerts")
326 let data = try await performRequest(method: "GET", path: "/api/v1/alerts")
322 return try Self.jsonDecoder.decode(AlertConfig.self, from: data)
327 return try Self.jsonDecoder.decode(AlertConfig.self, from: data)
323− } catch let error as ParseableError {
328+ } catch let newEndpointError {
324− if case .serverError(let code, _) = error, code == 404 {
329+ do {
325− // New endpoint not available, try legacy below
330+ let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/alert")
326− } else {
331+ return try Self.jsonDecoder.decode(AlertConfig.self, from: data)
327− throw error
332+ } catch {
333+ // Legacy also failed — surface the original (more informative) error.
334+ throw newEndpointError
328 }
335 }
329 }
336 }
330− let encoded = try Self.encodePathComponent(stream)
331− let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/alert")
332− return try Self.jsonDecoder.decode(AlertConfig.self, from: data)
333 }
337 }
334
338
335 // MARK: - Retention
339 // MARK: - Retention
ParseableViewer/ViewModels/LiveTailViewModel.swift+10−1
@@ -321,7 +321,11 @@ final class LiveTailViewModel {
321 let idealStart = lastTimestamp ?? now.addingTimeInterval(-30)
321 let idealStart = lastTimestamp ?? now.addingTimeInterval(-30)
322 let safeStart = now.addingTimeInterval(-90)
322 let safeStart = now.addingTimeInterval(-90)
323 let queryStart = min(idealStart, safeStart)
323 let queryStart = min(idealStart, safeStart)
324+ // ORDER BY DESC keeps the newest rows when the page is capped, so a busy
325+ // stream that exceeds the limit within one ~90 s window silently loses the
326+ // oldest rows in that window. Keep a generous page to make that rare; this
327+ // HTTP-polling tail can't match true gRPC streaming throughput regardless.
324− let sql = "SELECT * FROM \(QueryViewModel.escapeSQLIdentifier(stream)) ORDER BY p_timestamp DESC LIMIT 200"
328+ let sql = "SELECT * FROM \(QueryViewModel.escapeSQLIdentifier(stream)) ORDER BY p_timestamp DESC LIMIT 1000"
325
329
326 do {
330 do {
327 let records = try await client.query(sql: sql, startTime: queryStart, endTime: now)
331 let records = try await client.query(sql: sql, startTime: queryStart, endTime: now)
@@ -361,6 +365,11 @@ final class LiveTailViewModel {
361 if entries.count > maxEntries {
365 if entries.count > maxEntries {
362 let excess = entries.count - maxEntries
366 let excess = entries.count - maxEntries
363 droppedCount += excess
367 droppedCount += excess
368+ // Entries are not globally time-sorted across polls (a late or
369+ // backfilled record appends after newer ones), so trimming by
370+ // array position could evict newer entries. Sort by timestamp
371+ // first so the suffix keeps the chronologically newest.
372+ entries.sort { $0.timestamp < $1.timestamp }
364 entries = Array(entries.suffix(maxEntries))
373 entries = Array(entries.suffix(maxEntries))
365
374
366 // Rebuild fingerprint set from stored values (no re-hashing)
375 // Rebuild fingerprint set from stored values (no re-hashing)
ParseableViewer/ViewModels/QueryViewModel.swift+4−1
@@ -199,7 +199,10 @@ final class QueryViewModel {
199 guard !visible.isEmpty else { return }
199 guard !visible.isEmpty else { return }
200
200
201 let newColumnList: String
201 let newColumnList: String
202+ // Use `*` whenever every column is visible, regardless of display order —
203+ // reordering columns (a set-preserving permutation) must not pin the query
204+ // to an explicit snapshot that hides newly-appearing fields on re-query.
202− if visible == columns {
205+ if visible.count == columns.count && Set(visible) == Set(columns) {
203 newColumnList = "*"
206 newColumnList = "*"
204 } else {
207 } else {
205 newColumnList = visible.map { Self.escapeSQLIdentifier($0) }.joined(separator: ", ")
208 newColumnList = visible.map { Self.escapeSQLIdentifier($0) }.joined(separator: ", ")
ParseableViewer/Views/LogTableView.swift+32−1
@@ -20,7 +20,11 @@ func idealColumnWidth(for column: String, records: [LogRecord]) -> CGFloat {
20 let sampleCount = min(records.count, 200)
20 let sampleCount = min(records.count, 200)
21
21
22 var widest = measureTextWidth(column, font: headerMeasureFont)
22 var widest = measureTextWidth(column, font: headerMeasureFont)
23+ // Sample evenly across the whole result set, not just the first N rows —
24+ // otherwise a wide value that sorts beyond row N is never measured and the
25+ // column auto-fits too narrow after sorting.
26+ let step = max(1, records.count / max(sampleCount, 1))
23− for i in 0..<sampleCount {
27+ for i in stride(from: 0, to: records.count, by: step).prefix(sampleCount) {
24 let text = records[i][column]?.displayString ?? ""
28 let text = records[i][column]?.displayString ?? ""
25 widest = max(widest, measureTextWidth(text, font: cellMeasureFont))
29 widest = max(widest, measureTextWidth(text, font: cellMeasureFont))
26 }
30 }
@@ -114,6 +118,25 @@ func parseSeverity(from value: String) -> SeverityLevel {
114 return .unknown
118 return .unknown
115}
119}
116
120
121+/// Columns (lowercased) that carry an OpenTelemetry `severity_number` (1–24)
122+/// rather than an RFC 5424 syslog severity (0–7). The two scales overlap, so
123+/// numeric values from these columns must be interpreted on the OTel scale.
124+private let otelSeverityColumns: Set<String> = ["severity_number", "otel.severity"]
125+
126+/// Maps an OpenTelemetry `severity_number` (1–24) to a ``SeverityLevel``.
127+/// Higher is more severe, in contrast to RFC 5424.
128+func parseOTelSeverityNumber(_ num: Int) -> SeverityLevel {
129+ switch num {
130+ case 1...4: return .trace
131+ case 5...8: return .debug
132+ case 9...12: return .info
133+ case 13...16: return .warning
134+ case 17...20: return .error
135+ case 21...24: return .fatal
136+ default: return .unknown
137+ }
138+}
139+
117/// Extracts the severity level from a log record by checking only known severity columns.
140/// Extracts the severity level from a log record by checking only known severity columns.
118func extractSeverity(from record: LogRecord, severityColumns: Set<String>) -> SeverityLevel {
141func extractSeverity(from record: LogRecord, severityColumns: Set<String>) -> SeverityLevel {
119 for col in severityColumns {
142 for col in severityColumns {
@@ -124,6 +147,14 @@ func extractSeverity(from record: LogRecord, severityColumns: Set<String>) -> Se
124 case .int(let i): str = String(i)
147 case .int(let i): str = String(i)
125 default: continue
148 default: continue
126 }
149 }
150+ // OpenTelemetry severity columns use a 1–24 scale that collides with
151+ // RFC 5424's 0–7, so disambiguate numeric values by column name.
152+ if otelSeverityColumns.contains(col.lowercased()),
153+ let num = Int(str.trimmingCharacters(in: .whitespaces)) {
154+ let level = parseOTelSeverityNumber(num)
155+ if level != .unknown { return level }
156+ continue
157+ }
127 let level = parseSeverity(from: str)
158 let level = parseSeverity(from: str)
128 if level != .unknown { return level }
159 if level != .unknown { return level }
129 }
160 }
ParseableViewer/Views/SQLAutoComplete.swift+8−3
@@ -140,8 +140,10 @@ enum SQLCompletionProvider {
140 }
140 }
141 }
141 }
142
142
143− // Don't show the popup when the only match is an exact hit
143+ // Don't show the popup when the only match is an exact hit. Compare the
144+ // insert text (unquoted), not the display text — a table's display text is
145+ // wrapped in quotes (`"name"`) and would never match the bare prefix.
144− if items.count == 1 && items[0].text.uppercased() == uppercasePrefix {
146+ if items.count == 1 && items[0].insertText.uppercased() == uppercasePrefix {
145 return ([], prefix, range)
147 return ([], prefix, range)
146 }
148 }
147
149
@@ -151,7 +153,10 @@ enum SQLCompletionProvider {
151 // MARK: - Context Detection
153 // MARK: - Context Detection
152
154
153 static func determineContext(text: String, position: Int) -> SQLCompletionContext {
155 static func determineContext(text: String, position: Int) -> SQLCompletionContext {
156+ // `position` is a UTF-16 offset (computed via NSString), so slice with
157+ // NSString too. `String.prefix` counts grapheme clusters and would slice
158+ // at the wrong place when non-BMP characters (e.g. emoji) precede it.
154− let before = String(text.prefix(position))
159+ let before = (text as NSString).substring(to: position)
155 let tokens = tokenize(before)
160 let tokens = tokenize(before)
156
161
157 guard let lastToken = tokens.last?.uppercased() else {
162 guard let lastToken = tokens.last?.uppercased() else {