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)
commit80711872d21a9eb10b055f849c71d3f69ec17508 authoriko <mail@iko.soy> dateMon 16 Feb 2026, 23:25 (7 months ago) parent0ec0378
9 files changed+297−179
ParseableViewer/Models/JSONValue.swift+20−5 ParseableViewer/Services/ParseableClient.swift+72−14 ParseableViewer/ViewModels/LiveTailViewModel.swift+74−57 ParseableViewer/ViewModels/QueryViewModel.swift+31−10 ParseableViewer/Views/LiveTailView.swift+10−10 ParseableViewer/Views/LogDetailView.swift+5−5 ParseableViewer/Views/LogTableView.swift+57−44 ParseableViewerTests/LiveTailViewModelTests.swift+14−27 ParseableViewerTests/LogTableHelperTests.swift+14−7
ParseableViewer/Models/JSONValue.swift+20−5
@@ -60,16 +60,31 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
6060 }
6161 }
6262
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+
6382 /// Like `displayString` but serializes arrays/objects as compact JSON
6483 /// instead of placeholders like "[3 items]". Used for CSV export.
6584 var exportString: String {
6685 switch self {
6786 case .array, .object:
68− if let data = try? JSONEncoder().encode(self),
69− let json = String(data: data, encoding: .utf8) {
70− return json
71− }
72− return displayString
87+ return compactJSON
7388 default:
7489 return displayString
7590 }
ParseableViewer/Services/ParseableClient.swift+72−14
@@ -61,12 +61,42 @@ enum ParseableError: LocalizedError {
6161 }
6262}
6363
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+
6491final class ParseableClient: Sendable {
6592 let baseURL: URL
6693 let username: String
6794 let password: String
6895 private let session: URLSession
6996 private let authHeader: String
97+ private let cache = ResponseCache()
98+
99+ private static nonisolated(unsafe) let jsonDecoder = JSONDecoder()
70100
71101 private static let isoFormatter: ISO8601DateFormatter = {
72102 let f = ISO8601DateFormatter()
@@ -162,15 +192,21 @@ final class ParseableClient: Sendable {
162192 }
163193
164194 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+ }
165199 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
167203 }
168204
169205 // MARK: - Log Streams
170206
171207 func listStreams() async throws -> [LogStream] {
172208 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)
174210 }
175211
176212 private static func encodePathComponent(_ name: String) throws -> String {
@@ -194,21 +230,39 @@ final class ParseableClient: Sendable {
194230 }
195231
196232 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+ }
197237 let encoded = try Self.encodePathComponent(stream)
198238 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
200242 }
201243
202244 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+ }
203249 let encoded = try Self.encodePathComponent(stream)
204250 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
206254 }
207255
208256 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+ }
209261 let encoded = try Self.encodePathComponent(stream)
210262 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
212266 }
213267
214268 // MARK: - Query
@@ -232,13 +286,13 @@ final class ParseableClient: Sendable {
232286 let firstByte = responseData.first(where: { $0 != 0x20 && $0 != 0x0A && $0 != 0x0D && $0 != 0x09 })
233287 if firstByte == 0x7B { // '{'
234288 do {
235− return try JSONDecoder().decode(QueryResponse.self, from: responseData).records
289+ return try Self.jsonDecoder.decode(QueryResponse.self, from: responseData).records
236290 } catch {
237291 throw ParseableError.decodingError("Unexpected query response format")
238292 }
239293 } else {
240294 do {
241− return try JSONDecoder().decode([LogRecord].self, from: responseData)
295+ return try Self.jsonDecoder.decode([LogRecord].self, from: responseData)
242296 } catch {
243297 throw ParseableError.decodingError("Unexpected query response format")
244298 }
@@ -251,7 +305,7 @@ final class ParseableClient: Sendable {
251305 // Try new API first; only fall back to legacy per-stream endpoint on 404
252306 do {
253307 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)
255309 } catch let error as ParseableError {
256310 if case .serverError(let code, _) = error, code == 404 {
257311 // New endpoint not available, try legacy below
@@ -261,7 +315,7 @@ final class ParseableClient: Sendable {
261315 }
262316 let encoded = try Self.encodePathComponent(stream)
263317 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)
265319 }
266320
267321 // MARK: - Retention
@@ -271,11 +325,11 @@ final class ParseableClient: Sendable {
271325 let data = try await performRequest(method: "GET", path: "/api/v1/logstream/\(encoded)/retention")
272326 if data.isEmpty { return [] }
273327 // 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) {
275329 return array
276330 }
277331 do {
278− let single = try JSONDecoder().decode(RetentionConfig.self, from: data)
332+ let single = try Self.jsonDecoder.decode(RetentionConfig.self, from: data)
279333 return [single]
280334 } catch {
281335 throw ParseableError.decodingError("Unexpected retention response format")
@@ -286,7 +340,7 @@ final class ParseableClient: Sendable {
286340
287341 func listUsers() async throws -> [UserInfo] {
288342 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)
290344 }
291345
292346 // MARK: - Filters
@@ -294,17 +348,21 @@ final class ParseableClient: Sendable {
294348 func listFilters() async throws -> [ParseableFilter] {
295349 let data = try await performRequest(method: "GET", path: "/api/v1/filters")
296350 if data.isEmpty { return [] }
297− return try JSONDecoder().decode([ParseableFilter].self, from: data)
351+ return try Self.jsonDecoder.decode([ParseableFilter].self, from: data)
298352 }
299353
300354 func createFilter(_ filter: ParseableFilter) async throws -> ParseableFilter {
301355 let body = try JSONEncoder().encode(filter)
302356 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)
304358 }
305359
306360 func deleteFilter(id: String) async throws {
307361 let encoded = try Self.encodePathComponent(id)
308362 _ = try await performRequest(method: "DELETE", path: "/api/v1/filters/\(encoded)")
309363 }
364+
365+ func invalidateCache() async {
366+ await cache.invalidate()
367+ }
310368}
ParseableViewer/ViewModels/LiveTailViewModel.swift+74−57
@@ -36,20 +36,20 @@ final class LiveTailViewModel {
3636 timer?.invalidate()
3737 }
3838
39− // Cached formatters to avoid per-poll allocation
40− private static let displayFormatter: DateFormatter = {
39+ // Cached formatters — nonisolated(unsafe) so static methods can use them from Task.detached
40+ nonisolated(unsafe) private static let displayFormatter: DateFormatter = {
4141 let f = DateFormatter()
4242 f.dateFormat = "HH:mm:ss.SSS"
4343 return f
4444 }()
4545
46− private static let isoFractional: ISO8601DateFormatter = {
46+ nonisolated(unsafe) private static let isoFractional: ISO8601DateFormatter = {
4747 let f = ISO8601DateFormatter()
4848 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
4949 return f
5050 }()
5151
52− private static let isoBasic: ISO8601DateFormatter = {
52+ nonisolated(unsafe) private static let isoBasic: ISO8601DateFormatter = {
5353 let f = ISO8601DateFormatter()
5454 f.formatOptions = [.withInternetDateTime]
5555 return f
@@ -78,7 +78,9 @@ final class LiveTailViewModel {
7878 }
7979
8080 private(set) var cachedFilteredEntries: [LiveTailEntry] = []
81− private(set) var cachedFilteredRecords: [LogRecord] = []
81+ var cachedFilteredRecords: [LogRecord] {
82+ cachedFilteredEntries.map { $0.record }
83+ }
8284 private(set) var filteredEntriesGeneration: Int = 0
8385
8486 private func rebuildFilteredEntries() {
@@ -117,7 +119,6 @@ final class LiveTailViewModel {
117119 }
118120
119121 cachedFilteredEntries = result
120− cachedFilteredRecords = result.map { $0.record }
121122 filteredEntriesGeneration += 1
122123 }
123124
@@ -262,26 +263,21 @@ final class LiveTailViewModel {
262263
263264 // MARK: - Column Extraction
264265
265− private func extractColumns(from records: [LogRecord]) -> [String] {
266+ private func extractColumnsFromKeys(_ keys: Set<String>) -> [String] {
266267 let priorityFields = ["p_timestamp", "p_tags", "p_metadata", "level", "severity", "message", "msg"]
267− var allKeys = Set<String>()
268− for record in records {
269− allKeys.formUnion(record.keys)
270− }
271−
268+ var remaining = keys
272269 var ordered: [String] = []
273270 for field in priorityFields {
274− if allKeys.remove(field) != nil {
271+ if remaining.remove(field) != nil {
275272 ordered.append(field)
276273 }
277274 }
278− ordered.append(contentsOf: allKeys.sorted())
275+ ordered.append(contentsOf: remaining.sorted())
279276 return ordered
280277 }
281278
282279 private func updateColumns(stream: String) {
283− let allRecords = entries.map { $0.record }
284− let extracted = extractColumns(from: allRecords)
280+ let extracted = extractColumnsFromKeys(allKnownKeys)
285281
286282 if columns.isEmpty {
287283 // First time — apply saved config
@@ -330,23 +326,33 @@ final class LiveTailViewModel {
330326 do {
331327 let records = try await client.query(sql: sql, startTime: queryStart, endTime: now)
332328
333− var newEntries: [LiveTailEntry] = []
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 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
334349
335− for record in records {
336− let fp = Self.fingerprint(for: record)
337− guard !seenFingerprints.contains(fp) else { continue }
338− seenFingerprints.insert(fp)
339−
340− let timestamp = parseTimestamp(from: record) ?? now
341− let summary = buildSummary(from: record)
342−
343− newEntries.append(LiveTailEntry(
344− timestamp: timestamp,
345− record: record,
346− displayTimestamp: Self.displayFormatter.string(from: timestamp),
347− summary: summary,
348− fingerprint: fp
349− ))
350+ // Authoritative dedup on main actor
351+ var newEntries: [LiveTailEntry] = []
352+ for entry in candidateEntries {
353+ if seenFingerprints.insert(entry.fingerprint).inserted {
354+ newEntries.append(entry)
355+ }
350356 }
351357
352358 if !newEntries.isEmpty {
@@ -355,7 +361,7 @@ final class LiveTailViewModel {
355361 if entries.count > maxEntries {
356362 let excess = entries.count - maxEntries
357363 droppedCount += excess
358− entries.removeFirst(excess)
364+ entries = Array(entries.suffix(maxEntries))
359365
360366 // Rebuild fingerprint set from stored values (no re-hashing)
361367 seenFingerprints = Set(entries.map { $0.fingerprint })
@@ -393,27 +399,32 @@ final class LiveTailViewModel {
393399 }
394400 }
395401
396− /// Deterministic content-based fingerprint using sorted key-value pairs and FNV-1a.
397− static func fingerprint(for record: LogRecord) -> String {
398− var h0: UInt64 = 14695981039346656037
399− var h1: UInt64 = 14695981039346656037 &* 31
400− for key in record.keys.sorted() {
402+ /// Deterministic content-based fingerprint using XOR with golden-ratio mixing.
403+ /// Order-independent: O(k) instead of O(k log k) per record.
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 {
408+ var kh0: UInt64 = 14695981039346656037
409+ var kh1: UInt64 = 14695981039346656037 &* 31
401410 for byte in key.utf8 {
402− h0 = (h0 ^ UInt64(byte)) &* 1099511628211
403− h1 = (h1 ^ UInt64(byte)) &* 6700417
411+ kh0 = (kh0 ^ UInt64(byte)) &* 1099511628211
412+ kh1 = (kh1 ^ UInt64(byte)) &* 6700417
404413 }
405− h0 = (h0 ^ 0) &* 1099511628211 // separator
406− if let val = record[key] {
407− hashJSONValue(val, h0: &h0, h1: &h1)
408− }
409− h1 = (h1 ^ 0xFF) &* 6700417 // field separator
414+ kh0 = (kh0 ^ 0) &* 1099511628211 // separator
415+ hashJSONValue(value, h0: &kh0, h1: &kh1)
416+ kh1 = (kh1 ^ 0xFF) &* 6700417 // field separator
417+ xorAccum0 ^= kh0 &* 0x9E3779B97F4A7C15
418+ xorAccum1 ^= kh1 &* 0x9E3779B97F4A7C15
410419 }
420+ let h0 = 14695981039346656037 &+ xorAccum0
421+ let h1 = (14695981039346656037 &* 31) &+ xorAccum1
411422 return String(h0, radix: 36) + String(h1, radix: 36)
412423 }
413424
414425 /// Recursively hashes a JSONValue tree without allocating intermediate strings.
415426 /// 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) {
417428 switch value {
418429 case .null:
419430 h0 = (h0 ^ 0x00) &* 1099511628211
@@ -459,33 +470,39 @@ final class LiveTailViewModel {
459470 case .object(let dict):
460471 h0 = (h0 ^ 0x06) &* 1099511628211
461472 h1 = (h1 ^ 0x06) &* 6700417
462− for key in dict.keys.sorted() {
473+ var xor0: UInt64 = 0
474+ var xor1: UInt64 = 0
475+ for (key, value) in dict {
476+ var kh0: UInt64 = 14695981039346656037
477+ var kh1: UInt64 = 14695981039346656037 &* 31
463478 for byte in key.utf8 {
464− h0 = (h0 ^ UInt64(byte)) &* 1099511628211
465− h1 = (h1 ^ 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)
479+ kh0 = (kh0 ^ UInt64(byte)) &* 1099511628211
480+ kh1 = (kh1 ^ UInt64(byte)) &* 6700417
470481 }
482+ kh0 = (kh0 ^ 0xFD) &* 1099511628211 // key-value separator
483+ hashJSONValue(value, h0: &kh0, h1: &kh1)
484+ xor0 ^= kh0 &* 0x9E3779B97F4A7C15
485+ xor1 ^= kh1 &* 0x9E3779B97F4A7C15
471486 }
487+ h0 = h0 &+ xor0
488+ h1 = h1 &+ xor1
472489 }
473490 }
474491
475− func parseTimestamp(from record: LogRecord) -> Date? {
492+ nonisolated static func parseTimestamp(from record: LogRecord) -> Date? {
476493 guard let value = record["p_timestamp"] ?? record["timestamp"] ?? record["time"] ?? record["@timestamp"] else {
477494 return nil
478495 }
479496 if case .string(let str) = value {
480− if let date = Self.isoFractional.date(from: str) {
497+ if let date = isoFractional.date(from: str) {
481498 return date
482499 }
483− return Self.isoBasic.date(from: str)
500+ return isoBasic.date(from: str)
484501 }
485502 return nil
486503 }
487504
488− func buildSummary(from record: LogRecord) -> String {
505+ nonisolated static func buildSummary(from record: LogRecord) -> String {
489506 var parts: [String] = []
490507
491508 if let level = record["level"] ?? record["severity"] ?? record["log_level"] {
ParseableViewer/ViewModels/QueryViewModel.swift+31−10
@@ -6,13 +6,26 @@ final class QueryViewModel {
66 var sqlQuery = ""
77 var results: [LogRecord] = [] {
88 didSet {
9− cachedSearchTexts = results.map { record in
10− record.values.map { $0.displayString }.joined(separator: " ")
9+ // Show all results immediately
10+ filteredResults = results
11+ // Build search texts asynchronously
12+ searchTextTask?.cancel()
13+ cachedSearchTexts = []
14+ let snapshot = results
15+ searchTextTask = Task {
16+ let texts = snapshot.map { record in
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+ }
1124 }
12− updateFilteredResults()
1325 }
1426 }
1527 private var cachedSearchTexts: [String] = []
28+ private var searchTextTask: Task<Void, Never>?
1629 var columns: [String] = []
1730 var columnOrder: [String] = []
1831 var hiddenColumns: Set<String> = []
@@ -458,19 +471,27 @@ final class QueryViewModel {
458471 static func buildCSV(records: [LogRecord], columns: [String]) -> String {
459472 guard !records.isEmpty, !columns.isEmpty else { return "" }
460473
461− var lines: [String] = []
462− lines.reserveCapacity(records.count + 1)
463− lines.append(columns.map { escapeCSV($0) }.joined(separator: ","))
474+ var csv = String()
475+ csv.reserveCapacity(columns.count * 50 * (records.count + 1))
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")
464483
484+ // Rows
465485 for record in records {
466− let row = columns.map { column in
486+ for (i, column) in columns.enumerated() {
487+ if i > 0 { csv.append(",") }
467488 let value = record[column]?.exportString ?? ""
468− return escapeCSV(value)
489+ csv.append(escapeCSV(value))
469490 }
470− lines.append(row.joined(separator: ","))
491+ csv.append("\n")
471492 }
472493
473− return lines.joined(separator: "\n") + "\n"
494+ return csv
474495 }
475496
476497 private static func escapeCSV(_ value: String) -> String {
ParseableViewer/Views/LiveTailView.swift+10−10
@@ -281,10 +281,11 @@ struct LiveTailView: View {
281281 private func rebuildSortedEntries() {
282282 let entries = viewModel.cachedFilteredEntries
283283 let cols = viewModel.visibleColumns
284+ let sevCols = buildSeverityColumnSet(columns: cols)
284285 guard let col = sortColumn else {
285286 cachedSorted = entries
286− cachedSeverities = entries.map { extractSeverity(from: $0.record) }
287− severityColumnSet = buildSeverityColumnSet(columns: cols)
287+ cachedSeverities = entries.map { extractSeverity(from: $0.record, severityColumns: sevCols) }
288+ severityColumnSet = sevCols
288289 return
289290 }
290291 liveTailSortTask?.cancel()
@@ -301,8 +302,8 @@ struct LiveTailView: View {
301302 }.value
302303 guard !Task.isCancelled else { return }
303304 cachedSorted = sorted
304− cachedSeverities = sorted.map { extractSeverity(from: $0.record) }
305− severityColumnSet = buildSeverityColumnSet(columns: cols)
305+ cachedSeverities = sorted.map { extractSeverity(from: $0.record, severityColumns: sevCols) }
306+ severityColumnSet = sevCols
306307 }
307308 }
308309
@@ -311,12 +312,12 @@ struct LiveTailView: View {
311312 ScrollView([.horizontal, .vertical]) {
312313 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
313314 Section {
314− ForEach(cachedSorted.indices, id: \.self) { index in
315+ ForEach(Array(cachedSorted.enumerated()), id: \.element.id) { index, entry in
315316 LogRowView(
316− record: cachedSorted[index].record,
317+ record: entry.record,
317318 columns: viewModel.visibleColumns,
318319 columnWidths: columnWidths,
319− isSelected: selectedRecord == cachedSorted[index].record,
320+ isSelected: selectedRecord == entry.record,
320321 isAlternate: index % 2 == 1,
321322 severity: index < cachedSeverities.count ? cachedSeverities[index] : .unknown,
322323 severityColumns: severityColumnSet,
@@ -325,12 +326,11 @@ struct LiveTailView: View {
325326 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
326327 }
327328 )
328− .id(cachedSorted[index].id)
329329 .onTapGesture {
330− if selectedRecord == cachedSorted[index].record {
330+ if selectedRecord == entry.record {
331331 selectedRecord = nil
332332 } else {
333− selectedRecord = cachedSorted[index].record
333+ selectedRecord = entry.record
334334 }
335335 }
336336 }
ParseableViewer/Views/LogDetailView.swift+5−5
@@ -71,25 +71,25 @@ struct LogDetailView: View {
7171
7272struct FormattedRecordView: View {
7373 let record: LogRecord
74+ let sortedKeys: [String]
7475
75− var sortedKeys: [String] {
76+ init(record: LogRecord) {
77+ self.record = record
7678 let priority = ["p_timestamp", "level", "severity", "message", "msg"]
7779 var keys = record.keys.sorted()
7880 var result: [String] = []
79−
8081 for p in priority {
8182 if let idx = keys.firstIndex(of: p) {
8283 result.append(keys.remove(at: idx))
8384 }
8485 }
8586 result.append(contentsOf: keys)
86− return result
87+ self.sortedKeys = result
8788 }
8889
8990 var body: some View {
90− let keys = sortedKeys
9191 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
9393 HStack(alignment: .top, spacing: 8) {
9494 Text(key)
9595 .font(.system(.caption, design: .monospaced))
ParseableViewer/Views/LogTableView.swift+57−44
@@ -114,19 +114,18 @@ func parseSeverity(from value: String) -> SeverityLevel {
114114 return .unknown
115115}
116116
117−/// Extracts the severity level from a log record by checking common column names.
118−func extractSeverity(from record: LogRecord) -> SeverityLevel {
119− for (key, value) in record {
120− if severityColumnNames.contains(key.lowercased()) {
121− let str: String
122− switch value {
123− case .string(let s): str = s
124− case .int(let i): str = String(i)
125− default: continue
126− }
127− let level = parseSeverity(from: str)
128− if level != .unknown { return level }
117+/// Extracts the severity level from a log record by checking only known severity columns.
118+func extractSeverity(from record: LogRecord, severityColumns: Set<String>) -> SeverityLevel {
119+ for col in severityColumns {
120+ guard let value = record[col] else { continue }
121+ let str: String
122+ switch value {
123+ case .string(let s): str = s
124+ case .int(let i): str = String(i)
125+ default: continue
129126 }
127+ let level = parseSeverity(from: str)
128+ if level != .unknown { return level }
130129 }
131130 return .unknown
132131}
@@ -155,6 +154,12 @@ func buildSeverityColumnSet(columns: [String]) -> Set<String> {
155154
156155// MARK: - LogTableView
157156
157+struct IndexedRecord: Identifiable {
158+ let id: Int
159+ let record: LogRecord
160+ let severity: SeverityLevel
161+}
162+
158163struct LogTableView: View {
159164 let records: [LogRecord]
160165 let columns: [String]
@@ -166,8 +171,7 @@ struct LogTableView: View {
166171 @State private var sortColumn: String?
167172 @State private var sortAscending = false
168173 @State private var selectedIndex: Int?
169− @State private var cachedSorted: [LogRecord] = []
170− @State private var cachedSeverities: [SeverityLevel] = []
174+ @State private var cachedSorted: [IndexedRecord] = []
171175 @State private var severityColumnSet: Set<String> = []
172176 @State private var sortTask: Task<Void, Never>?
173177 @State private var widthTask: Task<Void, Never>?
@@ -196,25 +200,25 @@ struct LogTableView: View {
196200 ScrollView([.horizontal, .vertical]) {
197201 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
198202 Section {
199− ForEach(cachedSorted.indices, id: \.self) { index in
203+ ForEach(Array(cachedSorted.enumerated()), id: \.element.id) { index, item in
200204 LogRowView(
201− record: cachedSorted[index],
205+ record: item.record,
202206 columns: columns,
203207 columnWidths: columnWidths,
204− isSelected: selectedIndex == index,
208+ isSelected: selectedIndex == item.id,
205209 isAlternate: index % 2 == 1,
206− severity: index < cachedSeverities.count ? cachedSeverities[index] : .unknown,
210+ severity: item.severity,
207211 severityColumns: severityColumnSet,
208212 wrapText: wrapText,
209213 onCellFilter: onCellFilter
210214 )
211215 .onTapGesture {
212− if selectedIndex == index {
216+ if selectedIndex == item.id {
213217 selectedIndex = nil
214218 selectedRecord = nil
215219 } else {
216− selectedIndex = index
217− selectedRecord = cachedSorted[index]
220+ selectedIndex = item.id
221+ selectedRecord = item.record
218222 }
219223 }
220224 }
@@ -277,25 +281,29 @@ struct LogTableView: View {
277281 let recs = records
278282 let col = sortColumn
279283 let asc = sortAscending
280− let sorted: [LogRecord]
281− if let col {
282− sorted = await Task.detached(priority: .userInitiated) {
283− recs.sorted { a, b in
284− let aVal = a[col] ?? .null
285− let bVal = b[col] ?? .null
284+ let sevCols = buildSeverityColumnSet(columns: columns)
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 {
289+ sorted = enumerated.sorted { a, b in
290+ let aVal = a.element[col] ?? .null
291+ let bVal = b.element[col] ?? .null
286292 return asc ? aVal < bVal : bVal < aVal
287293 }
288− }.value
289− } else {
290− sorted = recs
291− }
294+ } else {
295+ sorted = enumerated
296+ }
297+ return sorted.map {
298+ IndexedRecord(id: $0.offset, record: $0.element, severity: extractSeverity(from: $0.element, severityColumns: sevCols))
299+ }
300+ }.value
292301 guard !Task.isCancelled else { return }
293− cachedSorted = sorted
294− cachedSeverities = sorted.map { extractSeverity(from: $0) }
295− severityColumnSet = buildSeverityColumnSet(columns: columns)
302+ cachedSorted = indexed
303+ severityColumnSet = sevCols
296304 if let selectedRecord,
297− let idx = cachedSorted.firstIndex(where: { $0 == selectedRecord }) {
298− selectedIndex = idx
305+ let match = cachedSorted.first(where: { $0.record == selectedRecord }) {
306+ selectedIndex = match.id
299307 } else {
300308 selectedIndex = nil
301309 }
@@ -303,20 +311,25 @@ struct LogTableView: View {
303311 }
304312
305313 private func rebuildSort() {
314+ let sevCols = buildSeverityColumnSet(columns: columns)
315+ let enumerated = Array(records.enumerated())
316+ let sorted: [(offset: Int, element: LogRecord)]
306317 if let sortColumn {
307− cachedSorted = records.sorted { a, b in
308− let aVal = a[sortColumn] ?? .null
309− let bVal = b[sortColumn] ?? .null
318+ sorted = enumerated.sorted { a, b in
319+ let aVal = a.element[sortColumn] ?? .null
320+ let bVal = b.element[sortColumn] ?? .null
310321 return sortAscending ? aVal < bVal : bVal < aVal
311322 }
312323 } else {
313− cachedSorted = records
324+ sorted = enumerated
325+ }
326+ cachedSorted = sorted.map {
327+ IndexedRecord(id: $0.offset, record: $0.element, severity: extractSeverity(from: $0.element, severityColumns: sevCols))
314328 }
315− cachedSeverities = cachedSorted.map { extractSeverity(from: $0) }
316− severityColumnSet = buildSeverityColumnSet(columns: columns)
329+ severityColumnSet = sevCols
317330 if let selectedRecord,
318− let idx = cachedSorted.firstIndex(where: { $0 == selectedRecord }) {
319− selectedIndex = idx
331+ let match = cachedSorted.first(where: { $0.record == selectedRecord }) {
332+ selectedIndex = match.id
320333 } else {
321334 selectedIndex = nil
322335 }
ParseableViewerTests/LiveTailViewModelTests.swift+14−27
@@ -63,43 +63,38 @@ final class LiveTailViewModelTests: XCTestCase {
6363 // MARK: - Timestamp parsing
6464
6565 func testParseTimestampISO8601WithFractionalSeconds() {
66− let vm = LiveTailViewModel()
6766 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)
6968 XCTAssertNotNil(date)
7069 }
7170
7271 func testParseTimestampISO8601Basic() {
73− let vm = LiveTailViewModel()
7472 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)
7674 XCTAssertNotNil(date)
7775 }
7876
7977 func testParseTimestampFallbackFields() {
80− let vm = LiveTailViewModel()
81−
8278 // "timestamp" field
8379 let r1: LogRecord = ["timestamp": .string("2024-06-15T10:30:45Z")]
84− XCTAssertNotNil(vm.parseTimestamp(from: r1))
80+ XCTAssertNotNil(LiveTailViewModel.parseTimestamp(from: r1))
8581
8682 // "time" field
8783 let r2: LogRecord = ["time": .string("2024-06-15T10:30:45Z")]
88− XCTAssertNotNil(vm.parseTimestamp(from: r2))
84+ XCTAssertNotNil(LiveTailViewModel.parseTimestamp(from: r2))
8985
9086 // "@timestamp" field
9187 let r3: LogRecord = ["@timestamp": .string("2024-06-15T10:30:45Z")]
92− XCTAssertNotNil(vm.parseTimestamp(from: r3))
88+ XCTAssertNotNil(LiveTailViewModel.parseTimestamp(from: r3))
9389 }
9490
9591 func testParseTimestampPriorityOrder() {
96− let vm = LiveTailViewModel()
9792 // p_timestamp should take priority
9893 let record: LogRecord = [
9994 "p_timestamp": .string("2024-01-01T00:00:00Z"),
10095 "timestamp": .string("2024-12-31T23:59:59Z")
10196 ]
102− let date = vm.parseTimestamp(from: record)
97+ let date = LiveTailViewModel.parseTimestamp(from: record)
10398 XCTAssertNotNil(date)
10499
105100 let calendar = Calendar.current
@@ -109,64 +104,57 @@ final class LiveTailViewModelTests: XCTestCase {
109104 }
110105
111106 func testParseTimestampNoTimestampField() {
112− let vm = LiveTailViewModel()
113107 let record: LogRecord = ["message": .string("no timestamp")]
114− let date = vm.parseTimestamp(from: record)
108+ let date = LiveTailViewModel.parseTimestamp(from: record)
115109 XCTAssertNil(date)
116110 }
117111
118112 func testParseTimestampNonStringValue() {
119− let vm = LiveTailViewModel()
120113 let record: LogRecord = ["p_timestamp": .int(1718446245)]
121− let date = vm.parseTimestamp(from: record)
114+ let date = LiveTailViewModel.parseTimestamp(from: record)
122115 XCTAssertNil(date)
123116 }
124117
125118 func testParseTimestampInvalidString() {
126− let vm = LiveTailViewModel()
127119 let record: LogRecord = ["p_timestamp": .string("not-a-date")]
128− let date = vm.parseTimestamp(from: record)
120+ let date = LiveTailViewModel.parseTimestamp(from: record)
129121 XCTAssertNil(date)
130122 }
131123
132124 // MARK: - Summary building
133125
134126 func testBuildSummaryWithLevelAndMessage() {
135− let vm = LiveTailViewModel()
136127 let record: LogRecord = [
137128 "level": .string("info"),
138129 "message": .string("Server started")
139130 ]
140− let summary = vm.buildSummary(from: record)
131+ let summary = LiveTailViewModel.buildSummary(from: record)
141132 XCTAssertTrue(summary.contains("[info]"))
142133 XCTAssertTrue(summary.contains("Server started"))
143134 }
144135
145136 func testBuildSummaryAlternateFieldNames() {
146− let vm = LiveTailViewModel()
147137 let record: LogRecord = [
148138 "severity": .string("warn"),
149139 "msg": .string("Disk almost full")
150140 ]
151− let summary = vm.buildSummary(from: record)
141+ let summary = LiveTailViewModel.buildSummary(from: record)
152142 XCTAssertTrue(summary.contains("[warn]"))
153143 XCTAssertTrue(summary.contains("Disk almost full"))
154144 }
155145
156146 func testBuildSummaryFallbackToScalarFields() {
157− let vm = LiveTailViewModel()
158147 let record: LogRecord = [
159148 "status": .int(200),
160149 "path": .string("/api/health")
161150 ]
162− let summary = vm.buildSummary(from: record)
151+ let summary = LiveTailViewModel.buildSummary(from: record)
163152 XCTAssertFalse(summary.isEmpty)
164153 }
165154
166155 func testBuildSummaryEmptyRecord() {
167− let vm = LiveTailViewModel()
168156 let record: LogRecord = [:]
169− let summary = vm.buildSummary(from: record)
157+ let summary = LiveTailViewModel.buildSummary(from: record)
170158 XCTAssertEqual(summary, "")
171159 }
172160
@@ -269,12 +257,11 @@ final class LiveTailViewModelTests: XCTestCase {
269257 }
270258
271259 func testBuildSummaryWithLogField() {
272− let vm = LiveTailViewModel()
273260 let record: LogRecord = [
274261 "log_level": .string("debug"),
275262 "log": .string("Processing request")
276263 ]
277− let summary = vm.buildSummary(from: record)
264+ let summary = LiveTailViewModel.buildSummary(from: record)
278265 XCTAssertTrue(summary.contains("[debug]"))
279266 XCTAssertTrue(summary.contains("Processing request"))
280267 }
ParseableViewerTests/LogTableHelperTests.swift+14−7
@@ -161,37 +161,44 @@ final class LogTableHelperTests: XCTestCase {
161161
162162 func testExtractSeverityFromLevelColumn() {
163163 let record: LogRecord = ["level": .string("error"), "message": .string("boom")]
164− XCTAssertEqual(extractSeverity(from: record), .error)
164+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
165+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .error)
165166 }
166167
167168 func testExtractSeverityFromSeverityColumn() {
168169 let record: LogRecord = ["severity": .string("warning"), "msg": .string("watch out")]
169− XCTAssertEqual(extractSeverity(from: record), .warning)
170+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
171+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .warning)
170172 }
171173
172174 func testExtractSeverityFromSeverityTextColumn() {
173175 let record: LogRecord = ["severity_text": .string("FATAL")]
174− XCTAssertEqual(extractSeverity(from: record), .fatal)
176+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
177+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .fatal)
175178 }
176179
177180 func testExtractSeverityFromLogLevelColumn() {
178181 let record: LogRecord = ["log_level": .string("info")]
179− XCTAssertEqual(extractSeverity(from: record), .info)
182+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
183+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .info)
180184 }
181185
182186 func testExtractSeverityFromNumericValue() {
183187 let record: LogRecord = ["priority": .int(3)]
184− XCTAssertEqual(extractSeverity(from: record), .error)
188+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
189+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .error)
185190 }
186191
187192 func testExtractSeverityNoSeverityColumn() {
188193 let record: LogRecord = ["message": .string("hello"), "timestamp": .string("now")]
189− XCTAssertEqual(extractSeverity(from: record), .unknown)
194+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
195+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .unknown)
190196 }
191197
192198 func testExtractSeverityUnrecognizedValue() {
193199 let record: LogRecord = ["level": .string("custom_level")]
194− XCTAssertEqual(extractSeverity(from: record), .unknown)
200+ let sevCols = buildSeverityColumnSet(columns: Array(record.keys))
201+ XCTAssertEqual(extractSeverity(from: record, severityColumns: sevCols), .unknown)
195202 }
196203
197204 // MARK: - severityRowTint