git.iko.soy/parseable-macos
on master help sign in
Fix alerts not showing due to model mismatch with Parseable API (#25)
8bc3961iko
ParseableViewer/Models/AlertModels.swift+45−3
@@ -10,7 +10,7 @@ struct AlertConfig: Codable, Sendable {
10 }
10 }
11
11
12 init(from decoder: Decoder) throws {
12 init(from decoder: Decoder) throws {
13− // Handle both {"alerts": [...]} and direct array
13+ // Handle both {"alerts": [...], "version": "..."} and direct array [...]
14 if let container = try? decoder.container(keyedBy: CodingKeys.self) {
14 if let container = try? decoder.container(keyedBy: CodingKeys.self) {
15 self.alerts = try? container.decode([AlertRule].self, forKey: .alerts)
15 self.alerts = try? container.decode([AlertRule].self, forKey: .alerts)
16 self.version = try? container.decode(String.self, forKey: .version)
16 self.version = try? container.decode(String.self, forKey: .version)
@@ -27,12 +27,54 @@ struct AlertConfig: Codable, Sendable {
27}
27}
28
28
29struct AlertRule: Identifiable, Codable, Sendable {
29struct AlertRule: Identifiable, Codable, Sendable {
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
30− let name: String
43+ let name: String?
31 let message: String?
44 let message: String?
32 let rule: AlertRuleSpec?
45 let rule: AlertRuleSpec?
33 let targets: [AlertTarget]?
46 let targets: [AlertTarget]?
34
47
48+ /// Display name, preferring the modern `title` field over legacy `name`.
49+ var displayName: String { title ?? name ?? "Unnamed Alert" }
50+
35− var id: String { name }
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+ }
36}
78}
37
79
38struct AlertRuleSpec: Codable, Sendable {
80struct AlertRuleSpec: Codable, Sendable {
ParseableViewer/Views/AlertsView.swift+69−6
@@ -97,9 +97,28 @@ struct AlertRowView: View {
97 VStack(alignment: .leading, spacing: 6) {
97 VStack(alignment: .leading, spacing: 6) {
98 HStack {
98 HStack {
99 Image(systemName: "bell.fill")
99 Image(systemName: "bell.fill")
100− .foregroundStyle(.orange)
100+ .foregroundStyle(stateColor)
101− Text(alert.name)
101+ Text(alert.displayName)
102 .fontWeight(.medium)
102 .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+ }
103 }
122 }
104
123
105 if let message = alert.message {
124 if let message = alert.message {
@@ -108,14 +127,40 @@ struct AlertRowView: View {
108 .foregroundStyle(.secondary)
127 .foregroundStyle(.secondary)
109 }
128 }
110
129
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+
111− if let rule = alert.rule {
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 {
112 HStack {
157 HStack {
113− if let type = rule.type {
158+ ForEach(tags, id: \.self) { tag in
114− Label(type, systemImage: "gearshape")
159+ Text(tag)
115 .font(.caption2)
160 .font(.caption2)
116 .padding(.horizontal, 6)
161 .padding(.horizontal, 6)
117 .padding(.vertical, 2)
162 .padding(.vertical, 2)
118− .background(Color.blue.opacity(0.1))
163+ .background(Color.purple.opacity(0.1))
119 .clipShape(RoundedRectangle(cornerRadius: 4))
164 .clipShape(RoundedRectangle(cornerRadius: 4))
120 }
165 }
121 }
166 }
@@ -141,4 +186,22 @@ struct AlertRowView: View {
141 }
186 }
142 .padding(.vertical, 4)
187 .padding(.vertical, 4)
143 }
188 }
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+ }
144}
207}
ParseableViewerTests/ParseableClientTests.swift+38
@@ -52,6 +52,7 @@ final class ParseableClientTests: XCTestCase {
52 let config = try JSONDecoder().decode(AlertConfig.self, from: json)
52 let config = try JSONDecoder().decode(AlertConfig.self, from: json)
53 XCTAssertEqual(config.alerts?.count, 1)
53 XCTAssertEqual(config.alerts?.count, 1)
54 XCTAssertEqual(config.alerts?[0].name, "high-cpu")
54 XCTAssertEqual(config.alerts?[0].name, "high-cpu")
55+ XCTAssertEqual(config.alerts?[0].displayName, "high-cpu")
55 XCTAssertEqual(config.version, "v1")
56 XCTAssertEqual(config.version, "v1")
56 }
57 }
57
58
@@ -63,6 +64,7 @@ final class ParseableClientTests: XCTestCase {
63 let config = try JSONDecoder().decode(AlertConfig.self, from: json)
64 let config = try JSONDecoder().decode(AlertConfig.self, from: json)
64 XCTAssertEqual(config.alerts?.count, 1)
65 XCTAssertEqual(config.alerts?.count, 1)
65 XCTAssertEqual(config.alerts?[0].name, "disk-full")
66 XCTAssertEqual(config.alerts?[0].name, "disk-full")
67+ XCTAssertEqual(config.alerts?[0].displayName, "disk-full")
66 XCTAssertNil(config.version)
68 XCTAssertNil(config.version)
67 }
69 }
68
70
@@ -72,6 +74,42 @@ final class ParseableClientTests: XCTestCase {
72 XCTAssertNil(config.version)
74 XCTAssertNil(config.version)
73 }
75 }
74
76
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+
75 // MARK: - Retention config decoding
113 // MARK: - Retention config decoding
76
114
77 func testRetentionConfigArray() throws {
115 func testRetentionConfigArray() throws {