@@ -15,6 +15,10 @@ final class AppState {
15 private let networkMonitor = NWPathMonitor()
15 private let networkMonitor = NWPathMonitor()
16 private let networkMonitorQueue = DispatchQueue(label: "com.parseableviewer.network-monitor")
16 private let networkMonitorQueue = DispatchQueue(label: "com.parseableviewer.network-monitor")
17 private var connectTask: Task<Bool, Never>?
17 private var connectTask: Task<Bool, Never>?
18+ /// The connection `connectTask` is connecting to. `activeConnection` only
19+ /// switches once the new server answers, so this is what identifies an
20+ /// in-flight connect whose target the user deletes (see `removeConnection`).
21+ private var connectingConnectionID: UUID?
19 // MARK: - Stream State
23 // MARK: - Stream State
20 var streams: [LogStream] = []
24 var streams: [LogStream] = []
@@ -96,6 +100,7 @@ final class AppState {
97 if let activeID = ConnectionStore.loadActiveConnectionID(),
101 if let activeID = ConnectionStore.loadActiveConnectionID(),
98 let connection = connections.first(where: { $0.id == activeID }) {
102 let connection = connections.first(where: { $0.id == activeID }) {
103+ connectingConnectionID = connection.id
99 connectTask = Task { @MainActor [weak self] in
104 connectTask = Task { @MainActor [weak self] in
100 await self?.performConnect(to: connection) ?? false
105 await self?.performConnect(to: connection) ?? false
@@ -115,6 +120,7 @@ final class AppState {
115 existing.cancel()
120 existing.cancel()
116 _ = await existing.value
121 _ = await existing.value
123+ connectingConnectionID = connection.id
118 let task = Task { @MainActor [weak self] in
124 let task = Task { @MainActor [weak self] in
119 await self?.performConnect(to: connection) ?? false
125 await self?.performConnect(to: connection) ?? false
@@ -124,9 +130,16 @@ final class AppState {
125 @MainActor
131 @MainActor
126 private func performConnect(to connection: ServerConnection) async -> Bool {
132 private func performConnect(to connection: ServerConnection) async -> Bool {
134+ if connectingConnectionID == connection.id { connectingConnectionID = nil }
127 guard !isConnecting else { return false }
136 guard !isConnecting else { return false }
138+ // NWPathMonitor ignores loopback, so with no active network interface it
139+ // reports "unsatisfied" even though a local server (the common docker /
140+ // dev setup) is reachable. Don't block those; a genuinely unreachable
141+ // host still fails below with the same offline message.
129− if !isNetworkAvailable {
142+ if !isNetworkAvailable && !Self.isLoopback(connection.baseURL) {
130 self.errorMessage = "No internet connection. Check your network and try again."
143 self.errorMessage = "No internet connection. Check your network and try again."
131 self.showError = true
144 self.showError = true
132 return false
145 return false
@@ -134,7 +147,12 @@ final class AppState {
135 isConnecting = true
148 isConnecting = true
136 defer { isConnecting = false }
149 defer { isConnecting = false }
150+ // Reset the flag with the message: a previous failed connect left
151+ // `showError` set, and if its alert couldn't present (e.g. behind the
152+ // connection sheet), it would pop up later — after a successful retry —
153+ // reading "An unknown error occurred".
137 errorMessage = nil
154 errorMessage = nil
139 // When switching to a different server, clear stream-specific state
157 // When switching to a different server, clear stream-specific state
140 // so stale selections don't reference streams that don't exist.
158 // so stale selections don't reference streams that don't exist.
@@ -164,6 +182,7 @@ final class AppState {
165 // Test connection — throws on non-200 or network failure
183 // Test connection — throws on non-200 or network failure
166 try await newClient.checkHealth()
184 try await newClient.checkHealth()
185+ try await Self.verifyCredentials(with: newClient)
168 // If the task was cancelled while awaiting (e.g. user clicked
187 // If the task was cancelled while awaiting (e.g. user clicked
169 // Disconnect during auto-reconnect), bail before installing the
188 // Disconnect during auto-reconnect), bail before installing the
@@ -200,6 +219,12 @@ final class AppState {
200 case .success(let list):
219 case .success(let list):
201 self.streams = list
220 self.streams = list
202 self.streamLoadError = nil
221 self.streamLoadError = nil
222+ // Like refreshStreams: a selection that doesn't exist on this server
223+ // (e.g. one picked from a stale list while the connect was pending)
224+ // would keep every stream tab querying a missing stream.
225+ if let selected = selectedStream, !list.contains(where: { $0.name == selected }) {
226+ selectedStream = nil
203 case .failure(let error):
228 case .failure(let error):
204 self.streams = []
229 self.streams = []
205 self.streamLoadError = ParseableError.userFriendlyMessage(for: error)
230 self.streamLoadError = ParseableError.userFriendlyMessage(for: error)
@@ -265,8 +290,13 @@ final class AppState {
265 let loaded = try await client.listStreams()
290 let loaded = try await client.listStreams()
266 // Drop results if the active connection changed while awaiting. Still
291 // Drop results if the active connection changed while awaiting. Still
267 // clear isLoadingStreams: this task set the spinner, so leaving it true
292 // clear isLoadingStreams: this task set the spinner, so leaving it true
268− // wedges the indicator and disables Refresh.
293+ // wedges the indicator and disables Refresh. Compare the client too:
294+ // editing the active connection's URL/credentials keeps its id but
295+ // installs a new client for a possibly different server.
269− guard connID == activeConnection?.id else { isLoadingStreams = false; return }
296+ guard connID == activeConnection?.id, client === self.client else {
297+ isLoadingStreams = false
270 streams = loaded
300 streams = loaded
271 // Drop a selection that no longer exists server-side (e.g. the stream
301 // Drop a selection that no longer exists server-side (e.g. the stream
272 // was deleted from another client); otherwise the detail tabs keep
302 // was deleted from another client); otherwise the detail tabs keep
@@ -275,7 +305,10 @@ final class AppState {
275 selectedStream = nil
305 selectedStream = nil
277 } catch {
307 } catch {
278− guard connID == activeConnection?.id else { isLoadingStreams = false; return }
308+ guard connID == activeConnection?.id, client === self.client else {
309+ isLoadingStreams = false
279 let message = ParseableError.userFriendlyMessage(for: error)
312 let message = ParseableError.userFriendlyMessage(for: error)
280 streamLoadError = message
313 streamLoadError = message
281 self.errorMessage = "Failed to load streams: \(message)"
314 self.errorMessage = "Failed to load streams: \(message)"
@@ -342,9 +375,49 @@ final class AppState {
342 ConnectionStore.saveConnections(connections)
375 ConnectionStore.saveConnections(connections)
343 if activeConnection?.id == connection.id {
376 if activeConnection?.id == connection.id {
344 disconnect()
377 disconnect()
378+ } else if connectingConnectionID == connection.id {
379+ // The removed connection is still being connected to (e.g. Cancel in the
380+ // connection sheet, or deleting it in Settings during the launch
381+ // reconnect). `activeConnection` doesn't point at it yet, so without
382+ // this the connect would carry on and install a client for a
383+ // connection that no longer exists (or tear down the current session
384+ // when it fails) after the user abandoned it.
385+ connectTask?.cancel()
387+ connectingConnectionID = nil
388+ // Starting that connect already cleared the current server's stream
389+ // list and filters; reload them if we're still connected to it.
391+ Task { @MainActor [weak self] in
392+ await self?.refreshStreams()
393+ await self?.refreshFilters()
399+ /// `/api/v1/liveness` is unauthenticated on Parseable (it answers 200 to
400+ /// anyone, for load-balancer / k8s probes), so a health check alone accepts
401+ /// any username and password. Probe an authenticated endpoint and fail on
402+ /// 401. Other failures — e.g. a 403 for a role without the about permission —
403+ /// don't show the credentials are wrong, so they're ignored here. The client
404+ /// caches the response, so the follow-up `getAbout()` load reuses it.
405+ static func verifyCredentials(with client: ParseableClient) async throws {
407+ _ = try await client.getAbout()
408+ } catch ParseableError.unauthorized {
409+ throw ParseableError.unauthorized
411+ // Not an authentication failure; leave it to the best-effort loads.
415+ private static func isLoopback(_ url: URL?) -> Bool {
416+ guard let host = url?.host?.lowercased() else { return false }
417+ return host == "localhost" || host.hasSuffix(".localhost")
418+ || host == "::1" || host.hasPrefix("127.")
348 // MARK: - Stream Management
421 // MARK: - Stream Management
350 @MainActor
423 @MainActor
@@ -377,7 +450,10 @@ final class AppState {
377 let connID = activeConnection?.id
450 let connID = activeConnection?.id
378 isLoadingFilters = true
451 isLoadingFilters = true
379 let loaded = (try? await client.listFilters()) ?? []
452 let loaded = (try? await client.listFilters()) ?? []
380− guard connID == activeConnection?.id else { isLoadingFilters = false; return }
453+ guard connID == activeConnection?.id, client === self.client else {
454+ isLoadingFilters = false
381 filters = loaded
457 filters = loaded
382 isLoadingFilters = false
458 isLoadingFilters = false