git.iko.soy/parseable-android
on master help sign in
Fix alerts showing as "Unnamed Alert" by matching API response field names (#11)
4ea48deiko
app/src/main/java/com/parseable/android/data/model/Models.kt+38−2
@@ -119,19 +119,55 @@ data class AboutInfo(
119)
119)
120
120
121/**
121/**
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.
123 */
127 */
124@Serializable
128@Serializable
125data class Alert(
129data class Alert(
126 val id: String? = null,
130 val id: String? = null,
127 val version: String? = null,
131 val version: String? = null,
132+ // Per-stream alert format uses "name"; global summary uses "title"
128 val name: String? = null,
133 val name: String? = null,
134+ val title: String? = null,
129 val message: String? = null,
135 val message: String? = null,
130 val rule: JsonObject? = null,
136 val rule: JsonObject? = null,
131 val targets: List<JsonObject> = emptyList(),
137 val targets: List<JsonObject> = emptyList(),
138+ // Per-stream alert format
132 val stream: String? = null,
139 val stream: String? = null,
133 val enabled: Boolean? = null,
140 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+}
135
171
136/**
172/**
137 * Result wrapper for API calls
173 * 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(
104 contentPadding = PaddingValues(16.dp),
104 contentPadding = PaddingValues(16.dp),
105 verticalArrangement = Arrangement.spacedBy(8.dp),
105 verticalArrangement = Arrangement.spacedBy(8.dp),
106 ) {
106 ) {
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 ->
108 AlertCard(
108 AlertCard(
109 alert = alert,
109 alert = alert,
110 onDelete = if (alert.id != null) {
110 onDelete = if (alert.id != null) {
@@ -124,7 +124,7 @@ fun AlertsScreen(
124 onDismissRequest = viewModel::cancelDelete,
124 onDismissRequest = viewModel::cancelDelete,
125 title = { Text("Delete Alert") },
125 title = { Text("Delete Alert") },
126 text = {
126 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.")
128 },
128 },
129 confirmButton = {
129 confirmButton = {
130 TextButton(
130 TextButton(
@@ -165,10 +165,10 @@ private fun AlertCard(alert: Alert, onDelete: (() -> Unit)?) {
165 modifier = Modifier.weight(1f),
165 modifier = Modifier.weight(1f),
166 ) {
166 ) {
167 Icon(
167 Icon(
168− if (alert.enabled == true) Icons.Filled.NotificationsActive
168+ if (alert.isEnabled) Icons.Filled.NotificationsActive
169 else Icons.Filled.NotificationsOff,
169 else Icons.Filled.NotificationsOff,
170− contentDescription = if (alert.enabled == true) "Alert enabled" else "Alert disabled",
170+ contentDescription = if (alert.isEnabled) "Alert enabled" else "Alert disabled",
171− tint = if (alert.enabled == true) {
171+ tint = if (alert.isEnabled) {
172 MaterialTheme.colorScheme.primary
172 MaterialTheme.colorScheme.primary
173 } else {
173 } else {
174 MaterialTheme.colorScheme.onSurfaceVariant
174 MaterialTheme.colorScheme.onSurfaceVariant
@@ -177,17 +177,20 @@ private fun AlertCard(alert: Alert, onDelete: (() -> Unit)?) {
177 )
177 )
178 Spacer(modifier = Modifier.width(8.dp))
178 Spacer(modifier = Modifier.width(8.dp))
179 Text(
179 Text(
180− text = alert.name ?: "Unnamed Alert",
180+ text = alert.displayName,
181 style = MaterialTheme.typography.titleMedium,
181 style = MaterialTheme.typography.titleMedium,
182 fontWeight = FontWeight.SemiBold,
182 fontWeight = FontWeight.SemiBold,
183 )
183 )
184 }
184 }
185 Row(verticalAlignment = Alignment.CenterVertically) {
185 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) {
187 Text(
190 Text(
188− text = if (alert.enabled) "Enabled" else "Disabled",
191+ text = stateLabel,
189 style = MaterialTheme.typography.labelSmall,
192 style = MaterialTheme.typography.labelSmall,
190− color = if (alert.enabled) {
193+ color = if (alert.isEnabled) {
191 MaterialTheme.colorScheme.primary
194 MaterialTheme.colorScheme.primary
192 } else {
195 } else {
193 MaterialTheme.colorScheme.onSurfaceVariant
196 MaterialTheme.colorScheme.onSurfaceVariant
@@ -220,10 +223,20 @@ private fun AlertCard(alert: Alert, onDelete: (() -> Unit)?) {
220 )
223 )
221 }
224 }
222
225
223− if (alert.stream != null) {
226+ val streamText = alert.streamDisplay
227+ if (streamText != null) {
224 Spacer(modifier = Modifier.height(4.dp))
228 Spacer(modifier = Modifier.height(4.dp))
225 Text(
229 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}",
227 style = MaterialTheme.typography.labelMedium,
240 style = MaterialTheme.typography.labelMedium,
228 color = MaterialTheme.colorScheme.onSurfaceVariant,
241 color = MaterialTheme.colorScheme.onSurfaceVariant,
229 )
242 )
@@ -234,7 +247,16 @@ private fun AlertCard(alert: Alert, onDelete: (() -> Unit)?) {
234 HorizontalDivider()
247 HorizontalDivider()
235 Spacer(modifier = Modifier.height(8.dp))
248 Spacer(modifier = Modifier.height(8.dp))
236
249
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+
237 if (alert.rule != null) {
258 if (alert.rule != null) {
259+ Spacer(modifier = Modifier.height(4.dp))
238 Text(
260 Text(
239 text = "Rule:",
261 text = "Rule:",
240 style = MaterialTheme.typography.labelMedium,
262 style = MaterialTheme.typography.labelMedium,
app/src/test/java/com/parseable/android/AlertsViewModelTest.kt+5−5
@@ -44,8 +44,8 @@ class AlertsViewModelTest {
44 @Test
44 @Test
45 fun `refresh loads alerts on success`() = runTest {
45 fun `refresh loads alerts on success`() = runTest {
46 val alerts = listOf(
46 val alerts = listOf(
47− Alert(id = "1", name = "Alert 1", stream = "stream1"),
47+ Alert(id = "1", title = "Alert 1", datasets = listOf("stream1"), state = "NotTriggered"),
48− Alert(id = "2", name = "Alert 2", stream = "stream2"),
48+ Alert(id = "2", title = "Alert 2", datasets = listOf("stream2"), state = "Triggered"),
49 )
49 )
50 coEvery { repository.listAlerts() } returns ApiResult.Success(alerts)
50 coEvery { repository.listAlerts() } returns ApiResult.Success(alerts)
51
51
@@ -71,7 +71,7 @@ class AlertsViewModelTest {
71
71
72 @Test
72 @Test
73 fun `requestDelete sets alertToDelete`() {
73 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"))
75
75
76 viewModel.requestDelete(alert)
76 viewModel.requestDelete(alert)
77
77
@@ -81,7 +81,7 @@ class AlertsViewModelTest {
81
81
82 @Test
82 @Test
83 fun `cancelDelete clears alertToDelete`() {
83 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"))
85 viewModel.requestDelete(alert)
85 viewModel.requestDelete(alert)
86 assertEquals(alert, viewModel.state.value.alertToDelete)
86 assertEquals(alert, viewModel.state.value.alertToDelete)
87
87
@@ -93,7 +93,7 @@ class AlertsViewModelTest {
93 @Test
93 @Test
94 fun `deleteAlert on success refreshes list`() = runTest {
94 fun `deleteAlert on success refreshes list`() = runTest {
95 val alerts = listOf(
95 val alerts = listOf(
96− Alert(id = "2", name = "Alert 2", stream = "stream2"),
96+ Alert(id = "2", title = "Alert 2", datasets = listOf("stream2")),
97 )
97 )
98 coEvery { repository.deleteAlert("1") } returns ApiResult.Success("deleted")
98 coEvery { repository.deleteAlert("1") } returns ApiResult.Success("deleted")
99 coEvery { repository.listAlerts() } returns ApiResult.Success(alerts)
99 coEvery { repository.listAlerts() } returns ApiResult.Success(alerts)