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
e36d73biko
ParseableViewer/App/AppState.swift+9
@@ -266,6 +266,15 @@ final class AppState {
266 connections[index] = connection
266 connections[index] = connection
267 ConnectionStore.saveConnections(connections)
267 ConnectionStore.saveConnections(connections)
268 }
268 }
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+ }
269 }
278 }
270
279
271 @MainActor
280 @MainActor
ParseableViewer/Services/ParseableClient.swift+10−4
@@ -338,11 +338,17 @@ final class ParseableClient: Sendable {
338 let config = try Self.jsonDecoder.decode(AlertConfig.self, from: data)
338 let config = try Self.jsonDecoder.decode(AlertConfig.self, from: data)
339 // AlertConfig decodes leniently (every field via try?), so a 200 with
339 // AlertConfig decodes leniently (every field via try?), so a 200 with
340 // an unexpected body yields an empty, non-throwing config rather than
340 // an unexpected body yields an empty, non-throwing config rather than
341− // a DecodingError. Treat an empty result as "new endpoint unsupported"
341+ // a DecodingError — indistinguishable from a server that genuinely has
342− // and fall through to the legacy per-stream endpoint instead of
342+ // no alerts. Only fall back to the legacy per-stream endpoint when the
343− // silently reporting no alerts.
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.
344 if config.alerts?.isEmpty ?? true {
346 if config.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,
345− if let legacy = try? await legacyAlerts(), !(legacy.alerts?.isEmpty ?? true) {
351+ let legacy = try? await legacyAlerts(), !(legacy.alerts?.isEmpty ?? true) {
346 return legacy
352 return legacy
347 }
353 }
348 }
354 }
ParseableViewer/ViewModels/LiveTailViewModel.swift+22−4
@@ -34,6 +34,9 @@ final class LiveTailViewModel {
34 nonisolated(unsafe) private var timer: Timer?
34 nonisolated(unsafe) private var timer: Timer?
35 private var lastTimestamp: Date?
35 private var lastTimestamp: Date?
36 private var seenFingerprints: Set<String> = []
36 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] = []
37 /// Upper bound on the dedup fingerprint set. Kept well above `maxEntries` so
40 /// Upper bound on the dedup fingerprint set. Kept well above `maxEntries` so
38 /// fingerprints of recently-evicted entries survive long enough to suppress
41 /// fingerprints of recently-evicted entries survive long enough to suppress
39 /// duplicates that reappear inside the next poll's look-back window, while
42 /// duplicates that reappear inside the next poll's look-back window, while
@@ -181,6 +184,7 @@ final class LiveTailViewModel {
181 errorMessage = nil
184 errorMessage = nil
182 entries = []
185 entries = []
183 seenFingerprints = []
186 seenFingerprints = []
187+ seenFingerprintOrder = []
184 allKnownKeys = []
188 allKnownKeys = []
185 droppedCount = 0
189 droppedCount = 0
186 consecutiveErrors = 0
190 consecutiveErrors = 0
@@ -218,6 +222,7 @@ final class LiveTailViewModel {
218 pollGeneration += 1
222 pollGeneration += 1
219 entries = []
223 entries = []
220 seenFingerprints = []
224 seenFingerprints = []
225+ seenFingerprintOrder = []
221 // Advance the look-back floor to now: without this, the next poll queries
226 // Advance the look-back floor to now: without this, the next poll queries
222 // from the old `lastTimestamp` with an empty fingerprint set and re-inserts
227 // from the old `lastTimestamp` with an empty fingerprint set and re-inserts
223 // every just-cleared record still inside the ~90 s window, repopulating the
228 // every just-cleared record still inside the ~90 s window, repopulating the
@@ -428,6 +433,7 @@ final class LiveTailViewModel {
428 var newEntries: [LiveTailEntry] = []
433 var newEntries: [LiveTailEntry] = []
429 for entry in candidateEntries {
434 for entry in candidateEntries {
430 if seenFingerprints.insert(entry.fingerprint).inserted {
435 if seenFingerprints.insert(entry.fingerprint).inserted {
436+ seenFingerprintOrder.append(entry.fingerprint)
431 newEntries.append(entry)
437 newEntries.append(entry)
432 }
438 }
433 }
439 }
@@ -452,10 +458,22 @@ final class LiveTailViewModel {
452 // duplicate. Keep the full seen-set so dedup stays correct;
458 // duplicate. Keep the full seen-set so dedup stays correct;
453 // it is bounded below to avoid unbounded growth.
459 // it is bounded below to avoid unbounded growth.
454 if seenFingerprints.count > maxSeenFingerprints {
460 if seenFingerprints.count > maxSeenFingerprints {
455− // Retain the fingerprints of everything still buffered
461+ // Evict the OLDEST surplus fingerprints by insertion order,
456− // (those are the ones that matter for dedup) and discard
462+ // not by what's currently buffered. Collapsing to only the
457− // the older surplus.
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
458− seenFingerprints = Set(entries.map { $0.fingerprint })
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+ }
459 }
477 }
460 }
478 }
461
479
ParseableViewer/ViewModels/QueryViewModel.swift+12−2
@@ -13,11 +13,16 @@ final class QueryViewModel {
13 }
13 }
14 var results: [LogRecord] = [] {
14 var results: [LogRecord] = [] {
15 didSet {
15 didSet {
16− // Show all results immediately
17− filteredResults = results
18 // Build search texts asynchronously
16 // Build search texts asynchronously
19 searchTextTask?.cancel()
17 searchTextTask?.cancel()
20 cachedSearchTexts = []
18 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()
21 let snapshot = results
26 let snapshot = results
22 searchTextTask = Task {
27 searchTextTask = Task {
23 let texts = snapshot.map { record in
28 let texts = snapshot.map { record in
@@ -370,6 +375,11 @@ final class QueryViewModel {
370 try Task.checkCancellation()
375 try Task.checkCancellation()
371 let queryResults = try await client.query(sql: sql, startTime: startDate, endTime: endDate)
376 let queryResults = try await client.query(sql: sql, startTime: startDate, endTime: endDate)
372 try Task.checkCancellation()
377 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 }
373
383
374 results = queryResults
384 results = queryResults
375 queryDuration = CFAbsoluteTimeGetCurrent() - startTime
385 queryDuration = CFAbsoluteTimeGetCurrent() - startTime
ParseableViewer/Views/ConnectionSheet.swift+6−1
@@ -114,7 +114,12 @@ struct ConnectionSheet: View {
114 Button("Test Connection") {
114 Button("Test Connection") {
115 testConnection()
115 testConnection()
116 }
116 }
117+ .disabled(
118+ url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
119+ || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
120+ || isTesting
117− .disabled(url.isEmpty || username.isEmpty || isTesting || urlValidationError != nil)
121+ || urlValidationError != nil
122+ )
118 .accessibilityLabel("Test connection")
123 .accessibilityLabel("Test connection")
119
124
120 Button("Cancel") {
125 Button("Cancel") {
ParseableViewer/Views/ServerInfoView.swift+19−6
@@ -123,13 +123,16 @@ struct ServerInfoView: View {
123 }
123 }
124 .padding()
124 .padding()
125 }
125 }
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) {
127 await loadInfo()
129 await loadInfo()
128 }
130 }
129 }
131 }
130
132
131 private func loadInfo() async {
133 private func loadInfo() async {
132 guard let client = appState.client else { return }
134 guard let client = appState.client else { return }
135+ let connID = appState.activeConnection?.id
133 isLoading = true
136 isLoading = true
134 errorMessage = nil
137 errorMessage = nil
135 // Seed from the app-wide cache so first load isn't blank, but never fall
138 // Seed from the app-wide cache so first load isn't blank, but never fall
@@ -139,19 +142,29 @@ struct ServerInfoView: View {
139 async let healthCheck: Void = client.checkHealth()
142 async let healthCheck: Void = client.checkHealth()
140 async let aboutResult = client.getAbout()
143 async let aboutResult = client.getAbout()
141
144
145+ let healthy: Bool
142 do {
146 do {
143 try await healthCheck
147 try await healthCheck
144− isHealthy = true
148+ healthy = true
145 } catch {
149 } catch {
146− isHealthy = false
150+ healthy = false
147 }
151 }
152+ let aboutValue: ServerAbout?
153+ let aboutError: String?
148 do {
154 do {
149− about = try await aboutResult
155+ aboutValue = try await aboutResult
156+ aboutError = nil
150 } catch {
157 } catch {
151− about = nil
158+ aboutValue = nil
152− errorMessage = ParseableError.userFriendlyMessage(for: error)
159+ aboutError = ParseableError.userFriendlyMessage(for: error)
153 }
160 }
154
161
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
155 isLoading = false
168 isLoading = false
156 }
169 }
157}
170}
ParseableViewer/Views/SidebarView.swift+2−1
@@ -156,9 +156,10 @@ struct SidebarView: View {
156 TextField("Stream name", text: $newStreamName)
156 TextField("Stream name", text: $newStreamName)
157 Button("Create") {
157 Button("Create") {
158 let name = newStreamName.trimmingCharacters(in: .whitespaces)
158 let name = newStreamName.trimmingCharacters(in: .whitespaces)
159− guard !name.isEmpty else { return }
159+ guard !name.isEmpty else { newStreamName = ""; return }
160 if let error = Self.validateStreamName(name) {
160 if let error = Self.validateStreamName(name) {
161 appState.showErrorMessage(error)
161 appState.showErrorMessage(error)
162+ newStreamName = ""
162 return
163 return
163 }
164 }
164 Task {
165 Task {
ParseableViewer/Views/UsersView.swift+14−3
@@ -72,22 +72,33 @@ struct UsersView: View {
72 }
72 }
73 }
73 }
74 }
74 }
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) {
76 await loadUsers()
78 await loadUsers()
77 }
79 }
78 }
80 }
79
81
80 private func loadUsers() async {
82 private func loadUsers() async {
81 guard let client = appState.client else { return }
83 guard let client = appState.client else { return }
84+ let connID = appState.activeConnection?.id
82 isLoading = true
85 isLoading = true
83 errorMessage = nil
86 errorMessage = nil
84
87
88+ let loaded: [UserInfo]?
89+ let loadError: String?
85 do {
90 do {
86− users = try await client.listUsers()
91+ loaded = try await client.listUsers()
92+ loadError = nil
87 } catch {
93 } catch {
94+ loaded = nil
88− errorMessage = ParseableError.userFriendlyMessage(for: error)
95+ loadError = ParseableError.userFriendlyMessage(for: error)
89 }
96 }
90
97
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
91 isLoading = false
102 isLoading = false
92 }
103 }
93}
104}