fix(alerts/ui): page header, scroll, title preview, bell badge polish

Visual regressions surfaced during browser smoke:

1. Page headers — `SectionHeader` renders as 12px uppercase gray (a
   section divider, not a page title). Replace with proper h2 title
   + inline subtitle (`N firing · N total` etc.) and right-aligned
   actions, styled from `alerts-page.module.css`.

2. Undefined `--space-*` tokens — the project (and `@cameleer/design-system`)
   has never shipped `--space-sm|md|lg|xl`, even though many modules
   (SensitiveKeysPage, alerts CSS, …) reference them. The fallback
   to `initial` silently collapsed gaps/paddings to 0. Define the
   scale in `ui/src/index.css` so every consumer picks it up.

3. List scrolling — DataTable was using default pagination, but with
   no flex sizing the whole page scrolled. Add `fillHeight` and raise
   `pageSize`/list `limit` to 200 so the table gets sticky header +
   internal scroll + pinned pagination footer (Gmail-style). True
   cursor-based infinite scroll needs a backend change (filed as
   follow-up — `/alerts` only accepts `limit` today).

4. Title column clipping — `.titlePreview` used `white-space: nowrap`
   + fixed `max-width`, truncating message mid-UUID. Switch to a
   2-line `-webkit-line-clamp` so full context is visible.

5. Notification bell badge invisible — `NotificationBell.module.css`
   referenced undefined tokens (`--fg`, `--hover-bg`, `--bg`,
   `--muted`). Map to real DS tokens (`--text-primary`, `--bg-hover`,
   `#fff`, `--text-muted`). The admin user currently sees no badge
   because the backend `/alerts/unread-count` returns 0 (read
   receipts) — that's data, not UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-21 10:40:28 +02:00
parent 10e132cd50
commit 05f420d162
8 changed files with 126 additions and 72 deletions

View File

@@ -5,11 +5,11 @@
justify-content: center; justify-content: center;
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 8px; border-radius: var(--radius-md);
color: var(--fg); color: var(--text-primary);
text-decoration: none; text-decoration: none;
} }
.bell:hover { background: var(--hover-bg); } .bell:hover { background: var(--bg-hover); }
.badge { .badge {
position: absolute; position: absolute;
top: 2px; top: 2px;
@@ -18,7 +18,7 @@
height: 16px; height: 16px;
padding: 0 4px; padding: 0 4px;
border-radius: 8px; border-radius: 8px;
color: var(--bg); color: #fff;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
line-height: 16px; line-height: 16px;
@@ -26,4 +26,4 @@
} }
.badgeCritical { background: var(--error); } .badgeCritical { background: var(--error); }
.badgeWarning { background: var(--amber); } .badgeWarning { background: var(--amber); }
.badgeInfo { background: var(--muted); } .badgeInfo { background: var(--text-muted); }

View File

@@ -64,6 +64,14 @@
--text-muted: #766A5E; --text-muted: #766A5E;
/* White text on colored badge backgrounds (not in DS yet) */ /* White text on colored badge backgrounds (not in DS yet) */
--text-inverse: #fff; --text-inverse: #fff;
/* Spacing scale — DS doesn't ship these, but many app modules reference them.
Keep local here until the DS grows a real spacing system. */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
} }
[data-theme="dark"] { [data-theme="dark"] {

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { Bell } from 'lucide-react'; import { Bell } from 'lucide-react';
import { import {
SectionHeader, DataTable, EmptyState, SegmentedTabs, DataTable, EmptyState, SegmentedTabs,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader'; import { PageLoader } from '../../components/PageLoader';
@@ -71,17 +71,19 @@ export default function AllAlertsPage() {
return ( return (
<div className={css.page}> <div className={css.page}>
<div className={css.toolbar}> <header className={css.pageHeader}>
<SectionHeader>All alerts</SectionHeader> <div className={css.pageTitleGroup}>
</div> <h2 className={css.pageTitle}>All alerts</h2>
<span className={css.pageSubtitle}>{rows.length} shown</span>
<div className={css.filterBar}> </div>
<SegmentedTabs <div className={css.pageActions}>
tabs={Object.entries(STATE_FILTERS).map(([value, f]) => ({ value, label: f.label }))} <SegmentedTabs
active={filterKey} tabs={Object.entries(STATE_FILTERS).map(([value, f]) => ({ value, label: f.label }))}
onChange={setFilterKey} active={filterKey}
/> onChange={setFilterKey}
</div> />
</div>
</header>
{rows.length === 0 ? ( {rows.length === 0 ? (
<EmptyState <EmptyState
@@ -90,12 +92,14 @@ export default function AllAlertsPage() {
description="Try switching to a different state or widening your criteria." description="Try switching to a different state or widening your criteria."
/> />
) : ( ) : (
<div className={tableStyles.tableSection}> <div className={`${tableStyles.tableSection} ${css.tableWrap}`}>
<DataTable<AlertDto & { id: string }> <DataTable<AlertDto & { id: string }>
columns={columns as Column<AlertDto & { id: string }>[]} columns={columns as Column<AlertDto & { id: string }>[]}
data={rows as Array<AlertDto & { id: string }>} data={rows as Array<AlertDto & { id: string }>}
sortable sortable
flush flush
fillHeight
pageSize={200}
rowAccent={(row) => row.severity ? severityToAccent(row.severity) : undefined} rowAccent={(row) => row.severity ? severityToAccent(row.severity) : undefined}
expandedContent={renderAlertExpanded} expandedContent={renderAlertExpanded}
/> />

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { History } from 'lucide-react'; import { History } from 'lucide-react';
import { import {
SectionHeader, DataTable, EmptyState, DateRangePicker, DataTable, EmptyState, DateRangePicker,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader'; import { PageLoader } from '../../components/PageLoader';
@@ -86,13 +86,15 @@ export default function HistoryPage() {
return ( return (
<div className={css.page}> <div className={css.page}>
<div className={css.toolbar}> <header className={css.pageHeader}>
<SectionHeader>History</SectionHeader> <div className={css.pageTitleGroup}>
</div> <h2 className={css.pageTitle}>History</h2>
<span className={css.pageSubtitle}>{filtered.length} resolved</span>
<div className={css.filterBar}> </div>
<DateRangePicker value={dateRange} onChange={setDateRange} /> <div className={css.pageActions}>
</div> <DateRangePicker value={dateRange} onChange={setDateRange} />
</div>
</header>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<EmptyState <EmptyState
@@ -101,12 +103,14 @@ export default function HistoryPage() {
description="Nothing in the selected date range. Try widening it." description="Nothing in the selected date range. Try widening it."
/> />
) : ( ) : (
<div className={tableStyles.tableSection}> <div className={`${tableStyles.tableSection} ${css.tableWrap}`}>
<DataTable<AlertDto & { id: string }> <DataTable<AlertDto & { id: string }>
columns={columns as Column<AlertDto & { id: string }>[]} columns={columns as Column<AlertDto & { id: string }>[]}
data={filtered as Array<AlertDto & { id: string }>} data={filtered as Array<AlertDto & { id: string }>}
sortable sortable
flush flush
fillHeight
pageSize={200}
rowAccent={(row) => row.severity ? severityToAccent(row.severity) : undefined} rowAccent={(row) => row.severity ? severityToAccent(row.severity) : undefined}
expandedContent={renderAlertExpanded} expandedContent={renderAlertExpanded}
/> />

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { Inbox } from 'lucide-react'; import { Inbox } from 'lucide-react';
import { import {
Button, SectionHeader, DataTable, EmptyState, useToast, Button, DataTable, EmptyState, useToast,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader'; import { PageLoader } from '../../components/PageLoader';
@@ -19,7 +19,7 @@ import css from './alerts-page.module.css';
import tableStyles from '../../styles/table-section.module.css'; import tableStyles from '../../styles/table-section.module.css';
export default function InboxPage() { export default function InboxPage() {
const { data, isLoading, error } = useAlerts({ state: ['FIRING', 'ACKNOWLEDGED'], limit: 100 }); const { data, isLoading, error } = useAlerts({ state: ['FIRING', 'ACKNOWLEDGED'], limit: 200 });
const bulkRead = useBulkReadAlerts(); const bulkRead = useBulkReadAlerts();
const markRead = useMarkAlertRead(); const markRead = useMarkAlertRead();
const ack = useAckAlert(); const ack = useAckAlert();
@@ -123,19 +123,19 @@ export default function InboxPage() {
const selectedIds = Array.from(selected); const selectedIds = Array.from(selected);
const subtitle =
selectedIds.length > 0
? `${selectedIds.length} selected`
: `${unreadIds.length} firing · ${rows.length} total`;
return ( return (
<div className={css.page}> <div className={css.page}>
<div className={css.toolbar}> <header className={css.pageHeader}>
<SectionHeader>Inbox</SectionHeader> <div className={css.pageTitleGroup}>
</div> <h2 className={css.pageTitle}>Inbox</h2>
<span className={css.pageSubtitle}>{subtitle}</span>
<div className={css.bulkBar}> </div>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}> <div className={css.pageActions}>
{selectedIds.length > 0
? `${selectedIds.length} selected`
: `${unreadIds.length} unread`}
</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 'var(--space-sm)' }}>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
@@ -153,7 +153,7 @@ export default function InboxPage() {
Mark all read Mark all read
</Button> </Button>
</div> </div>
</div> </header>
{rows.length === 0 ? ( {rows.length === 0 ? (
<EmptyState <EmptyState
@@ -162,12 +162,14 @@ export default function InboxPage() {
description="No open alerts for you in this environment." description="No open alerts for you in this environment."
/> />
) : ( ) : (
<div className={tableStyles.tableSection}> <div className={`${tableStyles.tableSection} ${css.tableWrap}`}>
<DataTable<AlertDto & { id: string }> <DataTable<AlertDto & { id: string }>
columns={columns as Column<AlertDto & { id: string }>[]} columns={columns as Column<AlertDto & { id: string }>[]}
data={rows as Array<AlertDto & { id: string }>} data={rows as Array<AlertDto & { id: string }>}
sortable sortable
flush flush
fillHeight
pageSize={200}
rowAccent={(row) => row.severity ? severityToAccent(row.severity) : undefined} rowAccent={(row) => row.severity ? severityToAccent(row.severity) : undefined}
expandedContent={renderAlertExpanded} expandedContent={renderAlertExpanded}
/> />

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { Link, useNavigate } from 'react-router'; import { Link, useNavigate } from 'react-router';
import { FilePlus } from 'lucide-react'; import { FilePlus } from 'lucide-react';
import { import {
Button, SectionHeader, Toggle, useToast, Badge, DataTable, Button, Toggle, useToast, Badge, DataTable,
EmptyState, Dropdown, ConfirmDialog, EmptyState, Dropdown, ConfirmDialog,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system';
@@ -111,17 +111,17 @@ export default function RulesListPage() {
return ( return (
<div className={css.page}> <div className={css.page}>
<div className={css.toolbar}> <header className={css.pageHeader}>
<SectionHeader <div className={css.pageTitleGroup}>
action={ <h2 className={css.pageTitle}>Alert rules</h2>
<Link to="/alerts/rules/new"> <span className={css.pageSubtitle}>{rows.length} total</span>
<Button variant="primary">New rule</Button> </div>
</Link> <div className={css.pageActions}>
} <Link to="/alerts/rules/new">
> <Button variant="primary">New rule</Button>
Alert rules </Link>
</SectionHeader> </div>
</div> </header>
{rows.length === 0 ? ( {rows.length === 0 ? (
<EmptyState <EmptyState
@@ -135,11 +135,12 @@ export default function RulesListPage() {
} }
/> />
) : ( ) : (
<div className={tableStyles.tableSection}> <div className={`${tableStyles.tableSection} ${css.tableWrap}`}>
<DataTable<AlertRuleResponse & { id: string }> <DataTable<AlertRuleResponse & { id: string }>
columns={columns} columns={columns}
data={rows as (AlertRuleResponse & { id: string })[]} data={rows as (AlertRuleResponse & { id: string })[]}
flush flush
fillHeight
/> />
</div> </div>
)} )}

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { BellOff } from 'lucide-react'; import { BellOff } from 'lucide-react';
import { import {
Button, FormField, Input, SectionHeader, useToast, DataTable, Button, FormField, Input, useToast, DataTable,
EmptyState, ConfirmDialog, MonoText, EmptyState, ConfirmDialog, MonoText,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system';
@@ -92,9 +92,12 @@ export default function SilencesPage() {
return ( return (
<div className={css.page}> <div className={css.page}>
<div className={css.toolbar}> <header className={css.pageHeader}>
<SectionHeader>Alert silences</SectionHeader> <div className={css.pageTitleGroup}>
</div> <h2 className={css.pageTitle}>Alert silences</h2>
<span className={css.pageSubtitle}>{rows.length} active</span>
</div>
</header>
<section className={sectionStyles.section}> <section className={sectionStyles.section}>
<div <div
@@ -139,11 +142,12 @@ export default function SilencesPage() {
description="Nothing is currently silenced in this environment." description="Nothing is currently silenced in this environment."
/> />
) : ( ) : (
<div className={tableStyles.tableSection}> <div className={`${tableStyles.tableSection} ${css.tableWrap}`}>
<DataTable<AlertSilenceResponse & { id: string }> <DataTable<AlertSilenceResponse & { id: string }>
columns={columns} columns={columns}
data={rows.map((s) => ({ ...s, id: s.id ?? '' }))} data={rows.map((s) => ({ ...s, id: s.id ?? '' }))}
flush flush
fillHeight
/> />
</div> </div>
)} )}

View File

@@ -3,16 +3,48 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-md); gap: var(--space-md);
height: 100%;
min-height: 0;
} }
.toolbar { .pageHeader {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: var(--space-sm); gap: var(--space-md);
padding-bottom: var(--space-sm);
border-bottom: 1px solid var(--border-subtle);
flex-wrap: wrap; flex-wrap: wrap;
} }
.pageTitleGroup {
display: flex;
align-items: baseline;
gap: var(--space-sm);
min-width: 0;
}
.pageTitle {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
letter-spacing: -0.01em;
}
.pageSubtitle {
font-size: 13px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.pageActions {
display: flex;
gap: var(--space-sm);
align-items: center;
flex-shrink: 0;
}
.filterBar { .filterBar {
display: flex; display: flex;
gap: var(--space-sm); gap: var(--space-sm);
@@ -20,14 +52,11 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.bulkBar { .tableWrap {
flex: 1 1 auto;
min-height: 0;
display: flex; display: flex;
gap: var(--space-sm); flex-direction: column;
align-items: center;
padding: var(--space-sm) var(--space-md);
background: var(--bg-hover);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
} }
.titleCell { .titleCell {
@@ -54,10 +83,12 @@
.titlePreview { .titlePreview {
font-size: 12px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; word-break: break-word;
white-space: nowrap; line-height: 1.4;
max-width: 48ch;
} }
.expanded { .expanded {