git.iko.soy/parseable-macos
on master help sign in
Fix decoding of Parseable responses and alert pagination
f4c4cedClaude
ParseableViewer/Models/AlertModels.swift+21
@@ -85,6 +85,27 @@ struct AlertRule: Identifiable, Codable, Sendable {
85struct AlertRuleSpec: Codable, Sendable {
85struct AlertRuleSpec: Codable, Sendable {
86 let type: String?
86 let type: String?
87 let config: String?
87 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+ }
88}
109}
89
110
90struct AlertTarget: Codable, Sendable {
111struct AlertTarget: Codable, Sendable {
ParseableViewer/Models/LogStream.swift+97−11
@@ -80,11 +80,39 @@ struct SchemaField: Identifiable, Codable, Hashable, Sendable {
80 if let typeStr = try? container.decode(String.self, forKey: .dataType) {
80 if let typeStr = try? container.decode(String.self, forKey: .dataType) {
81 self.dataType = typeStr
81 self.dataType = typeStr
82 } else if let typeObj = try? container.decode(JSONValue.self, forKey: .dataType) {
82 } else if let typeObj = try? container.decode(JSONValue.self, forKey: .dataType) {
83− self.dataType = typeObj.displayString
83+ self.dataType = Self.describeArrowType(typeObj)
84 } else {
84 } else {
85 self.dataType = "Unknown"
85 self.dataType = "Unknown"
86 }
86 }
87 }
87 }
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+ }
88}
116}
89
117
90/// Formats a byte count that may arrive from the server as a fractional or
118/// Formats a byte count that may arrive from the server as a fractional or
@@ -106,14 +134,59 @@ private func formatByteSize(_ value: Int) -> String? {
106/// Normalizes a byte count that arrived as a JSON *string*. A bare numeric string
134/// Normalizes a byte count that arrived as a JSON *string*. A bare numeric string
107/// (e.g. "1048576") is a raw byte count and is formatted like the numeric branches
135/// (e.g. "1048576") is a raw byte count and is formatted like the numeric branches
108/// so identical sizes render consistently ("1 MB") regardless of whether the server
136/// 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
109−/// quoted the value; an already-formatted string (e.g. "1.2 GB") is passed through
139+/// other already-formatted string (e.g. "1.2 GB") is passed through unchanged.
110−/// unchanged.
111private func formatByteSize(_ value: String) -> String {
140private 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+ }
113 if let n = Int(trimmed) { return formatByteSize(n) ?? value }
145 if let n = Int(trimmed) { return formatByteSize(n) ?? value }
114 return value
146 return value
115}
147}
116
148
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+
117struct StreamStats: Codable, Sendable {
190struct StreamStats: Codable, Sendable {
118 let ingestion: IngestionStats?
191 let ingestion: IngestionStats?
119 let storage: StorageStats?
192 let storage: StorageStats?
@@ -165,7 +238,11 @@ struct StreamStats: Codable, Sendable {
165
238
166 init(from decoder: Decoder) throws {
239 init(from decoder: Decoder) throws {
167 let container = try decoder.container(keyedBy: CodingKeys.self)
240 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")
169 if let s = try? container.decode(String.self, forKey: .size) {
246 if let s = try? container.decode(String.self, forKey: .size) {
170 self.size = formatByteSize(s)
247 self.size = formatByteSize(s)
171 } else if let n = try? container.decode(Int.self, forKey: .size) {
248 } else if let n = try? container.decode(Int.self, forKey: .size) {
@@ -208,11 +285,20 @@ struct StreamInfo: Codable, Sendable {
208 }
285 }
209
286
210 init(from decoder: Decoder) throws {
287 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".
211− let container = try decoder.container(keyedBy: CodingKeys.self)
295+ let container = try decoder.container(keyedBy: FlexibleCodingKey.self)
212− self.createdAt = try? container.decode(String.self, forKey: .createdAt)
296+ self.createdAt = container.firstString("created-at", "createdAt", "created_at")
213− self.firstEventAt = try? container.decode(String.self, forKey: .firstEventAt)
297+ self.firstEventAt = container.firstString("first-event-at", "firstEventAt", "first_event_at")
214− self.cacheEnabled = try? container.decode(Bool.self, forKey: .cacheEnabled)
298+ self.cacheEnabled = container.firstBool("cache_enabled", "cacheEnabled", "cache-enabled")
215− self.timePartition = try? container.decode(String.self, forKey: .timePartition)
299+ self.timePartition = container.firstString("time_partition", "timePartition", "time-partition")
216− self.staticSchemaFlag = try? container.decode(Bool.self, forKey: .staticSchemaFlag)
300+ self.staticSchemaFlag = container.firstBool(
301+ "static_schema_flag", "staticSchemaFlag", "static-schema-flag"
302+ )
217 }
303 }
218}
304}
ParseableViewer/Models/ServerInfo.swift+33−7
@@ -15,33 +15,59 @@ struct ServerAbout: Codable, Sendable {
15 let llmActive: Bool?
15 let llmActive: Bool?
16 let oidcActive: Bool?
16 let oidcActive: Bool?
17
17
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.
18 private enum CodingKeys: String, CodingKey {
23 private enum CodingKeys: String, CodingKey {
19 case version, commit, mode, staging, store, license
24 case version, commit, mode, staging, store, license
20− case uiVersion = "ui_version"
25+ case uiVersion
21− case deploymentId = "deployment_id"
26+ case deploymentId
22− case grpcPort = "grpc_port"
27+ case grpcPort
23 case updateAvailable
28 case updateAvailable
24 case latestVersion
29 case latestVersion
25 case llmActive
30 case llmActive
26 case oidcActive
31 case oidcActive
27 }
32 }
28
33
34+ private enum SnakeCaseKeys: String, CodingKey {
35+ case uiVersion = "ui_version"
36+ case deploymentId = "deployment_id"
37+ case grpcPort = "grpc_port"
38+ }
39+
29 init(from decoder: Decoder) throws {
40 init(from decoder: Decoder) throws {
30 let container = try decoder.container(keyedBy: CodingKeys.self)
41 let container = try decoder.container(keyedBy: CodingKeys.self)
42+ let snakeCase = try? decoder.container(keyedBy: SnakeCaseKeys.self)
31 self.version = try? container.decode(String.self, forKey: .version)
43 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))
33 self.commit = try? container.decode(String.self, forKey: .commit)
46 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))
35 self.mode = try? container.decode(String.self, forKey: .mode)
49 self.mode = try? container.decode(String.self, forKey: .mode)
36 self.staging = try? container.decode(String.self, forKey: .staging)
50 self.staging = try? container.decode(String.self, forKey: .staging)
37 self.store = try? container.decode(StoreInfo.self, forKey: .store)
51 self.store = try? container.decode(StoreInfo.self, forKey: .store)
38− self.license = try? container.decode(String.self, forKey: .license)
52+ self.license = (try? container.decode(String.self, forKey: .license))
53+ ?? (try? container.decode(JSONValue.self, forKey: .license)).flatMap(Self.describeLicense)
39− self.grpcPort = try? container.decode(Int.self, forKey: .grpcPort)
54+ self.grpcPort = (try? container.decode(Int.self, forKey: .grpcPort))
55+ ?? (try? snakeCase?.decode(Int.self, forKey: .grpcPort))
40 self.updateAvailable = try? container.decode(Bool.self, forKey: .updateAvailable)
56 self.updateAvailable = try? container.decode(Bool.self, forKey: .updateAvailable)
41 self.latestVersion = try? container.decode(String.self, forKey: .latestVersion)
57 self.latestVersion = try? container.decode(String.self, forKey: .latestVersion)
42 self.llmActive = try? container.decode(Bool.self, forKey: .llmActive)
58 self.llmActive = try? container.decode(Bool.self, forKey: .llmActive)
43 self.oidcActive = try? container.decode(Bool.self, forKey: .oidcActive)
59 self.oidcActive = try? container.decode(Bool.self, forKey: .oidcActive)
44 }
60 }
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+ }
45}
71}
46
72
47struct StoreInfo: Codable, Sendable {
73struct StoreInfo: Codable, Sendable {
ParseableViewer/Services/ParseableClient.swift+44−2
@@ -409,9 +409,51 @@ final class ParseableClient: Sendable {
409 return try Self.jsonDecoder.decode(AlertConfig.self, from: data)
409 return try Self.jsonDecoder.decode(AlertConfig.self, from: data)
410 }
410 }
411
411
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+
412 do {
455 do {
413− let data = try await performRequest(method: "GET", path: "/api/v1/alerts")
456+ let (data, config) = try await modernAlerts()
414− let config = try Self.jsonDecoder.decode(AlertConfig.self, from: data)
415 // AlertConfig decodes leniently (every field via try?), so a 200 with
457 // AlertConfig decodes leniently (every field via try?), so a 200 with
416 // an unexpected body yields an empty, non-throwing config rather than
458 // an unexpected body yields an empty, non-throwing config rather than
417 // a DecodingError — indistinguishable from a server that genuinely has
459 // a DecodingError — indistinguishable from a server that genuinely has
ParseableViewerTests/ParseableClientTests.swift+58
@@ -646,4 +646,62 @@ final class ParseableClientTests: XCTestCase {
646 // Carriage returns in values should be wrapped in quotes per RFC 4180
646 // Carriage returns in values should be wrapped in quotes per RFC 4180
647 XCTAssertTrue(csv.contains("\"line1\r\nline2\""))
647 XCTAssertTrue(csv.contains("\"line1\r\nline2\""))
648 }
648 }
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+ }
649}
707}