git.iko.soy/parseable-macos
on master help sign in
Fix alerts not showing due to model mismatch with Parseable API (#25)

The AlertRule model had a non-optional `name` field, but the modern Parseable /api/v1/alerts endpoint returns `title` instead. This caused silent decoding failures (via `try?`) making all alerts appear as nil. Updated AlertRule to support both the modern API format (title, id, severity, state, alertType, datasets, tags, etc.) and the legacy per-stream format (name, message, rule, targets) with defensive decoding for all fields. Updated AlertsView to display severity badges, state indicators, alert type, datasets, and tags. https://claude.ai/code/session_01LiiEB3hCMJvm6tRALeqaVm Co-authored-by: Claude <noreply@anthropic.com>

commit8bc3961ed67971b35cafe52e7cd20fd0bc3a6083 authoriko <mail@iko.soy> dateSun 15 Feb 2026, 19:08 (8 months ago) parentcbbf39e
3 files changed+152−9
ParseableViewer/Models/AlertModels.swift+45−3 ParseableViewer/Views/AlertsView.swift+69−6 ParseableViewerTests/ParseableClientTests.swift+38
ParseableViewer/Models/AlertModels.swift+45−3
@@ -10,7 +10,7 @@ struct AlertConfig: Codable, Sendable {
1010 }
1111
1212 init(from decoder: Decoder) throws {
13− // Handle both {"alerts": [...]} and direct array
13+ // Handle both {"alerts": [...], "version": "..."} and direct array [...]
1414 if let container = try? decoder.container(keyedBy: CodingKeys.self) {
1515 self.alerts = try? container.decode([AlertRule].self, forKey: .alerts)
1616 self.version = try? container.decode(String.self, forKey: .version)
@@ -27,12 +27,54 @@ struct AlertConfig: Codable, Sendable {
2727}
2828
2929struct AlertRule: Identifiable, Codable, Sendable {
30− let name: String
30+ // Modern Parseable API fields (from /api/v1/alerts summary)
31+ let alertId: String?
32+ let title: String?
33+ let severity: String?
34+ let state: String?
35+ let alertType: String?
36+ let notificationState: String?
37+ let created: String?
38+ let tags: [String]?
39+ let datasets: [String]?
40+ let lastTriggeredAt: String?
41+
42+ // Legacy per-stream API fields
43+ let name: String?
3144 let message: String?
3245 let rule: AlertRuleSpec?
3346 let targets: [AlertTarget]?
3447
35− var id: String { name }
48+ /// Display name, preferring the modern `title` field over legacy `name`.
49+ var displayName: String { title ?? name ?? "Unnamed Alert" }
50+
51+ var id: String { alertId ?? name ?? title ?? UUID().uuidString }
52+
53+ private enum CodingKeys: String, CodingKey {
54+ case alertId = "id"
55+ case title, severity, state, alertType
56+ case notificationState, created, tags, datasets
57+ case lastTriggeredAt
58+ case name, message, rule, targets
59+ }
60+
61+ init(from decoder: Decoder) throws {
62+ let container = try decoder.container(keyedBy: CodingKeys.self)
63+ self.alertId = try? container.decode(String.self, forKey: .alertId)
64+ self.title = try? container.decode(String.self, forKey: .title)
65+ self.severity = try? container.decode(String.self, forKey: .severity)
66+ self.state = try? container.decode(String.self, forKey: .state)
67+ self.alertType = try? container.decode(String.self, forKey: .alertType)
68+ self.notificationState = try? container.decode(String.self, forKey: .notificationState)
69+ self.created = try? container.decode(String.self, forKey: .created)
70+ self.tags = try? container.decode([String].self, forKey: .tags)
71+ self.datasets = try? container.decode([String].self, forKey: .datasets)
72+ self.lastTriggeredAt = try? container.decode(String.self, forKey: .lastTriggeredAt)
73+ self.name = try? container.decode(String.self, forKey: .name)
74+ self.message = try? container.decode(String.self, forKey: .message)
75+ self.rule = try? container.decode(AlertRuleSpec.self, forKey: .rule)
76+ self.targets = try? container.decode([AlertTarget].self, forKey: .targets)
77+ }
3678}
3779
3880struct AlertRuleSpec: Codable, Sendable {
ParseableViewer/Views/AlertsView.swift+69−6
@@ -97,9 +97,28 @@ struct AlertRowView: View {
9797 VStack(alignment: .leading, spacing: 6) {
9898 HStack {
9999 Image(systemName: "bell.fill")
100− .foregroundStyle(.orange)
101− Text(alert.name)
100+ .foregroundStyle(stateColor)
101+ Text(alert.displayName)
102102 .fontWeight(.medium)
103+ Spacer()
104+ if let severity = alert.severity {
105+ Text(severity)
106+ .font(.caption2)
107+ .padding(.horizontal, 6)
108+ .padding(.vertical, 2)
109+ .background(severityColor.opacity(0.15))
110+ .foregroundStyle(severityColor)
111+ .clipShape(RoundedRectangle(cornerRadius: 4))
112+ }
113+ if let state = alert.state {
114+ Text(state)
115+ .font(.caption2)
116+ .padding(.horizontal, 6)
117+ .padding(.vertical, 2)
118+ .background(stateColor.opacity(0.15))
119+ .foregroundStyle(stateColor)
120+ .clipShape(RoundedRectangle(cornerRadius: 4))
121+ }
103122 }
104123
105124 if let message = alert.message {
@@ -108,14 +127,40 @@ struct AlertRowView: View {
108127 .foregroundStyle(.secondary)
109128 }
110129
111− if let rule = alert.rule {
130+ HStack(spacing: 8) {
131+ if let alertType = alert.alertType {
132+ Label(alertType, systemImage: "gearshape")
133+ .font(.caption2)
134+ .padding(.horizontal, 6)
135+ .padding(.vertical, 2)
136+ .background(Color.blue.opacity(0.1))
137+ .clipShape(RoundedRectangle(cornerRadius: 4))
138+ }
139+
140+ if let rule = alert.rule, let type = rule.type {
141+ Label(type, systemImage: "gearshape")
142+ .font(.caption2)
143+ .padding(.horizontal, 6)
144+ .padding(.vertical, 2)
145+ .background(Color.blue.opacity(0.1))
146+ .clipShape(RoundedRectangle(cornerRadius: 4))
147+ }
148+
149+ if let datasets = alert.datasets, !datasets.isEmpty {
150+ Label(datasets.joined(separator: ", "), systemImage: "cylinder")
151+ .font(.caption2)
152+ .foregroundStyle(.secondary)
153+ }
154+ }
155+
156+ if let tags = alert.tags, !tags.isEmpty {
112157 HStack {
113− if let type = rule.type {
114− Label(type, systemImage: "gearshape")
158+ ForEach(tags, id: \.self) { tag in
159+ Text(tag)
115160 .font(.caption2)
116161 .padding(.horizontal, 6)
117162 .padding(.vertical, 2)
118− .background(Color.blue.opacity(0.1))
163+ .background(Color.purple.opacity(0.1))
119164 .clipShape(RoundedRectangle(cornerRadius: 4))
120165 }
121166 }
@@ -141,4 +186,22 @@ struct AlertRowView: View {
141186 }
142187 .padding(.vertical, 4)
143188 }
189+
190+ private var stateColor: Color {
191+ switch alert.state?.lowercased() {
192+ case "triggered": return .red
193+ case "disabled": return .gray
194+ default: return .orange
195+ }
196+ }
197+
198+ private var severityColor: Color {
199+ switch alert.severity?.lowercased() {
200+ case "critical": return .red
201+ case "high": return .orange
202+ case "medium": return .yellow
203+ case "low": return .blue
204+ default: return .secondary
205+ }
206+ }
144207}
ParseableViewerTests/ParseableClientTests.swift+38
@@ -52,6 +52,7 @@ final class ParseableClientTests: XCTestCase {
5252 let config = try JSONDecoder().decode(AlertConfig.self, from: json)
5353 XCTAssertEqual(config.alerts?.count, 1)
5454 XCTAssertEqual(config.alerts?[0].name, "high-cpu")
55+ XCTAssertEqual(config.alerts?[0].displayName, "high-cpu")
5556 XCTAssertEqual(config.version, "v1")
5657 }
5758
@@ -63,6 +64,7 @@ final class ParseableClientTests: XCTestCase {
6364 let config = try JSONDecoder().decode(AlertConfig.self, from: json)
6465 XCTAssertEqual(config.alerts?.count, 1)
6566 XCTAssertEqual(config.alerts?[0].name, "disk-full")
67+ XCTAssertEqual(config.alerts?[0].displayName, "disk-full")
6668 XCTAssertNil(config.version)
6769 }
6870
@@ -72,6 +74,42 @@ final class ParseableClientTests: XCTestCase {
7274 XCTAssertNil(config.version)
7375 }
7476
77+ func testAlertConfigModernAPIFormat() throws {
78+ let json = """
79+ [{"id": "01HQ3...", "title": "High CPU Alert", "severity": "Critical", "state": "Triggered", "alertType": "Threshold", "datasets": ["backend-logs"], "created": "2025-01-01T00:00:00Z"}]
80+ """.data(using: .utf8)!
81+
82+ let config = try JSONDecoder().decode(AlertConfig.self, from: json)
83+ XCTAssertEqual(config.alerts?.count, 1)
84+ let alert = try XCTUnwrap(config.alerts?.first)
85+ XCTAssertEqual(alert.title, "High CPU Alert")
86+ XCTAssertEqual(alert.displayName, "High CPU Alert")
87+ XCTAssertEqual(alert.severity, "Critical")
88+ XCTAssertEqual(alert.state, "Triggered")
89+ XCTAssertEqual(alert.alertType, "Threshold")
90+ XCTAssertEqual(alert.datasets, ["backend-logs"])
91+ XCTAssertEqual(alert.alertId, "01HQ3...")
92+ XCTAssertNil(alert.name)
93+ }
94+
95+ func testAlertRuleDisplayNamePrefersTitle() throws {
96+ let json = """
97+ {"title": "My Alert", "name": "my-alert"}
98+ """.data(using: .utf8)!
99+
100+ let alert = try JSONDecoder().decode(AlertRule.self, from: json)
101+ XCTAssertEqual(alert.displayName, "My Alert")
102+ }
103+
104+ func testAlertRuleDisplayNameFallsBackToName() throws {
105+ let json = """
106+ {"name": "legacy-alert"}
107+ """.data(using: .utf8)!
108+
109+ let alert = try JSONDecoder().decode(AlertRule.self, from: json)
110+ XCTAssertEqual(alert.displayName, "legacy-alert")
111+ }
112+
75113 // MARK: - Retention config decoding
76114
77115 func testRetentionConfigArray() throws {