feat(ui/alerts): InboxPage with ack + bulk-read actions
AlertRow is reused by AllAlertsPage and HistoryPage. Marking a row as read happens when its link is followed (the detail sub-route will be added in phase 10 polish). FIRING rows get an amber left border.
This commit is contained in:
48
ui/src/pages/Alerts/AlertRow.tsx
Normal file
48
ui/src/pages/Alerts/AlertRow.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
|
import { Button, useToast } from '@cameleer/design-system';
|
||||||
|
import { AlertStateChip } from '../../components/AlertStateChip';
|
||||||
|
import { SeverityBadge } from '../../components/SeverityBadge';
|
||||||
|
import type { AlertDto } from '../../api/queries/alerts';
|
||||||
|
import { useAckAlert, useMarkAlertRead } from '../../api/queries/alerts';
|
||||||
|
import css from './alerts-page.module.css';
|
||||||
|
|
||||||
|
export function AlertRow({ alert, unread }: { alert: AlertDto; unread: boolean }) {
|
||||||
|
const ack = useAckAlert();
|
||||||
|
const markRead = useMarkAlertRead();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const onAck = async () => {
|
||||||
|
try {
|
||||||
|
await ack.mutateAsync(alert.id);
|
||||||
|
toast({ title: 'Acknowledged', description: alert.title, variant: 'success' });
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: 'Ack failed', description: String(e), variant: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${css.row} ${unread ? css.rowUnread : ''}`}
|
||||||
|
data-testid={`alert-row-${alert.id}`}
|
||||||
|
>
|
||||||
|
<SeverityBadge severity={alert.severity} />
|
||||||
|
<div className={css.body}>
|
||||||
|
<Link to={`/alerts/inbox/${alert.id}`} onClick={() => markRead.mutate(alert.id)}>
|
||||||
|
<strong>{alert.title}</strong>
|
||||||
|
</Link>
|
||||||
|
<div className={css.meta}>
|
||||||
|
<AlertStateChip state={alert.state} silenced={alert.silenced} />
|
||||||
|
<span className={css.time}>{alert.firedAt}</span>
|
||||||
|
</div>
|
||||||
|
<p className={css.message}>{alert.message}</p>
|
||||||
|
</div>
|
||||||
|
<div className={css.actions}>
|
||||||
|
{alert.state === 'FIRING' && (
|
||||||
|
<Button size="sm" variant="secondary" onClick={onAck} disabled={ack.isPending}>
|
||||||
|
Ack
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,48 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Button, SectionHeader, useToast } from '@cameleer/design-system';
|
||||||
|
import { PageLoader } from '../../components/PageLoader';
|
||||||
|
import { useAlerts, useBulkReadAlerts } from '../../api/queries/alerts';
|
||||||
|
import { AlertRow } from './AlertRow';
|
||||||
|
import css from './alerts-page.module.css';
|
||||||
|
|
||||||
export default function InboxPage() {
|
export default function InboxPage() {
|
||||||
return <div>InboxPage — coming soon</div>;
|
const { data, isLoading, error } = useAlerts({ state: ['FIRING', 'ACKNOWLEDGED'], limit: 100 });
|
||||||
|
const bulkRead = useBulkReadAlerts();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const unreadIds = useMemo(
|
||||||
|
() => (data ?? []).filter((a) => a.state === 'FIRING').map((a) => a.id),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) return <PageLoader />;
|
||||||
|
if (error) return <div className={css.page}>Failed to load alerts: {String(error)}</div>;
|
||||||
|
|
||||||
|
const rows = data ?? [];
|
||||||
|
|
||||||
|
const onMarkAllRead = async () => {
|
||||||
|
if (unreadIds.length === 0) return;
|
||||||
|
try {
|
||||||
|
await bulkRead.mutateAsync(unreadIds);
|
||||||
|
toast({ title: `Marked ${unreadIds.length} as read`, variant: 'success' });
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: 'Bulk read failed', description: String(e), variant: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.page}>
|
||||||
|
<div className={css.toolbar}>
|
||||||
|
<SectionHeader>Inbox</SectionHeader>
|
||||||
|
<Button variant="secondary" onClick={onMarkAllRead} disabled={bulkRead.isPending || unreadIds.length === 0}>
|
||||||
|
Mark all read
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className={css.empty}>No open alerts for you in this environment.</div>
|
||||||
|
) : (
|
||||||
|
rows.map((a) => <AlertRow key={a.id} alert={a} unread={a.state === 'FIRING'} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
18
ui/src/pages/Alerts/alerts-page.module.css
Normal file
18
ui/src/pages/Alerts/alerts-page.module.css
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
.page { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.rowUnread { border-left: 3px solid var(--accent); }
|
||||||
|
.body { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||||
|
.meta { display: flex; gap: 8px; font-size: 12px; color: var(--muted); }
|
||||||
|
.time { font-variant-numeric: tabular-nums; }
|
||||||
|
.message { margin: 0; font-size: 13px; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.actions { display: flex; align-items: center; }
|
||||||
|
.empty { padding: 48px; text-align: center; color: var(--muted); }
|
||||||
Reference in New Issue
Block a user