Merge pull request #89 from QRun-IO/feature/notification-center

feat(alerts): notification center - badge, popover, page and banners
This commit was merged in pull request #89.
This commit is contained in:
james.maes authored and GitHub committed 2026-10-13 12:11:23 -05:00
commit 6ed257565a
22 files changed
+2062 -237

No files matched your search

+5
View File
@@ -62,6 +62,11 @@
"marker": "command-menu-searching",
"gzipKB": 8.1
},
"notificationCenter": {
"$comment": "The notification center (qbit-alerts phase b): the bell popover (import() in AlertsBell on first open), the sticky-notification banners (import() in NotificationBannerSlot when the summary has banners) and the /app/notifications page (next/dynamic in its route). Turbopack bundles the shared parts (severity icons, per-item actions, the optimistic inbox mutation, MarkdownView) into each of the three async chunks, so the budget covers all three; the marker is the per-item read/unread button they share. Measured 15.2 KB gzip across the three (popover 4.3, banners 4.3, page 6.7) on 2026-10-13; budget about 8% above. The shell keeps the badge, poller with backoff, summary normalizer and banner slot: +0.7 KB first load on the /app routes (/app/_/_ 348.5 -> 349.2 KB, inside its 350 KB cap); the new /app/notifications route measures 339.5 KB; total JS 1092.9 -> 1098.0 KB excluding these chunks; CSS unchanged at 23.2 KB.",
"marker": "notification-toggle-read",
"gzipKB": 16.5
},
"recordPreview": {
"$comment": "The search result record preview (first-tier fields with file thumbnails, chips and dates; quick actions), loaded when a preview first shows: import() in SearchPage. It runs its own two queries (the useTableMetaData and useRecord keys) rather than importing those hooks, which measured +12.4 KB more total JS. Measured 7.1 KB gzip with thumbnails and chips (3.7 KB without); budget about 8% above.",
"marker": "search-record-preview",
@@ -0,0 +1,47 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file Notifications page — the notification center's full list (qbit-alerts), with filters
* and paging in the URL (`status`, `category`, `severity`, `offset`). A fixed page under `/app`
* like `/app/search`, so the static export emits it and the dashboard layout wraps it.
*/
'use client'
import React from 'react'
import dynamic from 'next/dynamic'
import { Loader2 } from 'lucide-react'
/** The page loads on demand, so its code stays out of every route's first load. */
const NotificationsPage = dynamic(() => import('@/components/alerts/NotificationsPage').then((module) => module.NotificationsPage), {
ssr: false,
loading: () => (
<p className="flex items-center gap-2 p-6 text-sm text-muted-foreground" role="status" data-qqq-id="notifications-page-loading">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Loading notifications…
</p>
),
})
/**
* Renders the notifications page at `/app/notifications`.
*
* @returns The page.
*/
export default function NotificationsRoute() {
return <NotificationsPage />
}
+2
View File
@@ -52,6 +52,7 @@ import { AssistantProvider, useAssistantController } from '@/components/assistan
import { AssistantHeaderButton } from '@/components/assistant/AssistantHeaderButton'
import { AssistantMount } from '@/components/assistant/AssistantMount'
import { AlertsBell, isAlertsInstalled } from '@/components/alerts/AlertsBell'
import { NotificationBannerSlot } from '@/components/alerts/NotificationBannerSlot'
/**
* Inner layout that renders the full dashboard chrome.
@@ -349,6 +350,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
) : (
<>
<BannerComponent banners={metaData?.branding?.banners} slot="QFMD_TOP_OF_BODY" className="mb-4 rounded-lg" />
<NotificationBannerSlot metaData={metaData} enabled={isAuthenticated} />
{children}
<CompanyFooter branding={metaData?.branding} />
</>
+7 -137
View File
@@ -14,9 +14,9 @@
* limitations under the License.
*/
// Tests for the Alerts bell, its popover, and the record panel: metadata gating (no request
// without the qbit's app), polling, badge, a11y, mark read, keyboard, and the panel's hidden /
// open / watch states.
// Tests for the Alerts record panel: metadata gating (no request without the qbit's app) and the
// panel's hidden / open / watch states. The bell, popover and banners are in
// NotificationCenter.test.tsx.
import React from 'react'
import { render, screen, waitFor, within } from '@testing-library/react'
@@ -27,9 +27,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { axe } from 'jest-axe'
import { server } from '@/mocks/node'
import type { AlertsInboxSummary, AlertsRecordPanel } from '@/types/alerts'
import type { AlertsRecordPanel } from '@/types/alerts'
import type { QInstance } from '@/types/metadata'
import { AlertsBell, isAlertsInstalled } from './AlertsBell'
import { isAlertsInstalled } from './AlertsBell'
import { AlertsRecordPanelSlot } from './AlertsRecordPanelSlot'
const router = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), prefetch: vi.fn() }))
@@ -39,17 +39,8 @@ vi.mock('next/navigation', () => ({
useSearchParams: () => new URLSearchParams(window.location.search),
}))
const SUMMARY: AlertsInboxSummary = {
unreadCount: 2,
highestUnreadSeverity: 'CRITICAL',
alertsHref: '/app/alerts',
pollSeconds: 60,
items: [
{ alertId: 7, title: 'Nightly backup did not run', severity: 'CRITICAL', status: 'OPEN', group: 'Backups', age: '3 h', href: '/app/alerts_alert/7', unread: true },
{ alertId: 6, title: '3 CDN purges failed', severity: 'ERROR', status: 'OPEN', group: 'CDN', age: '20 min', href: '/app/alerts_alert/6', unread: true },
{ alertId: 2, title: 'Old warning', severity: 'WARNING', status: 'ACKNOWLEDGED', group: null, age: '2 d', href: '/app/alerts_alert/2', unread: false },
],
}
/** The inbox summary (the record panel shows only when it loads). */
const SUMMARY = { unreadCount: 0, highestUnreadSeverity: null, notificationsHref: '/app/notifications', items: [], banners: [] }
const PANEL: AlertsRecordPanel = {
visible: true,
@@ -86,127 +77,6 @@ describe('isAlertsInstalled', () => {
})
})
describe('AlertsBell', () => {
beforeEach(() => {
router.push.mockReset()
})
it('sends no request and renders nothing when the metadata does not announce Alerts', async () => {
let calls = 0
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
calls++
return HttpResponse.json(SUMMARY)
}))
const without = renderWithClient(<AlertsBell metaData={WITHOUT_ALERTS} />)
const loading = renderWithClient(<AlertsBell metaData={undefined} />)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(calls).toBe(0)
expect(without.container).toBeEmptyDOMElement()
expect(loading.container).toBeEmptyDOMElement()
})
it('renders and polls the inbox summary when the metadata announces Alerts', async () => {
let calls = 0
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
calls++
return HttpResponse.json({ ...SUMMARY, pollSeconds: 0.05 })
}))
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
expect(await screen.findByRole('button', { name: 'Alerts, 2 unread' })).toBeInTheDocument()
await waitFor(() => expect(calls).toBeGreaterThanOrEqual(3))
})
it('renders nothing when the backend still answers that Alerts is not available (403 or 404)', async () => {
let calls = 0
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
calls++
return new HttpResponse(null, { status: 404 })
}))
const { container } = renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await waitFor(() => expect(calls).toBe(1))
expect(container).toBeEmptyDOMElement()
})
it('renders nothing for a body that is not a summary', async () => {
let calls = 0
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
calls++
return HttpResponse.json({ hello: 'world' })
}))
const { container } = renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await waitFor(() => expect(calls).toBe(1))
expect(container).toBeEmptyDOMElement()
})
it('shows the unread count, red for critical, with an accessible label', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
const bell = await screen.findByRole('button', { name: 'Alerts, 2 unread' })
expect(bell).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByText('2', { selector: '[data-qqq-id="alerts-unread-badge"]' })).toHaveClass('bg-destructive')
expect(document.querySelector('[data-qqq-id="alerts-unread-announcer"]')).toHaveAttribute('aria-live', 'polite')
})
it('opens the popover, opens an item (marking it read), and closes on Escape', async () => {
let readBody: unknown = null
server.use(
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)),
http.post('/qqq/v1/alerts/inbox/read', async ({ request }) => {
readBody = await request.json()
return HttpResponse.json({ updated: 1 })
})
)
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
const bell = await screen.findByRole('button', { name: 'Alerts, 2 unread' })
await user.click(bell)
const dialog = await screen.findByRole('dialog', { name: 'Alerts' })
expect(await axe(dialog)).toHaveNoViolations()
const items = within(dialog).getAllByRole('link')
expect(items[0]).toHaveTextContent('Nightly backup did not run')
expect(items[0]).toHaveTextContent('Backups · 3 h ago')
await waitFor(() => expect(items[0]).toHaveFocus())
await user.click(items[0])
expect(router.push).toHaveBeenCalledWith('/app/alerts_alert/7')
await waitFor(() => expect(readBody).toEqual({ alertId: 7 }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await user.click(bell)
await screen.findByRole('dialog', { name: 'Alerts' })
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(bell).toHaveFocus()
})
it('marks all read and opens the Alerts app', async () => {
let readBody: unknown = null
server.use(
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)),
http.post('/qqq/v1/alerts/inbox/read', async ({ request }) => {
readBody = await request.json()
return HttpResponse.json({ updated: 2 })
})
)
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Alerts, 2 unread' }))
await user.click(await screen.findByRole('button', { name: 'Mark all read' }))
await waitFor(() => expect(readBody).toEqual({ all: true }))
await user.click(screen.getByRole('link', { name: 'Open Alerts' }))
expect(router.push).toHaveBeenCalledWith('/app/alerts')
})
it('shows an empty state with no items', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({ ...SUMMARY, unreadCount: 0, highestUnreadSeverity: null, items: [] })))
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Alerts, none unread' }))
expect(await screen.findByText('Nothing needs you right now.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Mark all read' })).toBeDisabled()
})
})
describe('AlertsRecordPanelSlot', () => {
it('sends no request when the metadata does not announce Alerts', async () => {
let calls = 0
+51 -13
View File
@@ -15,13 +15,14 @@
*/
/**
* @file AlertsBell — the header bell for the Alerts qbit. Like the QQQ Assistant, it is gated
* on instance metadata: unless the metadata lists the qbit's `alerts` app (which QQQ includes
* only when qbit-alerts is installed and the user holds `alerts.view`, the same permission its
* `/alerts/*` routes require), it renders nothing and sends no request at all. When enabled it
* polls the inbox summary every 60 seconds while the tab is visible (TanStack Query pauses
* interval refetches in a hidden tab). The popover's code loads on first open, so the shell
* stays small.
* @file AlertsBell — the header bell of the notification center (qbit-alerts). Like the QQQ
* Assistant, it is gated on instance metadata: unless the metadata lists the qbit's `alerts`
* app (which QQQ includes only when qbit-alerts is installed and the user holds `alerts.view`,
* the same permission its `/alerts/*` routes require), it renders nothing and sends no request
* at all. When enabled it polls the inbox summary every `pollSeconds` (60) while the tab is
* visible (TanStack Query pauses interval refetches in a hidden tab and refetches when it shows
* again), backing off exponentially to 5 minutes after errors. The popover's code loads on
* first open, so the shell stays small.
*/
'use client'
@@ -54,10 +55,46 @@ export function isAlertsInstalled(metaData: QInstance | undefined): boolean {
return Boolean(app) && typeof app === 'object'
}
/** The longest wait between polls after errors (5 minutes). */
export const MAX_POLL_MS = 300_000
/** Consecutive failed summary loads (one shared query, so one counter). */
let failures = 0
/**
* The summary loader, counting consecutive failures for the backoff.
*
* @returns The summary.
*/
async function loadSummary(): Promise<AlertsInboxSummary | null> {
try {
const summary = await getAlertsInboxSummary()
failures = 0
return summary
} catch (error) {
failures++
throw error
}
}
/**
* The wait before the next poll: `pollSeconds` normally, doubling per consecutive failure up to
* {@link MAX_POLL_MS}; no polling once the backend has said Alerts is not for this user.
*
* @param data - The last summary (null: not available; undefined: none yet).
* @param failed - Consecutive failures.
* @returns Milliseconds, or false.
*/
export function pollDelay(data: AlertsInboxSummary | null | undefined, failed: number): number | false {
if (data === null && failed === 0) return false
const base = (data?.pollSeconds ?? 60) * 1000
return failed > 0 ? Math.min(base * 2 ** failed, MAX_POLL_MS) : base
}
/**
* The bell's inbox summary (null when the backend still answers that Alerts is unavailable).
* Shared with the record panel slot, which shows its panel only when this succeeds. It never
* fetches (nor polls) unless `enabled`.
* Shared with the record panel slot and the banner slot. It never fetches (nor polls) unless
* `enabled`.
*
* @param enabled - Whether to load it: authenticated and {@link isAlertsInstalled}.
* @returns The TanStack Query result.
@@ -65,12 +102,13 @@ export function isAlertsInstalled(metaData: QInstance | undefined): boolean {
export function useAlertsInbox(enabled: boolean) {
return useQuery<AlertsInboxSummary | null>({
queryKey: queryKeys.alertsInbox(),
queryFn: getAlertsInboxSummary,
queryFn: loadSummary,
enabled,
retry: false,
staleTime: 30_000,
refetchInterval: (query) => (query.state.data ? (query.state.data.pollSeconds ?? 60) * 1000 : false),
refetchInterval: (query) => pollDelay(query.state.data, failures),
refetchIntervalInBackground: false,
refetchOnWindowFocus: (query) => (query.state.data === null ? false : 'always'),
})
}
@@ -87,7 +125,7 @@ export function AlertsBell({ metaData }: { metaData: QInstance | undefined }) {
if (!data) return null
const unread = data.unreadCount
const label = unread > 0 ? `Alerts, ${unread} unread` : 'Alerts, none unread'
const label = unread > 0 ? `Notifications, ${unread} unread` : 'Notifications, none unread'
const critical = data.highestUnreadSeverity === 'CRITICAL'
return (
<div className="relative">
@@ -108,7 +146,7 @@ export function AlertsBell({ metaData }: { metaData: QInstance | undefined }) {
aria-hidden="true"
data-qqq-id="alerts-unread-badge"
>
{unread > 9 ? '9+' : unread}
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
+59 -55
View File
@@ -15,21 +15,21 @@
*/
/**
* @file AlertsBellPopover — the bell's popover (loaded on first open): one column of the latest
* alerts (severity dot, title, group, age), "Mark all read" and "Open Alerts". Opening an item
* marks it read. Focus stays inside while it is open; Escape or a click outside closes it.
* @file AlertsBellPopover — the bell's popover (loaded on first open): the latest
* notifications, newest first (severity icon, title, source, relative time), each with mark
* read/unread and dismiss when the backend sends its recipient id, plus "Mark all read" and
* "View all". Opening an item marks it read. Focus stays inside while it is open; Escape or a
* click outside closes it.
*/
'use client'
import React, { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useQueryClient } from '@tanstack/react-query'
import { markAlertsRead } from '@/lib/api/alerts'
import { queryKeys } from '@/lib/query-client'
import type { AlertsInboxSummary } from '@/types/alerts'
import { SeverityDot } from './SeverityDot'
import { useInboxActions } from '@/lib/hooks/use-inbox-actions'
import type { AlertsInboxSummary, InboxItem } from '@/types/alerts'
import { ItemActions, SeverityIcon, itemMeta, linkKind, readRequest } from './NotificationParts'
/** Props for the popover. */
export interface AlertsBellPopoverProps {
@@ -47,7 +47,6 @@ export interface AlertsBellPopoverProps {
*/
export default function AlertsBellPopover({ summary, onClose }: AlertsBellPopoverProps) {
const router = useRouter()
const queryClient = useQueryClient()
const panelRef = useRef<HTMLDivElement>(null)
// a dropdown under the bell from 640 px; full width under the header below that (inline, so no new CSS)
const [wide, setWide] = useState(() => typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(min-width: 640px)').matches)
@@ -73,7 +72,7 @@ export default function AlertsBellPopover({ summary, onClose }: AlertsBellPopove
return
}
if (event.key !== 'Tab' || !panel) return
const focusable = Array.from(panel.querySelectorAll<HTMLElement>('a,button'))
const focusable = Array.from(panel.querySelectorAll<HTMLElement>('a,button:not([disabled])'))
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
@@ -96,18 +95,13 @@ export default function AlertsBellPopover({ summary, onClose }: AlertsBellPopove
}
}, [onClose])
const refresh = () => queryClient.invalidateQueries({ queryKey: queryKeys.alertsInbox() })
const actions = useInboxActions()
const open = async (alertId: number, href: string, unread: boolean | undefined) => {
const openItem = (item: InboxItem) => {
const read = readRequest(item)
if (read) actions.mutate(read)
onClose()
router.push(href)
if (unread) {
try {
await markAlertsRead({ alertId })
} finally {
refresh()
}
}
if (item.href) router.push(item.href)
}
return (
@@ -115,70 +109,80 @@ export default function AlertsBellPopover({ summary, onClose }: AlertsBellPopove
ref={panelRef}
role="dialog"
aria-modal="false"
aria-label="Alerts"
aria-label="Notifications"
className="z-50 overflow-y-auto rounded-xl border border-border bg-card shadow-lg"
style={placement}
data-qqq-id="alerts-popover"
>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold text-foreground">Alerts</h2>
<h2 className="text-sm font-semibold text-foreground">Notifications</h2>
<span className="text-xs text-muted-foreground">{summary.unreadCount > 0 ? `${summary.unreadCount} unread` : 'All read'}</span>
</div>
{summary.items.length === 0 ? (
<p className="px-4 py-6 text-sm text-muted-foreground" data-qqq-id="alerts-popover-empty">Nothing needs you right now.</p>
<p className="px-4 py-6 text-sm text-muted-foreground" data-qqq-id="alerts-popover-empty">You have no notifications.</p>
) : (
<ul className="divide-y divide-border">
{summary.items.map((item) => (
<li key={item.alertId}>
<a
href={item.href}
onClick={(event) => {
event.preventDefault()
void open(item.alertId, item.href, item.unread)
}}
className={`flex items-start gap-3 px-4 py-3 text-sm hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring ${item.unread ? '' : 'opacity-75'}`}
data-qqq-id={`alerts-popover-item-${item.alertId}`}
>
<SeverityDot severity={item.severity} />
<span className="min-w-0 flex-1">
<span className={`block break-words text-foreground ${item.unread ? 'font-semibold' : ''}`}>{item.title}</span>
<span className="block text-xs text-muted-foreground">
{[item.group, item.age === 'just now' ? 'just now' : `${item.age} ago`].filter(Boolean).join(' · ')}
{summary.items.map((item) => {
const kind = linkKind(item.href)
const title = <span className={`block break-words text-foreground ${item.unread ? 'font-semibold' : ''}`}>{item.title}</span>
const meta = <span className="block text-xs text-muted-foreground">{itemMeta(item)}</span>
const linkClass = 'block min-w-0 flex-1 rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:underline'
return (
<li key={item.key} className={`flex items-start gap-3 py-3 pl-4 pr-2 text-sm ${item.unread ? '' : 'opacity-75'}`} data-qqq-id={`alerts-popover-item-${item.key}`}>
<SeverityIcon severity={item.severity} />
{kind === 'app' ? (
<a
href={item.href ?? undefined}
onClick={(event) => {
event.preventDefault()
openItem(item)
}}
className={linkClass}
>
{title}
{meta}
{item.unread && <span className="sr-only">(unread)</span>}
</a>
) : kind === 'external' ? (
<a href={item.href ?? undefined} target="_blank" rel="noopener noreferrer" onClick={() => openItem({ ...item, href: null })} className={linkClass}>
{title}
{meta}
{item.unread && <span className="sr-only">(unread)</span>}
</a>
) : (
<span className="min-w-0 flex-1">
{title}
{meta}
{item.unread && <span className="sr-only">(unread)</span>}
</span>
</span>
{item.unread && <span className="sr-only">(unread)</span>}
</a>
</li>
))}
)}
<ItemActions item={item} onAction={(request) => actions.mutate(request)} />
</li>
)
})}
</ul>
)}
<div className="flex items-center justify-between gap-2 border-t border-border px-4 py-3 text-sm">
<button
type="button"
disabled={summary.unreadCount === 0}
onClick={async () => {
try {
await markAlertsRead({ all: true })
} finally {
refresh()
}
}}
onClick={() => actions.mutate({ action: 'read', target: { all: true } })}
className="rounded-md px-2 py-1 text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
data-qqq-id="alerts-mark-all-read"
>
Mark all read
</button>
<a
href={summary.alertsHref}
href={summary.notificationsHref}
onClick={(event) => {
event.preventDefault()
onClose()
router.push(summary.alertsHref)
router.push(summary.notificationsHref)
}}
className="rounded-md px-2 py-1 font-medium text-primary hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-qqq-id="alerts-open-app"
data-qqq-id="alerts-view-all"
>
Open Alerts
View all
</a>
</div>
</div>
@@ -0,0 +1,47 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file NotificationBannerSlot — shows sticky notifications (the summary's `banners`) above the
* page until dismissed. It shares the bell's summary query, so it sends nothing of its own and
* nothing at all unless the metadata announces Alerts; the banner UI loads only when there is
* a banner to show.
*/
'use client'
import React, { Suspense, lazy } from 'react'
import type { QInstance } from '@/types/metadata'
import { isAlertsInstalled, useAlertsInbox } from './AlertsBell'
const NotificationBanners = lazy(() => import('./NotificationBanners'))
/**
* The banner slot.
*
* @param props - `metaData`: instance metadata; `enabled`: the user is signed in.
* @returns The banners, or null.
*/
export function NotificationBannerSlot({ metaData, enabled }: { metaData: QInstance | undefined; enabled: boolean }) {
const { data } = useAlertsInbox(enabled && isAlertsInstalled(metaData))
if (!data?.banners.length) return null
return (
<Suspense fallback={null}>
<NotificationBanners banners={data.banners} />
</Suspense>
)
}
@@ -0,0 +1,75 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file NotificationBanners — sticky notifications shown above the page (loaded on demand):
* severity, title, markdown body (rendered without HTML), an optional link, and Dismiss
* (`POST /alerts/inbox/dismiss`), which hides it at once.
*/
'use client'
import React from 'react'
import Link from 'next/link'
import { useInboxActions } from '@/lib/hooks/use-inbox-actions'
import type { InboxItem } from '@/types/alerts'
import { MarkdownView } from '@/components/sql-workbench/MarkdownView'
import { ItemActions, SeverityIcon, linkKind, severityLabel } from './NotificationParts'
// the severity's accent as a left border, on the card surface so it reads in light and dark mode
const ACCENT: Record<string, string> = { INFO: '#2563eb', SUCCESS: '#16a34a', WARNING: '#d97706', ERROR: '#ef4444', CRITICAL: '#dc2626' }
/**
* The banners.
*
* @param props - `banners`: the summary's sticky items.
* @returns A region with one banner per item.
*/
export default function NotificationBanners({ banners }: { banners: InboxItem[] }) {
const actions = useInboxActions()
return (
<section aria-label="Notification banners" className="mb-4 space-y-2" data-qqq-id="notification-banners">
{banners.map((item) => {
const kind = linkKind(item.href)
const label = item.linkLabel ?? 'Open'
return (
<div
key={item.key}
role={item.severity === 'CRITICAL' || item.severity === 'ERROR' ? 'alert' : 'status'}
aria-label={`${severityLabel(item.severity)}: ${item.title}`}
className="flex items-start gap-3 rounded-lg border border-border bg-card py-3 pl-4 pr-2 text-sm shadow-sm"
style={{ borderLeftWidth: '4px', borderLeftColor: ACCENT[item.severity] ?? ACCENT.INFO }}
data-qqq-id={`notification-banner-${item.key}`}
>
<SeverityIcon severity={item.severity} />
<div className="min-w-0 flex-1 space-y-1">
<p className="break-words font-semibold text-foreground">{item.title}</p>
{item.body && <MarkdownView source={item.body} />}
{kind === 'app' && (
<Link href={item.href ?? ''} className="inline-block font-medium text-primary underline underline-offset-2">{label}</Link>
)}
{kind === 'external' && (
<a href={item.href ?? ''} target="_blank" rel="noopener noreferrer" className="inline-block font-medium text-primary underline underline-offset-2">{label}</a>
)}
</div>
<ItemActions item={item} onAction={(request) => actions.mutate(request)} dismissOnly />
</div>
)
})}
</section>
)
}
@@ -0,0 +1,380 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Tests for the notification center's bell, popover and banner slot: metadata gating (no request
// without the qbit's app), polling with backoff and the hidden-tab pause, badge, a11y, per-item
// and bulk actions (optimistic, rolled back on failure), and older backends.
import React from 'react'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider, focusManager } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { axe } from 'jest-axe'
import { server } from '@/mocks/node'
import type { QInstance } from '@/types/metadata'
import { AlertsBell, MAX_POLL_MS, pollDelay } from './AlertsBell'
import { NotificationBannerSlot } from './NotificationBannerSlot'
const router = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), prefetch: vi.fn() }))
vi.mock('next/navigation', () => ({
useRouter: () => router,
usePathname: () => window.location.pathname,
useSearchParams: () => new URLSearchParams(window.location.search),
}))
/** The summary as the notification center backend sends it. */
const SUMMARY = {
unreadCount: 2,
highestUnreadSeverity: 'CRITICAL',
notificationsHref: '/app/notifications',
pollSeconds: 60,
items: [
{ id: 71, notificationId: 7, title: 'Nightly backup did not run', severity: 'CRITICAL', category: 'ALERT', source: 'Backups', age: '3 h', href: '/app/alerts_alert/7', unread: true },
{ id: 61, notificationId: 6, title: 'Release 4.1 is out', severity: 'SUCCESS', category: 'RELEASE', source: 'QQQ', age: '20 min', href: 'https://qrun.io/releases', unread: true },
{ id: 21, notificationId: 2, title: 'Old warning', severity: 'WARNING', category: 'SYSTEM', source: null, age: '2 d', href: null, unread: false },
],
banners: [] as unknown[],
}
/** The summary as qbit-alerts 0.1.0-RC.1 sends it (no `id`, `group` as the source). */
const LEGACY_SUMMARY = {
unreadCount: 1,
highestUnreadSeverity: 'ERROR',
alertsHref: '/app/alerts',
items: [{ alertId: 6, title: '3 CDN purges failed', severity: 'ERROR', status: 'OPEN', group: 'CDN', age: '20 min', href: '/app/alerts_alert/6', unread: true }],
}
/** Instance metadata announcing qbit-alerts (its `alerts` app, present when the user holds `alerts.view`). */
const WITH_ALERTS = { apps: { alerts: { name: 'alerts', label: 'Alerts' } }, tables: {}, processes: {} } as unknown as QInstance
/** Instance metadata without qbit-alerts (e.g. the QQQ sample app). */
const WITHOUT_ALERTS = { apps: { home: { name: 'home', label: 'Home' } }, tables: {}, processes: {} } as unknown as QInstance
/**
* Renders a component with a fresh query client.
*
* @param ui - The component.
* @returns The render result.
*/
function renderWithClient(ui: React.ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
}
/**
* Records the bodies posted to an inbox action.
*
* @param action - `read`, `unread` or `dismiss`.
* @returns The bodies, filled as requests arrive.
*/
function captureAction(action: string): unknown[] {
const bodies: unknown[] = []
server.use(http.post(`/qqq/v1/alerts/inbox/${action}`, async ({ request }) => {
bodies.push(await request.json())
return HttpResponse.json({ updated: 1 })
}))
return bodies
}
/**
* Waits.
*
* @param ms - Milliseconds.
* @returns A promise.
*/
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
describe('pollDelay', () => {
it('polls every pollSeconds, backs off exponentially to 5 minutes, and stops when unavailable', () => {
const summary = { unreadCount: 0, highestUnreadSeverity: null, items: [], banners: [], notificationsHref: '/app/notifications', pollSeconds: 30 }
expect(pollDelay(summary, 0)).toBe(30_000)
expect(pollDelay(undefined, 0)).toBe(60_000)
expect(pollDelay(summary, 1)).toBe(60_000)
expect(pollDelay(summary, 2)).toBe(120_000)
expect(pollDelay(undefined, 3)).toBe(MAX_POLL_MS)
expect(pollDelay(summary, 10)).toBe(MAX_POLL_MS)
expect(pollDelay(null, 0)).toBe(false)
})
})
describe('AlertsBell', () => {
beforeEach(() => {
router.push.mockReset()
})
afterEach(() => {
focusManager.setFocused(undefined)
})
it('sends no request and renders nothing when the metadata does not announce Alerts', async () => {
let calls = 0
server.use(http.get('/qqq/v1/alerts/*', () => {
calls++
return HttpResponse.json(SUMMARY)
}))
const without = renderWithClient(<AlertsBell metaData={WITHOUT_ALERTS} />)
const loading = renderWithClient(<AlertsBell metaData={undefined} />)
const banners = renderWithClient(<NotificationBannerSlot metaData={WITHOUT_ALERTS} enabled />)
await sleep(100)
expect(calls).toBe(0)
expect(without.container).toBeEmptyDOMElement()
expect(loading.container).toBeEmptyDOMElement()
expect(banners.container).toBeEmptyDOMElement()
})
it('renders and polls the inbox summary when the metadata announces Alerts', async () => {
let calls = 0
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
calls++
return HttpResponse.json({ ...SUMMARY, pollSeconds: 0.05 })
}))
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
expect(await screen.findByRole('button', { name: 'Notifications, 2 unread' })).toBeInTheDocument()
await waitFor(() => expect(calls).toBeGreaterThanOrEqual(3))
})
it('pauses polling while the tab is hidden and refetches when it shows again', async () => {
let calls = 0
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
calls++
return HttpResponse.json({ ...SUMMARY, pollSeconds: 0.05 })
}))
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await screen.findByRole('button', { name: 'Notifications, 2 unread' })
act(() => focusManager.setFocused(false))
await sleep(100)
const hidden = calls
await sleep(250)
expect(calls).toBe(hidden)
act(() => focusManager.setFocused(true))
await waitFor(() => expect(calls).toBeGreaterThan(hidden))
})
it('renders nothing when the backend still answers that Alerts is not available (403 or 404)', async () => {
let calls = 0
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
calls++
return new HttpResponse(null, { status: 404 })
}))
const { container } = renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await waitFor(() => expect(calls).toBe(1))
expect(container).toBeEmptyDOMElement()
})
it('renders nothing for a body that is not a summary', async () => {
let calls = 0
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
calls++
return HttpResponse.json({ hello: 'world' })
}))
const { container } = renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await waitFor(() => expect(calls).toBe(1))
expect(container).toBeEmptyDOMElement()
})
it('shows the unread count, red for critical, capped at 99+, with an accessible label', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
const first = renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
const bell = await screen.findByRole('button', { name: 'Notifications, 2 unread' })
expect(bell).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByText('2', { selector: '[data-qqq-id="alerts-unread-badge"]' })).toHaveClass('bg-destructive')
expect(document.querySelector('[data-qqq-id="alerts-unread-announcer"]')).toHaveAttribute('aria-live', 'polite')
first.unmount()
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({ ...SUMMARY, unreadCount: 140, highestUnreadSeverity: 'WARNING' })))
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await screen.findByRole('button', { name: 'Notifications, 140 unread' })
expect(screen.getByText('99+', { selector: '[data-qqq-id="alerts-unread-badge"]' })).toHaveClass('bg-primary')
})
it('opens the popover, opens an item (marking it read), and closes on Escape', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
const reads = captureAction('read')
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
const bell = await screen.findByRole('button', { name: 'Notifications, 2 unread' })
await user.click(bell)
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
expect(await axe(dialog)).toHaveNoViolations()
const items = within(dialog).getAllByRole('listitem')
expect(items[0]).toHaveTextContent('Nightly backup did not run')
expect(items[0]).toHaveTextContent('Backups · 3 h ago')
expect(within(items[0]).getByText('Critical')).toBeInTheDocument()
expect(within(items[1]).getByText('Success')).toBeInTheDocument()
expect(within(items[1]).getByRole('link')).toHaveAttribute('target', '_blank')
expect(within(items[2]).queryByRole('link')).not.toBeInTheDocument()
const link = within(items[0]).getByRole('link')
await waitFor(() => expect(link).toHaveFocus())
await user.click(link)
expect(router.push).toHaveBeenCalledWith('/app/alerts_alert/7')
await waitFor(() => expect(reads).toEqual([{ id: 71 }]))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await user.click(bell)
await screen.findByRole('dialog', { name: 'Notifications' })
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(bell).toHaveFocus()
})
it('keeps focus inside the popover', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
const first = within(dialog).getAllByRole('link')[0]
await waitFor(() => expect(first).toHaveFocus())
await user.keyboard('{Shift>}{Tab}{/Shift}')
expect(within(dialog).getByRole('link', { name: 'View all' })).toHaveFocus()
await user.tab()
expect(first).toHaveFocus()
})
it('marks one read and one unread, and dismisses one', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
const reads = captureAction('read')
const unreads = captureAction('unread')
const dismissals = captureAction('dismiss')
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
await user.click(within(dialog).getByRole('button', { name: 'Mark read: Nightly backup did not run' }))
await waitFor(() => expect(reads).toEqual([{ id: 71 }]))
await user.click(within(dialog).getByRole('button', { name: 'Mark unread: Old warning' }))
await waitFor(() => expect(unreads).toEqual([{ id: 21 }]))
await user.click(within(dialog).getByRole('button', { name: 'Dismiss: Release 4.1 is out' }))
await waitFor(() => expect(dismissals).toEqual([{ id: 61 }]))
})
it('applies an action at once, before the server answers', async () => {
let summary = SUMMARY
server.use(
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(summary)),
http.post('/qqq/v1/alerts/inbox/dismiss', async () => {
await sleep(200)
summary = { ...SUMMARY, unreadCount: 1, items: SUMMARY.items.slice(1) }
return HttpResponse.json({ updated: 1 })
})
)
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
await user.click(within(dialog).getByRole('button', { name: 'Dismiss: Nightly backup did not run' }))
expect(within(dialog).queryByText('Nightly backup did not run')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Notifications, 1 unread' })).toBeInTheDocument()
})
it('rolls back when an action fails', async () => {
server.use(
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)),
http.post('/qqq/v1/alerts/inbox/read', () => new HttpResponse(null, { status: 500 }))
)
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
await user.click(await screen.findByRole('button', { name: 'Mark all read' }))
expect(await screen.findByRole('button', { name: 'Notifications, 2 unread' })).toBeInTheDocument()
})
it('marks all read and opens the notifications page', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
const reads = captureAction('read')
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
await user.click(await screen.findByRole('button', { name: 'Mark all read' }))
await waitFor(() => expect(reads).toEqual([{ all: true }]))
await user.click(screen.getByRole('link', { name: 'View all' }))
expect(router.push).toHaveBeenCalledWith('/app/notifications')
})
it('shows older backends without per-item actions, marking read by alert id', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(LEGACY_SUMMARY)))
const reads = captureAction('read')
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Notifications, 1 unread' }))
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
expect(dialog).toHaveTextContent('CDN · 20 min ago')
expect(within(dialog).queryByRole('button', { name: /Dismiss/ })).not.toBeInTheDocument()
await user.click(within(dialog).getByRole('link', { name: /3 CDN purges failed/ }))
await waitFor(() => expect(reads).toEqual([{ alertId: 6 }]))
expect(router.push).toHaveBeenCalledWith('/app/alerts_alert/6')
})
it('shows an empty state with no items', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({ ...SUMMARY, unreadCount: 0, highestUnreadSeverity: null, items: [] })))
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Notifications, none unread' }))
expect(await screen.findByText('You have no notifications.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Mark all read' })).toBeDisabled()
})
})
describe('NotificationBannerSlot', () => {
it('shows sticky notifications until dismissed', async () => {
const dismissals: unknown[] = []
let summary = {
...SUMMARY,
banners: [{ id: 90, title: 'Maintenance tonight', body: 'The site is **read-only** from 22:00. [Details](/app/status)', severity: 'WARNING', category: 'SYSTEM', href: 'https://status.example.com', linkLabel: 'Status page', unread: true, sticky: true }] as unknown[],
}
server.use(
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(summary)),
http.post('/qqq/v1/alerts/inbox/dismiss', async ({ request }) => {
dismissals.push(await request.json())
summary = { ...summary, banners: [] }
return HttpResponse.json({ updated: 1 })
})
)
const user = userEvent.setup()
renderWithClient(<NotificationBannerSlot metaData={WITH_ALERTS} enabled />)
const banner = await screen.findByRole('status', { name: 'Warning: Maintenance tonight' })
expect(await axe(banner)).toHaveNoViolations()
expect(within(banner).getByText('read-only').tagName).toBe('STRONG')
expect(within(banner).getByRole('link', { name: 'Details' })).toHaveAttribute('href', '/app/status')
expect(within(banner).getByRole('link', { name: 'Status page' })).toHaveAttribute('target', '_blank')
expect(within(banner).queryByRole('button', { name: /Mark/ })).not.toBeInTheDocument()
await user.click(within(banner).getByRole('button', { name: 'Dismiss: Maintenance tonight' }))
await waitFor(() => expect(screen.queryByText('Maintenance tonight')).not.toBeInTheDocument())
expect(dismissals).toEqual([{ id: 90 }])
})
it('renders nothing without banners or when signed out', async () => {
let calls = 0
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
calls++
return HttpResponse.json(SUMMARY)
}))
const none = renderWithClient(<NotificationBannerSlot metaData={WITH_ALERTS} enabled />)
await waitFor(() => expect(calls).toBe(1))
expect(none.container).toBeEmptyDOMElement()
const signedOut = renderWithClient(<NotificationBannerSlot metaData={WITH_ALERTS} enabled={false} />)
await sleep(50)
expect(calls).toBe(1)
expect(signedOut.container).toBeEmptyDOMElement()
})
})
+179
View File
@@ -0,0 +1,179 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file Notification parts shared by the popover, the notifications page and the banners (all
* loaded on demand): the severity icon, the relative time, the safe link target, and the
* per-item read/unread and dismiss buttons (shown only for items with a recipient `id`).
*/
'use client'
import React from 'react'
import { AlertCircle, AlertTriangle, CheckCircle2, Info, Mail, MailOpen, OctagonAlert, X } from 'lucide-react'
import type { InboxItem, NotificationSeverity } from '@/types/alerts'
import type { InboxActionRequest } from '@/lib/hooks/use-inbox-actions'
// mid-tone colors that read on light and dark cards; inline so the icons add no CSS
const SEVERITY: Record<NotificationSeverity, { label: string; color: string; Icon: typeof Info }> = {
INFO: { label: 'Info', color: '#2563eb', Icon: Info },
SUCCESS: { label: 'Success', color: '#16a34a', Icon: CheckCircle2 },
WARNING: { label: 'Warning', color: '#d97706', Icon: AlertTriangle },
ERROR: { label: 'Error', color: '#ef4444', Icon: AlertCircle },
CRITICAL: { label: 'Critical', color: '#dc2626', Icon: OctagonAlert },
}
/**
* The severity's label ("Critical").
*
* @param severity - The severity.
* @returns Its label.
*/
export function severityLabel(severity: string): string {
return SEVERITY[severity as NotificationSeverity]?.label ?? severity
}
/**
* A severity icon with its name for screen readers.
*
* @param props - The severity.
* @returns The icon.
*/
export function SeverityIcon({ severity }: { severity: NotificationSeverity }) {
const { label, color, Icon } = SEVERITY[severity] ?? SEVERITY.INFO
return (
<span className="flex shrink-0 items-center pt-0.5" data-qqq-id={`notification-severity-${severity}`}>
<Icon className="h-4 w-4" style={{ color }} aria-hidden="true" />
<span className="sr-only">{label}</span>
</span>
)
}
/**
* The relative time of an item: the server's age ("3 h ago"), else from its delivery time.
*
* @param item - The item.
* @param now - The current time (tests).
* @returns The text, or empty.
*/
export function relativeTime(item: Pick<InboxItem, 'age' | 'deliveredAt'>, now: number = Date.now()): string {
if (item.age) return item.age === 'just now' ? item.age : `${item.age} ago`
const at = item.deliveredAt ? Date.parse(item.deliveredAt) : NaN
if (Number.isNaN(at)) return ''
const minutes = Math.round((now - at) / 60_000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${minutes} min ago`
const hours = Math.round(minutes / 60)
if (hours < 24) return `${hours} h ago`
return `${Math.round(hours / 24)} d ago`
}
/**
* Classifies an item's link: a same-origin path starting with a single `/` (client navigation),
* https (a new tab), or none (rendered as plain text). Backslashes, whitespace and control
* characters are refused outright, since browsers normalize `\` to `/` (`/\evil.com` would
* leave the app), as is a second character of `/` or `\`.
*
* @param href - The link.
* @returns The kind.
*/
export function linkKind(href: string | null | undefined): 'app' | 'external' | null {
if (!href || /[\\\s\u0000-\u001f\u007f]/.test(href) || href[1] === '/' || href[1] === '\\') return null
let url: URL
try {
url = new URL(href, window.location.origin)
} catch {
return null
}
if (href.startsWith('/')) return url.origin === window.location.origin ? 'app' : null
return url.protocol === 'https:' ? 'external' : null
}
/**
* The secondary line: source and relative time (and the category on the page).
*
* @param item - The item.
* @param withCategory - Include the category.
* @returns The text.
*/
export function itemMeta(item: InboxItem, withCategory = false): string {
const category = withCategory && item.category ? item.category.charAt(0) + item.category.slice(1).toLowerCase() : null
return [category, item.source, relativeTime(item)].filter(Boolean).join(' · ')
}
/** Props for {@link ItemActions}. */
export interface ItemActionsProps {
/** The item. */
item: InboxItem
/** Runs an action. */
onAction: (request: InboxActionRequest) => void
/** Hide the read/unread toggle (banners). */
dismissOnly?: boolean
}
const ICON_BUTTON = 'flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
/**
* Mark read/unread and dismiss for one item; nothing for items without a recipient id (older
* backends).
*
* @param props - Component properties.
* @returns The buttons, or null.
*/
export function ItemActions({ item, onAction, dismissOnly = false }: ItemActionsProps) {
const id = item.id
if (id === undefined) return null
return (
<span className="flex shrink-0 items-start gap-0.5">
{!dismissOnly && (
<button
type="button"
className={ICON_BUTTON}
onClick={() => onAction({ action: item.unread ? 'read' : 'unread', target: { id } })}
aria-label={`${item.unread ? 'Mark read' : 'Mark unread'}: ${item.title}`}
title={item.unread ? 'Mark read' : 'Mark unread'}
data-qqq-id={`notification-toggle-read-${id}`}
>
{item.unread ? <MailOpen className="h-4 w-4" aria-hidden="true" /> : <Mail className="h-4 w-4" aria-hidden="true" />}
</button>
)}
<button
type="button"
className={ICON_BUTTON}
onClick={() => onAction({ action: 'dismiss', target: { id } })}
aria-label={`Dismiss: ${item.title}`}
title="Dismiss"
data-qqq-id={`notification-dismiss-${id}`}
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</span>
)
}
/**
* The read target for opening an item: its recipient id, else (older backends) its alert id.
*
* @param item - The item.
* @returns The request, or null when it cannot be marked read.
*/
export function readRequest(item: InboxItem): InboxActionRequest | null {
if (!item.unread) return null
if (item.id !== undefined) return { action: 'read', target: { id: item.id } }
if (item.alertId !== undefined) return { action: 'read', target: { alertId: item.alertId } }
return null
}
@@ -0,0 +1,208 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Tests for the notifications page: metadata gating, filters and paging in the URL, the inbox
// query, safe markdown bodies, actions, and empty and error states.
import React from 'react'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { axe } from 'jest-axe'
import { server } from '@/mocks/node'
const nav = vi.hoisted(() => ({ params: new URLSearchParams(), push: vi.fn(), replace: vi.fn() }))
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: nav.push, replace: nav.replace, back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), prefetch: vi.fn() }),
usePathname: () => '/app/notifications',
useSearchParams: () => nav.params,
}))
vi.mock('@/lib/context/q-context', () => ({ useQContext: () => ({ setPageHeader: vi.fn() }) }))
const meta = vi.hoisted(() => ({ apps: { alerts: { name: 'alerts', label: 'Alerts' } } as Record<string, unknown> }))
vi.mock('@/lib/api/metadata', () => ({ loadMetaData: vi.fn(async () => ({ apps: meta.apps, tables: {}, processes: {} })) }))
import { NotificationsPage, filterFromParams } from './NotificationsPage'
/** One inbox item. */
function item(id: number, overrides: Record<string, unknown> = {}) {
return { id, notificationId: id, title: `Notice ${id}`, severity: 'INFO', category: 'SYSTEM', source: 'QQQ', age: '1 h', unread: false, ...overrides }
}
/**
* Renders the page with a fresh query client.
*
* @returns The render result.
*/
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={client}>
<NotificationsPage />
</QueryClientProvider>
)
}
/**
* Serves the inbox, recording each request's query.
*
* @param page - The response.
* @returns The recorded queries.
*/
function serveInbox(page: Record<string, unknown>): URLSearchParams[] {
const queries: URLSearchParams[] = []
server.use(http.get('/qqq/v1/alerts/inbox', ({ request }) => {
queries.push(new URL(request.url).searchParams)
return HttpResponse.json(page)
}))
return queries
}
describe('filterFromParams', () => {
it('reads status, category, severity and offset, with safe defaults', () => {
expect(filterFromParams(new URLSearchParams('status=unread&category=JOB&severity=ERROR&offset=25'))).toEqual({ status: 'unread', category: 'JOB', severity: 'ERROR', offset: 25, limit: 25 })
expect(filterFromParams(new URLSearchParams('status=bogus&offset=-4'))).toEqual({ status: 'all', category: '', severity: '', offset: 0, limit: 25 })
})
})
describe('NotificationsPage', () => {
beforeEach(() => {
nav.params = new URLSearchParams()
nav.push.mockReset()
nav.replace.mockReset()
meta.apps = { alerts: { name: 'alerts', label: 'Alerts' } }
})
it('sends no inbox request without the Alerts app', async () => {
meta.apps = {}
const queries = serveInbox({ items: [], total: 0 })
renderPage()
expect(await screen.findByText('Notifications are not available.')).toBeInTheDocument()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(queries).toHaveLength(0)
})
it('lists notifications with markdown bodies rendered without HTML', async () => {
const queries = serveInbox({
items: [
item(5, { title: 'Export finished', severity: 'SUCCESS', category: 'JOB', body: 'Your **export** is ready. <img src=x onerror=alert(1)>', href: '/app/exports/5', linkLabel: 'Download', unread: true }),
item(4, { title: 'Key rotated', severity: 'CRITICAL', category: 'SECURITY', body: '[docs](https://qrun.io/docs) and [bad](javascript:alert(1))' }),
],
total: 2,
offset: 0,
limit: 25,
})
const reads: unknown[] = []
server.use(http.post('/qqq/v1/alerts/inbox/read', async ({ request }) => {
reads.push(await request.json())
return HttpResponse.json({ updated: 1 })
}))
const user = userEvent.setup()
const { container } = renderPage()
const list = await screen.findByRole('list', { name: 'Notifications' })
expect(await axe(container)).toHaveNoViolations()
expect(queries[0].get('status')).toBe('all')
expect(queries[0].get('offset')).toBe('0')
expect(queries[0].get('limit')).toBe('25')
expect(queries[0].has('category')).toBe(false)
const items = within(list).getAllByRole('listitem')
expect(items[0]).toHaveTextContent('Job · QQQ · 1 h ago')
expect(within(items[0]).getByText('export').tagName).toBe('STRONG')
expect(container.querySelector('img')).toBeNull()
expect(within(items[0]).getByText('(unread)', { exact: false })).toBeInTheDocument()
expect(within(items[1]).getByText('Critical')).toBeInTheDocument()
expect(within(items[1]).getByRole('link', { name: 'docs' })).toHaveAttribute('href', 'https://qrun.io/docs')
expect(within(items[1]).queryByRole('link', { name: 'bad' })).not.toBeInTheDocument()
expect(screen.getByText('1–2 of 2')).toBeInTheDocument()
await user.click(within(items[0]).getByRole('link', { name: 'Download' }))
expect(nav.push).toHaveBeenCalledWith('/app/exports/5')
await waitFor(() => expect(reads).toEqual([{ id: 5 }]))
})
it('puts filters in the URL and sends them to the inbox', async () => {
nav.params = new URLSearchParams('status=unread&category=SECURITY&severity=ERROR&offset=25')
const queries = serveInbox({ items: [], total: 0, offset: 25, limit: 25 })
const user = userEvent.setup()
renderPage()
expect(await screen.findByText('No notifications match these filters.')).toBeInTheDocument()
expect(queries[0].get('status')).toBe('unread')
expect(queries[0].get('category')).toBe('SECURITY')
expect(queries[0].get('severity')).toBe('ERROR')
expect(queries[0].get('offset')).toBe('25')
expect(screen.getByRole('button', { name: 'Unread' })).toHaveAttribute('aria-pressed', 'true')
await user.click(screen.getByRole('button', { name: 'All' }))
expect(nav.replace).toHaveBeenLastCalledWith('/app/notifications?category=SECURITY&severity=ERROR', { scroll: false })
await user.selectOptions(screen.getByRole('combobox', { name: 'Category' }), 'RELEASE')
expect(nav.replace).toHaveBeenLastCalledWith('/app/notifications?status=unread&category=RELEASE&severity=ERROR', { scroll: false })
await user.selectOptions(screen.getByRole('combobox', { name: 'Severity' }), '')
expect(nav.replace).toHaveBeenLastCalledWith('/app/notifications?status=unread&category=SECURITY', { scroll: false })
})
it('pages through the inbox', async () => {
nav.params = new URLSearchParams('offset=25')
serveInbox({ items: Array.from({ length: 25 }, (_, index) => item(100 - index)), total: 60, offset: 25, limit: 25 })
const user = userEvent.setup()
renderPage()
expect(await screen.findByText('26–50 of 60')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Next' }))
expect(nav.replace).toHaveBeenLastCalledWith('/app/notifications?offset=50', { scroll: false })
await user.click(screen.getByRole('button', { name: 'Previous' }))
expect(nav.replace).toHaveBeenLastCalledWith('/app/notifications', { scroll: false })
})
it('marks read, unread and all read, and dismisses at once', async () => {
serveInbox({ items: [item(1, { unread: true }), item(2)], total: 2, offset: 0, limit: 25 })
const bodies: Array<[string, unknown]> = []
server.use(http.post('/qqq/v1/alerts/inbox/:action', async ({ request, params }) => {
bodies.push([String(params.action), await request.json()])
return HttpResponse.json({ updated: 1 })
}))
const user = userEvent.setup()
renderPage()
await screen.findByRole('list', { name: 'Notifications' })
await user.click(screen.getByRole('button', { name: 'Mark read: Notice 1' }))
await user.click(screen.getByRole('button', { name: 'Mark unread: Notice 2' }))
await user.click(screen.getByRole('button', { name: 'Mark all read' }))
await user.click(screen.getByRole('button', { name: 'Dismiss: Notice 2' }))
await waitFor(() => expect(bodies).toEqual([['read', { id: 1 }], ['unread', { id: 2 }], ['read', { all: true }], ['dismiss', { id: 2 }]]))
})
it('shows older items without actions, and the empty and error states', async () => {
serveInbox({ items: [{ alertId: 3, title: 'Legacy alert', severity: 'ERROR', group: 'CDN', age: '2 h', unread: true }], total: 1 })
const first = renderPage()
const list = await screen.findByRole('list', { name: 'Notifications' })
expect(list).toHaveTextContent('CDN · 2 h ago')
expect(within(list).queryByRole('button')).not.toBeInTheDocument()
first.unmount()
serveInbox({ items: [], total: 0 })
const empty = renderPage()
expect(await screen.findByText('You have no notifications.')).toBeInTheDocument()
empty.unmount()
server.use(http.get('/qqq/v1/alerts/inbox', () => new HttpResponse(null, { status: 500 })))
renderPage()
expect(await screen.findByRole('alert')).toHaveTextContent('Could not load notifications.')
serveInbox({ items: [item(9)], total: 1 })
await userEvent.setup().click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Notice 9')).toBeInTheDocument()
})
})
+272
View File
@@ -0,0 +1,272 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file NotificationsPage — the notification center's full list at `/app/notifications`
* (loaded on demand): All/Unread, category and severity filters, paging, markdown bodies
* (rendered without HTML), and the bell's actions. Filters and the offset live in the URL.
* Like the bell, it sends no request unless the instance metadata announces Alerts.
*/
'use client'
import React, { useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { getAlertsInbox } from '@/lib/api/alerts'
import { useQContext } from '@/lib/context/q-context'
import { useInboxActions } from '@/lib/hooks/use-inbox-actions'
import { useMetaData } from '@/lib/hooks/use-metadata'
import { queryKeys } from '@/lib/query-client'
import type { InboxFilter, InboxItem } from '@/types/alerts'
import { NOTIFICATION_CATEGORIES, NOTIFICATION_SEVERITIES } from '@/types/alerts'
import { BUTTON_CLASS } from '@/components/ui/controls'
import { MarkdownView } from '@/components/sql-workbench/MarkdownView'
import { isAlertsInstalled } from './AlertsBell'
import { ItemActions, SeverityIcon, itemMeta, linkKind, readRequest, severityLabel } from './NotificationParts'
/** Items per page. */
export const PAGE_SIZE = 25
const SELECT_CLASS = 'rounded-md border border-input bg-card px-2 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring'
/**
* "SECURITY" → "Security".
*
* @param value - An upper-case name.
* @returns The label.
*/
const titleCase = (value: string) => value.charAt(0) + value.slice(1).toLowerCase()
/**
* Reads the filter from the URL.
*
* @param params - The search params.
* @returns The filter.
*/
export function filterFromParams(params: URLSearchParams): InboxFilter {
const offset = Number.parseInt(params.get('offset') ?? '0', 10)
return {
status: params.get('status') === 'unread' ? 'unread' : 'all',
category: params.get('category') ?? '',
severity: params.get('severity') ?? '',
offset: Number.isFinite(offset) && offset > 0 ? offset : 0,
limit: PAGE_SIZE,
}
}
/**
* The page's URL for a filter (defaults left out).
*
* @param filter - The filter.
* @returns The path with its query.
*/
function pagePath(filter: InboxFilter): string {
const params = new URLSearchParams()
if (filter.status === 'unread') params.set('status', 'unread')
if (filter.category) params.set('category', filter.category)
if (filter.severity) params.set('severity', filter.severity)
if (filter.offset > 0) params.set('offset', String(filter.offset))
const query = params.toString()
return `/app/notifications${query ? `?${query}` : ''}`
}
/**
* The loading indicator.
*
* @returns The indicator.
*/
function Loading() {
return (
<p className="flex items-center gap-2 p-6 text-sm text-muted-foreground" role="status" data-qqq-id="notifications-loading">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Loading notifications…
</p>
)
}
/**
* The notifications page.
*
* @returns The page.
*/
export function NotificationsPage() {
const { setPageHeader } = useQContext()
useEffect(() => setPageHeader('Notifications'), [setPageHeader])
const { data: metaData, isLoading } = useMetaData()
if (isLoading) return <Loading />
if (!isAlertsInstalled(metaData)) {
return (
<p className="p-6 text-sm text-muted-foreground" data-qqq-id="notifications-unavailable">
Notifications are not available.
</p>
)
}
return <NotificationsList />
}
/**
* The filters, list and paging.
*
* @returns The list.
*/
function NotificationsList() {
const router = useRouter()
const searchParams = useSearchParams()
const filter = filterFromParams(new URLSearchParams(searchParams?.toString() ?? ''))
const navigate = (next: Partial<InboxFilter>) => router.replace(pagePath({ ...filter, offset: 0, ...next }), { scroll: false })
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.alertsInboxPage(filter),
queryFn: () => getAlertsInbox(filter),
placeholderData: keepPreviousData,
retry: false,
})
const actions = useInboxActions()
const first = data && data.total > 0 ? data.offset + 1 : 0
const last = data ? Math.min(data.offset + data.items.length, data.total) : 0
const filtered = filter.status === 'unread' || filter.category !== '' || filter.severity !== ''
/**
* Follows an item's link, marking it read.
*
* @param item - The item.
* @param event - The click.
*/
const follow = (item: InboxItem, event: React.MouseEvent) => {
const read = readRequest(item)
if (read) actions.mutate(read)
if (linkKind(item.href) === 'app' && item.href) {
event.preventDefault()
router.push(item.href)
}
}
return (
<div className="mx-auto w-full max-w-4xl space-y-4" data-qqq-id="notifications-page">
<div className="flex flex-wrap items-center gap-2">
<div role="group" aria-label="Show" className="inline-flex rounded-md border border-input bg-card p-0.5">
{(['all', 'unread'] as const).map((status) => (
<button
key={status}
type="button"
aria-pressed={filter.status === status}
onClick={() => navigate({ status })}
className={`rounded px-3 py-1 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring ${filter.status === status ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-accent'}`}
data-qqq-id={`notifications-status-${status}`}
>
{status === 'all' ? 'All' : 'Unread'}
</button>
))}
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
Category
<select value={filter.category} onChange={(event) => navigate({ category: event.target.value })} className={SELECT_CLASS} data-qqq-id="notifications-category">
<option value="">All</option>
{NOTIFICATION_CATEGORIES.map((category) => (
<option key={category} value={category}>{titleCase(category)}</option>
))}
</select>
</label>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
Severity
<select value={filter.severity} onChange={(event) => navigate({ severity: event.target.value })} className={SELECT_CLASS} data-qqq-id="notifications-severity">
<option value="">All</option>
{NOTIFICATION_SEVERITIES.map((severity) => (
<option key={severity} value={severity}>{severityLabel(severity)}</option>
))}
</select>
</label>
<button
type="button"
className={`${BUTTON_CLASS} sm:ml-auto`}
disabled={!data?.items.some((item) => item.unread)}
onClick={() => actions.mutate({ action: 'read', target: { all: true } })}
data-qqq-id="notifications-mark-all-read"
>
Mark all read
</button>
</div>
{isLoading ? (
<Loading />
) : isError ? (
<div className="flex flex-wrap items-center gap-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive" role="alert">
Could not load notifications.
<button type="button" className={BUTTON_CLASS} onClick={() => void refetch()}>
Retry
</button>
</div>
) : !data || data.items.length === 0 ? (
<p className="rounded-lg border border-border bg-card px-4 py-8 text-center text-sm text-muted-foreground" data-qqq-id="notifications-empty">
{filtered ? 'No notifications match these filters.' : 'You have no notifications.'}
</p>
) : (
<ul className="space-y-2" aria-label="Notifications">
{data.items.map((item) => {
const kind = linkKind(item.href)
return (
<li
key={item.key}
className={`flex items-start gap-3 rounded-lg border border-border bg-card py-3 pl-4 pr-2 text-sm ${item.unread ? '' : 'opacity-80'}`}
data-qqq-id={`notifications-item-${item.key}`}
>
<SeverityIcon severity={item.severity} />
<div className="min-w-0 flex-1 space-y-1">
<h2 className={`break-words text-sm text-foreground ${item.unread ? 'font-semibold' : 'font-medium'}`}>
{item.unread && <span className="mr-1.5 inline-block h-2 w-2 rounded-full bg-primary align-middle" aria-hidden="true" />}
{item.title}
{item.unread && <span className="sr-only"> (unread)</span>}
</h2>
<p className="text-xs text-muted-foreground">{itemMeta(item, true)}</p>
{item.body && <MarkdownView source={item.body} />}
{kind && (
<a
href={item.href ?? undefined}
target={kind === 'external' ? '_blank' : undefined}
rel={kind === 'external' ? 'noopener noreferrer' : undefined}
onClick={(event) => follow(item, event)}
className="inline-block font-medium text-primary underline underline-offset-2"
>
{item.linkLabel ?? 'Open'}
</a>
)}
</div>
<ItemActions item={item} onAction={(request) => actions.mutate(request)} />
</li>
)
})}
</ul>
)}
{data && data.total > 0 && (
<nav aria-label="Pages" className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<span>{`${first}–${last} of ${data.total}`}</span>
<span className="flex gap-2">
<button type="button" className={BUTTON_CLASS} disabled={filter.offset === 0} onClick={() => navigate({ offset: Math.max(0, filter.offset - PAGE_SIZE) })} data-qqq-id="notifications-previous">
Previous
</button>
<button type="button" className={BUTTON_CLASS} disabled={filter.offset + PAGE_SIZE >= data.total} onClick={() => navigate({ offset: filter.offset + PAGE_SIZE })} data-qqq-id="notifications-next">
Next
</button>
</span>
</nav>
)}
</div>
)
}
@@ -17,7 +17,8 @@
/**
* @file MarkdownView — renders a markdown cell's text as React elements (never as HTML):
* headings, paragraphs, bullet and numbered lists, fenced code, and inline bold, italic, code
* and http(s) links. Anything else is shown as text, so a notebook can never inject markup.
* and http(s) or relative app-path links. Anything else is shown as text, so a notebook (or a
* notification body) can never inject markup.
*/
'use client'
@@ -33,7 +34,7 @@ import React from 'react'
*/
export function renderInline(text: string, keyPrefix: string): React.ReactNode[] {
const nodes: React.ReactNode[] = []
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\((https?:\/\/[^\s)]+)\))/g
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\((https?:\/\/[^\s)]+|\/(?![/\\])[^\s)]*)\))/g
let last = 0
let match: RegExpExecArray | null
let index = 0
@@ -53,12 +54,13 @@ export function renderInline(text: string, keyPrefix: string): React.ReactNode[]
nodes.push(<em key={key}>{token.slice(1, -1)}</em>)
} else {
const label = token.slice(1, token.indexOf(']'))
const external = !match[2].startsWith('/')
nodes.push(
<a
key={key}
href={match[2]}
target="_blank"
rel="noopener noreferrer"
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
className="text-primary underline"
>
{label}
@@ -133,6 +133,14 @@ describe('MarkdownView', () => {
const { container } = render(<MarkdownView source="[x](javascript:alert(1))" />)
expect(container.querySelector('a')).toBeNull()
})
it('links relative app paths in the same tab, but not protocol-relative URLs', () => {
const { container } = render(<MarkdownView source="[open](/app/orders/7) and [bad](//evil.example)" />)
const links = container.querySelectorAll('a')
expect(links).toHaveLength(1)
expect(links[0]).toHaveAttribute('href', '/app/orders/7')
expect(links[0]).not.toHaveAttribute('target')
})
})
describe('PlanTree', () => {
+60 -8
View File
@@ -20,12 +20,32 @@ import { http, HttpResponse } from 'msw'
import { describe, expect, it } from 'vitest'
import { server } from '@/mocks/node'
import { getAlertsInboxSummary, getAlertsRecordPanel, markAlertsRead, watchAlertsRecord } from './alerts'
import { getAlertsInbox, getAlertsInboxSummary, getAlertsRecordPanel, normalizeInboxItem, postInboxAction, watchAlertsRecord } from './alerts'
describe('getAlertsInboxSummary', () => {
it('returns the summary, with defaults filled in', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({ unreadCount: 1, items: [] })))
await expect(getAlertsInboxSummary()).resolves.toEqual({ unreadCount: 1, highestUnreadSeverity: null, items: [], alertsHref: '/app/alerts', pollSeconds: undefined })
await expect(getAlertsInboxSummary()).resolves.toEqual({ unreadCount: 1, highestUnreadSeverity: null, items: [], banners: [], notificationsHref: '/app/notifications', pollSeconds: undefined })
})
it('normalizes items and banners, and keeps an app notificationsHref', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({
unreadCount: 1,
highestUnreadSeverity: 'SUCCESS',
notificationsHref: '/app/inbox',
pollSeconds: 30,
items: [{ id: 3, title: 'Hi', severity: 'SUCCESS', category: 'JOB', source: 'Jobs', unread: true }, { title: '' }, null],
banners: [{ id: 4, title: 'Down tonight', severity: 'WARNING', body: '**soon**', sticky: true }],
})))
const summary = await getAlertsInboxSummary()
expect(summary?.highestUnreadSeverity).toBe('SUCCESS')
expect(summary?.notificationsHref).toBe('/app/inbox')
expect(summary?.pollSeconds).toBe(30)
expect(summary?.items).toEqual([expect.objectContaining({ key: 'n3', id: 3, title: 'Hi', category: 'JOB', source: 'Jobs', unread: true })])
expect(summary?.banners).toEqual([expect.objectContaining({ key: 'n4', body: '**soon**', sticky: true, unread: false })])
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({ unreadCount: 0, items: [], notificationsHref: 'https://evil.example', highestUnreadSeverity: 'BOGUS' })))
await expect(getAlertsInboxSummary()).resolves.toMatchObject({ notificationsHref: '/app/notifications', highestUnreadSeverity: null })
})
it('returns null for 403, 404, and non-summary bodies', async () => {
@@ -45,12 +65,42 @@ describe('getAlertsInboxSummary', () => {
})
})
describe('markAlertsRead and watchAlertsRecord', () => {
describe('normalizeInboxItem', () => {
it('reads older backends: alertId, group and raisedAt', () => {
expect(normalizeInboxItem({ alertId: 6, title: 'Old', severity: 'ERROR', group: 'CDN', raisedAt: '2026-10-01T00:00:00Z', status: 'OPEN' }, 0)).toEqual({
key: 'a6', id: undefined, notificationId: undefined, alertId: 6, title: 'Old', body: null, severity: 'ERROR', category: null, source: 'CDN',
href: null, linkLabel: null, deliveredAt: '2026-10-01T00:00:00Z', age: null, unread: false, sticky: false,
})
expect(normalizeInboxItem({ title: 'Bare', severity: 'NOPE' }, 2)).toMatchObject({ key: 'i2', severity: 'INFO' })
expect(normalizeInboxItem('x', 0)).toBeNull()
})
})
describe('getAlertsInbox', () => {
it('sends the filter and normalizes the page', async () => {
let query: URLSearchParams | null = null
server.use(http.get('/qqq/v1/alerts/inbox', ({ request }) => {
query = new URL(request.url).searchParams
return HttpResponse.json({ items: [{ id: 1, title: 'A', severity: 'INFO' }], total: 40, offset: 25, limit: 25 })
}))
await expect(getAlertsInbox({ status: 'unread', category: 'JOB', severity: 'ERROR', offset: 25, limit: 25 })).resolves.toMatchObject({ total: 40, offset: 25, limit: 25, items: [{ id: 1 }] })
expect(Object.fromEntries(query!)).toEqual({ status: 'unread', category: 'JOB', severity: 'ERROR', offset: '25', limit: '25' })
})
it('fills paging from the filter for a sparse body', async () => {
server.use(http.get('/qqq/v1/alerts/inbox', () => HttpResponse.json({ items: [{ id: 1, title: 'A' }] })))
await expect(getAlertsInbox({ status: 'all', category: '', severity: '', offset: 0, limit: 25 })).resolves.toMatchObject({ total: 1, offset: 0, limit: 25 })
server.use(http.get('/qqq/v1/alerts/inbox', () => HttpResponse.json(null)))
await expect(getAlertsInbox({ status: 'all', category: '', severity: '', offset: 0, limit: 25 })).resolves.toMatchObject({ items: [], total: 0 })
})
})
describe('postInboxAction and watchAlertsRecord', () => {
it('posts JSON bodies and returns the counts', async () => {
const bodies: unknown[] = []
server.use(
http.post('/qqq/v1/alerts/inbox/read', async ({ request }) => {
bodies.push(await request.json())
http.post('/qqq/v1/alerts/inbox/:action', async ({ request, params }) => {
bodies.push([params.action, await request.json()])
return HttpResponse.json({ updated: 3 })
}),
http.post('/qqq/v1/alerts/record-panel/watch', async ({ request }) => {
@@ -58,9 +108,11 @@ describe('markAlertsRead and watchAlertsRecord', () => {
return HttpResponse.json({ created: true })
})
)
await expect(markAlertsRead({ all: true })).resolves.toBe(3)
await expect(postInboxAction('read', { all: true })).resolves.toBe(3)
await expect(postInboxAction('unread', { id: 4 })).resolves.toBe(3)
await expect(postInboxAction('dismiss', { ids: [4, 5] })).resolves.toBe(3)
await expect(watchAlertsRecord('blog_post', 5)).resolves.toBe(true)
expect(bodies).toEqual([{ all: true }, { tableName: 'blog_post', recordId: 5 }])
expect(bodies).toEqual([['read', { all: true }], ['unread', { id: 4 }], ['dismiss', { ids: [4, 5] }], { tableName: 'blog_post', recordId: 5 }])
})
it('treats odd bodies as zero / false', async () => {
@@ -68,7 +120,7 @@ describe('markAlertsRead and watchAlertsRecord', () => {
http.post('/qqq/v1/alerts/inbox/read', () => HttpResponse.json({})),
http.post('/qqq/v1/alerts/record-panel/watch', () => HttpResponse.json({ created: false }))
)
await expect(markAlertsRead({ alertId: 1 })).resolves.toBe(0)
await expect(postInboxAction('read', { alertId: 1 })).resolves.toBe(0)
await expect(watchAlertsRecord('blog_post', 5)).resolves.toBe(false)
})
})
+98 -14
View File
@@ -15,19 +15,85 @@
*/
/**
* @file Alerts API — the Alerts qbit's bell (`/alerts/inbox/*`) and record panel
* @file Alerts API — the Alerts qbit's notification center (`/alerts/inbox*`) and record panel
* (`/alerts/record-panel*`). Callers only request these when the instance metadata lists the
* qbit's `alerts` app (see `isAlertsInstalled`). As a fallback, 403 and 404 from the inbox
* summary (or a body that is not a summary, e.g. a root SPA's not-found page) still mean Alerts
* is not for this user, so the bell and panel stay hidden.
* is not for this user, so the bell and panel stay hidden. Items are normalized so older
* backends (qbit-alerts 0.1.0-RC.1: `alertId`, `group`, no `id`) still render.
*/
import type { AlertsInboxSummary, AlertsRecordPanel } from '@/types/alerts'
import type { AlertsInboxSummary, AlertsRecordPanel, InboxFilter, InboxItem, InboxPage, InboxTarget, NotificationSeverity } from '@/types/alerts'
import { getErrorStatusCode } from '@/lib/utils/error-utils'
import apiClient from './client'
/** The notifications page, unless the backend names another app path. */
export const NOTIFICATIONS_PATH = '/app/notifications'
const SEVERITIES = ['INFO', 'SUCCESS', 'WARNING', 'ERROR', 'CRITICAL']
/**
* Loads the bell: unread count, highest unread severity and the latest items.
* A finite number, or undefined.
*
* @param value - Any value.
* @returns The number, or undefined.
*/
const num = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
/**
* A non-empty string, or null.
*
* @param value - Any value.
* @returns The string, or null.
*/
const str = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null)
/**
* Normalizes one item of either backend shape; null when it has no title.
*
* @param raw - The item as sent.
* @param index - Its position (the fallback key).
* @returns The item, or null.
*/
export function normalizeInboxItem(raw: unknown, index: number): InboxItem | null {
if (!raw || typeof raw !== 'object') return null
const item = raw as Record<string, unknown>
const title = str(item.title)
if (!title) return null
const id = num(item.id)
const alertId = num(item.alertId)
const severity = SEVERITIES.includes(item.severity as string) ? (item.severity as NotificationSeverity) : 'INFO'
return {
key: id !== undefined ? `n${id}` : alertId !== undefined ? `a${alertId}` : `i${index}`,
id,
notificationId: num(item.notificationId),
alertId,
title,
body: str(item.body),
severity,
category: str(item.category),
source: str(item.source) ?? str(item.group),
href: str(item.href),
linkLabel: str(item.linkLabel),
deliveredAt: str(item.deliveredAt) ?? str(item.raisedAt),
age: str(item.age),
unread: item.unread === true,
sticky: item.sticky === true,
}
}
/**
* Normalizes a list of items, dropping any without a title.
*
* @param raw - The list as sent.
* @returns The items.
*/
function normalizeItems(raw: unknown): InboxItem[] {
return Array.isArray(raw) ? raw.map(normalizeInboxItem).filter((item): item is InboxItem => item !== null) : []
}
/**
* Loads the bell: unread count, highest unread severity, the latest items and the banners.
*
* @returns The summary, or `null` when Alerts is not available to this user.
* @throws The request error for any other failure.
@@ -42,25 +108,43 @@ export async function getAlertsInboxSummary(): Promise<AlertsInboxSummary | null
throw error
}
if (!data || typeof data !== 'object' || Array.isArray(data)) return null
const summary = data as Partial<AlertsInboxSummary>
const summary = data as Record<string, unknown>
if (typeof summary.unreadCount !== 'number' || !Array.isArray(summary.items)) return null
const href = str(summary.notificationsHref)
return {
unreadCount: summary.unreadCount,
highestUnreadSeverity: summary.highestUnreadSeverity ?? null,
items: summary.items,
alertsHref: typeof summary.alertsHref === 'string' ? summary.alertsHref : '/app/alerts',
pollSeconds: summary.pollSeconds,
highestUnreadSeverity: SEVERITIES.includes(summary.highestUnreadSeverity as string) ? (summary.highestUnreadSeverity as NotificationSeverity) : null,
items: normalizeItems(summary.items),
banners: normalizeItems(summary.banners),
notificationsHref: href && href.startsWith('/app/') ? href : NOTIFICATIONS_PATH,
pollSeconds: num(summary.pollSeconds),
}
}
/**
* Marks one alert's inbox item read, or all of the user's.
* Loads one page of the inbox.
*
* @param target - `{ alertId }` for one item, or `{ all: true }`.
* @returns How many items were marked read.
* @param filter - Status, category, severity and paging.
* @returns The page (an empty page for a body that is not one).
*/
export async function markAlertsRead(target: { alertId: number } | { all: true }): Promise<number> {
const data = await apiClient.post<{ updated?: number }>('/alerts/inbox/read', target)
export async function getAlertsInbox(filter: InboxFilter): Promise<InboxPage> {
const params: Record<string, string> = { status: filter.status, offset: String(filter.offset), limit: String(filter.limit) }
if (filter.category) params.category = filter.category
if (filter.severity) params.severity = filter.severity
const data = await apiClient.get<Record<string, unknown> | null>('/alerts/inbox', { params })
const items = normalizeItems(data?.items)
return { items, total: num(data?.total) ?? items.length, offset: num(data?.offset) ?? filter.offset, limit: num(data?.limit) ?? filter.limit }
}
/**
* Posts an inbox action.
*
* @param action - `read`, `unread` or `dismiss`.
* @param target - `{id}`, `{ids}`, `{all: true}`, or (read only, older backends) `{alertId}`.
* @returns How many items changed.
*/
export async function postInboxAction(action: 'read' | 'unread' | 'dismiss', target: InboxTarget): Promise<number> {
const data = await apiClient.post<{ updated?: number }>(`/alerts/inbox/${action}`, target)
return typeof data?.updated === 'number' ? data.updated : 0
}
+103
View File
@@ -0,0 +1,103 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Tests for the optimistic inbox updates and the notification helpers.
import { describe, expect, it } from 'vitest'
import type { AlertsInboxSummary, InboxItem } from '@/types/alerts'
import { linkKind, relativeTime } from '@/components/alerts/NotificationParts'
import { applyToItems, applyToPage, applyToSummary, targets } from './use-inbox-actions'
const item = (id: number, unread: boolean, extra: Partial<InboxItem> = {}): InboxItem => ({ key: `n${id}`, id, title: `T${id}`, severity: 'INFO', unread, ...extra })
const SUMMARY: AlertsInboxSummary = {
unreadCount: 5,
highestUnreadSeverity: 'ERROR',
items: [item(1, true), item(2, true), item(3, false)],
banners: [item(2, true, { sticky: true })],
notificationsHref: '/app/notifications',
}
describe('targets', () => {
it('matches by id, ids, all, or alert id', () => {
expect(targets(item(1, true), { id: 1 })).toBe(true)
expect(targets(item(1, true), { ids: [2, 1] })).toBe(true)
expect(targets({ key: 'a', title: 'x', severity: 'INFO', unread: true }, { ids: [1] })).toBe(false)
expect(targets(item(1, true), { all: true })).toBe(true)
expect(targets(item(1, true, { alertId: 9 }), { alertId: 9 })).toBe(true)
expect(targets(item(1, true), { id: 2 })).toBe(false)
})
})
describe('applyToItems', () => {
it('reads, unreads and dismisses, counting unread changes', () => {
expect(applyToItems(SUMMARY.items, { action: 'read', target: { id: 1 } }).unreadDelta).toBe(-1)
expect(applyToItems(SUMMARY.items, { action: 'read', target: { id: 3 } }).unreadDelta).toBe(0)
expect(applyToItems(SUMMARY.items, { action: 'unread', target: { id: 3 } }).unreadDelta).toBe(1)
const dismissed = applyToItems(SUMMARY.items, { action: 'dismiss', target: { ids: [2, 3] } })
expect(dismissed.items.map((each) => each.id)).toEqual([1])
expect(dismissed.unreadDelta).toBe(-1)
})
})
describe('applyToSummary', () => {
it('adjusts the unread count from the server total, not the visible items', () => {
expect(applyToSummary(SUMMARY, { action: 'read', target: { id: 1 } })).toMatchObject({ unreadCount: 4, highestUnreadSeverity: 'ERROR' })
expect(applyToSummary(SUMMARY, { action: 'read', target: { all: true } })).toMatchObject({ unreadCount: 0, highestUnreadSeverity: null })
const dismissed = applyToSummary(SUMMARY, { action: 'dismiss', target: { id: 2 } })
expect(dismissed.banners).toEqual([])
expect(dismissed.unreadCount).toBe(4)
expect(applyToSummary(SUMMARY, { action: 'read', target: { id: 2 } }).banners).toBe(SUMMARY.banners)
})
})
describe('applyToPage', () => {
it('drops dismissed items from the page and its total', () => {
expect(applyToPage({ items: SUMMARY.items, total: 30, offset: 0, limit: 25 }, { action: 'dismiss', target: { id: 1 } })).toMatchObject({ total: 29, items: [{ id: 2 }, { id: 3 }] })
expect(applyToPage({ items: SUMMARY.items, total: 3, offset: 0, limit: 25 }, { action: 'read', target: { all: true } }).items.every((each) => !each.unread)).toBe(true)
})
})
describe('relativeTime and linkKind', () => {
it('prefers the server age, else computes from the delivery time', () => {
const now = Date.parse('2026-10-13T12:00:00Z')
expect(relativeTime({ age: '3 h' })).toBe('3 h ago')
expect(relativeTime({ age: 'just now' })).toBe('just now')
expect(relativeTime({ deliveredAt: '2026-10-13T11:59:50Z' }, now)).toBe('just now')
expect(relativeTime({ deliveredAt: '2026-10-13T11:45:00Z' }, now)).toBe('15 min ago')
expect(relativeTime({ deliveredAt: '2026-10-13T09:00:00Z' }, now)).toBe('3 h ago')
expect(relativeTime({ deliveredAt: '2026-10-10T12:00:00Z' }, now)).toBe('3 d ago')
expect(relativeTime({ deliveredAt: 'nope' }, now)).toBe('')
expect(relativeTime({}, now)).toBe('')
})
it('links only same-origin app paths and https, refusing open redirects', () => {
expect(linkKind('/app/x')).toBe('app')
expect(linkKind('https://qrun.io/releases')).toBe('external')
expect(linkKind('//evil.com')).toBeNull()
expect(linkKind('/\\evil.com')).toBeNull()
expect(linkKind('\\\\evil.com')).toBeNull()
expect(linkKind('/app/x\\y')).toBeNull()
expect(linkKind('/app/ x')).toBeNull()
expect(linkKind('/app/\tx')).toBeNull()
expect(linkKind('javascript:alert(1)')).toBeNull()
expect(linkKind('data:text/html,x')).toBeNull()
expect(linkKind('http://x')).toBeNull()
expect(linkKind('https://')).toBeNull()
expect(linkKind(null)).toBeNull()
})
})
+137
View File
@@ -0,0 +1,137 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file useInboxActions — read, unread and dismiss for the notification center, applied
* optimistically to the bell's summary and every loaded page of the notifications page, rolled
* back (with a toast) on failure, and followed by a refetch of both. Used only by the lazily loaded popover,
* page and banner, so it stays out of the first load.
*/
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { postInboxAction } from '@/lib/api/alerts'
import { queryKeys } from '@/lib/query-client'
import type { AlertsInboxSummary, InboxItem, InboxPage, InboxTarget } from '@/types/alerts'
/** An inbox action. */
export type InboxAction = 'read' | 'unread' | 'dismiss'
/** One action and its target. */
export interface InboxActionRequest {
/** What to do. */
action: InboxAction
/** Which items. */
target: InboxTarget
}
/**
* Whether an item is the action's target.
*
* @param item - The item.
* @param target - The target.
* @returns True when it matches.
*/
export function targets(item: InboxItem, target: InboxTarget): boolean {
if ('all' in target) return true
if ('ids' in target) return item.id !== undefined && target.ids.includes(item.id)
if ('id' in target) return item.id === target.id
return item.alertId === target.alertId
}
/**
* Applies an action to a list of items.
*
* @param items - The items.
* @param request - The action and its target.
* @returns The new items and how the unread count changes.
*/
export function applyToItems(items: InboxItem[], { action, target }: InboxActionRequest): { items: InboxItem[]; unreadDelta: number } {
let unreadDelta = 0
const next: InboxItem[] = []
for (const item of items) {
if (!targets(item, target)) {
next.push(item)
} else if (action === 'dismiss') {
if (item.unread) unreadDelta--
} else {
const unread = action === 'unread'
if (item.unread !== unread) unreadDelta += unread ? 1 : -1
next.push({ ...item, unread })
}
}
return { items: next, unreadDelta }
}
/**
* Applies an action to the bell's summary.
*
* @param summary - The summary.
* @param request - The action and its target.
* @returns The new summary.
*/
export function applyToSummary(summary: AlertsInboxSummary, request: InboxActionRequest): AlertsInboxSummary {
const { items, unreadDelta } = applyToItems(summary.items, request)
const banners = request.action === 'dismiss' ? applyToItems(summary.banners, request).items : summary.banners
const allRead = request.action === 'read' && 'all' in request.target
const unreadCount = allRead ? 0 : Math.max(0, summary.unreadCount + unreadDelta)
return { ...summary, items, banners, unreadCount, highestUnreadSeverity: unreadCount === 0 ? null : summary.highestUnreadSeverity }
}
/**
* Applies an action to one page of the notifications page.
*
* @param page - The page.
* @param request - The action and its target.
* @returns The new page.
*/
export function applyToPage(page: InboxPage, request: InboxActionRequest): InboxPage {
const { items } = applyToItems(page.items, request)
return { ...page, items, total: Math.max(0, page.total - (page.items.length - items.length)) }
}
/**
* The mutation behind every inbox action.
*
* @returns A TanStack mutation taking an {@link InboxActionRequest}.
*/
export function useInboxActions() {
const queryClient = useQueryClient()
const summaryKey = queryKeys.alertsInbox()
const pageKey = queryKeys.alertsInboxPage()
return useMutation({
mutationFn: ({ action, target }: InboxActionRequest) => postInboxAction(action, target),
onMutate: async (request: InboxActionRequest) => {
await Promise.all([queryClient.cancelQueries({ queryKey: summaryKey }), queryClient.cancelQueries({ queryKey: pageKey })])
const summary = queryClient.getQueryData<AlertsInboxSummary | null>(summaryKey)
const pages = queryClient.getQueriesData<InboxPage>({ queryKey: pageKey })
if (summary) queryClient.setQueryData(summaryKey, applyToSummary(summary, request))
for (const [key, page] of pages) if (page) queryClient.setQueryData(key, applyToPage(page, request))
return { summary, pages }
},
onError: (_error, _request, context) => {
toast.error('Could not update notifications. Try again.')
if (!context) return
if (context.summary) queryClient.setQueryData(summaryKey, context.summary)
for (const [key, page] of context.pages) queryClient.setQueryData(key, page)
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: summaryKey })
void queryClient.invalidateQueries({ queryKey: pageKey })
},
})
}
+1
View File
@@ -101,6 +101,7 @@ const STATIC_PAGE_LABELS: Record<string, string> = {
'/app': 'Dashboard',
'/app/developer': 'Developer',
'/app/search': 'Search',
'/app/notifications': 'Notifications',
}
/**
+7
View File
@@ -455,6 +455,13 @@ export const queryKeys = {
* @returns The inbox summary query key tuple.
*/
alertsInbox: () => [...queryKeys.alerts(), 'inbox'] as const,
/**
* Key for the pages of the notifications page (`/alerts/inbox`); with a filter, one page.
*
* @param filter - The page's filter (omit for every page).
* @returns The inbox page query key tuple.
*/
alertsInboxPage: (filter?: object) => (filter ? ([...queryKeys.alerts(), 'inboxPage', filter] as const) : ([...queryKeys.alerts(), 'inboxPage'] as const)),
/**
* Key for a record's "Open alerts" panel.
*
+89 -6
View File
@@ -15,12 +15,93 @@
*/
/**
* @file Types for the Alerts qbit's bell and record panel (`/qqq/v1/alerts/*`).
* @file Types for the Alerts qbit's notification center (bell, popover, `/app/notifications`,
* banners) and record panel (`/qqq/v1/alerts/*`).
*/
/** An alert's severity, least to most severe. */
export type AlertSeverity = 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL'
/** A notification's severity: the alert severities plus SUCCESS. */
export type NotificationSeverity = 'INFO' | 'SUCCESS' | 'WARNING' | 'ERROR' | 'CRITICAL'
/** The notification categories the page filters on. */
export const NOTIFICATION_CATEGORIES = ['SYSTEM', 'JOB', 'SECURITY', 'RELEASE', 'MARKETING', 'SHARE', 'ALERT'] as const
/** A notification category. */
export type NotificationCategory = (typeof NOTIFICATION_CATEGORIES)[number]
/** The notification severities, least to most severe. */
export const NOTIFICATION_SEVERITIES: readonly NotificationSeverity[] = ['INFO', 'SUCCESS', 'WARNING', 'ERROR', 'CRITICAL']
/**
* One inbox item, normalized from either backend shape: the notification center
* (`{id, notificationId, title, body, severity, category, source, href, linkLabel, deliveredAt,
* age, unread, sticky}`) or qbit-alerts 0.1.0-RC.1 (`{alertId, title, severity, status, group,
* age, href, unread}`). Without `id` and `alertId` the item has no per-item actions.
*/
export interface InboxItem {
/** Stable React key. */
key: string
/** The recipient row id (the target of read, unread and dismiss). */
id?: number
/** The notification's id. */
notificationId?: number
/** Older backends: the alert id (`{alertId}` still marks it read). */
alertId?: number
/** Title. */
title: string
/** Markdown body (the page and banners only). */
body?: string | null
/** Severity. */
severity: NotificationSeverity
/** Category. */
category?: string | null
/** Who sent it (older backends: the alert group). */
source?: string | null
/** Where it links: a relative app path or https. */
href?: string | null
/** The link's label. */
linkLabel?: string | null
/** When it was delivered (ISO). */
deliveredAt?: string | null
/** Relative age from the server ("3 h"). */
age?: string | null
/** Not read yet. */
unread: boolean
/** A banner until dismissed. */
sticky?: boolean
}
/** `GET /alerts/inbox`: one page of the inbox. */
export interface InboxPage {
/** The items, newest first. */
items: InboxItem[]
/** Matching items in all. */
total: number
/** The page's offset. */
offset: number
/** The page size. */
limit: number
}
/** The `/alerts/inbox` filters. */
export interface InboxFilter {
/** `all` or `unread`. */
status: 'all' | 'unread'
/** A category, or empty for every category. */
category: string
/** A severity, or empty for every severity. */
severity: string
/** Offset of the first item. */
offset: number
/** Page size. */
limit: number
}
/** The target of read, unread and dismiss. */
export type InboxTarget = { id: number } | { ids: number[] } | { all: true } | { alertId: number }
/** One alert as the bell and the record panel show it. */
export interface AlertItem {
/** The alert record's id. */
@@ -43,16 +124,18 @@ export interface AlertItem {
unread?: boolean
}
/** `GET /alerts/inbox/summary`: the bell. */
/** `GET /alerts/inbox/summary`: the bell, normalized. */
export interface AlertsInboxSummary {
/** Unread inbox items. */
unreadCount: number
/** The highest severity among unread items, or null. */
highestUnreadSeverity: AlertSeverity | null
highestUnreadSeverity: NotificationSeverity | null
/** The latest items, newest first (at most 10). */
items: AlertItem[]
/** The Alerts app path. */
alertsHref: string
items: InboxItem[]
/** Sticky, undismissed, unexpired items shown as banners. */
banners: InboxItem[]
/** The notifications page path ("View all"). */
notificationsHref: string
/** How often to poll (seconds). */
pollSeconds?: number
}
+221
View File
@@ -0,0 +1,221 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// E2E: the notification center (qbit-alerts) against mocked /qqq/v1/alerts/inbox routes: no
// request without the Alerts app; bell badge, popover, the /app/notifications page and a sticky
// banner, readable at 390 and 1440 px in light and dark mode with no horizontal scrolling, axe,
// and the actions. Set QQQ_NOTIFICATIONS_SCREENSHOTS to a directory to also write screenshots.
import { test, expect, type Page, type Route } from '@playwright/test'
import { METADATA, setupApiMocks } from './api-mocks'
import { checkA11y } from './a11y-helpers'
/** Where screenshots go when set (a directory). */
const SCREENSHOT_DIR = process.env.QQQ_NOTIFICATIONS_SCREENSHOTS
const SEVERITIES = ['CRITICAL', 'ERROR', 'WARNING', 'SUCCESS', 'INFO'] as const
const CATEGORIES = ['ALERT', 'JOB', 'SECURITY', 'RELEASE', 'SYSTEM', 'SHARE', 'MARKETING'] as const
/** Thirty inbox items, newest first. */
const ITEMS = Array.from({ length: 30 }, (_, index) => ({
id: 100 - index,
notificationId: 50 - index,
title: [
'Nightly backup did not run on the primary database cluster',
'Order export finished',
'Your password was changed',
'QQQ 4.1 is available',
'Scheduled maintenance on Saturday',
][index % 5] + (index >= 5 ? ` (${index})` : ''),
body: index % 5 === 0
? 'The **nightly** backup job stopped at 02:14.\n\n- Check the [backup job](/app/person)\n- Read the [runbook](https://qrun.io/docs/backups)'
: 'Details for this notification, with `code` and a [link](/app/person).',
severity: SEVERITIES[index % 5],
category: CATEGORIES[index % 7],
source: ['Backups', 'Jobs', 'Security', 'Releases', 'System'][index % 5],
href: index % 5 === 3 ? 'https://qrun.io/releases/4.1' : '/app/person',
linkLabel: index % 5 === 3 ? 'Release notes' : 'Open',
deliveredAt: new Date(Date.now() - (index + 1) * 47 * 60_000).toISOString(),
age: index === 0 ? 'just now' : `${index * 47} min`,
unread: index < 4,
sticky: false,
}))
const BANNER = {
id: 7,
notificationId: 7,
title: 'Scheduled maintenance tonight from 22:00 to 23:00 UTC',
body: 'The admin will be **read-only** during the window. See the [status page](https://status.qrun.io) for updates.',
severity: 'WARNING',
category: 'SYSTEM',
source: 'System',
href: null,
linkLabel: null,
deliveredAt: new Date().toISOString(),
age: '5 min',
unread: true,
sticky: true,
}
/** Recorded inbox traffic. */
interface InboxMock {
/** Every request path under /alerts. */
requests: string[]
/** POST bodies by action. */
posts: Array<[string, unknown]>
}
/**
* Announces the Alerts app in the metadata (unless `installed` is false) and answers the inbox.
*
* @param page - The page.
* @param installed - Whether the metadata lists the alerts app.
* @returns The recorded traffic.
*/
async function mockInbox(page: Page, installed = true): Promise<InboxMock> {
const mock: InboxMock = { requests: [], posts: [] }
const items = ITEMS.map((item) => ({ ...item }))
let banners = [BANNER]
const json = (route: Route, body: unknown) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
await setupApiMocks(page)
if (installed) {
await page.route(/\/qqq\/v1\/metaData(\?|$)/, (route) => json(route, { ...METADATA, apps: { ...METADATA.apps, alerts: { name: 'alerts', label: 'Alerts', children: [] } } }))
}
await page.route('**/qqq/v1/alerts/**', async (route) => {
const request = route.request()
const url = new URL(request.url())
const path = url.pathname.replace('/qqq/v1/alerts', '')
mock.requests.push(path)
if (path === '/inbox/summary') {
const unread = items.filter((item) => item.unread)
return json(route, {
unreadCount: unread.length,
highestUnreadSeverity: unread.some((item) => item.severity === 'CRITICAL') ? 'CRITICAL' : unread[0]?.severity ?? null,
items: items.slice(0, 10).map(({ body: _body, ...rest }) => rest),
banners,
pollSeconds: 60,
notificationsHref: '/app/notifications',
})
}
if (path === '/inbox') {
const status = url.searchParams.get('status')
const offset = Number(url.searchParams.get('offset') ?? 0)
const limit = Number(url.searchParams.get('limit') ?? 25)
const matching = items.filter((item) => (status !== 'unread' || item.unread)
&& (!url.searchParams.get('category') || item.category === url.searchParams.get('category'))
&& (!url.searchParams.get('severity') || item.severity === url.searchParams.get('severity')))
return json(route, { items: matching.slice(offset, offset + limit), total: matching.length, offset, limit })
}
const action = path.replace('/inbox/', '')
const body = request.postDataJSON() as { id?: number; all?: boolean }
mock.posts.push([action, body])
for (const item of items) {
if (body.all || item.id === body.id) {
if (action === 'read') item.unread = false
if (action === 'unread') item.unread = true
}
}
if (action === 'dismiss') {
banners = banners.filter((banner) => banner.id !== body.id)
const index = items.findIndex((item) => item.id === body.id)
if (index >= 0) items.splice(index, 1)
}
return json(route, { updated: 1 })
})
return mock
}
/**
* Elements that scroll horizontally, and whether the document does.
*
* @param page - The page.
* @returns The offenders.
*/
async function horizontalScroll(page: Page): Promise<string[]> {
return page.evaluate(() => {
const found: string[] = []
const root = document.scrollingElement as HTMLElement
if (root.scrollWidth > window.innerWidth + 1) found.push('document')
for (const element of Array.from(document.querySelectorAll<HTMLElement>('body *'))) {
const style = getComputedStyle(element)
if (/(auto|scroll)/.test(style.overflowX) && element.scrollWidth > element.clientWidth + 1) found.push(element.dataset.qqqId ?? element.tagName)
}
return found
})
}
test.describe('Notification center', () => {
test('sends no Alerts request without the Alerts app', async ({ page }) => {
const mock = await mockInbox(page, false)
await page.goto('/app/person')
await expect(page.getByRole('heading', { name: 'People' }).first()).toBeVisible()
await page.waitForTimeout(500)
expect(mock.requests).toEqual([])
await expect(page.locator('[data-qqq-id="button-notifications"]')).toHaveCount(0)
})
for (const scheme of ['light', 'dark'] as const) {
for (const width of [390, 1440]) {
test(`bell, popover, banner and page at ${width} px (${scheme})`, async ({ page }) => {
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 })
await page.emulateMedia({ colorScheme: scheme })
await page.addInitScript((value) => { try { localStorage.setItem('qqq-dark-mode', String(value === 'dark')) } catch { /* ignore */ } }, scheme)
const mock = await mockInbox(page)
await page.goto('/app/person')
const bell = page.getByRole('button', { name: 'Notifications, 4 unread' })
await expect(bell).toBeVisible()
const banner = page.getByRole('status', { name: /Warning: Scheduled maintenance tonight/ })
await expect(banner).toBeVisible()
expect(await horizontalScroll(page)).toEqual([])
if (SCREENSHOT_DIR) await page.screenshot({ path: `${SCREENSHOT_DIR}/banner-${width}-${scheme}.png` })
await bell.click()
const popover = page.getByRole('dialog', { name: 'Notifications' })
await expect(popover).toBeVisible()
await expect(popover.getByRole('listitem')).toHaveCount(10)
const box = await popover.boundingBox()
expect(box!.x).toBeGreaterThanOrEqual(0)
expect(box!.x + box!.width).toBeLessThanOrEqual(width + 1)
expect(await horizontalScroll(page)).toEqual([])
if (SCREENSHOT_DIR) await page.screenshot({ path: `${SCREENSHOT_DIR}/popover-${width}-${scheme}.png` })
if (scheme === 'light' && width === 1440) await checkA11y(page)
await page.keyboard.press('Escape')
await expect(popover).toHaveCount(0)
await page.goto('/app/notifications')
const list = page.getByRole('list', { name: 'Notifications' })
await expect(list.locator(':scope > li')).toHaveCount(25)
await expect(page.getByText('1–25 of 30')).toBeVisible()
expect(await horizontalScroll(page)).toEqual([])
if (SCREENSHOT_DIR) await page.screenshot({ path: `${SCREENSHOT_DIR}/page-${width}-${scheme}.png`, fullPage: false })
if (scheme === 'light' && width === 1440) await checkA11y(page)
if (scheme === 'light' && width === 1440) {
await page.getByRole('button', { name: 'Next' }).click()
await expect(page.getByText('26–30 of 30')).toBeVisible()
await page.getByRole('button', { name: 'Unread', exact: true }).click()
await expect(page.getByText('1–4 of 4')).toBeVisible()
await page.getByRole('button', { name: /^Mark read: Nightly backup/ }).click()
await expect(page.getByRole('button', { name: 'Notifications, 3 unread' })).toBeVisible()
await page.getByRole('button', { name: /^Dismiss: Scheduled maintenance tonight/ }).click()
await expect(page.getByRole('status', { name: /Warning: Scheduled maintenance tonight/ })).toHaveCount(0)
expect(mock.posts).toEqual([['read', { id: 100 }], ['dismiss', { id: 7 }]])
}
})
}
}
})