feat(alerting): per-severity breakdown on unread-count DTO
Spec §13 calls for the notification bell to colour-code by highest
unread severity (CRITICAL → error, WARNING → amber, INFO → muted).
The old { count } DTO forced the UI to pick one static colour, so
NotificationBell shipped with a TODO. Grow the contract instead:
UnreadCountResponse = { total, bySeverity: { CRITICAL, WARNING, INFO } }
Guarantees:
- every severity is always present with a >=0 value (no undefined
keys on the wire), so the UI can branch without defaults.
- total = sum of bySeverity values — kept explicit on the wire for
cheap top-line display, not recomputed client-side.
Backend
- AlertInstanceRepository: replaces countUnreadForUser(long) with
countUnreadBySeverityForUser returning Map<AlertSeverity, Long>.
One SQL round-trip per (env, user) — GROUP BY ai.severity over the
same NOT EXISTS(alert_reads) filter.
- UnreadCountResponse.from(Map) normalises and defensively copies;
missing severities default to 0.
- InAppInboxQuery.countUnread now returns the DTO, caches the full
response (still 5s TTL) so severity breakdown gets the same
hit-rate as the total did before.
- AlertController just hands the DTO back.
Breaking change — no backwards-compat shim: the `count` field is
gone. UI and tests updated in the same commit; there are no other
API consumers in the tree.
Frontend
- Regenerated openapi.json + schema.d.ts against a fresh build of
the new backend.
- NotificationBell branches badge colour on the highest unread
severity (CRITICAL > WARNING > INFO) via new CSS variants.
- Tests cover all four paths: zero, critical-present, warning-only,
info-only.
Tests: 7 unit tests + 12 ITs (incl. new grouping + empty-map)
+ 49 vitest (was 46; +3 severity-branch assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -63,9 +63,7 @@ public class AlertController {
|
|||||||
|
|
||||||
@GetMapping("/unread-count")
|
@GetMapping("/unread-count")
|
||||||
public UnreadCountResponse unreadCount(@EnvPath Environment env) {
|
public UnreadCountResponse unreadCount(@EnvPath Environment env) {
|
||||||
String userId = currentUserId();
|
return inboxQuery.countUnread(env.id(), currentUserId());
|
||||||
long count = inboxQuery.countUnread(env.id(), userId);
|
|
||||||
return new UnreadCountResponse(count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
|
|||||||
@@ -1,3 +1,29 @@
|
|||||||
package com.cameleer.server.app.alerting.dto;
|
package com.cameleer.server.app.alerting.dto;
|
||||||
|
|
||||||
public record UnreadCountResponse(long count) {}
|
import com.cameleer.server.core.alerting.AlertSeverity;
|
||||||
|
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response shape for {@code GET /alerts/unread-count}.
|
||||||
|
* <p>
|
||||||
|
* {@code total} is the sum of {@code bySeverity} values. The UI branches bell colour on
|
||||||
|
* the highest severity present, so callers can inspect the map directly.
|
||||||
|
*/
|
||||||
|
public record UnreadCountResponse(long total, Map<AlertSeverity, Long> bySeverity) {
|
||||||
|
|
||||||
|
public UnreadCountResponse {
|
||||||
|
// Defensive copy + fill in missing severities as 0 so the UI never sees null/undefined.
|
||||||
|
EnumMap<AlertSeverity, Long> normalized = new EnumMap<>(AlertSeverity.class);
|
||||||
|
for (AlertSeverity s : AlertSeverity.values()) normalized.put(s, 0L);
|
||||||
|
if (bySeverity != null) bySeverity.forEach((k, v) -> normalized.put(k, v == null ? 0L : v));
|
||||||
|
bySeverity = Map.copyOf(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static UnreadCountResponse from(Map<AlertSeverity, Long> counts) {
|
||||||
|
long total = counts == null ? 0L
|
||||||
|
: counts.values().stream().filter(v -> v != null).mapToLong(Long::longValue).sum();
|
||||||
|
return new UnreadCountResponse(total, counts == null ? Map.of() : counts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.cameleer.server.app.alerting.notify;
|
package com.cameleer.server.app.alerting.notify;
|
||||||
|
|
||||||
|
import com.cameleer.server.app.alerting.dto.UnreadCountResponse;
|
||||||
import com.cameleer.server.core.alerting.AlertInstance;
|
import com.cameleer.server.core.alerting.AlertInstance;
|
||||||
import com.cameleer.server.core.alerting.AlertInstanceRepository;
|
import com.cameleer.server.core.alerting.AlertInstanceRepository;
|
||||||
|
import com.cameleer.server.core.alerting.AlertSeverity;
|
||||||
import com.cameleer.server.core.rbac.RbacService;
|
import com.cameleer.server.core.rbac.RbacService;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@@ -17,7 +19,8 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
* <p>
|
* <p>
|
||||||
* {@link #listInbox} returns alerts the user is allowed to see (targeted directly or via group/role).
|
* {@link #listInbox} returns alerts the user is allowed to see (targeted directly or via group/role).
|
||||||
* {@link #countUnread} is memoized per {@code (envId, userId)} for 5 seconds to avoid hammering
|
* {@link #countUnread} is memoized per {@code (envId, userId)} for 5 seconds to avoid hammering
|
||||||
* the database on every page render.
|
* the database on every page render. The memo caches the full per-severity breakdown so
|
||||||
|
* the UI can branch bell colour on the highest unread severity without a second call.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class InAppInboxQuery {
|
public class InAppInboxQuery {
|
||||||
@@ -31,8 +34,8 @@ public class InAppInboxQuery {
|
|||||||
/** Cache key for the unread count memo. */
|
/** Cache key for the unread count memo. */
|
||||||
private record Key(UUID envId, String userId) {}
|
private record Key(UUID envId, String userId) {}
|
||||||
|
|
||||||
/** Cache entry: cached count + expiry timestamp. */
|
/** Cache entry: cached response + expiry timestamp. */
|
||||||
private record Entry(long count, Instant expiresAt) {}
|
private record Entry(UnreadCountResponse response, Instant expiresAt) {}
|
||||||
|
|
||||||
private final ConcurrentHashMap<Key, Entry> memo = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<Key, Entry> memo = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
@@ -57,20 +60,21 @@ public class InAppInboxQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the count of unread (un-acked) alert instances visible to the user.
|
* Returns the unread (un-acked) alert count for the user, broken down by severity.
|
||||||
* <p>
|
* <p>
|
||||||
* The result is memoized for 5 seconds per {@code (envId, userId)}.
|
* Memoized for 5 seconds per {@code (envId, userId)}.
|
||||||
*/
|
*/
|
||||||
public long countUnread(UUID envId, String userId) {
|
public UnreadCountResponse countUnread(UUID envId, String userId) {
|
||||||
Key key = new Key(envId, userId);
|
Key key = new Key(envId, userId);
|
||||||
Instant now = Instant.now(clock);
|
Instant now = Instant.now(clock);
|
||||||
Entry cached = memo.get(key);
|
Entry cached = memo.get(key);
|
||||||
if (cached != null && now.isBefore(cached.expiresAt())) {
|
if (cached != null && now.isBefore(cached.expiresAt())) {
|
||||||
return cached.count();
|
return cached.response();
|
||||||
}
|
}
|
||||||
long count = instanceRepo.countUnreadForUser(envId, userId);
|
Map<AlertSeverity, Long> bySeverity = instanceRepo.countUnreadBySeverityForUser(envId, userId);
|
||||||
memo.put(key, new Entry(count, now.plusMillis(MEMO_TTL_MS)));
|
UnreadCountResponse response = UnreadCountResponse.from(bySeverity);
|
||||||
return count;
|
memo.put(key, new Entry(response, now.plusMillis(MEMO_TTL_MS)));
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -118,18 +118,24 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public long countUnreadForUser(UUID environmentId, String userId) {
|
public Map<AlertSeverity, Long> countUnreadBySeverityForUser(UUID environmentId, String userId) {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT COUNT(*) FROM alert_instances ai
|
SELECT ai.severity::text AS severity, COUNT(*) AS cnt
|
||||||
|
FROM alert_instances ai
|
||||||
WHERE ai.environment_id = ?
|
WHERE ai.environment_id = ?
|
||||||
AND ? = ANY(ai.target_user_ids)
|
AND ? = ANY(ai.target_user_ids)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM alert_reads ar
|
SELECT 1 FROM alert_reads ar
|
||||||
WHERE ar.user_id = ? AND ar.alert_instance_id = ai.id
|
WHERE ar.user_id = ? AND ar.alert_instance_id = ai.id
|
||||||
)
|
)
|
||||||
|
GROUP BY ai.severity
|
||||||
""";
|
""";
|
||||||
Long count = jdbc.queryForObject(sql, Long.class, environmentId, userId, userId);
|
EnumMap<AlertSeverity, Long> counts = new EnumMap<>(AlertSeverity.class);
|
||||||
return count == null ? 0L : count;
|
for (AlertSeverity s : AlertSeverity.values()) counts.put(s, 0L);
|
||||||
|
jdbc.query(sql, rs -> {
|
||||||
|
counts.put(AlertSeverity.valueOf(rs.getString("severity")), rs.getLong("cnt"));
|
||||||
|
}, environmentId, userId, userId);
|
||||||
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.cameleer.server.app.alerting.notify;
|
package com.cameleer.server.app.alerting.notify;
|
||||||
|
|
||||||
|
import com.cameleer.server.app.alerting.dto.UnreadCountResponse;
|
||||||
import com.cameleer.server.core.alerting.AlertInstance;
|
import com.cameleer.server.core.alerting.AlertInstance;
|
||||||
import com.cameleer.server.core.alerting.AlertInstanceRepository;
|
import com.cameleer.server.core.alerting.AlertInstanceRepository;
|
||||||
|
import com.cameleer.server.core.alerting.AlertSeverity;
|
||||||
import com.cameleer.server.core.rbac.GroupSummary;
|
import com.cameleer.server.core.rbac.GroupSummary;
|
||||||
import com.cameleer.server.core.rbac.RbacService;
|
import com.cameleer.server.core.rbac.RbacService;
|
||||||
import com.cameleer.server.core.rbac.RoleSummary;
|
import com.cameleer.server.core.rbac.RoleSummary;
|
||||||
@@ -14,7 +16,9 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
|||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.EnumMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
@@ -45,7 +49,6 @@ class InAppInboxQueryTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
// Build a Clock that delegates to the atomic counter so we can advance time precisely
|
|
||||||
tickableClock = new Clock() {
|
tickableClock = new Clock() {
|
||||||
@Override public ZoneOffset getZone() { return ZoneOffset.UTC; }
|
@Override public ZoneOffset getZone() { return ZoneOffset.UTC; }
|
||||||
@Override public Clock withZone(java.time.ZoneId zone) { return this; }
|
@Override public Clock withZone(java.time.ZoneId zone) { return this; }
|
||||||
@@ -54,8 +57,6 @@ class InAppInboxQueryTest {
|
|||||||
|
|
||||||
query = new InAppInboxQuery(instanceRepo, rbacService, tickableClock);
|
query = new InAppInboxQuery(instanceRepo, rbacService, tickableClock);
|
||||||
|
|
||||||
// RbacService stubs: return no groups/roles by default.
|
|
||||||
// Lenient: countUnread tests don't invoke listInbox → stubs would otherwise be flagged unused.
|
|
||||||
lenient().when(rbacService.getEffectiveGroupsForUser(anyString())).thenReturn(List.of());
|
lenient().when(rbacService.getEffectiveGroupsForUser(anyString())).thenReturn(List.of());
|
||||||
lenient().when(rbacService.getEffectiveRolesForUser(anyString())).thenReturn(List.of());
|
lenient().when(rbacService.getEffectiveRolesForUser(anyString())).thenReturn(List.of());
|
||||||
}
|
}
|
||||||
@@ -83,75 +84,107 @@ class InAppInboxQueryTest {
|
|||||||
USER_ID, List.of("OPERATOR"), 20);
|
USER_ID, List.of("OPERATOR"), 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// countUnread — bySeverity shape
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countUnread_totalIsSumOfBySeverityValues() {
|
||||||
|
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, USER_ID))
|
||||||
|
.thenReturn(severities(4L, 2L, 1L));
|
||||||
|
|
||||||
|
UnreadCountResponse response = query.countUnread(ENV_ID, USER_ID);
|
||||||
|
|
||||||
|
assertThat(response.total()).isEqualTo(7L);
|
||||||
|
assertThat(response.bySeverity())
|
||||||
|
.containsEntry(AlertSeverity.CRITICAL, 4L)
|
||||||
|
.containsEntry(AlertSeverity.WARNING, 2L)
|
||||||
|
.containsEntry(AlertSeverity.INFO, 1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countUnread_fillsMissingSeveritiesWithZero() {
|
||||||
|
// Repository returns only CRITICAL — WARNING/INFO must default to 0.
|
||||||
|
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, USER_ID))
|
||||||
|
.thenReturn(Map.of(AlertSeverity.CRITICAL, 3L));
|
||||||
|
|
||||||
|
UnreadCountResponse response = query.countUnread(ENV_ID, USER_ID);
|
||||||
|
|
||||||
|
assertThat(response.total()).isEqualTo(3L);
|
||||||
|
assertThat(response.bySeverity())
|
||||||
|
.containsEntry(AlertSeverity.CRITICAL, 3L)
|
||||||
|
.containsEntry(AlertSeverity.WARNING, 0L)
|
||||||
|
.containsEntry(AlertSeverity.INFO, 0L);
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// countUnread — memoization
|
// countUnread — memoization
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
|
||||||
void countUnread_firstCallHitsRepository() {
|
|
||||||
when(instanceRepo.countUnreadForUser(ENV_ID, USER_ID)).thenReturn(7L);
|
|
||||||
|
|
||||||
long count = query.countUnread(ENV_ID, USER_ID);
|
|
||||||
|
|
||||||
assertThat(count).isEqualTo(7L);
|
|
||||||
verify(instanceRepo, times(1)).countUnreadForUser(ENV_ID, USER_ID);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void countUnread_secondCallWithin5sUsesCache() {
|
void countUnread_secondCallWithin5sUsesCache() {
|
||||||
when(instanceRepo.countUnreadForUser(ENV_ID, USER_ID)).thenReturn(5L);
|
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, USER_ID))
|
||||||
|
.thenReturn(severities(1L, 2L, 2L));
|
||||||
|
|
||||||
long first = query.countUnread(ENV_ID, USER_ID);
|
UnreadCountResponse first = query.countUnread(ENV_ID, USER_ID);
|
||||||
// Advance time by 4 seconds — still within TTL
|
|
||||||
nowMillis.addAndGet(4_000L);
|
nowMillis.addAndGet(4_000L);
|
||||||
long second = query.countUnread(ENV_ID, USER_ID);
|
UnreadCountResponse second = query.countUnread(ENV_ID, USER_ID);
|
||||||
|
|
||||||
assertThat(first).isEqualTo(5L);
|
assertThat(first.total()).isEqualTo(5L);
|
||||||
assertThat(second).isEqualTo(5L);
|
assertThat(second.total()).isEqualTo(5L);
|
||||||
// Repository must have been called exactly once
|
verify(instanceRepo, times(1)).countUnreadBySeverityForUser(ENV_ID, USER_ID);
|
||||||
verify(instanceRepo, times(1)).countUnreadForUser(ENV_ID, USER_ID);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void countUnread_callAfter5sRefreshesCache() {
|
void countUnread_callAfter5sRefreshesCache() {
|
||||||
when(instanceRepo.countUnreadForUser(ENV_ID, USER_ID))
|
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, USER_ID))
|
||||||
.thenReturn(3L) // first call
|
.thenReturn(severities(1L, 1L, 1L)) // first call — total 3
|
||||||
.thenReturn(9L); // after cache expires
|
.thenReturn(severities(4L, 3L, 2L)); // after TTL — total 9
|
||||||
|
|
||||||
long first = query.countUnread(ENV_ID, USER_ID);
|
UnreadCountResponse first = query.countUnread(ENV_ID, USER_ID);
|
||||||
|
|
||||||
// Advance by exactly 5001 ms — TTL expired
|
|
||||||
nowMillis.addAndGet(5_001L);
|
nowMillis.addAndGet(5_001L);
|
||||||
long third = query.countUnread(ENV_ID, USER_ID);
|
UnreadCountResponse third = query.countUnread(ENV_ID, USER_ID);
|
||||||
|
|
||||||
assertThat(first).isEqualTo(3L);
|
assertThat(first.total()).isEqualTo(3L);
|
||||||
assertThat(third).isEqualTo(9L);
|
assertThat(third.total()).isEqualTo(9L);
|
||||||
// Repository called twice: once on cold-miss, once after TTL expiry
|
verify(instanceRepo, times(2)).countUnreadBySeverityForUser(ENV_ID, USER_ID);
|
||||||
verify(instanceRepo, times(2)).countUnreadForUser(ENV_ID, USER_ID);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void countUnread_differentUsersDontShareCache() {
|
void countUnread_differentUsersDontShareCache() {
|
||||||
when(instanceRepo.countUnreadForUser(ENV_ID, "alice")).thenReturn(2L);
|
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, "alice"))
|
||||||
when(instanceRepo.countUnreadForUser(ENV_ID, "bob")).thenReturn(8L);
|
.thenReturn(severities(0L, 1L, 1L));
|
||||||
|
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, "bob"))
|
||||||
|
.thenReturn(severities(2L, 2L, 4L));
|
||||||
|
|
||||||
long alice = query.countUnread(ENV_ID, "alice");
|
assertThat(query.countUnread(ENV_ID, "alice").total()).isEqualTo(2L);
|
||||||
long bob = query.countUnread(ENV_ID, "bob");
|
assertThat(query.countUnread(ENV_ID, "bob").total()).isEqualTo(8L);
|
||||||
|
verify(instanceRepo).countUnreadBySeverityForUser(ENV_ID, "alice");
|
||||||
assertThat(alice).isEqualTo(2L);
|
verify(instanceRepo).countUnreadBySeverityForUser(ENV_ID, "bob");
|
||||||
assertThat(bob).isEqualTo(8L);
|
|
||||||
verify(instanceRepo).countUnreadForUser(ENV_ID, "alice");
|
|
||||||
verify(instanceRepo).countUnreadForUser(ENV_ID, "bob");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void countUnread_differentEnvsDontShareCache() {
|
void countUnread_differentEnvsDontShareCache() {
|
||||||
UUID envA = UUID.randomUUID();
|
UUID envA = UUID.randomUUID();
|
||||||
UUID envB = UUID.randomUUID();
|
UUID envB = UUID.randomUUID();
|
||||||
when(instanceRepo.countUnreadForUser(envA, USER_ID)).thenReturn(1L);
|
when(instanceRepo.countUnreadBySeverityForUser(envA, USER_ID))
|
||||||
when(instanceRepo.countUnreadForUser(envB, USER_ID)).thenReturn(4L);
|
.thenReturn(severities(0L, 0L, 1L));
|
||||||
|
when(instanceRepo.countUnreadBySeverityForUser(envB, USER_ID))
|
||||||
|
.thenReturn(severities(1L, 1L, 2L));
|
||||||
|
|
||||||
assertThat(query.countUnread(envA, USER_ID)).isEqualTo(1L);
|
assertThat(query.countUnread(envA, USER_ID).total()).isEqualTo(1L);
|
||||||
assertThat(query.countUnread(envB, USER_ID)).isEqualTo(4L);
|
assertThat(query.countUnread(envB, USER_ID).total()).isEqualTo(4L);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static Map<AlertSeverity, Long> severities(long critical, long warning, long info) {
|
||||||
|
EnumMap<AlertSeverity, Long> m = new EnumMap<>(AlertSeverity.class);
|
||||||
|
m.put(AlertSeverity.CRITICAL, critical);
|
||||||
|
m.put(AlertSeverity.WARNING, warning);
|
||||||
|
m.put(AlertSeverity.INFO, info);
|
||||||
|
return m;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,20 +108,51 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void countUnreadForUser_decreasesAfterMarkRead() {
|
void countUnreadBySeverityForUser_decreasesAfterMarkRead() {
|
||||||
var inst = newInstance(ruleId, List.of(userId), List.of(), List.of());
|
var inst = newInstance(ruleId, List.of(userId), List.of(), List.of());
|
||||||
repo.save(inst);
|
repo.save(inst);
|
||||||
|
|
||||||
long before = repo.countUnreadForUser(envId, userId);
|
var before = repo.countUnreadBySeverityForUser(envId, userId);
|
||||||
assertThat(before).isEqualTo(1L);
|
assertThat(before)
|
||||||
|
.containsEntry(AlertSeverity.WARNING, 1L)
|
||||||
|
.containsEntry(AlertSeverity.CRITICAL, 0L)
|
||||||
|
.containsEntry(AlertSeverity.INFO, 0L);
|
||||||
|
|
||||||
// Insert read record directly (AlertReadRepository not yet wired in this test)
|
// Insert read record directly (AlertReadRepository not yet wired in this test)
|
||||||
jdbcTemplate.update(
|
jdbcTemplate.update(
|
||||||
"INSERT INTO alert_reads (user_id, alert_instance_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
|
"INSERT INTO alert_reads (user_id, alert_instance_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
|
||||||
userId, inst.id());
|
userId, inst.id());
|
||||||
|
|
||||||
long after = repo.countUnreadForUser(envId, userId);
|
var after = repo.countUnreadBySeverityForUser(envId, userId);
|
||||||
assertThat(after).isEqualTo(0L);
|
assertThat(after.values()).allMatch(v -> v == 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countUnreadBySeverityForUser_groupsBySeverity() {
|
||||||
|
// Each open instance needs its own rule to satisfy V13's unique partial index.
|
||||||
|
UUID critRule = seedRuleWithSeverity("crit", AlertSeverity.CRITICAL);
|
||||||
|
UUID warnRule = seedRuleWithSeverity("warn", AlertSeverity.WARNING);
|
||||||
|
UUID infoRule = seedRuleWithSeverity("info", AlertSeverity.INFO);
|
||||||
|
|
||||||
|
repo.save(newInstance(critRule, AlertSeverity.CRITICAL, List.of(userId), List.of(), List.of()));
|
||||||
|
repo.save(newInstance(warnRule, AlertSeverity.WARNING, List.of(userId), List.of(), List.of()));
|
||||||
|
repo.save(newInstance(infoRule, AlertSeverity.INFO, List.of(userId), List.of(), List.of()));
|
||||||
|
|
||||||
|
var counts = repo.countUnreadBySeverityForUser(envId, userId);
|
||||||
|
|
||||||
|
assertThat(counts)
|
||||||
|
.containsEntry(AlertSeverity.CRITICAL, 1L)
|
||||||
|
.containsEntry(AlertSeverity.WARNING, 1L)
|
||||||
|
.containsEntry(AlertSeverity.INFO, 1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countUnreadBySeverityForUser_emptyMapStillHasAllKeys() {
|
||||||
|
// No instances saved — every severity must still be present with value 0
|
||||||
|
// so callers never deal with null/missing keys.
|
||||||
|
var counts = repo.countUnreadBySeverityForUser(envId, userId);
|
||||||
|
assertThat(counts).hasSize(3);
|
||||||
|
assertThat(counts.values()).allMatch(v -> v == 0L);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -228,15 +259,34 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
|
|||||||
List<String> userIds,
|
List<String> userIds,
|
||||||
List<UUID> groupIds,
|
List<UUID> groupIds,
|
||||||
List<String> roleNames) {
|
List<String> roleNames) {
|
||||||
|
return newInstance(ruleId, AlertSeverity.WARNING, userIds, groupIds, roleNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AlertInstance newInstance(UUID ruleId,
|
||||||
|
AlertSeverity severity,
|
||||||
|
List<String> userIds,
|
||||||
|
List<UUID> groupIds,
|
||||||
|
List<String> roleNames) {
|
||||||
return new AlertInstance(
|
return new AlertInstance(
|
||||||
UUID.randomUUID(), ruleId, Map.of(), envId,
|
UUID.randomUUID(), ruleId, Map.of(), envId,
|
||||||
AlertState.FIRING, AlertSeverity.WARNING,
|
AlertState.FIRING, severity,
|
||||||
Instant.now(), null, null, null, null,
|
Instant.now(), null, null, null, null,
|
||||||
false, null, null,
|
false, null, null,
|
||||||
Map.of(), "title", "message",
|
Map.of(), "title", "message",
|
||||||
userIds, groupIds, roleNames);
|
userIds, groupIds, roleNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Inserts a minimal alert_rule with the given severity. */
|
||||||
|
private UUID seedRuleWithSeverity(String name, AlertSeverity severity) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO alert_rules (id, environment_id, name, severity, condition_kind, condition, " +
|
||||||
|
"notification_title_tmpl, notification_message_tmpl, created_by, updated_by) " +
|
||||||
|
"VALUES (?, ?, ?, ?::severity_enum, 'AGENT_STATE', '{}'::jsonb, 't', 'm', 'sys-user', 'sys-user')",
|
||||||
|
id, envId, name + "-" + id, severity.name());
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
/** Inserts a minimal alert_rule with re_notify_minutes=0 and returns its id. */
|
/** Inserts a minimal alert_rule with re_notify_minutes=0 and returns its id. */
|
||||||
private UUID seedRule(String name) {
|
private UUID seedRule(String name) {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.cameleer.server.core.alerting;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -14,7 +15,14 @@ public interface AlertInstanceRepository {
|
|||||||
String userId,
|
String userId,
|
||||||
List<String> userRoleNames,
|
List<String> userRoleNames,
|
||||||
int limit);
|
int limit);
|
||||||
long countUnreadForUser(UUID environmentId, String userId);
|
|
||||||
|
/**
|
||||||
|
* Count unread alert instances for the user, grouped by severity.
|
||||||
|
* <p>
|
||||||
|
* Always returns a map with an entry for every {@link AlertSeverity} (value 0 if no rows),
|
||||||
|
* so callers never need null-checks. Total unread count is the sum of the values.
|
||||||
|
*/
|
||||||
|
Map<AlertSeverity, Long> countUnreadBySeverityForUser(UUID environmentId, String userId);
|
||||||
void ack(UUID id, String userId, Instant when);
|
void ack(UUID id, String userId, Instant when);
|
||||||
void resolve(UUID id, Instant when);
|
void resolve(UUID id, Instant when);
|
||||||
void markSilenced(UUID id, boolean silenced);
|
void markSilenced(UUID id, boolean silenced);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
5
ui/src/api/schema.d.ts
vendored
5
ui/src/api/schema.d.ts
vendored
@@ -3257,7 +3257,10 @@ export interface components {
|
|||||||
};
|
};
|
||||||
UnreadCountResponse: {
|
UnreadCountResponse: {
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
count?: number;
|
total?: number;
|
||||||
|
bySeverity?: {
|
||||||
|
[key: string]: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
/** @description Agent instance summary with runtime metrics */
|
/** @description Agent instance summary with runtime metrics */
|
||||||
AgentInstanceResponse: {
|
AgentInstanceResponse: {
|
||||||
|
|||||||
@@ -18,10 +18,12 @@
|
|||||||
height: 16px;
|
height: 16px;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--error);
|
|
||||||
color: var(--bg);
|
color: var(--bg);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.badgeCritical { background: var(--error); }
|
||||||
|
.badgeWarning { background: var(--amber); }
|
||||||
|
.badgeInfo { background: var(--muted); }
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ function wrapper({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mockResponse(total: number, bySeverity: Record<string, number> = {}) {
|
||||||
|
(apiClient.GET as any).mockResolvedValue({
|
||||||
|
data: { total, bySeverity },
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe('NotificationBell', () => {
|
describe('NotificationBell', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -26,22 +33,37 @@ describe('NotificationBell', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders bell with no badge when zero unread', async () => {
|
it('renders bell with no badge when zero unread', async () => {
|
||||||
(apiClient.GET as any).mockResolvedValue({
|
mockResponse(0, { CRITICAL: 0, WARNING: 0, INFO: 0 });
|
||||||
data: { count: 0 },
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
render(<NotificationBell />, { wrapper });
|
render(<NotificationBell />, { wrapper });
|
||||||
expect(await screen.findByRole('button', { name: /notifications/i })).toBeInTheDocument();
|
expect(await screen.findByRole('button', { name: /notifications/i })).toBeInTheDocument();
|
||||||
// Badge is only rendered when count > 0; no numeric text should appear.
|
|
||||||
expect(screen.queryByText(/^\d+$/)).toBeNull();
|
expect(screen.queryByText(/^\d+$/)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows unread count badge when unread alerts exist', async () => {
|
it('shows unread total in badge', async () => {
|
||||||
(apiClient.GET as any).mockResolvedValue({
|
mockResponse(3, { CRITICAL: 1, WARNING: 2, INFO: 0 });
|
||||||
data: { count: 3 },
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
render(<NotificationBell />, { wrapper });
|
render(<NotificationBell />, { wrapper });
|
||||||
expect(await screen.findByText('3')).toBeInTheDocument();
|
expect(await screen.findByText('3')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('colours badge as CRITICAL when any critical unread present', async () => {
|
||||||
|
mockResponse(5, { CRITICAL: 1, WARNING: 4, INFO: 0 });
|
||||||
|
render(<NotificationBell />, { wrapper });
|
||||||
|
const badge = await screen.findByText('5');
|
||||||
|
expect(badge.className).toMatch(/badgeCritical/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('colours badge as WARNING when only warnings+info unread', async () => {
|
||||||
|
mockResponse(3, { CRITICAL: 0, WARNING: 2, INFO: 1 });
|
||||||
|
render(<NotificationBell />, { wrapper });
|
||||||
|
const badge = await screen.findByText('3');
|
||||||
|
expect(badge.className).toMatch(/badgeWarning/);
|
||||||
|
expect(badge.className).not.toMatch(/badgeCritical/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('colours badge as INFO when only info unread', async () => {
|
||||||
|
mockResponse(2, { CRITICAL: 0, WARNING: 0, INFO: 2 });
|
||||||
|
render(<NotificationBell />, { wrapper });
|
||||||
|
const badge = await screen.findByText('2');
|
||||||
|
expect(badge.className).toMatch(/badgeInfo/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,33 +6,33 @@ import css from './NotificationBell.module.css';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Global notification bell shown in the layout header. Links to the alerts
|
* Global notification bell shown in the layout header. Links to the alerts
|
||||||
* inbox and renders a badge with the unread-alert count for the currently
|
* inbox and renders a badge coloured by the highest unread severity
|
||||||
* selected environment.
|
* (CRITICAL > WARNING > INFO) — matches the sidebar SeverityBadge palette.
|
||||||
*
|
|
||||||
* Polling pause when the tab is hidden is handled by `useUnreadCount`'s
|
|
||||||
* `refetchIntervalInBackground: false`; no separate visibility subscription
|
|
||||||
* is needed. If per-severity coloring (spec §13) is re-introduced, the
|
|
||||||
* backend `UnreadCountResponse` must grow a `bySeverity` map.
|
|
||||||
*/
|
*/
|
||||||
export function NotificationBell() {
|
export function NotificationBell() {
|
||||||
const env = useSelectedEnv();
|
const env = useSelectedEnv();
|
||||||
const { data } = useUnreadCount();
|
const { data } = useUnreadCount();
|
||||||
|
|
||||||
const count = data?.count ?? 0;
|
|
||||||
|
|
||||||
if (!env) return null;
|
if (!env) return null;
|
||||||
|
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const bySeverity = data?.bySeverity ?? {};
|
||||||
|
const severityClass =
|
||||||
|
(bySeverity.CRITICAL ?? 0) > 0 ? css.badgeCritical
|
||||||
|
: (bySeverity.WARNING ?? 0) > 0 ? css.badgeWarning
|
||||||
|
: css.badgeInfo;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to="/alerts/inbox"
|
to="/alerts/inbox"
|
||||||
role="button"
|
role="button"
|
||||||
aria-label={`Notifications (${count} unread)`}
|
aria-label={`Notifications (${total} unread)`}
|
||||||
className={css.bell}
|
className={css.bell}
|
||||||
>
|
>
|
||||||
<Bell size={16} />
|
<Bell size={16} />
|
||||||
{count > 0 && (
|
{total > 0 && (
|
||||||
<span className={css.badge} aria-hidden>
|
<span className={`${css.badge} ${severityClass}`} aria-hidden>
|
||||||
{count > 99 ? '99+' : count}
|
{total > 99 ? '99+' : total}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
Reference in New Issue
Block a user