git.iko.soy/parseable-macos
on d947711 help sign in
17 files changed+49−528
since splitsnapshots
unifiedsplit
ParseableViewer/App/AppState.swift+5−81 ParseableViewer/App/ParseableViewerApp.swift+1−13 ParseableViewer/Info.plist−5 ParseableViewer/Models/AlertModels.swift−21 ParseableViewer/Models/LogStream.swift+11−97 ParseableViewer/Models/ServerConnection.swift+2−19 ParseableViewer/Models/ServerInfo.swift+7−33 ParseableViewer/Services/ParseableClient.swift+2−44 ParseableViewer/Views/AlertsView.swift+1−12 ParseableViewer/Views/ConnectionSheet.swift+7−27 ParseableViewer/Views/LogTableView.swift+6−53 ParseableViewer/Views/ServerInfoView.swift+2−12 ParseableViewer/Views/SidebarView.swift+1−19 ParseableViewer/Views/StreamDetailView.swift+4−18 ParseableViewer/Views/UsersView.swift−6 ParseableViewerTests/ParseableClientTests.swift−58 ParseableViewerTests/ServerConnectionTests.swift−10
ParseableViewer/App/AppState.swift+5−81
@@ -15,10 +15,6 @@ final class AppState {
1515 private let networkMonitor = NWPathMonitor()
1616 private let networkMonitorQueue = DispatchQueue(label: "com.parseableviewer.network-monitor")
1717 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?
2218
2319 // MARK: - Stream State
2420 var streams: [LogStream] = []
@@ -100,7 +96,6 @@ final class AppState {
10096
10197 if let activeID = ConnectionStore.loadActiveConnectionID(),
10298 let connection = connections.first(where: { $0.id == activeID }) {
103− connectingConnectionID = connection.id
10499 connectTask = Task { @MainActor [weak self] in
105100 await self?.performConnect(to: connection) ?? false
106101 }
@@ -120,7 +115,6 @@ final class AppState {
120115 existing.cancel()
121116 _ = await existing.value
122117 }
123− connectingConnectionID = connection.id
124118 let task = Task { @MainActor [weak self] in
125119 await self?.performConnect(to: connection) ?? false
126120 }
@@ -130,16 +124,9 @@ final class AppState {
130124
131125 @MainActor
132126 private func performConnect(to connection: ServerConnection) async -> Bool {
133− defer {
134− if connectingConnectionID == connection.id { connectingConnectionID = nil }
135− }
136127 guard !isConnecting else { return false }
137128
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 {
143130 self.errorMessage = "No internet connection. Check your network and try again."
144131 self.showError = true
145132 return false
@@ -147,12 +134,7 @@ final class AppState {
147134
148135 isConnecting = true
149136 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".
154137 errorMessage = nil
155− showError = false
156138
157139 // When switching to a different server, clear stream-specific state
158140 // so stale selections don't reference streams that don't exist.
@@ -182,7 +164,6 @@ final class AppState {
182164
183165 // Test connection — throws on non-200 or network failure
184166 try await newClient.checkHealth()
185− try await Self.verifyCredentials(with: newClient)
186167
187168 // If the task was cancelled while awaiting (e.g. user clicked
188169 // Disconnect during auto-reconnect), bail before installing the
@@ -219,12 +200,6 @@ final class AppState {
219200 case .success(let list):
220201 self.streams = list
221202 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
227− }
228203 case .failure(let error):
229204 self.streams = []
230205 self.streamLoadError = ParseableError.userFriendlyMessage(for: error)
@@ -290,13 +265,8 @@ final class AppState {
290265 let loaded = try await client.listStreams()
291266 // Drop results if the active connection changed while awaiting. Still
292267 // clear isLoadingStreams: this task set the spinner, so leaving it true
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.
296− guard connID == activeConnection?.id, client === self.client else {
297− isLoadingStreams = false
298− return
299− }
268+ // wedges the indicator and disables Refresh.
269+ guard connID == activeConnection?.id else { isLoadingStreams = false; return }
300270 streams = loaded
301271 // Drop a selection that no longer exists server-side (e.g. the stream
302272 // was deleted from another client); otherwise the detail tabs keep
@@ -305,10 +275,7 @@ final class AppState {
305275 selectedStream = nil
306276 }
307277 } catch {
308− guard connID == activeConnection?.id, client === self.client else {
309− isLoadingStreams = false
310− return
311− }
278+ guard connID == activeConnection?.id else { isLoadingStreams = false; return }
312279 let message = ParseableError.userFriendlyMessage(for: error)
313280 streamLoadError = message
314281 self.errorMessage = "Failed to load streams: \(message)"
@@ -375,49 +342,9 @@ final class AppState {
375342 ConnectionStore.saveConnections(connections)
376343 if activeConnection?.id == connection.id {
377344 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()
386− connectTask = nil
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.
390− if client != nil {
391− Task { @MainActor [weak self] in
392− await self?.refreshStreams()
393− await self?.refreshFilters()
394− }
395− }
396− }
397− }
398−
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 {
406− do {
407− _ = try await client.getAbout()
408− } catch ParseableError.unauthorized {
409− throw ParseableError.unauthorized
410− } catch {
411− // Not an authentication failure; leave it to the best-effort loads.
412345 }
413346 }
414347
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.")
419− }
420−
421348 // MARK: - Stream Management
422349
423350 @MainActor
@@ -450,10 +377,7 @@ final class AppState {
450377 let connID = activeConnection?.id
451378 isLoadingFilters = true
452379 let loaded = (try? await client.listFilters()) ?? []
453− guard connID == activeConnection?.id, client === self.client else {
454− isLoadingFilters = false
455− return
456− }
380+ guard connID == activeConnection?.id else { isLoadingFilters = false; return }
457381 filters = loaded
458382 isLoadingFilters = false
459383 }
ParseableViewer/App/ParseableViewerApp.swift+1−13
@@ -4,13 +4,6 @@ import SwiftUI
44struct ParseableViewerApp: App {
55 @State private var appState = AppState()
66
7− init() {
8− // All window content shares one AppState (see the File > New group below),
9− // so don't offer window tabs either: the tab bar's "+" would open a second
10− // window just like File > New Window.
11− NSWindow.allowsAutomaticWindowTabbing = false
12− }
13−
147 var body: some Scene {
158 WindowGroup {
169 ContentView()
@@ -24,12 +17,7 @@ struct ParseableViewerApp: App {
2417 AboutCommand()
2518 }
2619
27− // Replace (rather than extend) the File > New group to drop "New Window":
28− // connection, sheet, alert, selection and pending-filter state all live
29− // in the single shared AppState, so a second window would present every
30− // sheet and alert twice and race the first window's QueryView for
31− // saved-filter SQL.
32− CommandGroup(replacing: .newItem) {
20+ CommandGroup(after: .newItem) {
3321 Button("New Connection...") {
3422 appState.editingConnection = nil
3523 appState.showConnectionSheet = true
ParseableViewer/Info.plist−5
@@ -24,11 +24,6 @@
2424 <string>public.app-category.developer-tools</string>
2525 <key>LSMinimumSystemVersion</key>
2626 <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
27− <key>NSAppTransportSecurity</key>
28− <dict>
29− <key>NSAllowsArbitraryLoads</key>
30− <true/>
31− </dict>
3227 <key>NSMainNibFile</key>
3328 <string></string>
3429 <key>NSPrincipalClass</key>
ParseableViewer/Models/AlertModels.swift−21
@@ -85,27 +85,6 @@ struct AlertRule: Identifiable, Codable, Sendable {
8585struct AlertRuleSpec: Codable, Sendable {
8686 let type: String?
8787 let config: String?
88−
89− private enum CodingKeys: String, CodingKey {
90− case type, config
91− }
92−
93− init(from decoder: Decoder) throws {
94− let container = try decoder.container(keyedBy: CodingKeys.self)
95− self.type = try? container.decode(String.self, forKey: .type)
96− // The legacy per-stream rule is serialized as `{"type": …, "config": …}`
97− // where only composite rules carry a string config; column rules carry an
98− // object (`{"column": …, "operator": …, "value": …}`). Decoding that as a
99− // String threw, which made the whole spec — and its type badge — vanish.
100− // Keep non-string configs as compact JSON instead.
101− if let string = try? container.decode(String.self, forKey: .config) {
102− self.config = string
103− } else if let value = try? container.decode(JSONValue.self, forKey: .config), value != .null {
104− self.config = value.compactJSON
105− } else {
106− self.config = nil
107− }
108− }
10988}
11089
11190struct AlertTarget: Codable, Sendable {
ParseableViewer/Models/LogStream.swift+11−97
@@ -80,39 +80,11 @@ struct SchemaField: Identifiable, Codable, Hashable, Sendable {
8080 if let typeStr = try? container.decode(String.self, forKey: .dataType) {
8181 self.dataType = typeStr
8282 } else if let typeObj = try? container.decode(JSONValue.self, forKey: .dataType) {
83− self.dataType = Self.describeArrowType(typeObj)
83+ self.dataType = typeObj.displayString
8484 } else {
8585 self.dataType = "Unknown"
8686 }
8787 }
88−
89− /// Renders a serde-serialized Arrow `DataType`. Parameterized types are
90− /// externally tagged objects — `p_timestamp`, present in every Parseable
91− /// stream, arrives as `{"Timestamp": ["Millisecond", null]}` and a list as
92− /// `{"List": {<field>}}` — which `displayString` collapsed to the placeholder
93− /// "{1 fields}". Produce Arrow's own notation instead, e.g.
94− /// `Timestamp(Millisecond, None)` / `List(Utf8)`.
95− static func describeArrowType(_ value: JSONValue) -> String {
96− switch value {
97− case .null:
98− return "None"
99− case .string(let s):
100− return s
101− case .array(let items):
102− return items.map { describeArrowType($0) }.joined(separator: ", ")
103− case .object(let dict):
104− // A nested `Field` (list/map element, struct member): show its type.
105− if let fieldType = dict["data_type"] {
106− return describeArrowType(fieldType)
107− }
108− if dict.count == 1, let entry = dict.first {
109− return "\(entry.key)(\(describeArrowType(entry.value)))"
110− }
111− return value.compactJSON
112− default:
113− return value.displayString
114− }
115− }
11688}
11789
11890/// Formats a byte count that may arrive from the server as a fractional or
@@ -134,59 +106,14 @@ private func formatByteSize(_ value: Int) -> String? {
134106/// Normalizes a byte count that arrived as a JSON *string*. A bare numeric string
135107/// (e.g. "1048576") is a raw byte count and is formatted like the numeric branches
136108/// so identical sizes render consistently ("1 MB") regardless of whether the server
137−/// quoted the value. Parseable 1.x — the versions that quote sizes — sends them as
138−/// "<n> Bytes" (e.g. "1048576 Bytes"), so that unit suffix is accepted too. Any
139−/// other already-formatted string (e.g. "1.2 GB") is passed through unchanged.
109+/// quoted the value; an already-formatted string (e.g. "1.2 GB") is passed through
110+/// unchanged.
140111private func formatByteSize(_ value: String) -> String {
141− var trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
142− if trimmed.lowercased().hasSuffix(" bytes") {
143− trimmed = String(trimmed.dropLast(" bytes".count))
144− }
112+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
145113 if let n = Int(trimmed) { return formatByteSize(n) ?? value }
146114 return value
147115}
148116
149−/// A string-backed coding key for fields whose name differs across Parseable
150−/// versions (kebab-case, snake_case, and camelCase spellings of the same field).
151−private struct FlexibleCodingKey: CodingKey {
152− let stringValue: String
153− var intValue: Int? { nil }
154− init(stringValue: String) { self.stringValue = stringValue }
155− init?(intValue: Int) { nil }
156−}
157−
158−private extension KeyedDecodingContainer where Key == FlexibleCodingKey {
159− /// The first of `keys` that decodes as a string.
160− func firstString(_ keys: String...) -> String? {
161− for key in keys {
162− if let value = try? decode(String.self, forKey: FlexibleCodingKey(stringValue: key)) {
163− return value
164− }
165− }
166− return nil
167− }
168−
169− /// The first of `keys` that decodes as a boolean — either a JSON bool or the
170− /// strings "true"/"false" (Parseable serializes `static_schema_flag` as the
171− /// string "true").
172− func firstBool(_ keys: String...) -> Bool? {
173− for key in keys {
174− let codingKey = FlexibleCodingKey(stringValue: key)
175− if let value = try? decode(Bool.self, forKey: codingKey) {
176− return value
177− }
178− if let string = try? decode(String.self, forKey: codingKey) {
179− switch string.lowercased() {
180− case "true": return true
181− case "false": return false
182− default: continue
183− }
184− }
185− }
186− return nil
187− }
188−}
189−
190117struct StreamStats: Codable, Sendable {
191118 let ingestion: IngestionStats?
192119 let storage: StorageStats?
@@ -238,11 +165,7 @@ struct StreamStats: Codable, Sendable {
238165
239166 init(from decoder: Decoder) throws {
240167 let container = try decoder.container(keyedBy: CodingKeys.self)
241− // Parseable names this field `format` (e.g. "parquet") in every
242− // version; `type` never appears, so "Storage Type" always read N/A.
243− // Keep `type` as a fallback.
244− self.type = (try? decoder.container(keyedBy: FlexibleCodingKey.self))?
245− .firstString("format", "type")
168+ self.type = try? container.decode(String.self, forKey: .type)
246169 if let s = try? container.decode(String.self, forKey: .size) {
247170 self.size = formatByteSize(s)
248171 } else if let n = try? container.decode(Int.self, forKey: .size) {
@@ -285,20 +208,11 @@ struct StreamInfo: Codable, Sendable {
285208 }
286209
287210 init(from decoder: Decoder) throws {
288− // The /info key spellings vary by Parseable version: 1.x–2.x send
289− // `created-at`/`first-event-at` with the other fields in snake_case
290− // (`cache_enabled`, `time_partition`, `static_schema_flag`), while newer
291− // servers send camelCase throughout (`createdAt`, `timePartition`, …).
292− // The kebab-case `cache-enabled`/`time-partition`/`static-schema-flag`
293− // decoded previously were never sent by any version, so those rows always
294− // showed "N/A". The schema flag also arrives as the string "true".
295− let container = try decoder.container(keyedBy: FlexibleCodingKey.self)
296− self.createdAt = container.firstString("created-at", "createdAt", "created_at")
297− self.firstEventAt = container.firstString("first-event-at", "firstEventAt", "first_event_at")
298− self.cacheEnabled = container.firstBool("cache_enabled", "cacheEnabled", "cache-enabled")
299− self.timePartition = container.firstString("time_partition", "timePartition", "time-partition")
300− self.staticSchemaFlag = container.firstBool(
301− "static_schema_flag", "staticSchemaFlag", "static-schema-flag"
302− )
211+ let container = try decoder.container(keyedBy: CodingKeys.self)
212+ self.createdAt = try? container.decode(String.self, forKey: .createdAt)
213+ self.firstEventAt = try? container.decode(String.self, forKey: .firstEventAt)
214+ self.cacheEnabled = try? container.decode(Bool.self, forKey: .cacheEnabled)
215+ self.timePartition = try? container.decode(String.self, forKey: .timePartition)
216+ self.staticSchemaFlag = try? container.decode(Bool.self, forKey: .staticSchemaFlag)
303217 }
304218}
ParseableViewer/Models/ServerConnection.swift+2−19
@@ -56,32 +56,15 @@ struct ServerConnection: Identifiable, Codable, Hashable, Sendable {
5656 var baseURL: URL? {
5757 var urlString = url.trimmingCharacters(in: .whitespacesAndNewlines)
5858 if urlString.isEmpty { return nil }
59− if let schemeRange = Self.urlSchemeRange(in: urlString) {
60− // Normalize the (case-insensitive) scheme so `HTTP://host` behaves
61− // exactly like `http://host` everywhere downstream.
62− urlString.replaceSubrange(schemeRange, with: urlString[schemeRange].lowercased())
63− } else {
59+ if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
6460 urlString = "https://\(urlString)"
6561 }
6662 while urlString.hasSuffix("/") {
6763 urlString.removeLast()
6864 }
69− guard let parsed = URL(string: urlString), parsed.host != nil,
70− parsed.scheme == "http" || parsed.scheme == "https" else {
65+ guard let parsed = URL(string: urlString), parsed.host != nil else {
7166 return nil
7267 }
7368 return parsed
7469 }
75−
76− /// Whether `string` already starts with a URL scheme (`http://`, `HTTPS://`,
77− /// `ftp://`, …). Schemes are case-insensitive, so a case-sensitive
78− /// `hasPrefix("http://")` check turned `HTTP://host` into
79− /// `https://HTTP://host` — a URL whose host is "HTTP".
80− static func hasURLScheme(_ string: String) -> Bool {
81− urlSchemeRange(in: string) != nil
82− }
83−
84− private static func urlSchemeRange(in string: String) -> Range<String.Index>? {
85− string.range(of: "^[A-Za-z][A-Za-z0-9+.-]*://", options: .regularExpression)
86− }
8770}
ParseableViewer/Models/ServerInfo.swift+7−33
@@ -15,59 +15,33 @@ struct ServerAbout: Codable, Sendable {
1515 let llmActive: Bool?
1616 let oidcActive: Bool?
1717
18− // Parseable's /about handler has always sent camelCase keys (`uiVersion`,
19− // `deploymentId`, `grpcPort`), like the other fields here. Decoding only the
20− // snake_case spellings — which the server never sends — left UI Version and
21− // Deployment ID permanently "N/A" and hid the gRPC Port row. The snake_case
22− // forms are kept as a fallback.
2318 private enum CodingKeys: String, CodingKey {
2419 case version, commit, mode, staging, store, license
25− case uiVersion
26− case deploymentId
27− case grpcPort
20+ case uiVersion = "ui_version"
21+ case deploymentId = "deployment_id"
22+ case grpcPort = "grpc_port"
2823 case updateAvailable
2924 case latestVersion
3025 case llmActive
3126 case oidcActive
3227 }
3328
34− private enum SnakeCaseKeys: String, CodingKey {
35− case uiVersion = "ui_version"
36− case deploymentId = "deployment_id"
37− case grpcPort = "grpc_port"
38− }
39−
4029 init(from decoder: Decoder) throws {
4130 let container = try decoder.container(keyedBy: CodingKeys.self)
42− let snakeCase = try? decoder.container(keyedBy: SnakeCaseKeys.self)
4331 self.version = try? container.decode(String.self, forKey: .version)
44− self.uiVersion = (try? container.decode(String.self, forKey: .uiVersion))
45− ?? (try? snakeCase?.decode(String.self, forKey: .uiVersion))
32+ self.uiVersion = try? container.decode(String.self, forKey: .uiVersion)
4633 self.commit = try? container.decode(String.self, forKey: .commit)
47− self.deploymentId = (try? container.decode(String.self, forKey: .deploymentId))
48− ?? (try? snakeCase?.decode(String.self, forKey: .deploymentId))
34+ self.deploymentId = try? container.decode(String.self, forKey: .deploymentId)
4935 self.mode = try? container.decode(String.self, forKey: .mode)
5036 self.staging = try? container.decode(String.self, forKey: .staging)
5137 self.store = try? container.decode(StoreInfo.self, forKey: .store)
52− self.license = (try? container.decode(String.self, forKey: .license))
53− ?? (try? container.decode(JSONValue.self, forKey: .license)).flatMap(Self.describeLicense)
54− self.grpcPort = (try? container.decode(Int.self, forKey: .grpcPort))
55− ?? (try? snakeCase?.decode(Int.self, forKey: .grpcPort))
38+ self.license = try? container.decode(String.self, forKey: .license)
39+ self.grpcPort = try? container.decode(Int.self, forKey: .grpcPort)
5640 self.updateAvailable = try? container.decode(Bool.self, forKey: .updateAvailable)
5741 self.latestVersion = try? container.decode(String.self, forKey: .latestVersion)
5842 self.llmActive = try? container.decode(Bool.self, forKey: .llmActive)
5943 self.oidcActive = try? container.decode(Bool.self, forKey: .oidcActive)
6044 }
61−
62− /// Current servers send `license` as an object (`{"name": "AGPL-3.0-only",
63− /// "plan": "OSS", …}`) rather than a string, which decoded to nil ("N/A").
64− private static func describeLicense(_ value: JSONValue) -> String? {
65− guard case .object(let fields) = value, let name = fields["name"]?.stringValue else { return nil }
66− if let plan = fields["plan"]?.stringValue, !plan.isEmpty {
67− return "\(name) (\(plan))"
68− }
69− return name
70− }
7145}
7246
7347struct StoreInfo: Codable, Sendable {
ParseableViewer/Services/ParseableClient.swift+2−44
@@ -409,51 +409,9 @@ final class ParseableClient: Sendable {
409409 return try Self.jsonDecoder.decode(AlertConfig.self, from: data)
410410 }
411411
412− // `/api/v1/alerts` paginates (default page size 100, max 1000) over ALL
413− // alerts server-wide, ordered by state/severity/title. Filtering only the
414− // first page by stream (below) silently missed any stream whose alerts sort
415− // past it. Request full pages until a short one. Servers that predate
416− // pagination ignore the parameters and return everything each time, so a
417− // page that adds nothing new also ends the loop.
418− func modernAlerts() async throws -> (data: Data, config: AlertConfig) {
419− let pageSize = 1000
420− var firstData: Data?
421− var version: String?
422− var collected: [AlertRule] = []
423− var seenIDs = Set<String>()
424− var offset = 0
425− for _ in 0..<50 {
426− let data: Data
427− do {
428− data = try await performRequest(
429− method: "GET",
430− path: "/api/v1/alerts",
431− queryItems: [
432− URLQueryItem(name: "limit", value: String(pageSize)),
433− URLQueryItem(name: "offset", value: String(offset)),
434− ]
435− )
436− } catch ParseableError.serverError(let code, _) where code == 400 && offset == 0 {
437− // A server that rejects the paging parameters: one plain request.
438− let data = try await performRequest(method: "GET", path: "/api/v1/alerts")
439− return (data, try Self.jsonDecoder.decode(AlertConfig.self, from: data))
440− }
441− let page = try Self.jsonDecoder.decode(AlertConfig.self, from: data)
442− if firstData == nil {
443− firstData = data
444− version = page.version
445− }
446− let alerts = page.alerts ?? []
447− let fresh = alerts.filter { seenIDs.insert($0.id).inserted }
448− collected.append(contentsOf: fresh)
449− if alerts.count < pageSize || fresh.isEmpty { break }
450− offset += alerts.count
451− }
452− return (firstData ?? Data(), AlertConfig(alerts: collected, version: version))
453− }
454−
455412 do {
456− let (data, config) = try await modernAlerts()
413+ let data = try await performRequest(method: "GET", path: "/api/v1/alerts")
414+ let config = try Self.jsonDecoder.decode(AlertConfig.self, from: data)
457415 // AlertConfig decodes leniently (every field via try?), so a 200 with
458416 // an unexpected body yields an empty, non-throwing config rather than
459417 // a DecodingError — indistinguishable from a server that genuinely has
ParseableViewer/Views/AlertsView.swift+1−12
@@ -5,10 +5,6 @@ struct AlertsView: View {
55 @State private var alertConfig: AlertConfig?
66 @State private var isLoading = false
77 @State private var errorMessage: String?
8− /// Bumped by every load. SwiftUI cancels the previous `.task(id:)` load on a
9− /// stream switch, but that load still resumes (with a cancellation error) —
10− /// and must not touch `isLoading`, which now belongs to the newer load.
11− @State private var loadGeneration = 0
128
139 var body: some View {
1410 Group {
@@ -81,16 +77,12 @@ struct AlertsView: View {
8177 private func loadAlerts(stream: String) async {
8278 guard let client = appState.client else { return }
8379 let connID = appState.activeConnection?.id
84− loadGeneration &+= 1
85− let generation = loadGeneration
8680 isLoading = true
8781 errorMessage = nil
8882 alertConfig = nil
8983
9084 do {
9185 let config = try await client.getAlerts(stream: stream)
92− // Superseded by a newer load: leave its spinner and state alone.
93− guard generation == loadGeneration else { return }
9486 // The manual Refresh/Retry buttons spawn detached Tasks not tied to
9587 // `.task(id: stream)` cancellation, so a slow refresh for the old stream
9688 // can resolve after a switch. Drop stale results. Check the connection
@@ -102,7 +94,6 @@ struct AlertsView: View {
10294 connID == appState.activeConnection?.id else { isLoading = false; return }
10395 alertConfig = config
10496 } catch {
105− guard generation == loadGeneration else { return }
10697 guard stream == appState.selectedStream,
10798 connID == appState.activeConnection?.id else { isLoading = false; return }
10899 errorMessage = ParseableError.userFriendlyMessage(for: error)
@@ -212,9 +203,7 @@ struct AlertRowView: View {
212203 private var stateColor: Color {
213204 switch alert.state?.lowercased() {
214205 case "triggered": return .red
215− case "disabled", "silenced": return .gray
216− // Parseable's default, healthy state (and 2.x's "resolved") — not a warning.
217− case "not-triggered", "resolved": return .green
206+ case "disabled": return .gray
218207 // A reported-but-unrecognized state is a genuine "needs attention" signal,
219208 // so tint it orange; a missing state is simply unknown and stays neutral so
220209 // the bell icon isn't painted as a warning for every stateless alert.
ParseableViewer/Views/ConnectionSheet.swift+7−27
@@ -18,10 +18,6 @@ struct ConnectionSheet: View {
1818 /// retry (after a failed connect) updates it in place rather than appending a
1919 /// duplicate.
2020 @State private var createdConnection: ServerConnection?
21− /// Bumped on every field edit and every test run, so an in-flight Test
22− /// Connection whose fields have since changed doesn't post its (now stale)
23− /// success/failure under values that were never tested.
24− @State private var testGeneration = 0
2521
2622 enum TestResult {
2723 case success(String)
@@ -47,7 +43,7 @@ struct ConnectionSheet: View {
4743 TextField("Name:", text: $name)
4844 .textFieldStyle(.roundedBorder)
4945 .accessibilityLabel("Connection name")
50− .onChange(of: name) { _, _ in invalidateTestResult() }
46+ .onChange(of: name) { _, _ in testResult = nil }
5147
5248 TextField("Server URL:", text: $url)
5349 .textFieldStyle(.roundedBorder)
@@ -55,7 +51,7 @@ struct ConnectionSheet: View {
5551 .onChange(of: url) { _, newValue in
5652 urlValidationError = Self.validateURL(newValue)
5753 // A prior test result no longer reflects the edited fields.
58− invalidateTestResult()
54+ testResult = nil
5955 }
6056
6157 if let urlError = urlValidationError, !url.isEmpty {
@@ -67,12 +63,12 @@ struct ConnectionSheet: View {
6763 TextField("Username:", text: $username)
6864 .textFieldStyle(.roundedBorder)
6965 .accessibilityLabel("Username")
70− .onChange(of: username) { _, _ in invalidateTestResult() }
66+ .onChange(of: username) { _, _ in testResult = nil }
7167
7268 SecureField("Password:", text: $password)
7369 .textFieldStyle(.roundedBorder)
7470 .accessibilityLabel("Password")
75− .onChange(of: password) { _, _ in invalidateTestResult() }
71+ .onChange(of: password) { _, _ in testResult = nil }
7672 }
7773 .formStyle(.grouped)
7874 .padding()
@@ -169,16 +165,9 @@ struct ConnectionSheet: View {
169165 }
170166 }
171167
172− private func invalidateTestResult() {
173− testGeneration += 1
174− testResult = nil
175− }
176−
177168 private func testConnection() {
178169 isTesting = true
179170 testResult = nil
180− testGeneration += 1
181− let generation = testGeneration
182171
183172 Task {
184173 do {
@@ -190,19 +179,14 @@ struct ConnectionSheet: View {
190179 )
191180 let client = try ParseableClient(connection: conn)
192181 try await client.checkHealth()
193− // The health endpoint doesn't check credentials; see
194− // AppState.verifyCredentials.
195− try await AppState.verifyCredentials(with: client)
196182 await MainActor.run {
197− isTesting = false
198− guard generation == testGeneration else { return }
199183 testResult = .success("Connection successful")
184+ isTesting = false
200185 }
201186 } catch {
202187 await MainActor.run {
203− isTesting = false
204− guard generation == testGeneration else { return }
205188 testResult = .failure(ParseableError.userFriendlyMessage(for: error))
189+ isTesting = false
206190 }
207191 }
208192 }
@@ -237,10 +221,6 @@ struct ConnectionSheet: View {
237221 dismiss()
238222 } else {
239223 testResult = .failure(appState.errorMessage ?? "Connection failed")
240− // The sheet reports the failure inline; drop the app-wide alert
241− // performConnect also raised, which would otherwise surface
242− // behind/after the sheet (or twice, from the Settings window).
243− appState.showError = false
244224 }
245225 }
246226 }
@@ -251,7 +231,7 @@ struct ConnectionSheet: View {
251231 guard !trimmed.isEmpty else { return nil }
252232
253233 var normalized = trimmed
254− if !ServerConnection.hasURLScheme(normalized) {
234+ if !normalized.hasPrefix("http://") && !normalized.hasPrefix("https://") {
255235 normalized = "https://\(normalized)"
256236 }
257237
ParseableViewer/Views/LogTableView.swift+6−53
@@ -315,23 +315,7 @@ struct LogTableView: View {
315315 }
316316 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
317317 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
318− .onChange(of: selectedRecord) { _, newValue in
319− // The owner can drop the selection on its own (QueryViewModel
320− // clears it after every query). When the new result set equals the
321− // old one, `onChange(of: records)` doesn't fire, so nothing would
322− // reconcile the row highlight: the row stayed highlighted with no
323− // detail pane, and the next click on it merely "deselected" it.
324− if newValue == nil { selectedIndex = nil }
325− }
326318 .onAppear {
327− // This branch is unmounted while results are empty (e.g. across a
328− // stream switch, which clears them first), so `onChange(of: columns)`
329− // never sees the switch. Drop a sort column the new columns don't
330− // have — or hide — before sorting, or rows would be ordered by an
331− // invisible column with no header showing the sort.
332− if let sortColumn, !columns.contains(sortColumn) {
333− self.sortColumn = nil
334− }
335319 recomputeColumnWidths(columns: columns, records: records)
336320 rebuildSort()
337321 }
@@ -417,9 +401,6 @@ struct LogTableView: View {
417401 }
418402
419403 private func rebuildSort() {
420− // A debounced rebuild scheduled before the table unmounted must not land
421− // after (and overwrite) this synchronous one.
422− sortTask?.cancel()
423404 let sevCols = buildSeverityColumnSet(columns: columns)
424405 let enumerated = Array(records.enumerated())
425406 let sorted: [(offset: Int, element: LogRecord)]
@@ -495,28 +476,12 @@ struct LogHeaderView: View {
495476
496477 @State private var draggedColumn: String?
497478 @State private var dropTargetColumn: String?
498− /// `.contextMenu`'s builder runs during body evaluation, so the filter menus
499− /// compute their values while the view is being rendered. Writing an `@State`
500− /// dictionary from there is "modifying state during view update" (undefined
501− /// behavior, and it re-invalidates the header). The cache is therefore a plain
502− /// reference that isn't observed; `recordsGeneration` — bumped outside body
503− /// evaluation — is what invalidates it and triggers the re-render.
504− @State private var uniqueValuesCache = UniqueValuesCache()
505− @State private var recordsGeneration = 0
506−
507− private final class UniqueValuesCache {
508− var generation = -1
509− var values: [String: [JSONValue]] = [:]
510− }
479+ @State private var uniqueValuesCache: [String: [JSONValue]] = [:]
511480
512481 private static let maxFilterValues = 20
513482
514483 private func uniqueValues(for column: String) -> [JSONValue] {
515− if uniqueValuesCache.generation != recordsGeneration {
516− uniqueValuesCache.generation = recordsGeneration
517− uniqueValuesCache.values = [:]
518− }
519− if let cached = uniqueValuesCache.values[column] {
484+ if let cached = uniqueValuesCache[column] {
520485 return cached
521486 }
522487 var seen = Set<JSONValue>()
@@ -528,7 +493,7 @@ struct LogHeaderView: View {
528493 }
529494 }
530495 let sorted = result.sorted()
531− uniqueValuesCache.values[column] = sorted
496+ uniqueValuesCache[column] = sorted
532497 return sorted
533498 }
534499
@@ -671,7 +636,7 @@ struct LogHeaderView: View {
671636 .background(Color(nsColor: .controlBackgroundColor))
672637 .border(Color.secondary.opacity(0.2), width: 0.5)
673638 .onChange(of: records) { _, _ in
674− recordsGeneration &+= 1
639+ uniqueValuesCache = [:]
675640 }
676641 }
677642}
@@ -704,26 +669,14 @@ private struct ColumnDropDelegate: DropDelegate {
704669 }
705670
706671 func performDrop(info: DropInfo) -> Bool {
707− guard let dragged = draggedColumn, dragged != column,
708− let provider = info.itemProviders(for: [UTType.plainText]).first else {
672+ guard let dragged = draggedColumn, dragged != column else {
709673 draggedColumn = nil
710674 dropTargetColumn = nil
711675 return false
712676 }
677+ onMoveColumn?(dragged, column)
713678 draggedColumn = nil
714679 dropTargetColumn = nil
715− // `draggedColumn` is only cleared by a drop on a header, so a header drag
716− // that was cancelled (or dropped elsewhere) leaves it set, and any later
717− // plain-text drag onto a header would move that stale column. Only move
718− // when the dropped payload really is the column header being dragged.
719− let target = column
720− let move = onMoveColumn
721− _ = provider.loadObject(ofClass: NSString.self) { object, _ in
722− guard let payload = object as? NSString, payload as String == dragged else { return }
723− DispatchQueue.main.async {
724− move?(dragged, target)
725− }
726− }
727680 return true
728681 }
729682
ParseableViewer/Views/ServerInfoView.swift+2−12
@@ -9,9 +9,6 @@ struct ServerInfoView: View {
99 @State private var isLoading = true
1010 @State private var isHealthy = false
1111 @State private var errorMessage: String?
12− /// Bumped by every load so a superseded (cancelled) load can't clear the
13− /// newer load's spinner or overwrite its results.
14− @State private var loadGeneration = 0
1512
1613 var body: some View {
1714 ScrollView {
@@ -22,7 +19,7 @@ struct ServerInfoView: View {
2219 .fontWeight(.semibold)
2320 Spacer()
2421 Button {
25− Task { await loadInfo(forceRefresh: true) }
22+ Task { await loadInfo() }
2623 } label: {
2724 Image(systemName: "arrow.clockwise")
2825 }
@@ -156,18 +153,12 @@ struct ServerInfoView: View {
156153 }
157154 }
158155
159− private func loadInfo(forceRefresh: Bool = false) async {
156+ private func loadInfo() async {
160157 // Clear isLoading on the no-client path: since isLoading now starts `true`
161158 // (to avoid a red "Unhealthy" flash before the first check), an early return
162159 // without resetting it would wedge the spinner permanently.
163160 guard let client = appState.client else { isLoading = false; return }
164− // `getAbout()` is cached for 60 s; the Refresh button must bypass that.
165− if forceRefresh {
166− await client.invalidateCache()
167− }
168161 let connID = appState.activeConnection?.id
169− loadGeneration &+= 1
170− let generation = loadGeneration
171162 isLoading = true
172163 errorMessage = nil
173164 // Seed from the app-wide cache so first load isn't blank, but never fall
@@ -198,7 +189,6 @@ struct ServerInfoView: View {
198189 // load for the previous server can't overwrite the current one's state.
199190 // Still clear isLoading: this task owns the spinner it set, and leaving it
200191 // true on the stale path wedges the ProgressView and disables Refresh.
201− guard generation == loadGeneration else { return }
202192 guard connID == appState.activeConnection?.id else { isLoading = false; return }
203193 isHealthy = healthy
204194 about = aboutValue
ParseableViewer/Views/SidebarView.swift+1−19
@@ -6,14 +6,6 @@ struct SidebarView: View {
66 @State private var newStreamName = ""
77 @State private var showDeleteConfirm = false
88 @State private var streamToDelete: String?
9− /// The connection `streamToDelete` was picked on. Stream names aren't unique
10− /// across servers, and the delete runs against whatever client is current
11− /// when the user confirms — so a server switch while the stats lookup was in
12− /// flight could otherwise delete a same-named stream on the *new* server.
13− @State private var streamToDeleteConnectionID: UUID?
14− /// The client the stream was picked with. Editing the active connection's URL
15− /// keeps its id but installs a new client for a possibly different server.
16− @State private var streamToDeleteClient: ParseableClient?
179 @State private var deleteStreamDetail: String?
1810
1911 /// Validates a stream name, returning an error message or nil if valid.
@@ -73,10 +65,7 @@ struct SidebarView: View {
7365 // would show the wrong stats on a destructive
7466 // confirmation.
7567 let target = stream.name
76− let targetConnectionID = appState.activeConnection?.id
7768 streamToDelete = target
78− streamToDeleteConnectionID = targetConnectionID
79− streamToDeleteClient = appState.client
8069 deleteStreamDetail = nil
8170 Task {
8271 if let client = appState.client,
@@ -92,9 +81,7 @@ struct SidebarView: View {
9281 deleteStreamDetail = parts.joined(separator: ", ")
9382 }
9483 }
95− if streamToDelete == target,
96− appState.activeConnection?.id == targetConnectionID,
97− appState.client === streamToDeleteClient {
84+ if streamToDelete == target {
9885 showDeleteConfirm = true
9986 }
10087 }
@@ -200,11 +187,6 @@ struct SidebarView: View {
200187 .alert("Delete Stream", isPresented: $showDeleteConfirm) {
201188 Button("Delete", role: .destructive) {
202189 if let name = streamToDelete {
203− guard appState.activeConnection?.id == streamToDeleteConnectionID,
204− appState.client === streamToDeleteClient else {
205− appState.showErrorMessage("The server changed since \"\(name)\" was selected, so it was not deleted.")
206− return
207− }
208190 Task {
209191 do {
210192 try await appState.deleteStream(name: name)
ParseableViewer/Views/StreamDetailView.swift+4−18
@@ -8,9 +8,6 @@ struct StreamDetailView: View {
88 @State private var retention: [RetentionConfig] = []
99 @State private var isLoading = false
1010 @State private var errorMessage: String?
11− /// Bumped by every load. A superseded load (its `.task(id:)` cancelled by a
12− /// stream switch) still resumes, and must not clear the newer load's spinner.
13− @State private var loadGeneration = 0
1411
1512 var body: some View {
1613 Group {
@@ -28,7 +25,7 @@ struct StreamDetailView: View {
2825 Spacer()
2926
3027 Button {
31− Task { await loadData(stream: stream, forceRefresh: true) }
28+ Task { await loadData(stream: stream) }
3229 } label: {
3330 Image(systemName: "arrow.clockwise")
3431 }
@@ -47,7 +44,7 @@ struct StreamDetailView: View {
4744 Text(error)
4845 Spacer()
4946 Button("Retry") {
50− Task { await loadData(stream: stream, forceRefresh: true) }
47+ Task { await loadData(stream: stream) }
5148 }
5249 .buttonStyle(.bordered)
5350 .controlSize(.small)
@@ -177,22 +174,13 @@ struct StreamDetailView: View {
177174 }
178175 }
179176
180− private func loadData(stream: String, forceRefresh: Bool = false) async {
177+ private func loadData(stream: String) async {
181178 guard let client = appState.client else { return }
182− // The client caches schema/stats/info for 60 s. That's right for the
183− // automatic load on appear, but an explicit Refresh/Retry must hit the
184− // server — otherwise it repaints the same cached numbers and looks like a
185− // refresh that found nothing new.
186− if forceRefresh {
187− await client.invalidateCache()
188− }
189179 // Capture the connection this fetch belongs to. Two servers can expose a
190180 // same-named stream, so guarding on the stream name alone would let a
191181 // slow fetch write the previous server's data under the new server's
192182 // stream after a server switch.
193183 let connID = appState.activeConnection?.id
194− loadGeneration &+= 1
195− let generation = loadGeneration
196184 isLoading = true
197185 errorMessage = nil
198186 // Clear previous stream's data so it isn't shown under the new header
@@ -228,9 +216,7 @@ struct StreamDetailView: View {
228216 // resolve after the user switched. Drop stale results instead of writing
229217 // them under the new stream's header. Also check the connection so a
230218 // same-named stream on a different server doesn't accept the old server's
231− // data. Reset `isLoading` on the stale path or the spinner wedges forever —
232− // unless a newer load has started, which owns the spinner now.
233− guard generation == loadGeneration else { return }
219+ // data. Reset `isLoading` on the stale path or the spinner wedges forever.
234220 guard stream == appState.selectedStream, connID == appState.activeConnection?.id else {
235221 isLoading = false
236222 return
ParseableViewer/Views/UsersView.swift−6
@@ -5,9 +5,6 @@ struct UsersView: View {
55 @State private var users: [UserInfo] = []
66 @State private var isLoading = false
77 @State private var errorMessage: String?
8− /// Bumped by every load so a superseded (cancelled) load can't clear the
9− /// newer load's spinner.
10− @State private var loadGeneration = 0
118
129 var body: some View {
1310 VStack(spacing: 0) {
@@ -85,8 +82,6 @@ struct UsersView: View {
8582 private func loadUsers() async {
8683 guard let client = appState.client else { return }
8784 let connID = appState.activeConnection?.id
88− loadGeneration &+= 1
89− let generation = loadGeneration
9085 isLoading = true
9186 errorMessage = nil
9287 // Clear the previous server's list up front so it can't briefly show through
@@ -107,7 +102,6 @@ struct UsersView: View {
107102 // Drop results if the active connection changed while awaiting. Still clear
108103 // isLoading: this task set the spinner, so leaving it true on the stale path
109104 // wedges the ProgressView and disables Refresh.
110− guard generation == loadGeneration else { return }
111105 guard connID == appState.activeConnection?.id else { isLoading = false; return }
112106 if let loaded { users = loaded }
113107 errorMessage = loadError
ParseableViewerTests/ParseableClientTests.swift−58
@@ -646,62 +646,4 @@ final class ParseableClientTests: XCTestCase {
646646 // Carriage returns in values should be wrapped in quotes per RFC 4180
647647 XCTAssertTrue(csv.contains("\"line1\r\nline2\""))
648648 }
649−
650− // MARK: - Server response key spellings
651−
652− func testServerAboutDecodesCamelCaseKeys() throws {
653− let json = #"{"uiVersion": "v1.2", "deploymentId": "01H", "grpcPort": 8001}"#.data(using: .utf8)!
654− let about = try JSONDecoder().decode(ServerAbout.self, from: json)
655− XCTAssertEqual(about.uiVersion, "v1.2")
656− XCTAssertEqual(about.deploymentId, "01H")
657− XCTAssertEqual(about.grpcPort, 8001)
658− }
659−
660− func testStreamInfoDecodesSnakeCaseKeys() throws {
661− let json = #"{"created-at": "c", "first-event-at": "f", "time_partition": "ts", "static_schema_flag": "true", "cache_enabled": true}"#
662− .data(using: .utf8)!
663− let info = try JSONDecoder().decode(StreamInfo.self, from: json)
664− XCTAssertEqual(info.createdAt, "c")
665− XCTAssertEqual(info.firstEventAt, "f")
666− XCTAssertEqual(info.timePartition, "ts")
667− XCTAssertEqual(info.staticSchemaFlag, true)
668− XCTAssertEqual(info.cacheEnabled, true)
669− }
670−
671− func testStreamInfoDecodesCamelCaseKeys() throws {
672− let json = #"{"createdAt": "c", "firstEventAt": "f", "timePartition": "ts", "staticSchemaFlag": "true"}"#
673− .data(using: .utf8)!
674− let info = try JSONDecoder().decode(StreamInfo.self, from: json)
675− XCTAssertEqual(info.createdAt, "c")
676− XCTAssertEqual(info.firstEventAt, "f")
677− XCTAssertEqual(info.timePartition, "ts")
678− XCTAssertEqual(info.staticSchemaFlag, true)
679− }
680−
681− func testStorageStatsReadsFormatAndUnitSuffixedSizes() throws {
682− let json = #"{"storage": {"size": "2048 Bytes", "format": "parquet"}}"#.data(using: .utf8)!
683− let stats = try JSONDecoder().decode(StreamStats.self, from: json)
684− XCTAssertEqual(stats.storage?.type, "parquet")
685− XCTAssertEqual(stats.storage?.size, ByteCountFormatter.string(fromByteCount: 2048, countStyle: .file))
686− }
687−
688− func testSchemaFieldDescribesParameterizedArrowTypes() throws {
689− let json = #"{"name": "p_timestamp", "data_type": {"Timestamp": ["Millisecond", null]}}"#.data(using: .utf8)!
690− let field = try JSONDecoder().decode(SchemaField.self, from: json)
691− XCTAssertEqual(field.dataType, "Timestamp(Millisecond, None)")
692− }
693−
694− func testLegacyAlertRuleWithObjectConfigKeepsRuleType() throws {
695− let json = #"{"name": "a", "rule": {"type": "column", "config": {"column": "status", "operator": "=", "value": 500}}}"#
696− .data(using: .utf8)!
697− let rule = try JSONDecoder().decode(AlertRule.self, from: json)
698− XCTAssertEqual(rule.rule?.type, "column")
699− XCTAssertNotNil(rule.rule?.config)
700− }
701−
702− func testServerAboutDecodesObjectLicense() throws {
703− let json = #"{"license": {"name": "AGPL-3.0-only", "plan": "OSS"}}"#.data(using: .utf8)!
704− let about = try JSONDecoder().decode(ServerAbout.self, from: json)
705− XCTAssertEqual(about.license, "AGPL-3.0-only (OSS)")
706− }
707649}
ParseableViewerTests/ServerConnectionTests.swift−10
@@ -77,14 +77,4 @@ final class ServerConnectionTests: XCTestCase {
7777 let conn = ServerConnection(name: "test", url: "https://", username: "admin", password: "pass")
7878 XCTAssertNil(conn.baseURL)
7979 }
80−
81− func testBaseURLAcceptsUppercaseScheme() {
82− let conn = ServerConnection(name: "t", url: "HTTP://10.0.0.5:8000", username: "u", password: "p")
83− XCTAssertEqual(conn.baseURL?.absoluteString, "http://10.0.0.5:8000")
84− }
85−
86− func testBaseURLRejectsNonHTTPScheme() {
87− let conn = ServerConnection(name: "t", url: "ftp://example.com", username: "u", password: "p")
88− XCTAssertNil(conn.baseURL)
89− }
9080}