git.iko.soy/parseable-macos
on master help sign in
Implement 13 performance optimizations: shared decoder, response cache, async search, fingerprint XOR, CSV export, stable diffing (#48)
8071187iko
ParseableViewer/Models/JSONValue.swift+20−5
@@ -60,16 +60,31 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
60 }
60 }
61 }
61 }
62
62
63+ /// Compact JSON serialization without allocating a JSONEncoder.
64+ var compactJSON: String {
65+ switch self {
66+ case .null: return "null"
67+ case .bool(let v): return v ? "true" : "false"
68+ case .int(let v): return String(v)
69+ case .double(let v): return String(v)
70+ case .string(let v): return "\"\(Self.escapeJSONString(v))\""
71+ case .array(let items):
72+ return "[" + items.map { $0.compactJSON }.joined(separator: ",") + "]"
73+ case .object(let dict):
74+ let pairs = dict.keys.sorted().compactMap { key -> String? in
75+ guard let value = dict[key] else { return nil }
76+ return "\"\(Self.escapeJSONString(key))\":\(value.compactJSON)"
77+ }
78+ return "{" + pairs.joined(separator: ",") + "}"
79+ }
80+ }
81+
63 /// Like `displayString` but serializes arrays/objects as compact JSON
82 /// Like `displayString` but serializes arrays/objects as compact JSON
64 /// instead of placeholders like "[3 items]". Used for CSV export.
83 /// instead of placeholders like "[3 items]". Used for CSV export.
65 var exportString: String {
84 var exportString: String {
66 switch self {
85 switch self {
67 case .array, .object:
86 case .array, .object:
68− if let data = try? JSONEncoder().encode(self),
69− let json = String(data: data, encoding: .utf8) {
70− return json
87+ return compactJSON
71− }
72− return displayString
73 default:
88 default:
74 return displayString
89 return displayString
75 }
90 }
ParseableViewer/Services/ParseableClient.swift+72−14
@@ -61,12 +61,42 @@ enum ParseableError: LocalizedError {
61 }
61 }
62}
62}
63
63
64+private actor ResponseCache {
65+ private struct Entry: Sendable {
66+ let data: any Sendable
67+ let timestamp: Date
68+ }
69+
70+ private var cache: [String: Entry] = [:]
71+ private let ttl: TimeInterval = 60
72+
73+ func get<T: Sendable>(_ key: String, as type: T.Type) -> T? {
74+ guard let entry = cache[key],
75+ Date().timeIntervalSince(entry.timestamp) < ttl else {
76+ cache.removeValue(forKey: key)
77+ return nil
78+ }
79+ return entry.data as? T
80+ }
81+
82+ func set(_ key: String, value: some Sendable) {
83+ cache[key] = Entry(data: value, timestamp: Date())
84+ }
85+
86+ func invalidate() {
87+ cache.removeAll()
88+ }
89+}
90+
64final class ParseableClient: Sendable {
91final class ParseableClient: Sendable {
65 let baseURL: URL
92 let baseURL: URL
66 let username: String
93 let username: String
67 let password: String
94 let password: String
68 private let session: URLSession
95 private let session: URLSession
69 private let authHeader: String
96 private let authHeader: String
97+ private let cache = ResponseCache()
98+
99+ private static nonisolated(unsafe) let jsonDecoder = JSONDecoder()
70
100
71 private static let isoFormatter: ISO8601DateFormatter = {
101 private static let isoFormatter: ISO8601DateFormatter = {
72 let f = ISO8601DateFormatter()
102 let f = ISO8601DateFormatter()
@@ -162,15 +192,21 @@ final class ParseableClient: Sendable {
162 }
192 }
163
193
164 func getAbout() async throws -> ServerAbout {
194 func getAbout() async throws -> ServerAbout {
195+ let key = "about"
196+ if let cached: ServerAbout = await cache.get(key, as: ServerAbout.self) {
197+ return cached
198+ }
165 let data = try await performRequest(method: "GET", path: "/api/v1/about")
199 let data = try await performRequest(method: "GET", path: "/api/v1/about")
166− return try JSONDecoder().decode(ServerAbout.self, from: data)
200+ let result = try Self.jsonDecoder.decode(ServerAbout.self, from: data)
201+ await cache.set(key, value: result)
202+ return result
167 }
203 }
168
204
169 // MARK: - Log Streams
205 // MARK: - Log Streams
170
206
171 func listStreams() async throws -> [LogStream] {
207 func listStreams() async throws -> [LogStream] {
172 let data = try await performRequest(method: "GET", path: "/api/v1/logstream")
208 let data = try await performRequest(method: "GET", path: "/api/v1/logstream")
173− return try JSONDecoder().decode([LogStream].self, from: data)
209+ return try Self.jsonDecoder.decode([LogStream].self, from: data)
174 }
210 }
175
211
176 private static func encodePathComponent(_ name: String) throws -> String {
212 private static func encodePathComponent(_ name: String) throws -> String {
@@ -194,21 +230,39 @@ final class ParseableClient: Sendable {
194 }
230 }
195
231
196 func getStreamSchema(stream: String) async throws -> StreamSchema {
232 func getStreamSchema(stream: String) async throws -> StreamSchema {
233+ let key = "schema:\(stream)"
234+ if let cached: StreamSchema = await cache.get(key, as: StreamSchema.self) {
235+ return cached
236+ }
197 let encoded = try Self.encodePathComponent(stream)
237 let encoded = try Self.encodePathComponent(stream)
198 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/schema")
238 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/schema")
199− return try JSONDecoder().decode(StreamSchema.self, from: data)
239+ let result = try Self.jsonDecoder.decode(StreamSchema.self, from: data)
240+ await cache.set(key, value: result)
241+ return result
200 }
242 }
201
243
202 func getStreamStats(stream: String) async throws -> StreamStats {
244 func getStreamStats(stream: String) async throws -> StreamStats {
245+ let key = "stats:\(stream)"
246+ if let cached: StreamStats = await cache.get(key, as: StreamStats.self) {
247+ return cached
248+ }
203 let encoded = try Self.encodePathComponent(stream)
249 let encoded = try Self.encodePathComponent(stream)
204 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/stats")
250 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/stats")
205− return try JSONDecoder().decode(StreamStats.self, from: data)
251+ let result = try Self.jsonDecoder.decode(StreamStats.self, from: data)
252+ await cache.set(key, value: result)
253+ return result
206 }
254 }
207
255
208 func getStreamInfo(stream: String) async throws -> StreamInfo {
256 func getStreamInfo(stream: String) async throws -> StreamInfo {
257+ let key = "info:\(stream)"
258+ if let cached: StreamInfo = await cache.get(key, as: StreamInfo.self) {
259+ return cached
260+ }
209 let encoded = try Self.encodePathComponent(stream)
261 let encoded = try Self.encodePathComponent(stream)
210 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/info")
262 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/info")
211− return try JSONDecoder().decode(StreamInfo.self, from: data)
263+ let result = try Self.jsonDecoder.decode(StreamInfo.self, from: data)
264+ await cache.set(key, value: result)
265+ return result
212 }
266 }
213
267
214 // MARK: - Query
268 // MARK: - Query
@@ -232,13 +286,13 @@ final class ParseableClient: Sendable {
232 let firstByte = responseData.first(where: { $0 != 0x20 && $0 != 0x0A && $0 != 0x0D && $0 != 0x09 })
286 let firstByte = responseData.first(where: { $0 != 0x20 && $0 != 0x0A && $0 != 0x0D && $0 != 0x09 })
233 if firstByte == 0x7B { // '{'
287 if firstByte == 0x7B { // '{'
234 do {
288 do {
235− return try JSONDecoder().decode(QueryResponse.self, from: responseData).records
289+ return try Self.jsonDecoder.decode(QueryResponse.self, from: responseData).records
236 } catch {
290 } catch {
237 throw ParseableError.decodingError("Unexpected query response format")
291 throw ParseableError.decodingError("Unexpected query response format")
238 }
292 }
239 } else {
293 } else {
240 do {
294 do {
241− return try JSONDecoder().decode([LogRecord].self, from: responseData)
295+ return try Self.jsonDecoder.decode([LogRecord].self, from: responseData)
242 } catch {
296 } catch {
243 throw ParseableError.decodingError("Unexpected query response format")
297 throw ParseableError.decodingError("Unexpected query response format")
244 }
298 }
@@ -251,7 +305,7 @@ final class ParseableClient: Sendable {
251 // Try new API first; only fall back to legacy per-stream endpoint on 404
305 // Try new API first; only fall back to legacy per-stream endpoint on 404
252 do {
306 do {
253 let data = try await performRequest(method: "GET", path: "/api/v1/alerts")
307 let data = try await performRequest(method: "GET", path: "/api/v1/alerts")
254− return try JSONDecoder().decode(AlertConfig.self, from: data)
308+ return try Self.jsonDecoder.decode(AlertConfig.self, from: data)
255 } catch let error as ParseableError {
309 } catch let error as ParseableError {
256 if case .serverError(let code, _) = error, code == 404 {
310 if case .serverError(let code, _) = error, code == 404 {
257 // New endpoint not available, try legacy below
311 // New endpoint not available, try legacy below
@@ -261,7 +315,7 @@ final class ParseableClient: Sendable {
261 }
315 }
262 let encoded = try Self.encodePathComponent(stream)
316 let encoded = try Self.encodePathComponent(stream)
263 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/alert")
317 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/alert")
264− return try JSONDecoder().decode(AlertConfig.self, from: data)
318+ return try Self.jsonDecoder.decode(AlertConfig.self, from: data)
265 }
319 }
266
320
267 // MARK: - Retention
321 // MARK: - Retention
@@ -271,11 +325,11 @@ final class ParseableClient: Sendable {
271 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/retention")
325 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/retention")
272 if data.isEmpty { return [] }
326 if data.isEmpty { return [] }
273 // Handle both array and single object response
327 // Handle both array and single object response
274− if let array = try? JSONDecoder().decode([RetentionConfig].self, from: data) {
328+ if let array = try? Self.jsonDecoder.decode([RetentionConfig].self, from: data) {
275 return array
329 return array
276 }
330 }
277 do {
331 do {
278− let single = try JSONDecoder().decode(RetentionConfig.self, from: data)
332+ let single = try Self.jsonDecoder.decode(RetentionConfig.self, from: data)
279 return [single]
333 return [single]
280 } catch {
334 } catch {
281 throw ParseableError.decodingError("Unexpected retention response format")
335 throw ParseableError.decodingError("Unexpected retention response format")
@@ -286,7 +340,7 @@ final class ParseableClient: Sendable {
286
340
287 func listUsers() async throws -> [UserInfo] {
341 func listUsers() async throws -> [UserInfo] {
288 let data = try await performRequest(method: "GET", path: "/api/v1/user")
342 let data = try await performRequest(method: "GET", path: "/api/v1/user")
289− return try JSONDecoder().decode([UserInfo].self, from: data)
343+ return try Self.jsonDecoder.decode([UserInfo].self, from: data)
290 }
344 }
291
345
292 // MARK: - Filters
346 // MARK: - Filters
@@ -294,17 +348,21 @@ final class ParseableClient: Sendable {
294 func listFilters() async throws -> [ParseableFilter] {
348 func listFilters() async throws -> [ParseableFilter] {
295 let data = try await performRequest(method: "GET", path: "/api/v1/filters")
349 let data = try await performRequest(method: "GET", path: "/api/v1/filters")
296 if data.isEmpty { return [] }
350 if data.isEmpty { return [] }
297− return try JSONDecoder().decode([ParseableFilter].self, from: data)
351+ return try Self.jsonDecoder.decode([ParseableFilter].self, from: data)
298 }
352 }
299
353
300 func createFilter(_ filter: ParseableFilter) async throws -> ParseableFilter {
354 func createFilter(_ filter: ParseableFilter) async throws -> ParseableFilter {
301 let body = try JSONEncoder().encode(filter)
355 let body = try JSONEncoder().encode(filter)
302 let data = try await performRequest(method: "POST", path: "/api/v1/filters", body: body)
356 let data = try await performRequest(method: "POST", path: "/api/v1/filters", body: body)
303− return try JSONDecoder().decode(ParseableFilter.self, from: data)
357+ return try Self.jsonDecoder.decode(ParseableFilter.self, from: data)
304 }
358 }
305
359
306 func deleteFilter(id: String) async throws {
360 func deleteFilter(id: String) async throws {
307 let encoded = try Self.encodePathComponent(id)
361 let encoded = try Self.encodePathComponent(id)
308 _ = try await performRequest(method: "DELETE", path: "/api/v1/filters/\(encoded)")
362 _ = try await performRequest(method: "DELETE", path: "/api/v1/filters/\(encoded)")
309 }
363 }
364+
365+ func invalidateCache() async {
366+ await cache.invalidate()
367+ }
310}
368}
ParseableViewer/ViewModels/LiveTailViewModel.swift+74−57
@@ -36,20 +36,20 @@ final class LiveTailViewModel {
36 timer?.invalidate()
36 timer?.invalidate()
37 }
37 }
38
38
39− // Cached formatters to avoid per-poll allocation
39+ // Cached formatters — nonisolated(unsafe) so static methods can use them from Task.detached
40− private static let displayFormatter: DateFormatter = {
40+ nonisolated(unsafe) private static let displayFormatter: DateFormatter = {
41 let f = DateFormatter()
41 let f = DateFormatter()
42 f.dateFormat = "HH:mm:ss.SSS"
42 f.dateFormat = "HH:mm:ss.SSS"
43 return f
43 return f
44 }()
44 }()
45
45
46− private static let isoFractional: ISO8601DateFormatter = {
46+ nonisolated(unsafe) private static let isoFractional: ISO8601DateFormatter = {
47 let f = ISO8601DateFormatter()
47 let f = ISO8601DateFormatter()
48 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
48 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
49 return f
49 return f
50 }()
50 }()
51
51
52− private static let isoBasic: ISO8601DateFormatter = {
52+ nonisolated(unsafe) private static let isoBasic: ISO8601DateFormatter = {
53 let f = ISO8601DateFormatter()
53 let f = ISO8601DateFormatter()
54 f.formatOptions = [.withInternetDateTime]
54 f.formatOptions = [.withInternetDateTime]
55 return f
55 return f
@@ -78,7 +78,9 @@ final class LiveTailViewModel {
78 }
78 }
79
79
80 private(set) var cachedFilteredEntries: [LiveTailEntry] = []
80 private(set) var cachedFilteredEntries: [LiveTailEntry] = []
81− private(set) var cachedFilteredRecords: [LogRecord] = []
81+ var cachedFilteredRecords: [LogRecord] {
82+ cachedFilteredEntries.map { $0.record }
83+ }
82 private(set) var filteredEntriesGeneration: Int = 0
84 private(set) var filteredEntriesGeneration: Int = 0
83
85
84 private func rebuildFilteredEntries() {
86 private func rebuildFilteredEntries() {
@@ -117,7 +119,6 @@ final class LiveTailViewModel {
117 }
119 }
118
120
119 cachedFilteredEntries = result
121 cachedFilteredEntries = result
120− cachedFilteredRecords = result.map { $0.record }
121 filteredEntriesGeneration += 1
122 filteredEntriesGeneration += 1
122 }
123 }
123
124
@@ -262,26 +263,21 @@ final class LiveTailViewModel {
262
263
263 // MARK: - Column Extraction
264 // MARK: - Column Extraction
264
265
265− private func extractColumns(from records: [LogRecord]) -> [String] {
266+ private func extractColumnsFromKeys(_ keys: Set<String>) -> [String] {
266 let priorityFields = ["p_timestamp", "p_tags", "p_metadata", "level", "severity", "message", "msg"]
267 let priorityFields = ["p_timestamp", "p_tags", "p_metadata", "level", "severity", "message", "msg"]
267− var allKeys = Set<String>()
268+ var remaining = keys
268− for record in records {
269− allKeys.formUnion(record.keys)
270− }
271−
272 var ordered: [String] = []
269 var ordered: [String] = []
273 for field in priorityFields {
270 for field in priorityFields {
274− if allKeys.remove(field) != nil {
271+ if remaining.remove(field) != nil {
275 ordered.append(field)
272 ordered.append(field)
276 }
273 }
277 }
274 }
278− ordered.append(contentsOf: allKeys.sorted())
275+ ordered.append(contentsOf: remaining.sorted())
279 return ordered
276 return ordered
280 }
277 }
281
278
282 private func updateColumns(stream: String) {
279 private func updateColumns(stream: String) {
283− let allRecords = entries.map { $0.record }
280+ let extracted = extractColumnsFromKeys(allKnownKeys)
284− let extracted = extractColumns(from: allRecords)
285
281
286 if columns.isEmpty {
282 if columns.isEmpty {
287 // First time — apply saved config
283 // First time — apply saved config
@@ -330,23 +326,33 @@ final class LiveTailViewModel {
330 do {
326 do {
331 let records = try await client.query(sql: sql, startTime: queryStart, endTime: now)
327 let records = try await client.query(sql: sql, startTime: queryStart, endTime: now)
332
328
329+ // Process records off the main actor using a snapshot of seen fingerprints
330+ let seenSnapshot = seenFingerprints
331+ let pollTime = now
332+ let candidateEntries = await Task.detached(priority: .userInitiated) {
333− var newEntries: [LiveTailEntry] = []
333+ var results: [LiveTailEntry] = []
334+ for record in records {
335+ let fp = Self.fingerprint(for: record)
336+ guard !seenSnapshot.contains(fp) else { continue }
337+ let timestamp = Self.parseTimestamp(from: record) ?? pollTime
338+ let summary = Self.buildSummary(from: record)
339+ results.append(LiveTailEntry(
340+ timestamp: timestamp,
341+ record: record,
342+ displayTimestamp: Self.displayFormatter.string(from: timestamp),
343+ summary: summary,
344+ fingerprint: fp
345+ ))
346+ }
347+ return results
348+ }.value
334
349
335− for record in records {
350+ // Authoritative dedup on main actor
336− let fp = Self.fingerprint(for: record)
351+ var newEntries: [LiveTailEntry] = []
337− guard !seenFingerprints.contains(fp) else { continue }
352+ for entry in candidateEntries {
338− seenFingerprints.insert(fp)
353+ if seenFingerprints.insert(entry.fingerprint).inserted {
339−
340− let timestamp = parseTimestamp(from: record) ?? now
341− let summary = buildSummary(from: record)
342−
343− newEntries.append(LiveTailEntry(
354+ newEntries.append(entry)
344− timestamp: timestamp,
355+ }
345− record: record,
346− displayTimestamp: Self.displayFormatter.string(from: timestamp),
347− summary: summary,
348− fingerprint: fp
349− ))
350 }
356 }
351
357
352 if !newEntries.isEmpty {
358 if !newEntries.isEmpty {
@@ -355,7 +361,7 @@ final class LiveTailViewModel {
355 if entries.count > maxEntries {
361 if entries.count > maxEntries {
356 let excess = entries.count - maxEntries
362 let excess = entries.count - maxEntries
357 droppedCount += excess
363 droppedCount += excess
358− entries.removeFirst(excess)
364+ entries = Array(entries.suffix(maxEntries))
359
365
360 // Rebuild fingerprint set from stored values (no re-hashing)
366 // Rebuild fingerprint set from stored values (no re-hashing)
361 seenFingerprints = Set(entries.map { $0.fingerprint })
367 seenFingerprints = Set(entries.map { $0.fingerprint })
@@ -393,27 +399,32 @@ final class LiveTailViewModel {
393 }
399 }
394 }
400 }
395
401
396− /// Deterministic content-based fingerprint using sorted key-value pairs and FNV-1a.
402+ /// Deterministic content-based fingerprint using XOR with golden-ratio mixing.
403+ /// Order-independent: O(k) instead of O(k log k) per record.
397− static func fingerprint(for record: LogRecord) -> String {
404+ nonisolated static func fingerprint(for record: LogRecord) -> String {
405+ var xorAccum0: UInt64 = 0
406+ var xorAccum1: UInt64 = 0
407+ for (key, value) in record {
398− var h0: UInt64 = 14695981039346656037
408+ var kh0: UInt64 = 14695981039346656037
399− var h1: UInt64 = 14695981039346656037 &* 31
409+ var kh1: UInt64 = 14695981039346656037 &* 31
400− for key in record.keys.sorted() {
401 for byte in key.utf8 {
410 for byte in key.utf8 {
402− h0 = (h0 ^ UInt64(byte)) &* 1099511628211
411+ kh0 = (kh0 ^ UInt64(byte)) &* 1099511628211
403− h1 = (h1 ^ UInt64(byte)) &* 6700417
412+ kh1 = (kh1 ^ UInt64(byte)) &* 6700417
404 }
413 }
405− h0 = (h0 ^ 0) &* 1099511628211 // separator
414+ kh0 = (kh0 ^ 0) &* 1099511628211 // separator
406− if let val = record[key] {
407− hashJSONValue(val, h0: &h0, h1: &h1)
415+ hashJSONValue(value, h0: &kh0, h1: &kh1)
408− }
409− h1 = (h1 ^ 0xFF) &* 6700417 // field separator
416+ kh1 = (kh1 ^ 0xFF) &* 6700417 // field separator
417+ xorAccum0 ^= kh0 &* 0x9E3779B97F4A7C15
418+ xorAccum1 ^= kh1 &* 0x9E3779B97F4A7C15
410 }
419 }
420+ let h0 = 14695981039346656037 &+ xorAccum0
421+ let h1 = (14695981039346656037 &* 31) &+ xorAccum1
411 return String(h0, radix: 36) + String(h1, radix: 36)
422 return String(h0, radix: 36) + String(h1, radix: 36)
412 }
423 }
413
424
414 /// Recursively hashes a JSONValue tree without allocating intermediate strings.
425 /// Recursively hashes a JSONValue tree without allocating intermediate strings.
415 /// Uses type-discriminator bytes to prevent cross-type collisions.
426 /// Uses type-discriminator bytes to prevent cross-type collisions.
416− private static func hashJSONValue(_ value: JSONValue, h0: inout UInt64, h1: inout UInt64) {
427+ nonisolated private static func hashJSONValue(_ value: JSONValue, h0: inout UInt64, h1: inout UInt64) {
417 switch value {
428 switch value {
418 case .null:
429 case .null:
419 h0 = (h0 ^ 0x00) &* 1099511628211
430 h0 = (h0 ^ 0x00) &* 1099511628211
@@ -459,33 +470,39 @@ final class LiveTailViewModel {
459 case .object(let dict):
470 case .object(let dict):
460 h0 = (h0 ^ 0x06) &* 1099511628211
471 h0 = (h0 ^ 0x06) &* 1099511628211
461 h1 = (h1 ^ 0x06) &* 6700417
472 h1 = (h1 ^ 0x06) &* 6700417
473+ var xor0: UInt64 = 0
474+ var xor1: UInt64 = 0
462− for key in dict.keys.sorted() {
475+ for (key, value) in dict {
476+ var kh0: UInt64 = 14695981039346656037
477+ var kh1: UInt64 = 14695981039346656037 &* 31
463 for byte in key.utf8 {
478 for byte in key.utf8 {
464− h0 = (h0 ^ UInt64(byte)) &* 1099511628211
479+ kh0 = (kh0 ^ UInt64(byte)) &* 1099511628211
465− h1 = (h1 ^ UInt64(byte)) &* 6700417
480+ kh1 = (kh1 ^ UInt64(byte)) &* 6700417
466− }
467− h0 = (h0 ^ 0xFD) &* 1099511628211 // key-value separator
468− if let val = dict[key] {
469− hashJSONValue(val, h0: &h0, h1: &h1)
470 }
481 }
482+ kh0 = (kh0 ^ 0xFD) &* 1099511628211 // key-value separator
483+ hashJSONValue(value, h0: &kh0, h1: &kh1)
484+ xor0 ^= kh0 &* 0x9E3779B97F4A7C15
485+ xor1 ^= kh1 &* 0x9E3779B97F4A7C15
471 }
486 }
487+ h0 = h0 &+ xor0
488+ h1 = h1 &+ xor1
472 }
489 }
473 }
490 }
474
491
475− func parseTimestamp(from record: LogRecord) -> Date? {
492+ nonisolated static func parseTimestamp(from record: LogRecord) -> Date? {
476 guard let value = record["p_timestamp"] ?? record["timestamp"] ?? record["time"] ?? record["@timestamp"] else {
493 guard let value = record["p_timestamp"] ?? record["timestamp"] ?? record["time"] ?? record["@timestamp"] else {
477 return nil
494 return nil
478 }
495 }
479 if case .string(let str) = value {
496 if case .string(let str) = value {
480− if let date = Self.isoFractional.date(from: str) {
497+ if let date = isoFractional.date(from: str) {
481 return date
498 return date
482 }
499 }
483− return Self.isoBasic.date(from: str)
500+ return isoBasic.date(from: str)
484 }
501 }
485 return nil
502 return nil
486 }
503 }
487
504
488− func buildSummary(from record: LogRecord) -> String {
505+ nonisolated static func buildSummary(from record: LogRecord) -> String {
489 var parts: [String] = []
506 var parts: [String] = []
490
507
491 if let level = record["level"] ?? record["severity"] ?? record["log_level"] {
508 if let level = record["level"] ?? record["severity"] ?? record["log_level"] {
ParseableViewer/ViewModels/QueryViewModel.swift+31−10
@@ -6,13 +6,26 @@ final class QueryViewModel {
6 var sqlQuery = ""
6 var sqlQuery = ""
7 var results: [LogRecord] = [] {
7 var results: [LogRecord] = [] {
8 didSet {
8 didSet {
9+ // Show all results immediately
10+ filteredResults = results
11+ // Build search texts asynchronously
12+ searchTextTask?.cancel()
9− cachedSearchTexts = results.map { record in
13+ cachedSearchTexts = []
14+ let snapshot = results
15+ searchTextTask = Task {
16+ let texts = snapshot.map { record in
10− record.values.map { $0.displayString }.joined(separator: " ")
17+ record.values.map { $0.displayString }.joined(separator: " ")
18+ }
19+ guard !Task.isCancelled else { return }
20+ cachedSearchTexts = texts
21+ if !filterText.isEmpty {
22+ updateFilteredResults()
23+ }
11 }
24 }
12− updateFilteredResults()
13 }
25 }
14 }
26 }
15 private var cachedSearchTexts: [String] = []
27 private var cachedSearchTexts: [String] = []
28+ private var searchTextTask: Task<Void, Never>?
16 var columns: [String] = []
29 var columns: [String] = []
17 var columnOrder: [String] = []
30 var columnOrder: [String] = []
18 var hiddenColumns: Set<String> = []
31 var hiddenColumns: Set<String> = []
@@ -458,19 +471,27 @@ final class QueryViewModel {
458 static func buildCSV(records: [LogRecord], columns: [String]) -> String {
471 static func buildCSV(records: [LogRecord], columns: [String]) -> String {
459 guard !records.isEmpty, !columns.isEmpty else { return "" }
472 guard !records.isEmpty, !columns.isEmpty else { return "" }
460
473
461− var lines: [String] = []
474+ var csv = String()
462− lines.reserveCapacity(records.count + 1)
475+ csv.reserveCapacity(columns.count * 50 * (records.count + 1))
463− lines.append(columns.map { escapeCSV($0) }.joined(separator: ","))
476+
477+ // Header
478+ for (i, col) in columns.enumerated() {
479+ if i > 0 { csv.append(",") }
480+ csv.append(escapeCSV(col))
481+ }
482+ csv.append("\n")
464
483
484+ // Rows
465 for record in records {
485 for record in records {
466− let row = columns.map { column in
486+ for (i, column) in columns.enumerated() {
487+ if i > 0 { csv.append(",") }
467 let value = record[column]?.exportString ?? ""
488 let value = record[column]?.exportString ?? ""
468− return escapeCSV(value)
489+ csv.append(escapeCSV(value))
469 }
490 }
470− lines.append(row.joined(separator: ","))
491+ csv.append("\n")
471 }
492 }
472
493
473− return lines.joined(separator: "\n") + "\n"
494+ return csv
474 }
495 }
475
496
476 private static func escapeCSV(_ value: String) -> String {
497 private static func escapeCSV(_ value: String) -> String {
ParseableViewer/Views/LiveTailView.swift+10−10
@@ -281,10 +281,11 @@ struct LiveTailView: View {
281 private func rebuildSortedEntries() {
281 private func rebuildSortedEntries() {
282 let entries = viewModel.cachedFilteredEntries
282 let entries = viewModel.cachedFilteredEntries
283 let cols = viewModel.visibleColumns
283 let cols = viewModel.visibleColumns
284+ let sevCols = buildSeverityColumnSet(columns: cols)
284 guard let col = sortColumn else {
285 guard let col = sortColumn else {
285 cachedSorted = entries
286 cachedSorted = entries
286− cachedSeverities = entries.map { extractSeverity(from: $0.record) }
287+ cachedSeverities = entries.map { extractSeverity(from: $0.record, severityColumns: sevCols) }
287− severityColumnSet = buildSeverityColumnSet(columns: cols)
288+ severityColumnSet = sevCols
288 return
289 return
289 }
290 }
290 liveTailSortTask?.cancel()
291 liveTailSortTask?.cancel()
@@ -301,8 +302,8 @@ struct LiveTailView: View {
301 }.value
302 }.value
302 guard !Task.isCancelled else { return }
303 guard !Task.isCancelled else { return }
303 cachedSorted = sorted
304 cachedSorted = sorted
304− cachedSeverities = sorted.map { extractSeverity(from: $0.record) }
305+ cachedSeverities = sorted.map { extractSeverity(from: $0.record, severityColumns: sevCols) }
305− severityColumnSet = buildSeverityColumnSet(columns: cols)
306+ severityColumnSet = sevCols
306 }
307 }
307 }
308 }
308
309
@@ -311,12 +312,12 @@ struct LiveTailView: View {
311 ScrollView([.horizontal, .vertical]) {
312 ScrollView([.horizontal, .vertical]) {
312 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
313 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
313 Section {
314 Section {
314− ForEach(cachedSorted.indices, id: \.self) { index in
315+ ForEach(Array(cachedSorted.enumerated()), id: \.element.id) { index, entry in
315 LogRowView(
316 LogRowView(
316− record: cachedSorted[index].record,
317+ record: entry.record,
317 columns: viewModel.visibleColumns,
318 columns: viewModel.visibleColumns,
318 columnWidths: columnWidths,
319 columnWidths: columnWidths,
319− isSelected: selectedRecord == cachedSorted[index].record,
320+ isSelected: selectedRecord == entry.record,
320 isAlternate: index % 2 == 1,
321 isAlternate: index % 2 == 1,
321 severity: index < cachedSeverities.count ? cachedSeverities[index] : .unknown,
322 severity: index < cachedSeverities.count ? cachedSeverities[index] : .unknown,
322 severityColumns: severityColumnSet,
323 severityColumns: severityColumnSet,
@@ -325,12 +326,11 @@ struct LiveTailView: View {
325 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
326 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
326 }
327 }
327 )
328 )
328− .id(cachedSorted[index].id)
329 .onTapGesture {
329 .onTapGesture {
330− if selectedRecord == cachedSorted[index].record {
330+ if selectedRecord == entry.record {
331 selectedRecord = nil
331 selectedRecord = nil
332 } else {
332 } else {
333− selectedRecord = cachedSorted[index].record
333+ selectedRecord = entry.record
334 }
334 }
335 }
335 }
336 }
336 }
ParseableViewer/Views/LogDetailView.swift+5−5
@@ -71,25 +71,25 @@ struct LogDetailView: View {
71
71
72struct FormattedRecordView: View {
72struct FormattedRecordView: View {
73 let record: LogRecord
73 let record: LogRecord
74+ let sortedKeys: [String]
74
75
75− var sortedKeys: [String] {
76+ init(record: LogRecord) {
77+ self.record = record
76 let priority = ["p_timestamp", "level", "severity", "message", "msg"]
78 let priority = ["p_timestamp", "level", "severity", "message", "msg"]
77 var keys = record.keys.sorted()
79 var keys = record.keys.sorted()
78 var result: [String] = []
80 var result: [String] = []
79−
80 for p in priority {
81 for p in priority {
81 if let idx = keys.firstIndex(of: p) {
82 if let idx = keys.firstIndex(of: p) {
82 result.append(keys.remove(at: idx))
83 result.append(keys.remove(at: idx))
83 }
84 }
84 }
85 }
85 result.append(contentsOf: keys)
86 result.append(contentsOf: keys)
86− return result
87+ self.sortedKeys = result
87 }
88 }
88
89
89 var body: some View {
90 var body: some View {
90− let keys = sortedKeys
91 VStack(alignment: .leading, spacing: 1) {
91 VStack(alignment: .leading, spacing: 1) {
92− ForEach(Array(keys.enumerated()), id: \.element) { index, key in
92+ ForEach(Array(sortedKeys.enumerated()), id: \.element) { index, key in
93 HStack(alignment: .top, spacing: 8) {
93 HStack(alignment: .top, spacing: 8) {
94 Text(key)
94 Text(key)
95 .font(.system(.caption, design: .monospaced))
95 .font(.system(.caption, design: .monospaced))
ParseableViewer/Views/LogTableView.swift+57−44
@@ -114,19 +114,18 @@ func parseSeverity(from value: String) -> SeverityLevel {
114 return .unknown
114 return .unknown
115}
115}
116
116
117−/// Extracts the severity level from a log record by checking common column names.
117+/// Extracts the severity level from a log record by checking only known severity columns.
118−func extractSeverity(from record: LogRecord) -> SeverityLevel {
118+func extractSeverity(from record: LogRecord, severityColumns: Set<String>) -> SeverityLevel {
119− for (key, value) in record {
119+ for col in severityColumns {
120− if severityColumnNames.contains(key.lowercased()) {
120+ guard let value = record[col] else { continue }
121− let str: String
121+ let str: String
122− switch value {
122+ switch value {
123− case .string(let s): str = s
123+ case .string(let s): str = s
124− case .int(let i): str = String(i)
124+ case .int(let i): str = String(i)
125− default: continue
125+ default: continue
126− }
127− let level = parseSeverity(from: str)
128− if level != .unknown { return level }
129 }
126 }
127+ let level = parseSeverity(from: str)
128+ if level != .unknown { return level }
130 }
129 }
131 return .unknown
130 return .unknown
132}
131}
@@ -155,6 +154,12 @@ func buildSeverityColumnSet(columns: [String]) -> Set<String> {
155
154
156// MARK: - LogTableView
155// MARK: - LogTableView
157
156
157+struct IndexedRecord: Identifiable {
158+ let id: Int
159+ let record: LogRecord
160+ let severity: SeverityLevel
161+}
162+
158struct LogTableView: View {
163struct LogTableView: View {
159 let records: [LogRecord]
164 let records: [LogRecord]
160 let columns: [String]
165 let columns: [String]
@@ -166,8 +171,7 @@ struct LogTableView: View {
166 @State private var sortColumn: String?
171 @State private var sortColumn: String?
167 @State private var sortAscending = false
172 @State private var sortAscending = false
168 @State private var selectedIndex: Int?
173 @State private var selectedIndex: Int?
169− @State private var cachedSorted: [LogRecord] = []
174+ @State private var cachedSorted: [IndexedRecord] = []
170− @State private var cachedSeverities: [SeverityLevel] = []
171 @State private var severityColumnSet: Set<String> = []
175 @State private var severityColumnSet: Set<String> = []
172 @State private var sortTask: Task<Void, Never>?
176 @State private var sortTask: Task<Void, Never>?
173 @State private var widthTask: Task<Void, Never>?
177 @State private var widthTask: Task<Void, Never>?
@@ -196,25 +200,25 @@ struct LogTableView: View {
196 ScrollView([.horizontal, .vertical]) {
200 ScrollView([.horizontal, .vertical]) {
197 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
201 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
198 Section {
202 Section {
199− ForEach(cachedSorted.indices, id: \.self) { index in
203+ ForEach(Array(cachedSorted.enumerated()), id: \.element.id) { index, item in
200 LogRowView(
204 LogRowView(
201− record: cachedSorted[index],
205+ record: item.record,
202 columns: columns,
206 columns: columns,
203 columnWidths: columnWidths,
207 columnWidths: columnWidths,
204− isSelected: selectedIndex == index,
208+ isSelected: selectedIndex == item.id,
205 isAlternate: index % 2 == 1,
209 isAlternate: index % 2 == 1,
206− severity: index < cachedSeverities.count ? cachedSeverities[index] : .unknown,
210+ severity: item.severity,
207 severityColumns: severityColumnSet,
211 severityColumns: severityColumnSet,
208 wrapText: wrapText,
212 wrapText: wrapText,
209 onCellFilter: onCellFilter
213 onCellFilter: onCellFilter
210 )
214 )
211 .onTapGesture {
215 .onTapGesture {
212− if selectedIndex == index {
216+ if selectedIndex == item.id {
213 selectedIndex = nil
217 selectedIndex = nil
214 selectedRecord = nil
218 selectedRecord = nil
215 } else {
219 } else {
216− selectedIndex = index
220+ selectedIndex = item.id
217− selectedRecord = cachedSorted[index]
221+ selectedRecord = item.record
218 }
222 }
219 }
223 }
220 }
224 }
@@ -277,25 +281,29 @@ struct LogTableView: View {
277 let recs = records
281 let recs = records
278 let col = sortColumn
282 let col = sortColumn
279 let asc = sortAscending
283 let asc = sortAscending
280− let sorted: [LogRecord]
284+ let sevCols = buildSeverityColumnSet(columns: columns)
281− if let col {
282− sorted = await Task.detached(priority: .userInitiated) {
285+ let indexed: [IndexedRecord] = await Task.detached(priority: .userInitiated) {
286+ let enumerated = Array(recs.enumerated())
287+ let sorted: [(offset: Int, element: LogRecord)]
288+ if let col {
283− recs.sorted { a, b in
289+ sorted = enumerated.sorted { a, b in
284− let aVal = a[col] ?? .null
290+ let aVal = a.element[col] ?? .null
285− let bVal = b[col] ?? .null
291+ let bVal = b.element[col] ?? .null
286 return asc ? aVal < bVal : bVal < aVal
292 return asc ? aVal < bVal : bVal < aVal
287 }
293 }
288− }.value
289− } else {
294+ } else {
290− sorted = recs
295+ sorted = enumerated
291− }
296+ }
297+ return sorted.map {
298+ IndexedRecord(id: $0.offset, record: $0.element, severity: extractSeverity(from: $0.element, severityColumns: sevCols))
299+ }
300+ }.value
292 guard !Task.isCancelled else { return }
301 guard !Task.isCancelled else { return }
293− cachedSorted = sorted
302+ cachedSorted = indexed
294− cachedSeverities = sorted.map { extractSeverity(from: $0) }
295− severityColumnSet = buildSeverityColumnSet(columns: columns)
303+ severityColumnSet = sevCols
296 if let selectedRecord,
304 if let selectedRecord,
297− let idx = cachedSorted.firstIndex(where: { $0 == selectedRecord }) {
305+ let match = cachedSorted.first(where: { $0.record == selectedRecord }) {
298− selectedIndex = idx
306+ selectedIndex = match.id
299 } else {
307 } else {
300 selectedIndex = nil
308 selectedIndex = nil
301 }
309 }
@@ -303,20 +311,25 @@ struct LogTableView: View {
303 }
311 }
304
312
305 private func rebuildSort() {
313 private func rebuildSort() {
314+ let sevCols = buildSeverityColumnSet(columns: columns)
315+ let enumerated = Array(records.enumerated())
316+ let sorted: [(offset: Int, element: LogRecord)]
306 if let sortColumn {
317 if let sortColumn {
307− cachedSorted = records.sorted { a, b in
318+ sorted = enumerated.sorted { a, b in
308− let aVal = a[sortColumn] ?? .null
319+ let aVal = a.element[sortColumn] ?? .null
309− let bVal = b[sortColumn] ?? .null
320+ let bVal = b.element[sortColumn] ?? .null
310 return sortAscending ? aVal < bVal : bVal < aVal
321 return sortAscending ? aVal < bVal : bVal < aVal
311 }
322 }
312 } else {
323 } else {
324+ sorted = enumerated
325+ }
313− cachedSorted = records
326+ cachedSorted = sorted.map {
327+ IndexedRecord(id: $0.offset, record: $0.element, severity: extractSeverity(from: $0.element, severityColumns: sevCols))
314 }
328 }
315− cachedSeverities = cachedSorted.map { extractSeverity(from: $0) }
316− severityColumnSet = buildSeverityColumnSet(columns: columns)
329+ severityColumnSet = sevCols
317 if let selectedRecord,
330 if let selectedRecord,
318− let idx = cachedSorted.firstIndex(where: { $0 == selectedRecord }) {
331+ let match = cachedSorted.first(where: { $0.record == selectedRecord }) {
319− selectedIndex = idx
332+ selectedIndex = match.id
320 } else {
333 } else {
321 selectedIndex = nil
334 selectedIndex = nil
322 }
335 }
ParseableViewerTests/LiveTailViewModelTests.swift+14−27
@@ -63,43 +63,38 @@ final class LiveTailViewModelTests: XCTestCase {
63 // MARK: - Timestamp parsing
63 // MARK: - Timestamp parsing
64
64
65 func testParseTimestampISO8601WithFractionalSeconds() {
65 func testParseTimestampISO8601WithFractionalSeconds() {
66− let vm = LiveTailViewModel()
67 let record: LogRecord = ["p_timestamp": .string("2024-06-15T10:30:45.123Z")]
66 let record: LogRecord = ["p_timestamp": .string("2024-06-15T10:30:45.123Z")]
68− let date = vm.parseTimestamp(from: record)
67+ let date = LiveTailViewModel.parseTimestamp(from: record)
69 XCTAssertNotNil(date)
68 XCTAssertNotNil(date)
70 }
69 }
71
70
72 func testParseTimestampISO8601Basic() {
71 func testParseTimestampISO8601Basic() {
73− let vm = LiveTailViewModel()
74 let record: LogRecord = ["p_timestamp": .string("2024-06-15T10:30:45Z")]
72 let record: LogRecord = ["p_timestamp": .string("2024-06-15T10:30:45Z")]
75− let date = vm.parseTimestamp(from: record)
73+ let date = LiveTailViewModel.parseTimestamp(from: record)
76 XCTAssertNotNil(date)
74 XCTAssertNotNil(date)
77 }
75 }
78
76
79 func testParseTimestampFallbackFields() {
77 func testParseTimestampFallbackFields() {
80− let vm = LiveTailViewModel()
81−
82 // "timestamp" field
78 // "timestamp" field
83 let r1: LogRecord = ["timestamp": .string("2024-06-15T10:30:45Z")]
79 let r1: LogRecord = ["timestamp": .string("2024-06-15T10:30:45Z")]
84− XCTAssertNotNil(vm.parseTimestamp(from: r1))
80+ XCTAssertNotNil(LiveTailViewModel.parseTimestamp(from: r1))
85
81
86 // "time" field
82 // "time" field
87 let r2: LogRecord = ["time": .string("2024-06-15T10:30:45Z")]
83 let r2: LogRecord = ["time": .string("2024-06-15T10:30:45Z")]
88− XCTAssertNotNil(vm.parseTimestamp(from: r2))
84+ XCTAssertNotNil(LiveTailViewModel.parseTimestamp(from: r2))
89
85
90 // "@timestamp" field
86 // "@timestamp" field
91 let r3: LogRecord = ["@timestamp": .string("2024-06-15T10:30:45Z")]
87 let r3: LogRecord = ["@timestamp": .string("2024-06-15T10:30:45Z")]
92− XCTAssertNotNil(vm.parseTimestamp(from: r3))
88+ XCTAssertNotNil(LiveTailViewModel.parseTimestamp(from: r3))
93 }
89 }
94
90
95 func testParseTimestampPriorityOrder() {
91 func testParseTimestampPriorityOrder() {
96− let vm = LiveTailViewModel()
97 // p_timestamp should take priority
92 // p_timestamp should take priority
98 let record: LogRecord = [
93 let record: LogRecord = [
99 "p_timestamp": .string("2024-01-01T00:00:00Z"),
94 "p_timestamp": .string("2024-01-01T00:00:00Z"),
100 "timestamp": .string("2024-12-31T23:59:59Z")
95 "timestamp": .string("2024-12-31T23:59:59Z")
101 ]
96 ]
102− let date = vm.parseTimestamp(from: record)
97+ let date = LiveTailViewModel.parseTimestamp(from: record)
103 XCTAssertNotNil(date)
98 XCTAssertNotNil(date)
104
99
105 let calendar = Calendar.current
100 let calendar = Calendar.current
@@ -109,64 +104,57 @@ final class LiveTailViewModelTests: XCTestCase {
109 }
104 }
110
105
111 func testParseTimestampNoTimestampField() {
106 func testParseTimestampNoTimestampField() {
112− let vm = LiveTailViewModel()
113 let record: LogRecord = ["message": .string("no timestamp")]
107 let record: LogRecord = ["message": .string("no timestamp")]
114− let date = vm.parseTimestamp(from: record)
108+ let date = LiveTailViewModel.parseTimestamp(from: record)
115 XCTAssertNil(date)
109 XCTAssertNil(date)
116 }
110 }
117
111
118 func testParseTimestampNonStringValue() {
112 func testParseTimestampNonStringValue() {
119− let vm = LiveTailViewModel()
120 let record: LogRecord = ["p_timestamp": .int(1718446245)]
113 let record: LogRecord = ["p_timestamp": .int(1718446245)]
121− let date = vm.parseTimestamp(from: record)
114+ let date = LiveTailViewModel.parseTimestamp(from: record)
122 XCTAssertNil(date)
115 XCTAssertNil(date)
123 }
116 }
124
117
125 func testParseTimestampInvalidString() {
118 func testParseTimestampInvalidString() {
126− let vm = LiveTailViewModel()
127 let record: LogRecord = ["p_timestamp": .string("not-a-date")]
119 let record: LogRecord = ["p_timestamp": .string("not-a-date")]
128− let date = vm.parseTimestamp(from: record)
120+ let date = LiveTailViewModel.parseTimestamp(from: record)
129 XCTAssertNil(date)
121 XCTAssertNil(date)
130 }
122 }
131
123
132 // MARK: - Summary building
124 // MARK: - Summary building
133
125
134 func testBuildSummaryWithLevelAndMessage() {
126 func testBuildSummaryWithLevelAndMessage() {
135− let vm = LiveTailViewModel()
136 let record: LogRecord = [
127 let record: LogRecord = [
137 "level": .string("info"),
128 "level": .string("info"),
138 "message": .string("Server started")
129 "message": .string("Server started")
139 ]
130 ]
140− let summary = vm.buildSummary(from: record)
131+ let summary = LiveTailViewModel.buildSummary(from: record)
141 XCTAssertTrue(summary.contains("[info]"))
132 XCTAssertTrue(summary.contains("[info]"))
142 XCTAssertTrue(summary.contains("Server started"))
133 XCTAssertTrue(summary.contains("Server started"))
143 }
134 }
144
135
145 func testBuildSummaryAlternateFieldNames() {
136 func testBuildSummaryAlternateFieldNames() {
146− let vm = LiveTailViewModel()
147 let record: LogRecord = [
137 let record: LogRecord = [
148 "severity": .string("warn"),
138 "severity": .string("warn"),
149 "msg": .string("Disk almost full")
139 "msg": .string("Disk almost full")
150 ]
140 ]
151− let summary = vm.buildSummary(from: record)
141+ let summary = LiveTailViewModel.buildSummary(from: record)
152 XCTAssertTrue(summary.contains("[warn]"))
142 XCTAssertTrue(summary.contains("[warn]"))
153 XCTAssertTrue(summary.contains("Disk almost full"))
143 XCTAssertTrue(summary.contains("Disk almost full"))
154 }
144 }
155
145
156 func testBuildSummaryFallbackToScalarFields() {
146 func testBuildSummaryFallbackToScalarFields() {
157− let vm = LiveTailViewModel()
158 let record: LogRecord = [
147 let record: LogRecord = [
159 "status": .int(200),
148 "status": .int(200),
160 "path": .string("/api/health")
149 "path": .string("/api/health")
161 ]
150 ]
162− let summary = vm.buildSummary(from: record)
151+ let summary = LiveTailViewModel.buildSummary(from: record)
163 XCTAssertFalse(summary.isEmpty)
152 XCTAssertFalse(summary.isEmpty)
164 }
153 }
165
154
166 func testBuildSummaryEmptyRecord() {
155 func testBuildSummaryEmptyRecord() {
167− let vm = LiveTailViewModel()
168 let record: LogRecord = [:]
156 let record: LogRecord = [:]
169− let summary = vm.buildSummary(from: record)
157+ let summary = LiveTailViewModel.buildSummary(from: record)
170 XCTAssertEqual(summary, "")
158 XCTAssertEqual(summary, "")
171 }
159 }
172
160
@@ -269,12 +257,11 @@ final class LiveTailViewModelTests: XCTestCase {
269 }
257 }
270
258
271 func testBuildSummaryWithLogField() {
259 func testBuildSummaryWithLogField() {
272− let vm = LiveTailViewModel()
273 let record: LogRecord = [
260 let record: LogRecord = [
274 "log_level": .string("debug"),
261 "log_level": .string("debug"),
275 "log": .string("Processing request")
262 "log": .string("Processing request")
276 ]
263 ]
277− let summary = vm.buildSummary(from: record)
264+ let summary = LiveTailViewModel.buildSummary(from: record)
278 XCTAssertTrue(summary.contains("[debug]"))
265 XCTAssertTrue(summary.contains("[debug]"))
279 XCTAssertTrue(summary.contains("Processing request"))
266 XCTAssertTrue(summary.contains("Processing request"))
280 }
267 }
ParseableViewerTests/LogTableHelperTests.swift+14−7
@@ -161,37 +161,44 @@ final class LogTableHelperTests: XCTestCase {
161
161
162 func testExtractSeverityFromLevelColumn() {
162 func testExtractSeverityFromLevelColumn() {
163 let record: LogRecord = ["level": .string("error"), "message": .string("boom")]
163 let record: LogRecord = ["level": .string("error"), "message": .string("boom")]
164+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
164− XCTAssertEqual(extractSeverity(from: record), .error)
165+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .error)
165 }
166 }
166
167
167 func testExtractSeverityFromSeverityColumn() {
168 func testExtractSeverityFromSeverityColumn() {
168 let record: LogRecord = ["severity": .string("warning"), "msg": .string("watch out")]
169 let record: LogRecord = ["severity": .string("warning"), "msg": .string("watch out")]
170+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
169− XCTAssertEqual(extractSeverity(from: record), .warning)
171+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .warning)
170 }
172 }
171
173
172 func testExtractSeverityFromSeverityTextColumn() {
174 func testExtractSeverityFromSeverityTextColumn() {
173 let record: LogRecord = ["severity_text": .string("FATAL")]
175 let record: LogRecord = ["severity_text": .string("FATAL")]
176+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
174− XCTAssertEqual(extractSeverity(from: record), .fatal)
177+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .fatal)
175 }
178 }
176
179
177 func testExtractSeverityFromLogLevelColumn() {
180 func testExtractSeverityFromLogLevelColumn() {
178 let record: LogRecord = ["log_level": .string("info")]
181 let record: LogRecord = ["log_level": .string("info")]
182+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
179− XCTAssertEqual(extractSeverity(from: record), .info)
183+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .info)
180 }
184 }
181
185
182 func testExtractSeverityFromNumericValue() {
186 func testExtractSeverityFromNumericValue() {
183 let record: LogRecord = ["priority": .int(3)]
187 let record: LogRecord = ["priority": .int(3)]
188+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
184− XCTAssertEqual(extractSeverity(from: record), .error)
189+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .error)
185 }
190 }
186
191
187 func testExtractSeverityNoSeverityColumn() {
192 func testExtractSeverityNoSeverityColumn() {
188 let record: LogRecord = ["message": .string("hello"), "timestamp": .string("now")]
193 let record: LogRecord = ["message": .string("hello"), "timestamp": .string("now")]
194+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
189− XCTAssertEqual(extractSeverity(from: record), .unknown)
195+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .unknown)
190 }
196 }
191
197
192 func testExtractSeverityUnrecognizedValue() {
198 func testExtractSeverityUnrecognizedValue() {
193 let record: LogRecord = ["level": .string("custom_level")]
199 let record: LogRecord = ["level": .string("custom_level")]
200+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
194− XCTAssertEqual(extractSeverity(from: record), .unknown)
201+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .unknown)
195 }
202 }
196
203
197 // MARK: - severityRowTint
204 // MARK: - severityRowTint