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:
hsiegeln
2026-04-20 18:15:56 +02:00
parent 18cacb33ee
commit 09b49f096c
12 changed files with 248 additions and 96 deletions

View File

@@ -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}")

View File

@@ -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);
}
}

View File

@@ -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;
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@@ -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

View File

@@ -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;
} }
} }

View File

@@ -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();

View File

@@ -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

View File

@@ -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: {

View File

@@ -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); }

View File

@@ -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/);
});
}); });

View File

@@ -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>