git.iko.soy/parseable-macos
on master help sign in
Fix 8 bugs: live-tail dedup, query filter, server staleness, connection refresh, alerts fallback, cancellation guard, input validation, stream dialog
commite36d73b70707b3f9d309afc39953d5c868eea1e3 authoriko <mail@iko.soy> dateSun 21 Jun 2026, 11:18 (3 months ago) parent6535031
8 files changed+94−21
ParseableViewer/App/AppState.swift+9 ParseableViewer/Services/ParseableClient.swift+10−4 ParseableViewer/ViewModels/LiveTailViewModel.swift+22−4 ParseableViewer/ViewModels/QueryViewModel.swift+12−2 ParseableViewer/Views/ConnectionSheet.swift+6−1 ParseableViewer/Views/ServerInfoView.swift+19−6 ParseableViewer/Views/SidebarView.swift+2−1 ParseableViewer/Views/UsersView.swift+14−3
ParseableViewer/App/AppState.swift+9
@@ -266,6 +266,15 @@ final class AppState {
266266 connections[index] = connection
267267 ConnectionStore.saveConnections(connections)
268268 }
269+ // If the active connection was edited, refresh the displayed metadata and
270+ // rebuild the live client so subsequent requests use the new URL/credentials
271+ // instead of the stale ones captured when the client was first created.
272+ if activeConnection?.id == connection.id {
273+ activeConnection = connection
274+ if let newClient = try? ParseableClient(connection: connection) {
275+ client = newClient
276+ }
277+ }
269278 }
270279
271280 @MainActor
ParseableViewer/Services/ParseableClient.swift+10−4
@@ -338,11 +338,17 @@ final class ParseableClient: Sendable {
338338 let config = try Self.jsonDecoder.decode(AlertConfig.self, from: data)
339339 // AlertConfig decodes leniently (every field via try?), so a 200 with
340340 // an unexpected body yields an empty, non-throwing config rather than
341− // a DecodingError. Treat an empty result as "new endpoint unsupported"
342− // and fall through to the legacy per-stream endpoint instead of
343− // silently reporting no alerts.
341+ // a DecodingError — indistinguishable from a server that genuinely has
342+ // no alerts. Only fall back to the legacy per-stream endpoint when the
343+ // body ISN'T a recognizable alerts container (a top-level array, or an
344+ // object carrying an "alerts" key). When it is, an empty result is
345+ // authoritative and must not be replaced with unrelated legacy alerts.
344346 if config.alerts?.isEmpty ?? true {
345− if let legacy = try? await legacyAlerts(), !(legacy.alerts?.isEmpty ?? true) {
347+ let json = try? JSONSerialization.jsonObject(with: data)
348+ let isRecognizedShape = json is [Any]
349+ || (json as? [String: Any])?["alerts"] != nil
350+ if !isRecognizedShape,
351+ let legacy = try? await legacyAlerts(), !(legacy.alerts?.isEmpty ?? true) {
346352 return legacy
347353 }
348354 }
ParseableViewer/ViewModels/LiveTailViewModel.swift+22−4
@@ -34,6 +34,9 @@ final class LiveTailViewModel {
3434 nonisolated(unsafe) private var timer: Timer?
3535 private var lastTimestamp: Date?
3636 private var seenFingerprints: Set<String> = []
37+ /// Insertion-ordered mirror of `seenFingerprints` (oldest first), used to
38+ /// evict the least-recently-seen fingerprints when the set is trimmed.
39+ private var seenFingerprintOrder: [String] = []
3740 /// Upper bound on the dedup fingerprint set. Kept well above `maxEntries` so
3841 /// fingerprints of recently-evicted entries survive long enough to suppress
3942 /// duplicates that reappear inside the next poll's look-back window, while
@@ -181,6 +184,7 @@ final class LiveTailViewModel {
181184 errorMessage = nil
182185 entries = []
183186 seenFingerprints = []
187+ seenFingerprintOrder = []
184188 allKnownKeys = []
185189 droppedCount = 0
186190 consecutiveErrors = 0
@@ -218,6 +222,7 @@ final class LiveTailViewModel {
218222 pollGeneration += 1
219223 entries = []
220224 seenFingerprints = []
225+ seenFingerprintOrder = []
221226 // Advance the look-back floor to now: without this, the next poll queries
222227 // from the old `lastTimestamp` with an empty fingerprint set and re-inserts
223228 // every just-cleared record still inside the ~90 s window, repopulating the
@@ -428,6 +433,7 @@ final class LiveTailViewModel {
428433 var newEntries: [LiveTailEntry] = []
429434 for entry in candidateEntries {
430435 if seenFingerprints.insert(entry.fingerprint).inserted {
436+ seenFingerprintOrder.append(entry.fingerprint)
431437 newEntries.append(entry)
432438 }
433439 }
@@ -452,10 +458,22 @@ final class LiveTailViewModel {
452458 // duplicate. Keep the full seen-set so dedup stays correct;
453459 // it is bounded below to avoid unbounded growth.
454460 if seenFingerprints.count > maxSeenFingerprints {
455− // Retain the fingerprints of everything still buffered
456− // (those are the ones that matter for dedup) and discard
457− // the older surplus.
458− seenFingerprints = Set(entries.map { $0.fingerprint })
461+ // Evict the OLDEST surplus fingerprints by insertion order,
462+ // not by what's currently buffered. Collapsing to only the
463+ // buffered set would drop recently-evicted fingerprints that
464+ // still fall inside the next poll's ~90 s look-back window,
465+ // re-admitting those records as duplicates. Keeping the
466+ // most-recently-seen tail preserves a full window of evicted
467+ // fingerprints. Buffered fingerprints are always retained so
468+ // a re-fetch of a buffered record stays deduplicated.
469+ let overflow = seenFingerprints.count - maxSeenFingerprints
470+ let buffered = Set(entries.map { $0.fingerprint })
471+ let toDrop = Set(seenFingerprintOrder.prefix(overflow))
472+ .subtracting(buffered)
473+ if !toDrop.isEmpty {
474+ seenFingerprints.subtract(toDrop)
475+ seenFingerprintOrder.removeAll { toDrop.contains($0) }
476+ }
459477 }
460478 }
461479
ParseableViewer/ViewModels/QueryViewModel.swift+12−2
@@ -13,11 +13,16 @@ final class QueryViewModel {
1313 }
1414 var results: [LogRecord] = [] {
1515 didSet {
16− // Show all results immediately
17− filteredResults = results
1816 // Build search texts asynchronously
1917 searchTextTask?.cancel()
2018 cachedSearchTexts = []
19+ // Apply the current filter immediately using the inline fallback path
20+ // (the cache was just invalidated). Calling updateFilteredResults()
21+ // rather than `filteredResults = results` avoids briefly showing
22+ // unfiltered rows while a filter is active, and avoids leaving a stale
23+ // unfiltered set if the async rebuild below is cancelled before it
24+ // re-applies the filter.
25+ updateFilteredResults()
2126 let snapshot = results
2227 searchTextTask = Task {
2328 let texts = snapshot.map { record in
@@ -370,6 +375,11 @@ final class QueryViewModel {
370375 try Task.checkCancellation()
371376 let queryResults = try await client.query(sql: sql, startTime: startDate, endTime: endDate)
372377 try Task.checkCancellation()
378+ // Re-check before committing. A superseding executeQuery() cancels
379+ // this task (queryTask?.cancel()) before starting the next one, but
380+ // cancellation landing just after the check above would otherwise
381+ // let a stale query clobber the newer one's results/columns.
382+ guard !Task.isCancelled else { return }
373383
374384 results = queryResults
375385 queryDuration = CFAbsoluteTimeGetCurrent() - startTime
ParseableViewer/Views/ConnectionSheet.swift+6−1
@@ -114,7 +114,12 @@ struct ConnectionSheet: View {
114114 Button("Test Connection") {
115115 testConnection()
116116 }
117− .disabled(url.isEmpty || username.isEmpty || isTesting || urlValidationError != nil)
117+ .disabled(
118+ url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
119+ || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
120+ || isTesting
121+ || urlValidationError != nil
122+ )
118123 .accessibilityLabel("Test connection")
119124
120125 Button("Cancel") {
ParseableViewer/Views/ServerInfoView.swift+19−6
@@ -123,13 +123,16 @@ struct ServerInfoView: View {
123123 }
124124 .padding()
125125 }
126− .task {
126+ // Key on the active connection so switching (or reconnecting to a
127+ // different) server reloads instead of showing the previous server's info.
128+ .task(id: appState.activeConnection?.id) {
127129 await loadInfo()
128130 }
129131 }
130132
131133 private func loadInfo() async {
132134 guard let client = appState.client else { return }
135+ let connID = appState.activeConnection?.id
133136 isLoading = true
134137 errorMessage = nil
135138 // Seed from the app-wide cache so first load isn't blank, but never fall
@@ -139,19 +142,29 @@ struct ServerInfoView: View {
139142 async let healthCheck: Void = client.checkHealth()
140143 async let aboutResult = client.getAbout()
141144
145+ let healthy: Bool
142146 do {
143147 try await healthCheck
144− isHealthy = true
148+ healthy = true
145149 } catch {
146− isHealthy = false
150+ healthy = false
147151 }
152+ let aboutValue: ServerAbout?
153+ let aboutError: String?
148154 do {
149− about = try await aboutResult
155+ aboutValue = try await aboutResult
156+ aboutError = nil
150157 } catch {
151− about = nil
152− errorMessage = ParseableError.userFriendlyMessage(for: error)
158+ aboutValue = nil
159+ aboutError = ParseableError.userFriendlyMessage(for: error)
153160 }
154161
162+ // Drop results if the active connection changed while awaiting, so a slow
163+ // load for the previous server can't overwrite the current one's state.
164+ guard connID == appState.activeConnection?.id else { return }
165+ isHealthy = healthy
166+ about = aboutValue
167+ errorMessage = aboutError
155168 isLoading = false
156169 }
157170}
ParseableViewer/Views/SidebarView.swift+2−1
@@ -156,9 +156,10 @@ struct SidebarView: View {
156156 TextField("Stream name", text: $newStreamName)
157157 Button("Create") {
158158 let name = newStreamName.trimmingCharacters(in: .whitespaces)
159− guard !name.isEmpty else { return }
159+ guard !name.isEmpty else { newStreamName = ""; return }
160160 if let error = Self.validateStreamName(name) {
161161 appState.showErrorMessage(error)
162+ newStreamName = ""
162163 return
163164 }
164165 Task {
ParseableViewer/Views/UsersView.swift+14−3
@@ -72,22 +72,33 @@ struct UsersView: View {
7272 }
7373 }
7474 }
75− .task {
75+ // Key on the active connection so switching (or reconnecting to a
76+ // different) server reloads instead of showing the previous server's users.
77+ .task(id: appState.activeConnection?.id) {
7678 await loadUsers()
7779 }
7880 }
7981
8082 private func loadUsers() async {
8183 guard let client = appState.client else { return }
84+ let connID = appState.activeConnection?.id
8285 isLoading = true
8386 errorMessage = nil
8487
88+ let loaded: [UserInfo]?
89+ let loadError: String?
8590 do {
86− users = try await client.listUsers()
91+ loaded = try await client.listUsers()
92+ loadError = nil
8793 } catch {
88− errorMessage = ParseableError.userFriendlyMessage(for: error)
94+ loaded = nil
95+ loadError = ParseableError.userFriendlyMessage(for: error)
8996 }
9097
98+ // Drop results if the active connection changed while awaiting.
99+ guard connID == appState.activeConnection?.id else { return }
100+ if let loaded { users = loaded }
101+ errorMessage = loadError
91102 isLoading = false
92103 }
93104}