fix(alerts/ui): bell position, content tabs hidden, filters, novice labels
Surfaced during second smoke:
1. Notification bell moved — was first child of TopBar (left of
breadcrumb); now rendered inside the `environment` slot so it
sits between the env selector and the user menu, matching user
expectations.
2. Content tabs (Exchanges/Dashboard/Runtime/Deployments) hidden on
`/alerts/*` — the operational tabs don't apply there.
3. Inbox / All alerts filters now actually filter. `AlertController.list`
accepts only `limit` — `state`/`severity` query params are dropped
server-side. Move `useAlerts` to fetch once per env (limit 200) and
apply filters client-side via react-query `select`, with a stable
queryKey so filter toggles are instant and don't re-request. True
server-side filter needs a backend change (follow-up).
4. Novice-friendly labels:
- Inbox subtitle: "99 firing · 100 total" → "99 need attention ·
100 total in inbox"
- All alerts filter: Open/Firing/Acked/All →
"Currently open"/"Firing now"/"Acknowledged"/"All states"
- All alerts subtitle: "N shown" → "N matching your filter"
- History subtitle: "N resolved" → "N resolved alert(s) in range"
- Rules subtitle: "N total" → "N rule(s) configured"
- Silences subtitle: "N active" → "N active silence(s)" or
"Nothing silenced right now"
- Column headers: "State" → "Status", rules "Kind" → "Type",
rules "Targets" → "Notifies"
- Buttons: "Ack" → "Acknowledge", silence "End" → "End early"
Updated alerts.test.tsx and e2e selector to match new behavior/labels.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,10 @@ describe('useAlerts', () => {
|
|||||||
useEnvironmentStore.setState({ environment: 'dev' });
|
useEnvironmentStore.setState({ environment: 'dev' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fetches alerts for selected env and passes filter query params', async () => {
|
it('fetches up to 200 alerts for selected env (no server-side filter params)', async () => {
|
||||||
|
// Backend AlertController.list accepts only `limit`; state/severity are
|
||||||
|
// dropped server-side. We therefore fetch once per env and filter
|
||||||
|
// client-side via react-query `select`.
|
||||||
(apiClient.GET as any).mockResolvedValue({ data: [], error: null });
|
(apiClient.GET as any).mockResolvedValue({ data: [], error: null });
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useAlerts({ state: 'FIRING', severity: ['CRITICAL', 'WARNING'] }),
|
() => useAlerts({ state: 'FIRING', severity: ['CRITICAL', 'WARNING'] }),
|
||||||
@@ -34,16 +37,29 @@ describe('useAlerts', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
params: expect.objectContaining({
|
params: expect.objectContaining({
|
||||||
path: { envSlug: 'dev' },
|
path: { envSlug: 'dev' },
|
||||||
query: expect.objectContaining({
|
query: { limit: 200 },
|
||||||
state: ['FIRING'],
|
|
||||||
severity: ['CRITICAL', 'WARNING'],
|
|
||||||
limit: 100,
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies state + severity filters client-side via select', async () => {
|
||||||
|
const dataset = [
|
||||||
|
{ id: '1', state: 'FIRING', severity: 'CRITICAL', title: 'a' },
|
||||||
|
{ id: '2', state: 'FIRING', severity: 'WARNING', title: 'b' },
|
||||||
|
{ id: '3', state: 'ACKNOWLEDGED', severity: 'CRITICAL', title: 'c' },
|
||||||
|
{ id: '4', state: 'RESOLVED', severity: 'INFO', title: 'd' },
|
||||||
|
];
|
||||||
|
(apiClient.GET as any).mockResolvedValue({ data: dataset, error: null });
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useAlerts({ state: ['FIRING'], severity: ['CRITICAL', 'WARNING'] }),
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
const ids = (result.current.data ?? []).map((a: any) => a.id);
|
||||||
|
expect(ids).toEqual(['1', '2']);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not fetch when no env is selected', () => {
|
it('does not fetch when no env is selected', () => {
|
||||||
useEnvironmentStore.setState({ environment: undefined });
|
useEnvironmentStore.setState({ environment: undefined });
|
||||||
const { result } = renderHook(() => useAlerts(), { wrapper });
|
const { result } = renderHook(() => useAlerts(), { wrapper });
|
||||||
|
|||||||
@@ -28,11 +28,28 @@ function toArray<T>(v: T | T[] | undefined): T[] | undefined {
|
|||||||
// openapi-fetch regardless of what the TS types say; we therefore cast the
|
// openapi-fetch regardless of what the TS types say; we therefore cast the
|
||||||
// call options to `any` to bypass the generated type oddity.
|
// call options to `any` to bypass the generated type oddity.
|
||||||
|
|
||||||
/** List alert instances in the current env. Polls every 30s (pauses in background). */
|
/** List alert instances in the current env. Polls every 30s (pauses in background).
|
||||||
|
*
|
||||||
|
* The backend's `AlertController.list` accepts only `limit` — `state` /
|
||||||
|
* `severity` query params are dropped. We fetch up to 200 alerts once per
|
||||||
|
* env (cached under a stable key) and apply filters client-side via
|
||||||
|
* react-query's `select` so filter switches on the All / History / Inbox
|
||||||
|
* pages are instant and don't each fire their own request. True server-side
|
||||||
|
* filtering needs a backend change (follow-up).
|
||||||
|
*/
|
||||||
export function useAlerts(filter: AlertsFilter = {}) {
|
export function useAlerts(filter: AlertsFilter = {}) {
|
||||||
const env = useSelectedEnv();
|
const env = useSelectedEnv();
|
||||||
|
const fetchLimit = 200;
|
||||||
|
const stateSet = filter.state === undefined
|
||||||
|
? undefined
|
||||||
|
: new Set(toArray(filter.state));
|
||||||
|
const severitySet = filter.severity === undefined
|
||||||
|
? undefined
|
||||||
|
: new Set(toArray(filter.severity));
|
||||||
|
const applyLimit = filter.limit;
|
||||||
|
const ruleIdFilter = filter.ruleId;
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['alerts', env, filter],
|
queryKey: ['alerts', env, 'list', fetchLimit],
|
||||||
enabled: !!env,
|
enabled: !!env,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
refetchIntervalInBackground: false,
|
refetchIntervalInBackground: false,
|
||||||
@@ -43,18 +60,21 @@ export function useAlerts(filter: AlertsFilter = {}) {
|
|||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
path: { envSlug: env },
|
path: { envSlug: env },
|
||||||
query: {
|
query: { limit: fetchLimit },
|
||||||
state: toArray(filter.state),
|
|
||||||
severity: toArray(filter.severity),
|
|
||||||
ruleId: filter.ruleId,
|
|
||||||
limit: filter.limit ?? 100,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
} as any,
|
} as any,
|
||||||
);
|
);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data as AlertDto[];
|
return data as AlertDto[];
|
||||||
},
|
},
|
||||||
|
select: (all) => {
|
||||||
|
let out = all;
|
||||||
|
if (stateSet) out = out.filter((a) => a.state && stateSet.has(a.state));
|
||||||
|
if (severitySet) out = out.filter((a) => a.severity && severitySet.has(a.severity));
|
||||||
|
if (ruleIdFilter) out = out.filter((a) => a.ruleId === ruleIdFilter);
|
||||||
|
if (applyLimit !== undefined) out = out.slice(0, applyLimit);
|
||||||
|
return out;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -957,18 +957,20 @@ function LayoutContent() {
|
|||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={breadcrumb}
|
breadcrumb={breadcrumb}
|
||||||
environment={
|
environment={
|
||||||
|
<>
|
||||||
<EnvironmentSelector
|
<EnvironmentSelector
|
||||||
environments={environments}
|
environments={environments}
|
||||||
value={selectedEnv}
|
value={selectedEnv}
|
||||||
onChange={setSelectedEnv}
|
onChange={setSelectedEnv}
|
||||||
/>
|
/>
|
||||||
|
<NotificationBell />
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
user={username ? { name: username } : undefined}
|
user={username ? { name: username } : undefined}
|
||||||
userMenuItems={userMenuItems}
|
userMenuItems={userMenuItems}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
onNavigate={navigate}
|
onNavigate={navigate}
|
||||||
>
|
>
|
||||||
<NotificationBell />
|
|
||||||
<SearchTrigger onClick={() => setPaletteOpen(true)} />
|
<SearchTrigger onClick={() => setPaletteOpen(true)} />
|
||||||
<ButtonGroup
|
<ButtonGroup
|
||||||
items={STATUS_ITEMS}
|
items={STATUS_ITEMS}
|
||||||
@@ -1006,7 +1008,7 @@ function LayoutContent() {
|
|||||||
data={searchData}
|
data={searchData}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isAdminPage && (
|
{!isAdminPage && !isAlertsPage && (
|
||||||
<ContentTabs active={scope.tab} onChange={setTab} scope={scope} />
|
<ContentTabs active={scope.tab} onChange={setTab} scope={scope} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ import tableStyles from '../../styles/table-section.module.css';
|
|||||||
type AlertState = NonNullable<AlertDto['state']>;
|
type AlertState = NonNullable<AlertDto['state']>;
|
||||||
|
|
||||||
const STATE_FILTERS: Record<string, { label: string; values: AlertState[] }> = {
|
const STATE_FILTERS: Record<string, { label: string; values: AlertState[] }> = {
|
||||||
open: { label: 'Open', values: ['PENDING', 'FIRING', 'ACKNOWLEDGED'] },
|
open: { label: 'Currently open', values: ['PENDING', 'FIRING', 'ACKNOWLEDGED'] },
|
||||||
firing: { label: 'Firing', values: ['FIRING'] },
|
firing: { label: 'Firing now', values: ['FIRING'] },
|
||||||
acked: { label: 'Acked', values: ['ACKNOWLEDGED'] },
|
acked: { label: 'Acknowledged', values: ['ACKNOWLEDGED'] },
|
||||||
all: { label: 'All', values: ['PENDING', 'FIRING', 'ACKNOWLEDGED', 'RESOLVED'] },
|
all: { label: 'All states', values: ['PENDING', 'FIRING', 'ACKNOWLEDGED', 'RESOLVED'] },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AllAlertsPage() {
|
export default function AllAlertsPage() {
|
||||||
@@ -41,7 +41,7 @@ export default function AllAlertsPage() {
|
|||||||
render: (_, row) => row.severity ? <SeverityBadge severity={row.severity} /> : null,
|
render: (_, row) => row.severity ? <SeverityBadge severity={row.severity} /> : null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'state', header: 'State', width: '140px',
|
key: 'state', header: 'Status', width: '140px',
|
||||||
render: (_, row) => row.state ? <AlertStateChip state={row.state} silenced={row.silenced} /> : null,
|
render: (_, row) => row.state ? <AlertStateChip state={row.state} silenced={row.silenced} /> : null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -74,7 +74,7 @@ export default function AllAlertsPage() {
|
|||||||
<header className={css.pageHeader}>
|
<header className={css.pageHeader}>
|
||||||
<div className={css.pageTitleGroup}>
|
<div className={css.pageTitleGroup}>
|
||||||
<h2 className={css.pageTitle}>All alerts</h2>
|
<h2 className={css.pageTitle}>All alerts</h2>
|
||||||
<span className={css.pageSubtitle}>{rows.length} shown</span>
|
<span className={css.pageSubtitle}>{rows.length} matching your filter</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={css.pageActions}>
|
<div className={css.pageActions}>
|
||||||
<SegmentedTabs
|
<SegmentedTabs
|
||||||
|
|||||||
@@ -88,8 +88,12 @@ export default function HistoryPage() {
|
|||||||
<div className={css.page}>
|
<div className={css.page}>
|
||||||
<header className={css.pageHeader}>
|
<header className={css.pageHeader}>
|
||||||
<div className={css.pageTitleGroup}>
|
<div className={css.pageTitleGroup}>
|
||||||
<h2 className={css.pageTitle}>History</h2>
|
<h2 className={css.pageTitle}>Alert history</h2>
|
||||||
<span className={css.pageSubtitle}>{filtered.length} resolved</span>
|
<span className={css.pageSubtitle}>
|
||||||
|
{filtered.length === 0
|
||||||
|
? 'No resolved alerts in range'
|
||||||
|
: `${filtered.length} resolved alert${filtered.length === 1 ? '' : 's'} in range`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={css.pageActions}>
|
<div className={css.pageActions}>
|
||||||
<DateRangePicker value={dateRange} onChange={setDateRange} />
|
<DateRangePicker value={dateRange} onChange={setDateRange} />
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export default function InboxPage() {
|
|||||||
row.severity ? <SeverityBadge severity={row.severity} /> : null,
|
row.severity ? <SeverityBadge severity={row.severity} /> : null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'state', header: 'State', width: '140px',
|
key: 'state', header: 'Status', width: '140px',
|
||||||
render: (_, row) =>
|
render: (_, row) =>
|
||||||
row.state ? <AlertStateChip state={row.state} silenced={row.silenced} /> : null,
|
row.state ? <AlertStateChip state={row.state} silenced={row.silenced} /> : null,
|
||||||
},
|
},
|
||||||
@@ -108,11 +108,11 @@ export default function InboxPage() {
|
|||||||
) : '—',
|
) : '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'ack', header: '', width: '70px',
|
key: 'ack', header: '', width: '120px',
|
||||||
render: (_, row) =>
|
render: (_, row) =>
|
||||||
row.state === 'FIRING' ? (
|
row.state === 'FIRING' ? (
|
||||||
<Button size="sm" variant="secondary" onClick={() => onAck(row.id, row.title ?? undefined)}>
|
<Button size="sm" variant="secondary" onClick={() => onAck(row.id, row.title ?? undefined)}>
|
||||||
Ack
|
Acknowledge
|
||||||
</Button>
|
</Button>
|
||||||
) : null,
|
) : null,
|
||||||
},
|
},
|
||||||
@@ -126,7 +126,7 @@ export default function InboxPage() {
|
|||||||
const subtitle =
|
const subtitle =
|
||||||
selectedIds.length > 0
|
selectedIds.length > 0
|
||||||
? `${selectedIds.length} selected`
|
? `${selectedIds.length} selected`
|
||||||
: `${unreadIds.length} firing · ${rows.length} total`;
|
: `${unreadIds.length} need attention · ${rows.length} total in inbox`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css.page}>
|
<div className={css.page}>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default function RulesListPage() {
|
|||||||
render: (_, r) => <Link to={`/alerts/rules/${r.id}`}>{r.name}</Link>,
|
render: (_, r) => <Link to={`/alerts/rules/${r.id}`}>{r.name}</Link>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'conditionKind', header: 'Kind', width: '160px',
|
key: 'conditionKind', header: 'Type', width: '160px',
|
||||||
render: (_, r) => <Badge label={r.conditionKind ?? ''} color="auto" variant="outlined" />,
|
render: (_, r) => <Badge label={r.conditionKind ?? ''} color="auto" variant="outlined" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -85,7 +85,7 @@ export default function RulesListPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'targets', header: 'Targets', width: '90px',
|
key: 'targets', header: 'Notifies', width: '90px',
|
||||||
render: (_, r) => String(r.targets?.length ?? 0),
|
render: (_, r) => String(r.targets?.length ?? 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -114,7 +114,9 @@ export default function RulesListPage() {
|
|||||||
<header className={css.pageHeader}>
|
<header className={css.pageHeader}>
|
||||||
<div className={css.pageTitleGroup}>
|
<div className={css.pageTitleGroup}>
|
||||||
<h2 className={css.pageTitle}>Alert rules</h2>
|
<h2 className={css.pageTitle}>Alert rules</h2>
|
||||||
<span className={css.pageSubtitle}>{rows.length} total</span>
|
<span className={css.pageSubtitle}>
|
||||||
|
{rows.length === 0 ? 'No rules yet' : `${rows.length} rule${rows.length === 1 ? '' : 's'} configured`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={css.pageActions}>
|
<div className={css.pageActions}>
|
||||||
<Link to="/alerts/rules/new">
|
<Link to="/alerts/rules/new">
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export default function SilencesPage() {
|
|||||||
key: 'actions', header: '', width: '90px',
|
key: 'actions', header: '', width: '90px',
|
||||||
render: (_, s) => (
|
render: (_, s) => (
|
||||||
<Button variant="ghost" size="sm" onClick={() => setPendingEnd(s)}>
|
<Button variant="ghost" size="sm" onClick={() => setPendingEnd(s)}>
|
||||||
End
|
End early
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -95,7 +95,11 @@ export default function SilencesPage() {
|
|||||||
<header className={css.pageHeader}>
|
<header className={css.pageHeader}>
|
||||||
<div className={css.pageTitleGroup}>
|
<div className={css.pageTitleGroup}>
|
||||||
<h2 className={css.pageTitle}>Alert silences</h2>
|
<h2 className={css.pageTitle}>Alert silences</h2>
|
||||||
<span className={css.pageSubtitle}>{rows.length} active</span>
|
<span className={css.pageSubtitle}>
|
||||||
|
{rows.length === 0
|
||||||
|
? 'Nothing silenced right now'
|
||||||
|
: `${rows.length} active silence${rows.length === 1 ? '' : 's'}`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ test.describe('alerting UI smoke', () => {
|
|||||||
|
|
||||||
await page
|
await page
|
||||||
.getByRole('row', { name: new RegExp(unique) })
|
.getByRole('row', { name: new RegExp(unique) })
|
||||||
.getByRole('button', { name: /^end$/i })
|
.getByRole('button', { name: /^end early$/i })
|
||||||
.click();
|
.click();
|
||||||
const confirmEnd = page.getByRole('dialog');
|
const confirmEnd = page.getByRole('dialog');
|
||||||
await expect(confirmEnd.getByText(/end silence/i)).toBeVisible();
|
await expect(confirmEnd.getByText(/end silence/i)).toBeVisible();
|
||||||
|
|||||||
Reference in New Issue
Block a user