2026-04-21 19:15:52 +02:00
|
|
|
import { useState, useEffect } from 'react';
|
|
|
|
|
import { useSearchParams } from 'react-router';
|
2026-04-21 10:14:19 +02:00
|
|
|
import { BellOff } from 'lucide-react';
|
|
|
|
|
import {
|
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>
2026-04-21 10:40:28 +02:00
|
|
|
Button, FormField, Input, useToast, DataTable,
|
2026-04-21 10:14:19 +02:00
|
|
|
EmptyState, ConfirmDialog, MonoText,
|
|
|
|
|
} from '@cameleer/design-system';
|
|
|
|
|
import type { Column } from '@cameleer/design-system';
|
2026-04-20 14:08:27 +02:00
|
|
|
import { PageLoader } from '../../components/PageLoader';
|
|
|
|
|
import {
|
|
|
|
|
useAlertSilences,
|
|
|
|
|
useCreateSilence,
|
|
|
|
|
useDeleteSilence,
|
|
|
|
|
type AlertSilenceResponse,
|
|
|
|
|
} from '../../api/queries/alertSilences';
|
2026-04-21 20:23:14 +02:00
|
|
|
import { describeApiError } from '../../api/errors';
|
2026-04-20 14:08:27 +02:00
|
|
|
import sectionStyles from '../../styles/section-card.module.css';
|
2026-04-21 10:14:19 +02:00
|
|
|
import tableStyles from '../../styles/table-section.module.css';
|
|
|
|
|
import css from './alerts-page.module.css';
|
2026-04-20 14:08:27 +02:00
|
|
|
|
2026-04-20 13:44:44 +02:00
|
|
|
export default function SilencesPage() {
|
2026-04-20 14:08:27 +02:00
|
|
|
const { data, isLoading, error } = useAlertSilences();
|
|
|
|
|
const create = useCreateSilence();
|
|
|
|
|
const remove = useDeleteSilence();
|
|
|
|
|
const { toast } = useToast();
|
|
|
|
|
|
|
|
|
|
const [reason, setReason] = useState('');
|
|
|
|
|
const [matcherRuleId, setMatcherRuleId] = useState('');
|
|
|
|
|
const [matcherAppSlug, setMatcherAppSlug] = useState('');
|
|
|
|
|
const [hours, setHours] = useState(1);
|
2026-04-21 10:14:19 +02:00
|
|
|
const [pendingEnd, setPendingEnd] = useState<AlertSilenceResponse | null>(null);
|
2026-04-20 14:08:27 +02:00
|
|
|
|
2026-04-21 19:15:52 +02:00
|
|
|
const [searchParams] = useSearchParams();
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const r = searchParams.get('ruleId');
|
|
|
|
|
if (r) setMatcherRuleId(r);
|
|
|
|
|
}, [searchParams]);
|
|
|
|
|
|
2026-04-20 14:08:27 +02:00
|
|
|
if (isLoading) return <PageLoader />;
|
2026-04-21 10:14:19 +02:00
|
|
|
if (error) return <div className={css.page}>Failed to load silences: {String(error)}</div>;
|
|
|
|
|
|
|
|
|
|
const rows = data ?? [];
|
2026-04-20 14:08:27 +02:00
|
|
|
|
|
|
|
|
const onCreate = async () => {
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const endsAt = new Date(now.getTime() + hours * 3600_000);
|
|
|
|
|
const matcher: Record<string, string> = {};
|
|
|
|
|
if (matcherRuleId) matcher.ruleId = matcherRuleId;
|
|
|
|
|
if (matcherAppSlug) matcher.appSlug = matcherAppSlug;
|
|
|
|
|
if (Object.keys(matcher).length === 0) {
|
|
|
|
|
toast({ title: 'Silence needs at least one matcher field', variant: 'error' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await create.mutateAsync({
|
|
|
|
|
matcher,
|
|
|
|
|
reason: reason || undefined,
|
|
|
|
|
startsAt: now.toISOString(),
|
|
|
|
|
endsAt: endsAt.toISOString(),
|
|
|
|
|
});
|
|
|
|
|
setReason('');
|
|
|
|
|
setMatcherRuleId('');
|
|
|
|
|
setMatcherAppSlug('');
|
|
|
|
|
setHours(1);
|
|
|
|
|
toast({ title: 'Silence created', variant: 'success' });
|
|
|
|
|
} catch (e) {
|
2026-04-21 20:23:14 +02:00
|
|
|
toast({ title: 'Create failed', description: describeApiError(e), variant: 'error' });
|
2026-04-20 14:08:27 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-21 10:14:19 +02:00
|
|
|
const confirmEnd = async () => {
|
|
|
|
|
if (!pendingEnd) return;
|
2026-04-20 14:08:27 +02:00
|
|
|
try {
|
2026-04-21 10:14:19 +02:00
|
|
|
await remove.mutateAsync(pendingEnd.id!);
|
2026-04-20 14:08:27 +02:00
|
|
|
toast({ title: 'Silence removed', variant: 'success' });
|
|
|
|
|
} catch (e) {
|
2026-04-21 20:23:14 +02:00
|
|
|
toast({ title: 'Remove failed', description: describeApiError(e), variant: 'error' });
|
2026-04-21 10:14:19 +02:00
|
|
|
} finally {
|
|
|
|
|
setPendingEnd(null);
|
2026-04-20 14:08:27 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-21 10:14:19 +02:00
|
|
|
const columns: Column<AlertSilenceResponse & { id: string }>[] = [
|
|
|
|
|
{
|
|
|
|
|
key: 'matcher', header: 'Matcher',
|
|
|
|
|
render: (_, s) => <MonoText size="xs">{JSON.stringify(s.matcher)}</MonoText>,
|
|
|
|
|
},
|
|
|
|
|
{ key: 'reason', header: 'Reason', render: (_, s) => s.reason ?? '—' },
|
|
|
|
|
{ key: 'startsAt', header: 'Starts', width: '200px' },
|
|
|
|
|
{ key: 'endsAt', header: 'Ends', width: '200px' },
|
|
|
|
|
{
|
|
|
|
|
key: 'actions', header: '', width: '90px',
|
|
|
|
|
render: (_, s) => (
|
|
|
|
|
<Button variant="ghost" size="sm" onClick={() => setPendingEnd(s)}>
|
2026-04-21 11:48:33 +02:00
|
|
|
End early
|
2026-04-21 10:14:19 +02:00
|
|
|
</Button>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
];
|
2026-04-20 14:08:27 +02:00
|
|
|
|
|
|
|
|
return (
|
2026-04-21 10:14:19 +02:00
|
|
|
<div className={css.page}>
|
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>
2026-04-21 10:40:28 +02:00
|
|
|
<header className={css.pageHeader}>
|
|
|
|
|
<div className={css.pageTitleGroup}>
|
|
|
|
|
<h2 className={css.pageTitle}>Alert silences</h2>
|
2026-04-21 11:48:33 +02:00
|
|
|
<span className={css.pageSubtitle}>
|
|
|
|
|
{rows.length === 0
|
|
|
|
|
? 'Nothing silenced right now'
|
|
|
|
|
: `${rows.length} active silence${rows.length === 1 ? '' : 's'}`}
|
|
|
|
|
</span>
|
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>
2026-04-21 10:40:28 +02:00
|
|
|
</div>
|
|
|
|
|
</header>
|
2026-04-21 10:14:19 +02:00
|
|
|
|
|
|
|
|
<section className={sectionStyles.section}>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
display: 'grid',
|
|
|
|
|
gridTemplateColumns: 'repeat(4, minmax(0, 1fr)) auto',
|
|
|
|
|
gap: 'var(--space-sm)',
|
|
|
|
|
alignItems: 'end',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<FormField label="Rule ID" hint="Exact rule id (optional)">
|
2026-04-20 14:08:27 +02:00
|
|
|
<Input value={matcherRuleId} onChange={(e) => setMatcherRuleId(e.target.value)} />
|
|
|
|
|
</FormField>
|
2026-04-21 10:14:19 +02:00
|
|
|
<FormField label="App slug" hint="App slug (optional)">
|
2026-04-20 14:08:27 +02:00
|
|
|
<Input value={matcherAppSlug} onChange={(e) => setMatcherAppSlug(e.target.value)} />
|
|
|
|
|
</FormField>
|
2026-04-21 10:14:19 +02:00
|
|
|
<FormField label="Duration" hint="Hours">
|
2026-04-20 14:08:27 +02:00
|
|
|
<Input
|
|
|
|
|
type="number"
|
|
|
|
|
min={1}
|
|
|
|
|
value={hours}
|
|
|
|
|
onChange={(e) => setHours(Number(e.target.value))}
|
|
|
|
|
/>
|
|
|
|
|
</FormField>
|
2026-04-21 10:14:19 +02:00
|
|
|
<FormField label="Reason" hint="Context for operators">
|
2026-04-20 14:08:27 +02:00
|
|
|
<Input
|
|
|
|
|
value={reason}
|
|
|
|
|
onChange={(e) => setReason(e.target.value)}
|
|
|
|
|
placeholder="Maintenance window"
|
|
|
|
|
/>
|
|
|
|
|
</FormField>
|
|
|
|
|
<Button variant="primary" size="sm" onClick={onCreate} disabled={create.isPending}>
|
|
|
|
|
Create silence
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-04-21 10:14:19 +02:00
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{rows.length === 0 ? (
|
|
|
|
|
<EmptyState
|
|
|
|
|
icon={<BellOff size={32} />}
|
|
|
|
|
title="No silences"
|
|
|
|
|
description="Nothing is currently silenced in this environment."
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
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>
2026-04-21 10:40:28 +02:00
|
|
|
<div className={`${tableStyles.tableSection} ${css.tableWrap}`}>
|
2026-04-21 10:14:19 +02:00
|
|
|
<DataTable<AlertSilenceResponse & { id: string }>
|
|
|
|
|
columns={columns}
|
|
|
|
|
data={rows.map((s) => ({ ...s, id: s.id ?? '' }))}
|
|
|
|
|
flush
|
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>
2026-04-21 10:40:28 +02:00
|
|
|
fillHeight
|
2026-04-21 10:14:19 +02:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<ConfirmDialog
|
|
|
|
|
open={!!pendingEnd}
|
|
|
|
|
onClose={() => setPendingEnd(null)}
|
|
|
|
|
onConfirm={confirmEnd}
|
|
|
|
|
title="End silence?"
|
|
|
|
|
message="End this silence early? Affected rules will resume firing."
|
|
|
|
|
confirmText="End silence"
|
|
|
|
|
variant="warning"
|
|
|
|
|
loading={remove.isPending}
|
|
|
|
|
/>
|
2026-04-20 14:08:27 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
2026-04-20 13:44:44 +02:00
|
|
|
}
|