git.iko.soy/parseable-macos
on master help sign in
17 files changed+528−49
since splitsnapshots
unifiedsplit
ParseableViewer/App/AppState.swift+81−5 ParseableViewer/App/ParseableViewerApp.swift+13−1 ParseableViewer/Info.plist+5 ParseableViewer/Models/AlertModels.swift+21 ParseableViewer/Models/LogStream.swift+97−11 ParseableViewer/Models/ServerConnection.swift+19−2 ParseableViewer/Models/ServerInfo.swift+33−7 ParseableViewer/Services/ParseableClient.swift+44−2 ParseableViewer/Views/AlertsView.swift+12−1 ParseableViewer/Views/ConnectionSheet.swift+27−7 ParseableViewer/Views/LogTableView.swift+53−6 ParseableViewer/Views/ServerInfoView.swift+12−2 ParseableViewer/Views/SidebarView.swift+19−1 ParseableViewer/Views/StreamDetailView.swift+18−4 ParseableViewer/Views/UsersView.swift+6 ParseableViewerTests/ParseableClientTests.swift+58 ParseableViewerTests/ServerConnectionTests.swift+10
ParseableViewer/App/AppState.swift+81−5
@@ -15,6 +15,10 @@ 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?
1822
1923 // MARK: - Stream State
2024 var streams: [LogStream] = []
@@ -96,6 +100,7 @@ final class AppState {
96100
97101 if let activeID = ConnectionStore.loadActiveConnectionID(),
98102 let connection = connections.first(where: { $0.id == activeID }) {
103+ connectingConnectionID = connection.id
99104 connectTask = Task { @MainActor [weak self] in
100105 await self?.performConnect(to: connection) ?? false
101106 }
@@ -115,6 +120,7 @@ final class AppState {
115120 existing.cancel()
116121 _ = await existing.value
117122 }
123+ connectingConnectionID = connection.id
118124 let task = Task { @MainActor [weak self] in
119125 await self?.performConnect(to: connection) ?? false
120126 }
@@ -124,9 +130,16 @@ final class AppState {
124130
125131 @MainActor
126132 private func performConnect(to connection: ServerConnection) async -> Bool {
133+ defer {
134+ if connectingConnectionID == connection.id { connectingConnectionID = nil }
135+ }
127136 guard !isConnecting else { return false }
128137
129− if !isNetworkAvailable {
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) {
130143 self.errorMessage = "No internet connection. Check your network and try again."
131144 self.showError = true
132145 return false
@@ -134,7 +147,12 @@ final class AppState {
134147
135148 isConnecting = true
136149 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".
137154 errorMessage = nil
155+ showError = false
138156
139157 // When switching to a different server, clear stream-specific state
140158 // so stale selections don't reference streams that don't exist.
@@ -164,6 +182,7 @@ final class AppState {
164182
165183 // Test connection — throws on non-200 or network failure
166184 try await newClient.checkHealth()
185+ try await Self.verifyCredentials(with: newClient)
167186
168187 // If the task was cancelled while awaiting (e.g. user clicked
169188 // Disconnect during auto-reconnect), bail before installing the
@@ -200,6 +219,12 @@ final class AppState {
200219 case .success(let list):
201220 self.streams = list
202221 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+ }
203228 case .failure(let error):
204229 self.streams = []
205230 self.streamLoadError = ParseableError.userFriendlyMessage(for: error)
@@ -265,8 +290,13 @@ final class AppState {
265290 let loaded = try await client.listStreams()
266291 // Drop results if the active connection changed while awaiting. Still
267292 // clear isLoadingStreams: this task set the spinner, so leaving it true
268− // wedges the indicator and disables Refresh.
269− guard connID == activeConnection?.id else { isLoadingStreams = false; return }
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+ }
270300 streams = loaded
271301 // Drop a selection that no longer exists server-side (e.g. the stream
272302 // was deleted from another client); otherwise the detail tabs keep
@@ -275,7 +305,10 @@ final class AppState {
275305 selectedStream = nil
276306 }
277307 } catch {
278− guard connID == activeConnection?.id else { isLoadingStreams = false; return }
308+ guard connID == activeConnection?.id, client === self.client else {
309+ isLoadingStreams = false
310+ return
311+ }
279312 let message = ParseableError.userFriendlyMessage(for: error)
280313 streamLoadError = message
281314 self.errorMessage = "Failed to load streams: \(message)"
@@ -342,9 +375,49 @@ final class AppState {
342375 ConnectionStore.saveConnections(connections)
343376 if activeConnection?.id == connection.id {
344377 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.
345412 }
346413 }
347414
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+
348421 // MARK: - Stream Management
349422
350423 @MainActor
@@ -377,7 +450,10 @@ final class AppState {
377450 let connID = activeConnection?.id
378451 isLoadingFilters = true
379452 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
455+ return
456+ }
381457 filters = loaded
382458 isLoadingFilters = false
383459 }
ParseableViewer/App/ParseableViewerApp.swift+13−1
@@ -4,6 +4,13 @@ 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+
714 var body: some Scene {
815 WindowGroup {
916 ContentView()
@@ -17,7 +24,12 @@ struct ParseableViewerApp: App {
1724 AboutCommand()
1825 }
1926
20− CommandGroup(after: .newItem) {
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) {
2133 Button("New Connection...") {
2234 appState.editingConnection = nil
2335 appState.showConnectionSheet = true
ParseableViewer/Info.plist+5
@@ -24,6 +24,11 @@
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>
2732 <key>NSMainNibFile</key>
2833 <string></string>
2934 <key>NSPrincipalClass</key>
ParseableViewer/Models/AlertModels.swift+21
@@ -85,6 +85,27 @@ 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+ }
88109}
89110
90111struct AlertTarget: Codable, Sendable {
ParseableViewer/Models/LogStream.swift+97−11
@@ -80,11 +80,39 @@ 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 = typeObj.displayString
83+ self.dataType = Self.describeArrowType(typeObj)
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+ }
88116}
89117
90118/// Formats a byte count that may arrive from the server as a fractional or
@@ -106,14 +134,59 @@ private func formatByteSize(_ value: Int) -> String? {
106134/// Normalizes a byte count that arrived as a JSON *string*. A bare numeric string
107135/// (e.g. "1048576") is a raw byte count and is formatted like the numeric branches
108136/// so identical sizes render consistently ("1 MB") regardless of whether the server
109−/// quoted the value; an already-formatted string (e.g. "1.2 GB") is passed through
110−/// unchanged.
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.
111140private func formatByteSize(_ value: String) -> String {
112− let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
141+ var trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
142+ if trimmed.lowercased().hasSuffix(" bytes") {
143+ trimmed = String(trimmed.dropLast(" bytes".count))
144+ }
113145 if let n = Int(trimmed) { return formatByteSize(n) ?? value }
114146 return value
115147}
116148
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+
117190struct StreamStats: Codable, Sendable {
118191 let ingestion: IngestionStats?
119192 let storage: StorageStats?
@@ -165,7 +238,11 @@ struct StreamStats: Codable, Sendable {
165238
166239 init(from decoder: Decoder) throws {
167240 let container = try decoder.container(keyedBy: CodingKeys.self)
168− self.type = try? container.decode(String.self, forKey: .type)
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")
169246 if let s = try? container.decode(String.self, forKey: .size) {
170247 self.size = formatByteSize(s)
171248 } else if let n = try? container.decode(Int.self, forKey: .size) {
@@ -208,11 +285,20 @@ struct StreamInfo: Codable, Sendable {
208285 }
209286
210287 init(from decoder: Decoder) throws {
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)
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+ )
217303 }
218304}
ParseableViewer/Models/ServerConnection.swift+19−2
@@ -56,15 +56,32 @@ 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 !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
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 {
6064 urlString = "https://\(urlString)"
6165 }
6266 while urlString.hasSuffix("/") {
6367 urlString.removeLast()
6468 }
65− guard let parsed = URL(string: urlString), parsed.host != nil else {
69+ guard let parsed = URL(string: urlString), parsed.host != nil,
70+ parsed.scheme == "http" || parsed.scheme == "https" else {
6671 return nil
6772 }
6873 return parsed
6974 }
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+ }
7087}
ParseableViewer/Models/ServerInfo.swift+33−7
@@ -15,33 +15,59 @@ 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.
1823 private enum CodingKeys: String, CodingKey {
1924 case version, commit, mode, staging, store, license
20− case uiVersion = "ui_version"
21− case deploymentId = "deployment_id"
22− case grpcPort = "grpc_port"
25+ case uiVersion
26+ case deploymentId
27+ case grpcPort
2328 case updateAvailable
2429 case latestVersion
2530 case llmActive
2631 case oidcActive
2732 }
2833
34+ private enum SnakeCaseKeys: String, CodingKey {
35+ case uiVersion = "ui_version"
36+ case deploymentId = "deployment_id"
37+ case grpcPort = "grpc_port"
38+ }
39+
2940 init(from decoder: Decoder) throws {
3041 let container = try decoder.container(keyedBy: CodingKeys.self)
42+ let snakeCase = try? decoder.container(keyedBy: SnakeCaseKeys.self)
3143 self.version = try? container.decode(String.self, forKey: .version)
32− self.uiVersion = try? container.decode(String.self, forKey: .uiVersion)
44+ self.uiVersion = (try? container.decode(String.self, forKey: .uiVersion))
45+ ?? (try? snakeCase?.decode(String.self, forKey: .uiVersion))
3346 self.commit = try? container.decode(String.self, forKey: .commit)
34− self.deploymentId = try? container.decode(String.self, forKey: .deploymentId)
47+ self.deploymentId = (try? container.decode(String.self, forKey: .deploymentId))
48+ ?? (try? snakeCase?.decode(String.self, forKey: .deploymentId))
3549 self.mode = try? container.decode(String.self, forKey: .mode)
3650 self.staging = try? container.decode(String.self, forKey: .staging)
3751 self.store = try? container.decode(StoreInfo.self, forKey: .store)
38− self.license = try? container.decode(String.self, forKey: .license)
39− self.grpcPort = try? container.decode(Int.self, forKey: .grpcPort)
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))
4056 self.updateAvailable = try? container.decode(Bool.self, forKey: .updateAvailable)
4157 self.latestVersion = try? container.decode(String.self, forKey: .latestVersion)
4258 self.llmActive = try? container.decode(Bool.self, forKey: .llmActive)
4359 self.oidcActive = try? container.decode(Bool.self, forKey: .oidcActive)
4460 }
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+ }
4571}
4672
4773struct StoreInfo: Codable, Sendable {
ParseableViewer/Services/ParseableClient.swift+44−2
@@ -409,9 +409,51 @@ 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+
412455 do {
413− let data = try await performRequest(method: "GET", path: "/api/v1/alerts")
414− let config = try Self.jsonDecoder.decode(AlertConfig.self, from: data)
456+ let (data, config) = try await modernAlerts()
415457 // AlertConfig decodes leniently (every field via try?), so a 200 with
416458 // an unexpected body yields an empty, non-throwing config rather than
417459 // a DecodingError — indistinguishable from a server that genuinely has
ParseableViewer/Views/AlertsView.swift+12−1
@@ -5,6 +5,10 @@ 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
812
913 var body: some View {
1014 Group {
@@ -77,12 +81,16 @@ struct AlertsView: View {
7781 private func loadAlerts(stream: String) async {
7882 guard let client = appState.client else { return }
7983 let connID = appState.activeConnection?.id
84+ loadGeneration &+= 1
85+ let generation = loadGeneration
8086 isLoading = true
8187 errorMessage = nil
8288 alertConfig = nil
8389
8490 do {
8591 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 }
8694 // The manual Refresh/Retry buttons spawn detached Tasks not tied to
8795 // `.task(id: stream)` cancellation, so a slow refresh for the old stream
8896 // can resolve after a switch. Drop stale results. Check the connection
@@ -94,6 +102,7 @@ struct AlertsView: View {
94102 connID == appState.activeConnection?.id else { isLoading = false; return }
95103 alertConfig = config
96104 } catch {
105+ guard generation == loadGeneration else { return }
97106 guard stream == appState.selectedStream,
98107 connID == appState.activeConnection?.id else { isLoading = false; return }
99108 errorMessage = ParseableError.userFriendlyMessage(for: error)
@@ -203,7 +212,9 @@ struct AlertRowView: View {
203212 private var stateColor: Color {
204213 switch alert.state?.lowercased() {
205214 case "triggered": return .red
206− case "disabled": return .gray
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
207218 // A reported-but-unrecognized state is a genuine "needs attention" signal,
208219 // so tint it orange; a missing state is simply unknown and stays neutral so
209220 // the bell icon isn't painted as a warning for every stateless alert.
ParseableViewer/Views/ConnectionSheet.swift+27−7
@@ -18,6 +18,10 @@ 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
2125
2226 enum TestResult {
2327 case success(String)
@@ -43,7 +47,7 @@ struct ConnectionSheet: View {
4347 TextField("Name:", text: $name)
4448 .textFieldStyle(.roundedBorder)
4549 .accessibilityLabel("Connection name")
46− .onChange(of: name) { _, _ in testResult = nil }
50+ .onChange(of: name) { _, _ in invalidateTestResult() }
4751
4852 TextField("Server URL:", text: $url)
4953 .textFieldStyle(.roundedBorder)
@@ -51,7 +55,7 @@ struct ConnectionSheet: View {
5155 .onChange(of: url) { _, newValue in
5256 urlValidationError = Self.validateURL(newValue)
5357 // A prior test result no longer reflects the edited fields.
54− testResult = nil
58+ invalidateTestResult()
5559 }
5660
5761 if let urlError = urlValidationError, !url.isEmpty {
@@ -63,12 +67,12 @@ struct ConnectionSheet: View {
6367 TextField("Username:", text: $username)
6468 .textFieldStyle(.roundedBorder)
6569 .accessibilityLabel("Username")
66− .onChange(of: username) { _, _ in testResult = nil }
70+ .onChange(of: username) { _, _ in invalidateTestResult() }
6771
6872 SecureField("Password:", text: $password)
6973 .textFieldStyle(.roundedBorder)
7074 .accessibilityLabel("Password")
71− .onChange(of: password) { _, _ in testResult = nil }
75+ .onChange(of: password) { _, _ in invalidateTestResult() }
7276 }
7377 .formStyle(.grouped)
7478 .padding()
@@ -165,9 +169,16 @@ struct ConnectionSheet: View {
165169 }
166170 }
167171
172+ private func invalidateTestResult() {
173+ testGeneration += 1
174+ testResult = nil
175+ }
176+
168177 private func testConnection() {
169178 isTesting = true
170179 testResult = nil
180+ testGeneration += 1
181+ let generation = testGeneration
171182
172183 Task {
173184 do {
@@ -179,14 +190,19 @@ struct ConnectionSheet: View {
179190 )
180191 let client = try ParseableClient(connection: conn)
181192 try await client.checkHealth()
193+ // The health endpoint doesn't check credentials; see
194+ // AppState.verifyCredentials.
195+ try await AppState.verifyCredentials(with: client)
182196 await MainActor.run {
183− testResult = .success("Connection successful")
184197 isTesting = false
198+ guard generation == testGeneration else { return }
199+ testResult = .success("Connection successful")
185200 }
186201 } catch {
187202 await MainActor.run {
188− testResult = .failure(ParseableError.userFriendlyMessage(for: error))
189203 isTesting = false
204+ guard generation == testGeneration else { return }
205+ testResult = .failure(ParseableError.userFriendlyMessage(for: error))
190206 }
191207 }
192208 }
@@ -221,6 +237,10 @@ struct ConnectionSheet: View {
221237 dismiss()
222238 } else {
223239 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
224244 }
225245 }
226246 }
@@ -231,7 +251,7 @@ struct ConnectionSheet: View {
231251 guard !trimmed.isEmpty else { return nil }
232252
233253 var normalized = trimmed
234− if !normalized.hasPrefix("http://") && !normalized.hasPrefix("https://") {
254+ if !ServerConnection.hasURLScheme(normalized) {
235255 normalized = "https://\(normalized)"
236256 }
237257
ParseableViewer/Views/LogTableView.swift+53−6
@@ -315,7 +315,23 @@ 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+ }
318326 .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+ }
319335 recomputeColumnWidths(columns: columns, records: records)
320336 rebuildSort()
321337 }
@@ -401,6 +417,9 @@ struct LogTableView: View {
401417 }
402418
403419 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()
404423 let sevCols = buildSeverityColumnSet(columns: columns)
405424 let enumerated = Array(records.enumerated())
406425 let sorted: [(offset: Int, element: LogRecord)]
@@ -476,12 +495,28 @@ struct LogHeaderView: View {
476495
477496 @State private var draggedColumn: String?
478497 @State private var dropTargetColumn: String?
479− @State private var uniqueValuesCache: [String: [JSONValue]] = [:]
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+ }
480511
481512 private static let maxFilterValues = 20
482513
483514 private func uniqueValues(for column: String) -> [JSONValue] {
484− if let cached = uniqueValuesCache[column] {
515+ if uniqueValuesCache.generation != recordsGeneration {
516+ uniqueValuesCache.generation = recordsGeneration
517+ uniqueValuesCache.values = [:]
518+ }
519+ if let cached = uniqueValuesCache.values[column] {
485520 return cached
486521 }
487522 var seen = Set<JSONValue>()
@@ -493,7 +528,7 @@ struct LogHeaderView: View {
493528 }
494529 }
495530 let sorted = result.sorted()
496− uniqueValuesCache[column] = sorted
531+ uniqueValuesCache.values[column] = sorted
497532 return sorted
498533 }
499534
@@ -636,7 +671,7 @@ struct LogHeaderView: View {
636671 .background(Color(nsColor: .controlBackgroundColor))
637672 .border(Color.secondary.opacity(0.2), width: 0.5)
638673 .onChange(of: records) { _, _ in
639− uniqueValuesCache = [:]
674+ recordsGeneration &+= 1
640675 }
641676 }
642677}
@@ -669,14 +704,26 @@ private struct ColumnDropDelegate: DropDelegate {
669704 }
670705
671706 func performDrop(info: DropInfo) -> Bool {
672− guard let dragged = draggedColumn, dragged != column else {
707+ guard let dragged = draggedColumn, dragged != column,
708+ let provider = info.itemProviders(for: [UTType.plainText]).first else {
673709 draggedColumn = nil
674710 dropTargetColumn = nil
675711 return false
676712 }
677− onMoveColumn?(dragged, column)
678713 draggedColumn = nil
679714 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+ }
680727 return true
681728 }
682729
ParseableViewer/Views/ServerInfoView.swift+12−2
@@ -9,6 +9,9 @@ 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
1215
1316 var body: some View {
1417 ScrollView {
@@ -19,7 +22,7 @@ struct ServerInfoView: View {
1922 .fontWeight(.semibold)
2023 Spacer()
2124 Button {
22− Task { await loadInfo() }
25+ Task { await loadInfo(forceRefresh: true) }
2326 } label: {
2427 Image(systemName: "arrow.clockwise")
2528 }
@@ -153,12 +156,18 @@ struct ServerInfoView: View {
153156 }
154157 }
155158
156− private func loadInfo() async {
159+ private func loadInfo(forceRefresh: Bool = false) async {
157160 // Clear isLoading on the no-client path: since isLoading now starts `true`
158161 // (to avoid a red "Unhealthy" flash before the first check), an early return
159162 // without resetting it would wedge the spinner permanently.
160163 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+ }
161168 let connID = appState.activeConnection?.id
169+ loadGeneration &+= 1
170+ let generation = loadGeneration
162171 isLoading = true
163172 errorMessage = nil
164173 // Seed from the app-wide cache so first load isn't blank, but never fall
@@ -189,6 +198,7 @@ struct ServerInfoView: View {
189198 // load for the previous server can't overwrite the current one's state.
190199 // Still clear isLoading: this task owns the spinner it set, and leaving it
191200 // true on the stale path wedges the ProgressView and disables Refresh.
201+ guard generation == loadGeneration else { return }
192202 guard connID == appState.activeConnection?.id else { isLoading = false; return }
193203 isHealthy = healthy
194204 about = aboutValue
ParseableViewer/Views/SidebarView.swift+19−1
@@ -6,6 +6,14 @@ 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?
917 @State private var deleteStreamDetail: String?
1018
1119 /// Validates a stream name, returning an error message or nil if valid.
@@ -65,7 +73,10 @@ struct SidebarView: View {
6573 // would show the wrong stats on a destructive
6674 // confirmation.
6775 let target = stream.name
76+ let targetConnectionID = appState.activeConnection?.id
6877 streamToDelete = target
78+ streamToDeleteConnectionID = targetConnectionID
79+ streamToDeleteClient = appState.client
6980 deleteStreamDetail = nil
7081 Task {
7182 if let client = appState.client,
@@ -81,7 +92,9 @@ struct SidebarView: View {
8192 deleteStreamDetail = parts.joined(separator: ", ")
8293 }
8394 }
84− if streamToDelete == target {
95+ if streamToDelete == target,
96+ appState.activeConnection?.id == targetConnectionID,
97+ appState.client === streamToDeleteClient {
8598 showDeleteConfirm = true
8699 }
87100 }
@@ -187,6 +200,11 @@ struct SidebarView: View {
187200 .alert("Delete Stream", isPresented: $showDeleteConfirm) {
188201 Button("Delete", role: .destructive) {
189202 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+ }
190208 Task {
191209 do {
192210 try await appState.deleteStream(name: name)
ParseableViewer/Views/StreamDetailView.swift+18−4
@@ -8,6 +8,9 @@ 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
1114
1215 var body: some View {
1316 Group {
@@ -25,7 +28,7 @@ struct StreamDetailView: View {
2528 Spacer()
2629
2730 Button {
28− Task { await loadData(stream: stream) }
31+ Task { await loadData(stream: stream, forceRefresh: true) }
2932 } label: {
3033 Image(systemName: "arrow.clockwise")
3134 }
@@ -44,7 +47,7 @@ struct StreamDetailView: View {
4447 Text(error)
4548 Spacer()
4649 Button("Retry") {
47− Task { await loadData(stream: stream) }
50+ Task { await loadData(stream: stream, forceRefresh: true) }
4851 }
4952 .buttonStyle(.bordered)
5053 .controlSize(.small)
@@ -174,13 +177,22 @@ struct StreamDetailView: View {
174177 }
175178 }
176179
177− private func loadData(stream: String) async {
180+ private func loadData(stream: String, forceRefresh: Bool = false) async {
178181 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+ }
179189 // Capture the connection this fetch belongs to. Two servers can expose a
180190 // same-named stream, so guarding on the stream name alone would let a
181191 // slow fetch write the previous server's data under the new server's
182192 // stream after a server switch.
183193 let connID = appState.activeConnection?.id
194+ loadGeneration &+= 1
195+ let generation = loadGeneration
184196 isLoading = true
185197 errorMessage = nil
186198 // Clear previous stream's data so it isn't shown under the new header
@@ -216,7 +228,9 @@ struct StreamDetailView: View {
216228 // resolve after the user switched. Drop stale results instead of writing
217229 // them under the new stream's header. Also check the connection so a
218230 // same-named stream on a different server doesn't accept the old server's
219− // data. Reset `isLoading` on the stale path or the spinner wedges forever.
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 }
220234 guard stream == appState.selectedStream, connID == appState.activeConnection?.id else {
221235 isLoading = false
222236 return
ParseableViewer/Views/UsersView.swift+6
@@ -5,6 +5,9 @@ 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
811
912 var body: some View {
1013 VStack(spacing: 0) {
@@ -82,6 +85,8 @@ struct UsersView: View {
8285 private func loadUsers() async {
8386 guard let client = appState.client else { return }
8487 let connID = appState.activeConnection?.id
88+ loadGeneration &+= 1
89+ let generation = loadGeneration
8590 isLoading = true
8691 errorMessage = nil
8792 // Clear the previous server's list up front so it can't briefly show through
@@ -102,6 +107,7 @@ struct UsersView: View {
102107 // Drop results if the active connection changed while awaiting. Still clear
103108 // isLoading: this task set the spinner, so leaving it true on the stale path
104109 // wedges the ProgressView and disables Refresh.
110+ guard generation == loadGeneration else { return }
105111 guard connID == appState.activeConnection?.id else { isLoading = false; return }
106112 if let loaded { users = loaded }
107113 errorMessage = loadError
ParseableViewerTests/ParseableClientTests.swift+58
@@ -646,4 +646,62 @@ 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+ }
649707}
ParseableViewerTests/ServerConnectionTests.swift+10
@@ -77,4 +77,14 @@ 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+ }
8090}