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:
@@ -6,33 +6,33 @@ import css from './NotificationBell.module.css';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* selected environment.
|
||||
*
|
||||
* 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.
|
||||
* inbox and renders a badge coloured by the highest unread severity
|
||||
* (CRITICAL > WARNING > INFO) — matches the sidebar SeverityBadge palette.
|
||||
*/
|
||||
export function NotificationBell() {
|
||||
const env = useSelectedEnv();
|
||||
const { data } = useUnreadCount();
|
||||
|
||||
const count = data?.count ?? 0;
|
||||
|
||||
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 (
|
||||
<Link
|
||||
to="/alerts/inbox"
|
||||
role="button"
|
||||
aria-label={`Notifications (${count} unread)`}
|
||||
aria-label={`Notifications (${total} unread)`}
|
||||
className={css.bell}
|
||||
>
|
||||
<Bell size={16} />
|
||||
{count > 0 && (
|
||||
<span className={css.badge} aria-hidden>
|
||||
{count > 99 ? '99+' : count}
|
||||
{total > 0 && (
|
||||
<span className={`${css.badge} ${severityClass}`} aria-hidden>
|
||||
{total > 99 ? '99+' : total}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
Reference in New Issue
Block a user