diff --git a/docs/memory/feature-flows/events-page.md b/docs/memory/feature-flows/events-page.md index bbb53515a..901aff1d0 100644 --- a/docs/memory/feature-flows/events-page.md +++ b/docs/memory/feature-flows/events-page.md @@ -29,7 +29,7 @@ As a **platform user**, I want to see all notifications from my agents in one pl **Template Structure**: - Header with title and refresh button (lines 8-26) - Filter controls panel (lines 29-117): agent, type, priority, status dropdowns + show dismissed checkbox -- Stats cards grid (lines 120-137): pending (red), acknowledged (green), total, agents count (blue) +- Stats cards grid: pending (red — the global count from `GET /api/notifications/count`, independent of the list filter and page, #3449), acknowledged (green), shown (the rows rendered below), agents count (blue) - Bulk actions bar (lines 140-164): shown when items selected - Notifications list (lines 167-320): cards with checkbox, icon, content, actions - Load more button (lines 311-319): pagination @@ -61,7 +61,7 @@ As a **platform user**, I want to see all notifications from my agents in one pl | State | Type | Description | |-------|------|-------------| | `notifications` | Array | Loaded notifications | -| `pendingCount` | Number | Count of pending notifications (for badge) | +| `pendingCount` | Number | Global pending count (badges, Operations header). Server figure from `fetchPendingCount()`; never derived from the loaded list (#3449) | | `loading` | Boolean | Loading state | | `error` | String | Error message | | `totalCount` | Number | Total notification count from API | diff --git a/src/frontend/src/components/operator/NotificationsPanel.vue b/src/frontend/src/components/operator/NotificationsPanel.vue index 05bc72018..24b70d872 100644 --- a/src/frontend/src/components/operator/NotificationsPanel.vue +++ b/src/frontend/src/components/operator/NotificationsPanel.vue @@ -93,7 +93,9 @@
-
+ +
{{ notificationsStore.pendingCount }}
Pending
@@ -101,9 +103,9 @@
{{ acknowledgedCount }}
Acknowledged
-
-
{{ notificationsStore.totalCount }}
-
Total
+
+
{{ displayedNotifications.length }}
+
Shown
{{ Object.keys(notificationsStore.agentCounts).length }}
diff --git a/src/frontend/src/stores/notifications.js b/src/frontend/src/stores/notifications.js index 173932dc7..18407643f 100644 --- a/src/frontend/src/stores/notifications.js +++ b/src/frontend/src/stores/notifications.js @@ -14,6 +14,10 @@ import { readStoredToken } from '../utils/platformSession' export const useNotificationsStore = defineStore('notifications', () => { // State const notifications = ref([]) + // #3449: the GLOBAL pending count — every accessible agent, every page. It is + // a server figure (fetchPendingCount) and is never derived from + // `notifications`, which is one filtered, page-capped slice: the Operations + // header, the tab badge and the NavBar badge all read this. const pendingCount = ref(0) const loading = ref(false) const error = ref(null) @@ -110,9 +114,11 @@ export const useNotificationsStore = defineStore('notifications', () => { totalCount.value = response.data.count || 0 hasMore.value = notifications.value.length < totalCount.value - // Update pending count from filtered results - updatePendingCount() hasLoaded.value = true + // #3449: the list never writes pendingCount. Re-read the true count + // beside a first-page load so the badge and the rows are of one moment; + // not awaited — the list must not wait on, or fail with, the badge. + if (offset === 0) fetchPendingCount() } catch (err) { console.error('Failed to fetch notifications:', err) error.value = err.response?.data?.detail || 'Failed to load notifications' @@ -135,10 +141,6 @@ export const useNotificationsStore = defineStore('notifications', () => { } } - function updatePendingCount() { - pendingCount.value = notifications.value.filter(n => n.status === 'pending').length - } - async function acknowledgeNotification(notificationId) { try { const token = readStoredToken() @@ -148,11 +150,13 @@ export const useNotificationsStore = defineStore('notifications', () => { // Update local state const notification = notifications.value.find(n => n.id === notificationId) + const wasPending = !notification || notification.status === 'pending' if (notification) { notification.status = 'acknowledged' notification.acknowledged_at = new Date().toISOString() } - pendingCount.value = Math.max(0, pendingCount.value - 1) + // #3449: only a row that WAS pending leaves the pending count. + if (wasPending) pendingCount.value = Math.max(0, pendingCount.value - 1) return true } catch (err) { @@ -170,6 +174,7 @@ export const useNotificationsStore = defineStore('notifications', () => { // Update local state const notification = notifications.value.find(n => n.id === notificationId) + const wasPending = !notification || notification.status === 'pending' if (notification) { notification.status = 'dismissed' } @@ -179,7 +184,8 @@ export const useNotificationsStore = defineStore('notifications', () => { notifications.value = notifications.value.filter(n => n.id !== notificationId) } - pendingCount.value = Math.max(0, pendingCount.value - 1) + // #3449: dismissing an acknowledged row removes no pending item. + if (wasPending) pendingCount.value = Math.max(0, pendingCount.value - 1) return true } catch (err) { diff --git a/src/frontend/tests/unit/notificationsPendingCount.spec.js b/src/frontend/tests/unit/notificationsPendingCount.spec.js new file mode 100644 index 000000000..4ef850997 --- /dev/null +++ b/src/frontend/tests/unit/notificationsPendingCount.spec.js @@ -0,0 +1,173 @@ +// @vitest-environment jsdom +/** + * #3449 — the global pending-notification count is independent of the list. + * + * `pendingCount` backs the Operations header, the Notifications tab badge and + * the NavBar badge. It used to be recomputed from whatever rows the + * Notifications tab had loaded, so a status / agent filter (or simply the page + * limit) rewrote all three. It is a server figure (`GET + * /api/notifications/count`) — the list never writes it — and the panel's + * fourth card states what it is: the rows shown, not a total. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' + +vi.mock('axios', () => { + const inst = { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), defaults: { headers: { common: {} } } } + return { default: inst } +}) +vi.mock('@/api', () => { + const inst = { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } + return { default: inst } +}) +vi.mock('@/utils/platformSession', () => ({ readStoredToken: () => 'test-token' })) + +import axios from 'axios' +import { useNotificationsStore } from '@/stores/notifications' +import NotificationsPanel from '@/components/operator/NotificationsPanel.vue' + +const GLOBAL_PENDING = 502 + +function rows(n, status, agent = 'agent-a') { + return Array.from({ length: n }, (_, i) => ({ + id: `${status}-${agent}-${i}`, + agent_name: agent, + notification_type: 'info', + title: `Notification ${i}`, + priority: 'normal', + status, + created_at: '2026-10-09T08:00:00Z', + message: null, + metadata: null, + })) +} + +// The backend as the store sees it: a true pending count, and a list endpoint +// that honours the status filter and caps the page at `limit`. +function serve({ pending = GLOBAL_PENDING, acknowledged = 34 } = {}) { + axios.get.mockImplementation(async (url) => { + if (url.startsWith('/api/notifications/count')) { + return { data: { status: 'pending', count: pending } } + } + const q = new URLSearchParams(url.split('?')[1] || '') + const limit = Number(q.get('limit') || 50) + const status = q.get('status') + const all = status === 'acknowledged' + ? rows(acknowledged, 'acknowledged') + : rows(pending, 'pending') + const page = all.slice(0, limit) + return { data: { count: page.length, notifications: page } } + }) + axios.post.mockResolvedValue({ data: {} }) +} + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + serve() +}) + +describe('store: pendingCount is the server count, never the loaded list', () => { + it('a status filter on the list leaves the global pending count alone', async () => { + const store = useNotificationsStore() + await store.fetchPendingCount() + expect(store.pendingCount).toBe(GLOBAL_PENDING) + + await store.fetchNotifications({ status: 'acknowledged' }) + await flushPromises() + + expect(store.notifications).toHaveLength(34) + expect(store.pendingCount).toBe(GLOBAL_PENDING) + }) + + it('the page limit does not cap the global pending count', async () => { + const store = useNotificationsStore() + await store.fetchPendingCount() + + await store.fetchNotifications() // default filter: status=pending, limit=50 + await flushPromises() + + expect(store.notifications).toHaveLength(50) + expect(store.pendingCount).toBe(GLOBAL_PENDING) + }) + + it('an agent filter leaves the global pending count alone', async () => { + const store = useNotificationsStore() + await store.fetchPendingCount() + + axios.get.mockImplementation(async (url) => ( + url.startsWith('/api/notifications/count') + ? { data: { status: 'pending', count: GLOBAL_PENDING } } + : { data: { count: 2, notifications: rows(2, 'pending', 'agent-b') } } + )) + await store.fetchNotifications({ agentName: 'agent-b' }) + await flushPromises() + + expect(store.notifications).toHaveLength(2) + expect(store.pendingCount).toBe(GLOBAL_PENDING) + }) + + it('a list fetch refreshes the count from the count endpoint', async () => { + const store = useNotificationsStore() + await store.fetchNotifications({ status: 'acknowledged' }) + await flushPromises() + + expect(axios.get.mock.calls.some(([u]) => u.startsWith('/api/notifications/count'))).toBe(true) + expect(store.pendingCount).toBe(GLOBAL_PENDING) + }) + + it('acknowledging a pending row takes one off; dismissing an acknowledged row does not', async () => { + const store = useNotificationsStore() + await store.fetchNotifications() + await flushPromises() + await store.acknowledgeNotification(store.notifications[0].id) + expect(store.pendingCount).toBe(GLOBAL_PENDING - 1) + + // The row is acknowledged now — dismissing it removes no pending item. + await store.dismissNotification(store.notifications[0].id) + expect(store.pendingCount).toBe(GLOBAL_PENDING - 1) + + await store.dismissNotification(store.notifications[0].id) + expect(store.pendingCount).toBe(GLOBAL_PENDING - 2) + }) + + it('a live pending event counts even when the list filter hides it', async () => { + const store = useNotificationsStore() + store.setFilters({ status: 'acknowledged' }) + await flushPromises() + expect(store.pendingCount).toBe(GLOBAL_PENDING) + + store.addNotification({ id: 'live-1', agent_name: 'agent-a', status: 'pending', priority: 'normal' }) + + expect(store.notifications.some(n => n.id === 'live-1')).toBe(false) + expect(store.pendingCount).toBe(GLOBAL_PENDING + 1) + }) +}) + +describe('NotificationsPanel: the cards say what they count', () => { + function cards(wrapper) { + const out = {} + wrapper.findAll('[data-testid^="notif-stat-"]').forEach((el) => { + out[el.attributes('data-testid').replace('notif-stat-', '')] = el.text() + }) + return out + } + + it('Pending stays the global figure under a filter, and the row card is "Shown", not "Total"', async () => { + const wrapper = mount(NotificationsPanel, { global: { stubs: { teleport: true } } }) + await flushPromises() + + expect(cards(wrapper).pending).toBe(`${GLOBAL_PENDING}Pending`) + expect(cards(wrapper).shown).toBe('50Shown') + + // Agent · Type · Priority · Status — the Status select is the fourth. + await wrapper.findAll('select')[3].setValue('acknowledged') + await flushPromises() + + const after = cards(wrapper) + expect(after.pending).toBe(`${GLOBAL_PENDING}Pending`) + expect(after.shown).toBe('34Shown') + expect(wrapper.text()).not.toContain('Total') + }) +})