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

File diff suppressed because one or more lines are too long

View File

@@ -3257,7 +3257,10 @@ export interface components {
};
UnreadCountResponse: {
/** Format: int64 */
count?: number;
total?: number;
bySeverity?: {
[key: string]: number;
};
};
/** @description Agent instance summary with runtime metrics */
AgentInstanceResponse: {

View File

@@ -18,10 +18,12 @@
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--error);
color: var(--bg);
font-size: 10px;
font-weight: 600;
line-height: 16px;
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', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -26,22 +33,37 @@ describe('NotificationBell', () => {
});
it('renders bell with no badge when zero unread', async () => {
(apiClient.GET as any).mockResolvedValue({
data: { count: 0 },
error: null,
});
mockResponse(0, { CRITICAL: 0, WARNING: 0, INFO: 0 });
render(<NotificationBell />, { wrapper });
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();
});
it('shows unread count badge when unread alerts exist', async () => {
(apiClient.GET as any).mockResolvedValue({
data: { count: 3 },
error: null,
});
it('shows unread total in badge', async () => {
mockResponse(3, { CRITICAL: 1, WARNING: 2, INFO: 0 });
render(<NotificationBell />, { wrapper });
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
* 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>