git.iko.soy/parseable-android
on master help sign in
Fix alerts showing as "Unnamed Alert" by matching API response field names (#11)

The Parseable GET /api/v1/alerts endpoint returns alert summaries using `title` (not `name`), `state` (not `enabled`), and `datasets` (not `stream`). Since the Alert model fields didn't match the JSON keys, kotlinx serialization silently set them all to null. Add the correct fields (title, state, severity, alertType, datasets, etc.) to the Alert model alongside the existing per-stream fields, with computed properties (displayName, isEnabled, streamDisplay) that handle both response formats. Update the UI and tests accordingly. https://claude.ai/code/session_0198sR2BUHahfxq4j5zKqVkq Co-authored-by: Claude <noreply@anthropic.com>

commit4ea48de2831f9ed11fdfe89786d43337aa5d7dcc authoriko <mail@iko.soy> dateSun 15 Feb 2026, 18:36 (8 months ago) parentc9b724f
3 files changed+76−18
app/src/main/java/com/parseable/android/data/model/Models.kt+38−2 app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsScreen.kt+33−11 app/src/test/java/com/parseable/android/AlertsViewModelTest.kt+5−5
app/src/main/java/com/parseable/android/data/model/Models.kt+38−2
@@ -119,19 +119,55 @@ data class AboutInfo(
119119)
120120
121121/**
122− * Alert configuration
122+ * Alert summary returned by GET /api/v1/alerts.
123+ *
124+ * The global alerts endpoint returns summary objects with fields like `title`, `state`,
125+ * `severity`, and `datasets`. The per-stream endpoint uses `name` and `alerts[]` nesting.
126+ * Both shapes are supported via optional fields.
123127 */
124128@Serializable
125129data class Alert(
126130 val id: String? = null,
127131 val version: String? = null,
132+ // Per-stream alert format uses "name"; global summary uses "title"
128133 val name: String? = null,
134+ val title: String? = null,
129135 val message: String? = null,
130136 val rule: JsonObject? = null,
131137 val targets: List<JsonObject> = emptyList(),
138+ // Per-stream alert format
132139 val stream: String? = null,
133140 val enabled: Boolean? = null,
134−)
141+ // Global summary fields
142+ val state: String? = null,
143+ val severity: String? = null,
144+ @SerialName("alertType")
145+ val alertType: String? = null,
146+ @SerialName("notificationState")
147+ val notificationState: String? = null,
148+ val created: String? = null,
149+ @SerialName("lastTriggeredAt")
150+ val lastTriggeredAt: String? = null,
151+ val tags: List<String> = emptyList(),
152+ val datasets: List<String> = emptyList(),
153+) {
154+ /** Best-effort display name from either response format. */
155+ val displayName: String get() = title ?: name ?: "Unnamed Alert"
156+
157+ /** Whether the alert is active, derived from either response format. */
158+ val isEnabled: Boolean get() = when {
159+ state != null -> state != "Disabled"
160+ enabled != null -> enabled
161+ else -> true
162+ }
163+
164+ /** Stream(s) this alert is configured for. */
165+ val streamDisplay: String? get() = when {
166+ datasets.isNotEmpty() -> datasets.joinToString(", ")
167+ stream != null -> stream
168+ else -> null
169+ }
170+}
135171
136172/**
137173 * Result wrapper for API calls
app/src/main/java/com/parseable/android/ui/screens/alerts/AlertsScreen.kt+33−11
@@ -104,7 +104,7 @@ fun AlertsScreen(
104104 contentPadding = PaddingValues(16.dp),
105105 verticalArrangement = Arrangement.spacedBy(8.dp),
106106 ) {
107− itemsIndexed(state.alerts, key = { index, alert -> alert.id ?: alert.name ?: "alert_$index" }) { _, alert ->
107+ itemsIndexed(state.alerts, key = { index, alert -> alert.id ?: "alert_$index" }) { _, alert ->
108108 AlertCard(
109109 alert = alert,
110110 onDelete = if (alert.id != null) {
@@ -124,7 +124,7 @@ fun AlertsScreen(
124124 onDismissRequest = viewModel::cancelDelete,
125125 title = { Text("Delete Alert") },
126126 text = {
127− Text("Are you sure you want to delete \"${alert.name ?: "this alert"}\"? This action cannot be undone.")
127+ Text("Are you sure you want to delete \"${alert.displayName}\"? This action cannot be undone.")
128128 },
129129 confirmButton = {
130130 TextButton(
@@ -165,10 +165,10 @@ private fun AlertCard(alert: Alert, onDelete: (() -> Unit)?) {
165165 modifier = Modifier.weight(1f),
166166 ) {
167167 Icon(
168− if (alert.enabled == true) Icons.Filled.NotificationsActive
168+ if (alert.isEnabled) Icons.Filled.NotificationsActive
169169 else Icons.Filled.NotificationsOff,
170− contentDescription = if (alert.enabled == true) "Alert enabled" else "Alert disabled",
171− tint = if (alert.enabled == true) {
170+ contentDescription = if (alert.isEnabled) "Alert enabled" else "Alert disabled",
171+ tint = if (alert.isEnabled) {
172172 MaterialTheme.colorScheme.primary
173173 } else {
174174 MaterialTheme.colorScheme.onSurfaceVariant
@@ -177,17 +177,20 @@ private fun AlertCard(alert: Alert, onDelete: (() -> Unit)?) {
177177 )
178178 Spacer(modifier = Modifier.width(8.dp))
179179 Text(
180− text = alert.name ?: "Unnamed Alert",
180+ text = alert.displayName,
181181 style = MaterialTheme.typography.titleMedium,
182182 fontWeight = FontWeight.SemiBold,
183183 )
184184 }
185185 Row(verticalAlignment = Alignment.CenterVertically) {
186− if (alert.enabled != null) {
186+ val stateLabel = alert.state ?: if (alert.enabled != null) {
187+ if (alert.enabled) "Enabled" else "Disabled"
188+ } else null
189+ if (stateLabel != null) {
187190 Text(
188− text = if (alert.enabled) "Enabled" else "Disabled",
191+ text = stateLabel,
189192 style = MaterialTheme.typography.labelSmall,
190− color = if (alert.enabled) {
193+ color = if (alert.isEnabled) {
191194 MaterialTheme.colorScheme.primary
192195 } else {
193196 MaterialTheme.colorScheme.onSurfaceVariant
@@ -220,10 +223,20 @@ private fun AlertCard(alert: Alert, onDelete: (() -> Unit)?) {
220223 )
221224 }
222225
223− if (alert.stream != null) {
226+ val streamText = alert.streamDisplay
227+ if (streamText != null) {
224228 Spacer(modifier = Modifier.height(4.dp))
225229 Text(
226− text = "Stream: ${alert.stream}",
230+ text = "Stream: $streamText",
231+ style = MaterialTheme.typography.labelMedium,
232+ color = MaterialTheme.colorScheme.onSurfaceVariant,
233+ )
234+ }
235+
236+ if (alert.severity != null) {
237+ Spacer(modifier = Modifier.height(4.dp))
238+ Text(
239+ text = "Severity: ${alert.severity}",
227240 style = MaterialTheme.typography.labelMedium,
228241 color = MaterialTheme.colorScheme.onSurfaceVariant,
229242 )
@@ -234,7 +247,16 @@ private fun AlertCard(alert: Alert, onDelete: (() -> Unit)?) {
234247 HorizontalDivider()
235248 Spacer(modifier = Modifier.height(8.dp))
236249
250+ if (alert.alertType != null) {
251+ Text(
252+ text = "Type: ${alert.alertType}",
253+ style = MaterialTheme.typography.bodySmall,
254+ color = MaterialTheme.colorScheme.onSurfaceVariant,
255+ )
256+ }
257+
237258 if (alert.rule != null) {
259+ Spacer(modifier = Modifier.height(4.dp))
238260 Text(
239261 text = "Rule:",
240262 style = MaterialTheme.typography.labelMedium,
app/src/test/java/com/parseable/android/AlertsViewModelTest.kt+5−5
@@ -44,8 +44,8 @@ class AlertsViewModelTest {
4444 @Test
4545 fun `refresh loads alerts on success`() = runTest {
4646 val alerts = listOf(
47− Alert(id = "1", name = "Alert 1", stream = "stream1"),
48− Alert(id = "2", name = "Alert 2", stream = "stream2"),
47+ Alert(id = "1", title = "Alert 1", datasets = listOf("stream1"), state = "NotTriggered"),
48+ Alert(id = "2", title = "Alert 2", datasets = listOf("stream2"), state = "Triggered"),
4949 )
5050 coEvery { repository.listAlerts() } returns ApiResult.Success(alerts)
5151
@@ -71,7 +71,7 @@ class AlertsViewModelTest {
7171
7272 @Test
7373 fun `requestDelete sets alertToDelete`() {
74− val alert = Alert(id = "1", name = "Alert 1", stream = "stream1")
74+ val alert = Alert(id = "1", title = "Alert 1", datasets = listOf("stream1"))
7575
7676 viewModel.requestDelete(alert)
7777
@@ -81,7 +81,7 @@ class AlertsViewModelTest {
8181
8282 @Test
8383 fun `cancelDelete clears alertToDelete`() {
84− val alert = Alert(id = "1", name = "Alert 1", stream = "stream1")
84+ val alert = Alert(id = "1", title = "Alert 1", datasets = listOf("stream1"))
8585 viewModel.requestDelete(alert)
8686 assertEquals(alert, viewModel.state.value.alertToDelete)
8787
@@ -93,7 +93,7 @@ class AlertsViewModelTest {
9393 @Test
9494 fun `deleteAlert on success refreshes list`() = runTest {
9595 val alerts = listOf(
96− Alert(id = "2", name = "Alert 2", stream = "stream2"),
96+ Alert(id = "2", title = "Alert 2", datasets = listOf("stream2")),
9797 )
9898 coEvery { repository.deleteAlert("1") } returns ApiResult.Success("deleted")
9999 coEvery { repository.listAlerts() } returns ApiResult.Success(alerts)