@@ -15,10 +15,6 @@ 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?
23 // MARK: - Stream State
19 // MARK: - Stream State
24 var streams: [LogStream] = []
20 var streams: [LogStream] = []
@@ -100,7 +96,6 @@ final class AppState {
101 if let activeID = ConnectionStore.loadActiveConnectionID(),
97 if let activeID = ConnectionStore.loadActiveConnectionID(),
102 let connection = connections.first(where: { $0.id == activeID }) {
98 let connection = connections.first(where: { $0.id == activeID }) {
103− connectingConnectionID = connection.id
104 connectTask = Task { @MainActor [weak self] in
99 connectTask = Task { @MainActor [weak self] in
105 await self?.performConnect(to: connection) ?? false
100 await self?.performConnect(to: connection) ?? false
@@ -120,7 +115,6 @@ final class AppState {
120 existing.cancel()
115 existing.cancel()
121 _ = await existing.value
116 _ = await existing.value
123− connectingConnectionID = connection.id
124 let task = Task { @MainActor [weak self] in
118 let task = Task { @MainActor [weak self] in
125 await self?.performConnect(to: connection) ?? false
119 await self?.performConnect(to: connection) ?? false
@@ -130,16 +124,9 @@ final class AppState {
131 @MainActor
125 @MainActor
132 private func performConnect(to connection: ServerConnection) async -> Bool {
126 private func performConnect(to connection: ServerConnection) async -> Bool {
134− if connectingConnectionID == connection.id { connectingConnectionID = nil }
136 guard !isConnecting else { return false }
127 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.
142− if !isNetworkAvailable && !Self.isLoopback(connection.baseURL) {
129+ if !isNetworkAvailable {
143 self.errorMessage = "No internet connection. Check your network and try again."
130 self.errorMessage = "No internet connection. Check your network and try again."
144 self.showError = true
131 self.showError = true
145 return false
132 return false
@@ -147,12 +134,7 @@ final class AppState {
148 isConnecting = true
135 isConnecting = true
149 defer { isConnecting = false }
136 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".
154 errorMessage = nil
137 errorMessage = nil
157 // When switching to a different server, clear stream-specific state
139 // When switching to a different server, clear stream-specific state
158 // so stale selections don't reference streams that don't exist.
140 // so stale selections don't reference streams that don't exist.
@@ -182,7 +164,6 @@ final class AppState {
183 // Test connection — throws on non-200 or network failure
165 // Test connection — throws on non-200 or network failure
184 try await newClient.checkHealth()
166 try await newClient.checkHealth()
185− try await Self.verifyCredentials(with: newClient)
187 // If the task was cancelled while awaiting (e.g. user clicked
168 // If the task was cancelled while awaiting (e.g. user clicked
188 // Disconnect during auto-reconnect), bail before installing the
169 // Disconnect during auto-reconnect), bail before installing the
@@ -219,12 +200,6 @@ final class AppState {
219 case .success(let list):
200 case .success(let list):
220 self.streams = list
201 self.streams = list
221 self.streamLoadError = nil
202 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
228 case .failure(let error):
203 case .failure(let error):
229 self.streams = []
204 self.streams = []
230 self.streamLoadError = ParseableError.userFriendlyMessage(for: error)
205 self.streamLoadError = ParseableError.userFriendlyMessage(for: error)
@@ -290,13 +265,8 @@ final class AppState {
290 let loaded = try await client.listStreams()
265 let loaded = try await client.listStreams()
291 // Drop results if the active connection changed while awaiting. Still
266 // Drop results if the active connection changed while awaiting. Still
292 // clear isLoadingStreams: this task set the spinner, so leaving it true
267 // clear isLoadingStreams: this task set the spinner, so leaving it true
293− // wedges the indicator and disables Refresh. Compare the client too:
268+ // wedges the indicator and disables Refresh.
294− // editing the active connection's URL/credentials keeps its id but
295− // installs a new client for a possibly different server.
296− guard connID == activeConnection?.id, client === self.client else {
269+ guard connID == activeConnection?.id else { isLoadingStreams = false; return }
297− isLoadingStreams = false
300 streams = loaded
270 streams = loaded
301 // Drop a selection that no longer exists server-side (e.g. the stream
271 // Drop a selection that no longer exists server-side (e.g. the stream
302 // was deleted from another client); otherwise the detail tabs keep
272 // was deleted from another client); otherwise the detail tabs keep
@@ -305,10 +275,7 @@ final class AppState {
305 selectedStream = nil
275 selectedStream = nil
307 } catch {
277 } catch {
308− guard connID == activeConnection?.id, client === self.client else {
278+ guard connID == activeConnection?.id else { isLoadingStreams = false; return }
309− isLoadingStreams = false
312 let message = ParseableError.userFriendlyMessage(for: error)
279 let message = ParseableError.userFriendlyMessage(for: error)
313 streamLoadError = message
280 streamLoadError = message
314 self.errorMessage = "Failed to load streams: \(message)"
281 self.errorMessage = "Failed to load streams: \(message)"
@@ -375,49 +342,9 @@ final class AppState {
375 ConnectionStore.saveConnections(connections)
342 ConnectionStore.saveConnections(connections)
376 if activeConnection?.id == connection.id {
343 if activeConnection?.id == connection.id {
377 disconnect()
344 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.")
421 // MARK: - Stream Management
348 // MARK: - Stream Management
423 @MainActor
350 @MainActor
@@ -450,10 +377,7 @@ final class AppState {
450 let connID = activeConnection?.id
377 let connID = activeConnection?.id
451 isLoadingFilters = true
378 isLoadingFilters = true
452 let loaded = (try? await client.listFilters()) ?? []
379 let loaded = (try? await client.listFilters()) ?? []
453− guard connID == activeConnection?.id, client === self.client else {
380+ guard connID == activeConnection?.id else { isLoadingFilters = false; return }
454− isLoadingFilters = false
457 filters = loaded
381 filters = loaded
458 isLoadingFilters = false
382 isLoadingFilters = false