Compare commits

10 Commits

Author SHA1 Message Date
hsiegeln
8a6744d3e9 chore: refresh GitNexus stats + drop stale tsbuildinfo
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / build (push) Has been cancelled
GitNexus analyze --embeddings after the alerts-inbox-redesign branch
brought the graph to 8780 symbols / 22753 relationships (was 8527/22174
in AGENTS.md and 8603/22281 in CLAUDE.md). The stat-header drift between
AGENTS.md and CLAUDE.md is an artifact of separate reindexes — both now
in sync.

ui/tsconfig.app.tsbuildinfo was a stale tsc incremental-build cache
that shouldn't be tracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:39:36 +02:00
hsiegeln
88804aca2c fix(alerts): final sweep — drop ACKNOWLEDGED from AlertStateChip + CMD-K; harden V17 IT
UI: AlertStateChip.LABELS and .COLORS no longer include ACKNOWLEDGED
(dropped in V17). AlertStateChip.test.tsx test-cases trimmed to the
three remaining states. LayoutShell CMD-K now searches FIRING alerts
with acked=false (was state=[FIRING,ACKNOWLEDGED]).

Test: V17MigrationIT.open_rule_index_predicate_is_reworked replaced
with a structural-only assertion (index exists, indisunique). The
pg_get_indexdef pretty-printer varies across Postgres versions, so
predicate semantics are verified behaviorally in
PostgresAlertInstanceRepositoryIT (findOpenForRule_* +
save_rejectsSecondOpenInstanceForSameRuleAndExchange).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:29:58 +02:00
hsiegeln
0cd0a27452 docs(alerts): rules + CLAUDE.md — inbox redesign, V17 migration
- .claude/rules/ui.md: rewrite Alerts section — sidebar trims to
  Inbox/Rules/Silences, InboxPage description updated (4 filters, row
  actions, bulk toolbar, soft-delete undo), SilenceRuleMenu documented,
  SilencesPage ?ruleId= prefill noted.
- CLAUDE.md: V17 migration entry describing enum/column/table/index
  changes for the inbox redesign.
- .claude/rules/app-classes.md AlertController bullet already updated
  in the T6 drive-by.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:21:27 +02:00
hsiegeln
9f28c69709 test(ui/alerts): InboxPage — filter defaults, toggle behavior, role-gated delete, undo toast
Covers: default useAlerts call (FIRING + hide-acked + hide-read),
Hide-acked toggle removes the acked filter, Acknowledge button only
renders for unacked rows, bulk-delete confirmation dialog with count,
delete buttons hidden for non-OPERATOR users, row-delete wires to
useDeleteAlert + renders an Undo action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:19:51 +02:00
hsiegeln
b20f08b3d0 feat(ui/alerts): SilencesPage prefills Rule ID from ?ruleId= query param
Used by InboxPage's 'Silence rule… → Custom…' flow to carry the alert's
ruleId into the silence creation form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:15:52 +02:00
hsiegeln
35fea645b6 fix(ui/alerts): InboxPage polish — status colors, selected-scrub on delete, drop stale comment
- STATE_ITEMS gains color dots (text-muted/error/success) to match SEVERITY_ITEMS
- onDeleteOne removes the deleted id from the selection Set so a follow-up bulk
  action doesn't try to re-delete a tombstoned row
- drop stale comment block that described an alternative SilenceRulesForSelection
  implementation not matching the shipped code

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:14:55 +02:00
hsiegeln
2bc214e324 feat(ui/alerts): single inbox — filter bar, silence/delete row + bulk actions
Replaces the old FIRING+ACK hardcoded inbox with the single filterable
inbox:

- Filter bar: Severity · Status (PENDING/FIRING/RESOLVED, default FIRING) ·
  Hide acked (default on) · Hide read (default on).
- Row actions: Ack, Mark read, Silence rule… (quick menu), Delete
  (OPERATOR+, soft delete with undo toast wired to useRestoreAlert).
- Bulk toolbar: Ack N · Mark N read · Silence rules · Delete N
  (ConfirmDialog; OPERATOR+).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:09:22 +02:00
hsiegeln
837fcbf926 feat(ui/alerts): SilenceRuleMenu — 1h/8h/24h/custom duration menu
Used by InboxPage row + bulk actions to silence an alert's underlying
rule for a chosen preset window. 'Custom…' routes to
/alerts/silences?ruleId=<id> (T13 adds the prefill wire).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:05:30 +02:00
hsiegeln
e3b656f159 refactor(ui/alerts): single inbox — remove AllAlerts + History pages, trim sidebar
Sidebar Alerts section now just: Inbox · Rules · Silences. The /alerts
redirect still lands in /alerts/inbox; /alerts/all and /alerts/history
routes are gone (no redirect — stale URLs 404 per clean-break policy).

Also updates sidebar-utils.test.ts to match the new 3-entry shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:02:12 +02:00
hsiegeln
be703eb71d feat(ui/alerts): hooks for bulk-ack, delete, bulk-delete, restore + acked/read filter params
- useAlerts gains acked/read filter params threaded into query + queryKey
- new mutations: useBulkAckAlerts, useDeleteAlert, useBulkDeleteAlerts, useRestoreAlert
- all cache-invalidate the alerts list and unread-count on success

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:00:18 +02:00
18 changed files with 691 additions and 330 deletions

View File

@@ -36,15 +36,14 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments
## Alerts
- **Sidebar section** (`buildAlertsTreeNodes` in `ui/src/components/sidebar-utils.ts`) — Inbox, All, Rules, Silences, History.
- **Routes** in `ui/src/router.tsx`: `/alerts`, `/alerts/inbox`, `/alerts/all`, `/alerts/history`, `/alerts/rules`, `/alerts/rules/new`, `/alerts/rules/:id`, `/alerts/silences`.
- **Sidebar section** (`buildAlertsTreeNodes` in `ui/src/components/sidebar-utils.ts`) — Inbox, Rules, Silences.
- **Routes** in `ui/src/router.tsx`: `/alerts` (redirect to inbox), `/alerts/inbox`, `/alerts/rules`, `/alerts/rules/new`, `/alerts/rules/:id`, `/alerts/silences`. No redirects for the retired `/alerts/all` and `/alerts/history` — stale URLs 404 per the clean-break policy.
- **Pages** under `ui/src/pages/Alerts/`:
- `InboxPage.tsx`user-targeted FIRING/ACK'd alerts with bulk-read.
- `AllAlertsPage.tsx` — env-wide list with state-chip filter.
- `HistoryPage.tsx` — RESOLVED alerts.
- `InboxPage.tsx`single filterable inbox. Filters: severity (multi), state (PENDING/FIRING/RESOLVED, default FIRING), Hide acked toggle (default on), Hide read toggle (default on). Row actions: Acknowledge, Mark read, Silence rule… (duration quick menu), Delete (OPERATOR+, soft-delete with undo toast wired to `useRestoreAlert`). Bulk toolbar (selection-driven): Acknowledge N · Mark N read · Silence rules · Delete N (ConfirmDialog; OPERATOR+).
- `SilenceRuleMenu.tsx` — DS `Dropdown`-based duration picker (1h / 8h / 24h / Custom…). Used by the row-level and bulk silence actions. "Custom…" navigates to `/alerts/silences?ruleId=<id>`.
- `RulesListPage.tsx` — CRUD + enable/disable toggle + env-promotion dropdown (pure UI prefill, no new endpoint).
- `RuleEditor/RuleEditorWizard.tsx` — 5-step wizard (Scope / Condition / Trigger / Notify / Review). `form-state.ts` is the single source of truth (`initialForm` / `toRequest` / `validateStep`). Seven condition-form subcomponents under `RuleEditor/condition-forms/` — including `AgentLifecycleForm.tsx` (multi-select event-type chips for the six-entry `AgentLifecycleEventType` allowlist + lookback-window input).
- `SilencesPage.tsx` — matcher-based create + end-early.
- `SilencesPage.tsx` — matcher-based create + end-early. Reads `?ruleId=` search param to prefill the Rule ID field (driven by InboxPage's "Silence rule… → Custom…" flow).
- `AlertRow.tsx` shared list row; `alerts-page.module.css` shared styling.
- **Components**:
- `NotificationBell.tsx` — polls `/alerts/unread-count` every 30 s (paused when tab hidden via TanStack Query `refetchIntervalInBackground: false`).

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-server** (8527 symbols, 22174 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **cameleer-server** (8780 symbols, 22753 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -73,6 +73,7 @@ PostgreSQL (Flyway): `cameleer-server-app/src/main/resources/db/migration/`
- V14 — Repair EXCHANGE_MATCH alert_rules persisted with fireMode=null (sets fireMode=PER_EXCHANGE + perExchangeLingerSeconds=300); paired with stricter `ExchangeMatchCondition` ctor that now rejects null fireMode.
- V15 — Discriminate open-instance uniqueness by `context->'exchange'->>'id'` so EXCHANGE_MATCH/PER_EXCHANGE emits one alert_instance per matching exchange; scalar kinds resolve to `''` and keep one-open-per-rule.
- V16 — Generalise the V15 discriminator to prefer `context->>'_subjectFingerprint'` (falls back to the V15 `exchange.id` expression for legacy rows). Enables AGENT_LIFECYCLE to emit one alert_instance per `(agent, eventType, timestamp)` via a canonical fingerprint in the evaluator firing's context.
- V17 — Alerts inbox redesign: drop `ACKNOWLEDGED` from `alert_state_enum` (ack is now orthogonal via `acked_at`), add `read_at` + `deleted_at` timestamp columns (global, no per-user tracking), drop `alert_reads` table entirely, rework the V13/V15/V16 open-rule unique index predicate to `state IN ('PENDING','FIRING') AND deleted_at IS NULL` so ack doesn't close the slot and soft-delete frees it.
ClickHouse: `cameleer-server-app/src/main/resources/clickhouse/init.sql` (run idempotently on startup)
@@ -100,7 +101,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-server** (8603 symbols, 22281 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **cameleer-server** (8780 symbols, 22753 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -35,14 +35,24 @@ class V17MigrationIT extends AbstractPostgresIT {
}
@Test
void open_rule_index_predicate_is_reworked() {
String def = jdbcTemplate.queryForObject("""
SELECT pg_get_indexdef(indexrelid)
FROM pg_index
void open_rule_index_exists_and_is_unique() {
// Structural check only — the pg_get_indexdef pretty-printer varies across
// Postgres versions. Predicate semantics (ack doesn't close; soft-delete
// frees the slot; RESOLVED excluded) are covered behaviorally by
// PostgresAlertInstanceRepositoryIT#findOpenForRule_* and
// #save_rejectsSecondOpenInstanceForSameRuleAndExchange.
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)::int FROM pg_indexes
WHERE indexname = 'alert_instances_open_rule_uq'
AND tablename = 'alert_instances'
""", Integer.class);
assertThat(count).isEqualTo(1);
Boolean isUnique = jdbcTemplate.queryForObject("""
SELECT indisunique FROM pg_index
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
WHERE pg_class.relname = 'alert_instances_open_rule_uq'
""", String.class);
assertThat(def).contains("state = ANY (ARRAY['PENDING'::alert_state_enum, 'FIRING'::alert_state_enum])");
assertThat(def).contains("deleted_at IS NULL");
""", Boolean.class);
assertThat(isUnique).isTrue();
}
}

View File

@@ -11,6 +11,8 @@ type AlertSeverity = NonNullable<AlertDto['severity']>;
export interface AlertsFilter {
state?: AlertState | AlertState[];
severity?: AlertSeverity | AlertSeverity[];
acked?: boolean;
read?: boolean;
ruleId?: string;
limit?: number;
}
@@ -47,7 +49,7 @@ export function useAlerts(filter: AlertsFilter = {}) {
const severityKey = severityArr ? [...severityArr].sort() : null;
return useQuery({
queryKey: ['alerts', env, 'list', fetchLimit, stateKey, severityKey],
queryKey: ['alerts', env, 'list', fetchLimit, stateKey, severityKey, filter.acked ?? null, filter.read ?? null],
enabled: !!env,
refetchInterval: 30_000,
refetchIntervalInBackground: false,
@@ -56,6 +58,8 @@ export function useAlerts(filter: AlertsFilter = {}) {
const query: Record<string, unknown> = { limit: fetchLimit };
if (stateArr && stateArr.length > 0) query.state = stateArr;
if (severityArr && severityArr.length > 0) query.severity = severityArr;
if (filter.acked !== undefined) query.acked = filter.acked;
if (filter.read !== undefined) query.read = filter.read;
const { data, error } = await apiClient.GET(
'/environments/{envSlug}/alerts',
{
@@ -180,3 +184,80 @@ export function useBulkReadAlerts() {
},
});
}
/** Acknowledge a batch of alert instances. */
export function useBulkAckAlerts() {
const env = useSelectedEnv();
const qc = useQueryClient();
return useMutation({
mutationFn: async (ids: string[]) => {
if (!env) throw new Error('no env');
const { error } = await apiClient.POST(
'/environments/{envSlug}/alerts/bulk-ack',
{ params: { path: { envSlug: env } }, body: { instanceIds: ids } } as any,
);
if (error) throw error;
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['alerts', env] }),
});
}
/** Delete (soft) a single alert instance. */
export function useDeleteAlert() {
const env = useSelectedEnv();
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
if (!env) throw new Error('no env');
const { error } = await apiClient.DELETE(
'/environments/{envSlug}/alerts/{id}',
{ params: { path: { envSlug: env, id } } } as any,
);
if (error) throw error;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['alerts', env] });
qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] });
},
});
}
/** Delete (soft) a batch of alert instances. */
export function useBulkDeleteAlerts() {
const env = useSelectedEnv();
const qc = useQueryClient();
return useMutation({
mutationFn: async (ids: string[]) => {
if (!env) throw new Error('no env');
const { error } = await apiClient.POST(
'/environments/{envSlug}/alerts/bulk-delete',
{ params: { path: { envSlug: env } }, body: { instanceIds: ids } } as any,
);
if (error) throw error;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['alerts', env] });
qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] });
},
});
}
/** Restore a soft-deleted alert instance. */
export function useRestoreAlert() {
const env = useSelectedEnv();
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
if (!env) throw new Error('no env');
const { error } = await apiClient.POST(
'/environments/{envSlug}/alerts/{id}/restore',
{ params: { path: { envSlug: env, id } } } as any,
);
if (error) throw error;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['alerts', env] });
qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] });
},
});
}

View File

@@ -11,7 +11,6 @@ describe('AlertStateChip', () => {
it.each([
['PENDING', /pending/i],
['FIRING', /firing/i],
['ACKNOWLEDGED', /acknowledged/i],
['RESOLVED', /resolved/i],
] as const)('renders %s label', (state, pattern) => {
renderWithTheme(<AlertStateChip state={state} />);

View File

@@ -6,14 +6,12 @@ type State = NonNullable<AlertDto['state']>;
const LABELS: Record<State, string> = {
PENDING: 'Pending',
FIRING: 'Firing',
ACKNOWLEDGED: 'Acknowledged',
RESOLVED: 'Resolved',
};
const COLORS: Record<State, 'auto' | 'success' | 'warning' | 'error'> = {
PENDING: 'warning',
FIRING: 'error',
ACKNOWLEDGED: 'warning',
RESOLVED: 'success',
};

View File

@@ -368,7 +368,7 @@ function LayoutContent() {
const { data: envRecords = [] } = useEnvironments();
// Open alerts + rules for CMD-K (env-scoped).
const { data: cmdkAlerts } = useAlerts({ state: ['FIRING', 'ACKNOWLEDGED'], limit: 100 });
const { data: cmdkAlerts } = useAlerts({ state: ['FIRING'], acked: false, limit: 100 });
const { data: cmdkRules } = useAlertRules();
// Merge environments from both the environments table and agent heartbeats

View File

@@ -2,16 +2,14 @@ import { describe, it, expect } from 'vitest';
import { buildAlertsTreeNodes } from './sidebar-utils';
describe('buildAlertsTreeNodes', () => {
it('returns 5 entries with inbox/all/rules/silences/history paths', () => {
it('returns 3 entries with inbox/rules/silences paths', () => {
const nodes = buildAlertsTreeNodes();
expect(nodes).toHaveLength(5);
expect(nodes).toHaveLength(3);
const paths = nodes.map((n) => n.path);
expect(paths).toEqual([
'/alerts/inbox',
'/alerts/all',
'/alerts/rules',
'/alerts/silences',
'/alerts/history',
]);
});
});

View File

@@ -1,6 +1,6 @@
import { createElement, type ReactNode } from 'react';
import type { SidebarTreeNode } from '@cameleer/design-system';
import { AlertTriangle, Inbox, List, ScrollText, BellOff } from 'lucide-react';
import { AlertTriangle, Inbox, BellOff } from 'lucide-react';
/* ------------------------------------------------------------------ */
/* Domain types (moved out of DS — no longer exported there) */
@@ -117,15 +117,13 @@ export function buildAdminTreeNodes(opts?: { infrastructureEndpoints?: boolean }
/**
* Alerts tree — static nodes for the alerting section.
* Paths: /alerts/{inbox|all|rules|silences|history}
* Paths: /alerts/{inbox|rules|silences}
*/
export function buildAlertsTreeNodes(): SidebarTreeNode[] {
const icon = (el: ReactNode) => el;
return [
{ id: 'alerts-inbox', label: 'Inbox', path: '/alerts/inbox', icon: icon(createElement(Inbox, { size: 14 })) },
{ id: 'alerts-all', label: 'All', path: '/alerts/all', icon: icon(createElement(List, { size: 14 })) },
{ id: 'alerts-rules', label: 'Rules', path: '/alerts/rules', icon: icon(createElement(AlertTriangle, { size: 14 })) },
{ id: 'alerts-silences', label: 'Silences', path: '/alerts/silences', icon: icon(createElement(BellOff, { size: 14 })) },
{ id: 'alerts-history', label: 'History', path: '/alerts/history', icon: icon(createElement(ScrollText, { size: 14 })) },
];
}

View File

@@ -1,115 +0,0 @@
import { useState } from 'react';
import { Link } from 'react-router';
import { Bell } from 'lucide-react';
import {
ButtonGroup, DataTable, EmptyState,
} from '@cameleer/design-system';
import type { ButtonGroupItem, Column } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader';
import { SeverityBadge } from '../../components/SeverityBadge';
import { AlertStateChip } from '../../components/AlertStateChip';
import {
useAlerts, useMarkAlertRead,
type AlertDto,
} from '../../api/queries/alerts';
import { severityToAccent } from './severity-utils';
import { formatRelativeTime } from './time-utils';
import { renderAlertExpanded } from './alert-expanded';
import css from './alerts-page.module.css';
import tableStyles from '../../styles/table-section.module.css';
type AlertState = NonNullable<AlertDto['state']>;
const STATE_ITEMS: ButtonGroupItem[] = [
{ value: 'FIRING', label: 'Firing', color: 'var(--error)' },
{ value: 'ACKNOWLEDGED', label: 'Acknowledged', color: 'var(--warning)' },
{ value: 'PENDING', label: 'Pending', color: 'var(--text-muted)' },
{ value: 'RESOLVED', label: 'Resolved', color: 'var(--success)' },
];
const DEFAULT_OPEN_STATES = new Set<string>(['PENDING', 'FIRING', 'ACKNOWLEDGED']);
export default function AllAlertsPage() {
const [stateSel, setStateSel] = useState<Set<string>>(() => new Set(DEFAULT_OPEN_STATES));
const stateValues: AlertState[] | undefined = stateSel.size === 0
? undefined
: [...stateSel] as AlertState[];
const { data, isLoading, error } = useAlerts({ state: stateValues, limit: 200 });
const markRead = useMarkAlertRead();
const rows = data ?? [];
const columns: Column<AlertDto>[] = [
{
key: 'severity', header: 'Severity', width: '110px',
render: (_, row) => row.severity ? <SeverityBadge severity={row.severity} /> : null,
},
{
key: 'state', header: 'Status', width: '140px',
render: (_, row) => row.state ? <AlertStateChip state={row.state} silenced={row.silenced} /> : null,
},
{
key: 'title', header: 'Title',
render: (_, row) => (
<div className={css.titleCell}>
<Link to={`/alerts/inbox/${row.id}`} onClick={() => row.id && markRead.mutate(row.id)}>
{row.title ?? '(untitled)'}
</Link>
{row.message && <span className={css.titlePreview}>{row.message}</span>}
</div>
),
},
{
key: 'firedAt', header: 'Fired at', width: '140px', sortable: true,
render: (_, row) =>
row.firedAt ? (
<span title={row.firedAt} style={{ fontVariantNumeric: 'tabular-nums' }}>
{formatRelativeTime(row.firedAt)}
</span>
) : '—',
},
];
if (isLoading) return <PageLoader />;
if (error) return <div className={css.page}>Failed to load alerts: {String(error)}</div>;
return (
<div className={css.page}>
<header className={css.pageHeader}>
<div className={css.pageTitleGroup}>
<h2 className={css.pageTitle}>All alerts</h2>
<span className={css.pageSubtitle}>{rows.length} matching your filter</span>
</div>
<div className={css.pageActions}>
<ButtonGroup
items={STATE_ITEMS}
value={stateSel}
onChange={setStateSel}
/>
</div>
</header>
{rows.length === 0 ? (
<EmptyState
icon={<Bell size={32} />}
title="No alerts match this filter"
description="Try switching to a different state or widening your criteria."
/>
) : (
<div className={`${tableStyles.tableSection} ${css.tableWrap}`}>
<DataTable<AlertDto & { id: string }>
columns={columns as Column<AlertDto & { id: string }>[]}
data={rows as Array<AlertDto & { id: string }>}
sortable
flush
fillHeight
pageSize={200}
rowAccent={(row) => row.severity ? severityToAccent(row.severity) : undefined}
expandedContent={renderAlertExpanded}
/>
</div>
)}
</div>
);
}

View File

@@ -1,119 +0,0 @@
import { Link } from 'react-router';
import { History } from 'lucide-react';
import {
DataTable, EmptyState, useGlobalFilters,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader';
import { SeverityBadge } from '../../components/SeverityBadge';
import {
useAlerts, type AlertDto,
} from '../../api/queries/alerts';
import { severityToAccent } from './severity-utils';
import { formatRelativeTime } from './time-utils';
import { renderAlertExpanded } from './alert-expanded';
import css from './alerts-page.module.css';
import tableStyles from '../../styles/table-section.module.css';
/** Duration in s/m/h/d. Pure, best-effort. */
function formatDuration(from?: string | null, to?: string | null): string {
if (!from || !to) return '—';
const ms = new Date(to).getTime() - new Date(from).getTime();
if (ms < 0 || Number.isNaN(ms)) return '—';
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s`;
if (sec < 3600) return `${Math.floor(sec / 60)}m`;
if (sec < 86_400) return `${Math.floor(sec / 3600)}h`;
return `${Math.floor(sec / 86_400)}d`;
}
export default function HistoryPage() {
const { timeRange } = useGlobalFilters();
// useAlerts doesn't accept a time range today; filter client-side
// against the global TimeRangeDropdown in the top bar.
const { data, isLoading, error } = useAlerts({ state: 'RESOLVED', limit: 200 });
const filtered = (data ?? []).filter((a) => {
if (!a.firedAt) return false;
const t = new Date(a.firedAt).getTime();
return t >= timeRange.start.getTime() && t <= timeRange.end.getTime();
});
const columns: Column<AlertDto>[] = [
{
key: 'severity', header: 'Severity', width: '110px',
render: (_, row) => row.severity ? <SeverityBadge severity={row.severity} /> : null,
},
{
key: 'title', header: 'Title',
render: (_, row) => (
<div className={css.titleCell}>
<Link to={`/alerts/inbox/${row.id}`}>{row.title ?? '(untitled)'}</Link>
{row.message && <span className={css.titlePreview}>{row.message}</span>}
</div>
),
},
{
key: 'firedAt', header: 'Fired at', width: '140px', sortable: true,
render: (_, row) =>
row.firedAt ? (
<span title={row.firedAt} style={{ fontVariantNumeric: 'tabular-nums' }}>
{formatRelativeTime(row.firedAt)}
</span>
) : '—',
},
{
key: 'resolvedAt', header: 'Resolved at', width: '140px', sortable: true,
render: (_, row) =>
row.resolvedAt ? (
<span title={row.resolvedAt} style={{ fontVariantNumeric: 'tabular-nums' }}>
{formatRelativeTime(row.resolvedAt)}
</span>
) : '—',
},
{
key: 'duration', header: 'Duration', width: '90px',
render: (_, row) => formatDuration(row.firedAt, row.resolvedAt),
},
];
if (isLoading) return <PageLoader />;
if (error) return <div className={css.page}>Failed to load history: {String(error)}</div>;
return (
<div className={css.page}>
<header className={css.pageHeader}>
<div className={css.pageTitleGroup}>
<h2 className={css.pageTitle}>Alert history</h2>
<span className={css.pageSubtitle}>
{filtered.length === 0
? 'No resolved alerts in range'
: `${filtered.length} resolved alert${filtered.length === 1 ? '' : 's'} in range`}
</span>
</div>
</header>
{filtered.length === 0 ? (
<EmptyState
icon={<History size={32} />}
title="No resolved alerts"
description="Nothing in the selected date range. Try widening it."
/>
) : (
<div className={`${tableStyles.tableSection} ${css.tableWrap}`}>
<DataTable<AlertDto & { id: string }>
columns={columns as Column<AlertDto & { id: string }>[]}
data={filtered as Array<AlertDto & { id: string }>}
sortable
flush
fillHeight
pageSize={200}
rowAccent={(row) => row.severity ? severityToAccent(row.severity) : undefined}
expandedContent={renderAlertExpanded}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,209 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router';
import { ThemeProvider, ToastProvider } from '@cameleer/design-system';
import InboxPage from './InboxPage';
import type { AlertDto } from '../../api/queries/alerts';
import { useAuthStore } from '../../auth/auth-store';
import { useEnvironmentStore } from '../../api/environment-store';
// ── hook mocks ──────────────────────────────────────────────────────────────
// Capture the args passed to useAlerts so we can assert on filter params.
const alertsCallArgs = vi.fn();
const deleteMock = vi.fn().mockResolvedValue(undefined);
const bulkDeleteMock = vi.fn().mockResolvedValue(undefined);
const ackMock = vi.fn().mockResolvedValue(undefined);
const markReadMutateAsync = vi.fn().mockResolvedValue(undefined);
const markReadMutate = vi.fn();
const bulkAckMock = vi.fn().mockResolvedValue(undefined);
const bulkReadMock = vi.fn().mockResolvedValue(undefined);
const restoreMock = vi.fn().mockResolvedValue(undefined);
const createSilenceMock = vi.fn().mockResolvedValue(undefined);
// alertsMock is a factory — each call records its args and returns the current
// rows so we can change the fixture per test.
let currentRows: AlertDto[] = [];
vi.mock('../../api/queries/alerts', () => ({
useAlerts: (filter: unknown) => {
alertsCallArgs(filter);
return { data: currentRows, isLoading: false, error: null };
},
useAckAlert: () => ({ mutateAsync: ackMock, isPending: false }),
useMarkAlertRead: () => ({ mutateAsync: markReadMutateAsync, mutate: markReadMutate, isPending: false }),
useBulkReadAlerts: () => ({ mutateAsync: bulkReadMock, isPending: false }),
useBulkAckAlerts: () => ({ mutateAsync: bulkAckMock, isPending: false }),
useDeleteAlert: () => ({ mutateAsync: deleteMock, isPending: false }),
useBulkDeleteAlerts: () => ({ mutateAsync: bulkDeleteMock, isPending: false }),
useRestoreAlert: () => ({ mutateAsync: restoreMock }),
}));
vi.mock('../../api/queries/alertSilences', () => ({
useCreateSilence: () => ({ mutateAsync: createSilenceMock, isPending: false }),
}));
// ── fixture rows ────────────────────────────────────────────────────────────
const ROW_FIRING: AlertDto = {
id: '11111111-1111-1111-1111-111111111111',
ruleId: 'rrrrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr',
state: 'FIRING',
severity: 'CRITICAL',
title: 'Order pipeline down',
message: 'msg',
firedAt: '2026-04-21T10:00:00Z',
ackedAt: undefined,
ackedBy: undefined,
resolvedAt: undefined,
readAt: undefined,
silenced: false,
currentValue: undefined,
threshold: undefined,
environmentId: 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee',
context: {},
};
const ROW_ACKED: AlertDto = {
...ROW_FIRING,
id: '22222222-2222-2222-2222-222222222222',
ackedAt: '2026-04-21T10:05:00Z',
ackedBy: 'alice',
};
// ── mount helper ────────────────────────────────────────────────────────────
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<ThemeProvider>
<QueryClientProvider client={qc}>
<ToastProvider>
<MemoryRouter initialEntries={['/alerts/inbox']}>
<InboxPage />
</MemoryRouter>
</ToastProvider>
</QueryClientProvider>
</ThemeProvider>,
);
}
// ── setup ───────────────────────────────────────────────────────────────────
beforeEach(() => {
vi.clearAllMocks();
// Reset mocks to their default resolved values after clearAllMocks
deleteMock.mockResolvedValue(undefined);
bulkDeleteMock.mockResolvedValue(undefined);
ackMock.mockResolvedValue(undefined);
markReadMutateAsync.mockResolvedValue(undefined);
bulkAckMock.mockResolvedValue(undefined);
bulkReadMock.mockResolvedValue(undefined);
restoreMock.mockResolvedValue(undefined);
createSilenceMock.mockResolvedValue(undefined);
currentRows = [ROW_FIRING];
// Set OPERATOR role and an env so hooks are enabled
useAuthStore.setState({ roles: ['OPERATOR'] });
useEnvironmentStore.setState({ environment: 'dev' });
});
// ── tests ───────────────────────────────────────────────────────────────────
describe('InboxPage', () => {
it('calls useAlerts with default filters: state=[FIRING], acked=false, read=false', () => {
mount();
// useAlerts is called during render; check the first call's filter arg
expect(alertsCallArgs).toHaveBeenCalledWith(
expect.objectContaining({
state: ['FIRING'],
acked: false,
read: false,
}),
);
});
it('unchecking "Hide acked" removes the acked filter', () => {
mount();
// Initial call should include acked: false
expect(alertsCallArgs).toHaveBeenCalledWith(
expect.objectContaining({ acked: false }),
);
// Find and uncheck the "Hide acked" toggle
const hideAckedToggle = screen.getByRole('checkbox', { name: /hide acked/i });
fireEvent.click(hideAckedToggle);
// After unchecking, useAlerts should be called without acked: false
// (the component passes `undefined` when the toggle is off)
const lastCall = alertsCallArgs.mock.calls[alertsCallArgs.mock.calls.length - 1][0];
expect(lastCall.acked).toBeUndefined();
});
it('shows Acknowledge button only on rows where ackedAt is null', () => {
currentRows = [ROW_FIRING, ROW_ACKED];
mount();
// ROW_FIRING has ackedAt=undefined → Ack button should appear
// ROW_ACKED has ackedAt set → Ack button should NOT appear
// The row-level Ack button label is "Ack"
const ackButtons = screen.getAllByRole('button', { name: /^ack$/i });
expect(ackButtons).toHaveLength(1);
});
it('opens bulk-delete confirmation with the correct count', async () => {
currentRows = [ROW_FIRING, ROW_ACKED];
mount();
// Use the "Select all" checkbox in the filter bar to select all 2 rows.
// It is labelled "Select all (2)" when nothing is selected.
const selectAllCb = screen.getByRole('checkbox', { name: /select all/i });
// fireEvent.click toggles checkboxes and triggers React's onChange
fireEvent.click(selectAllCb);
// After selection the bulk toolbar should show a "Delete N" button
const deleteButton = await screen.findByRole('button', { name: /^delete 2$/i });
fireEvent.click(deleteButton);
// The ConfirmDialog should now be open with the count in the message
await waitFor(() => {
expect(screen.getByText(/delete 2 alerts/i)).toBeInTheDocument();
});
});
it('hides Delete buttons when user lacks OPERATOR role', () => {
useAuthStore.setState({ roles: ['VIEWER'] });
mount();
// Neither the row-level "Delete alert" button nor the bulk "Delete N" button should appear
expect(screen.queryByRole('button', { name: /delete alert/i })).toBeNull();
// No selection is active so "Delete N" wouldn't appear anyway, but confirm
// there's also no element with "Delete" that would open the confirm dialog
const deleteButtons = screen
.queryAllByRole('button')
.filter((btn) => /^delete\b/i.test(btn.textContent ?? ''));
expect(deleteButtons).toHaveLength(0);
});
it('clicking row Delete invokes useDeleteAlert and shows an Undo toast', async () => {
mount();
const deleteAlertButton = screen.getByRole('button', { name: /delete alert/i });
fireEvent.click(deleteAlertButton);
// Verify deleteMock was called with the row's id
await waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith(ROW_FIRING.id);
});
// After deletion a toast appears with "Deleted" title and an "Undo" button
await waitFor(() => {
expect(screen.getByText(/deleted/i)).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /undo/i })).toBeInTheDocument();
});
});

View File

@@ -1,75 +1,177 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router';
import { Inbox } from 'lucide-react';
import { Link, useNavigate } from 'react-router';
import { Inbox, Trash2 } from 'lucide-react';
import {
Button, ButtonGroup, DataTable, EmptyState, useToast,
Button, ButtonGroup, ConfirmDialog, DataTable, EmptyState, Toggle, useToast,
} from '@cameleer/design-system';
import type { ButtonGroupItem, Column } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader';
import { SeverityBadge } from '../../components/SeverityBadge';
import { AlertStateChip } from '../../components/AlertStateChip';
import {
useAlerts, useAckAlert, useBulkReadAlerts, useMarkAlertRead,
useAlerts, useAckAlert, useBulkAckAlerts, useBulkReadAlerts, useMarkAlertRead,
useDeleteAlert, useBulkDeleteAlerts, useRestoreAlert,
type AlertDto,
} from '../../api/queries/alerts';
import { useCreateSilence } from '../../api/queries/alertSilences';
import { useAuthStore } from '../../auth/auth-store';
import { SilenceRuleMenu } from './SilenceRuleMenu';
import { severityToAccent } from './severity-utils';
import { formatRelativeTime } from './time-utils';
import { renderAlertExpanded } from './alert-expanded';
import css from './alerts-page.module.css';
import tableStyles from '../../styles/table-section.module.css';
type Severity = NonNullable<AlertDto['severity']>;
type AlertSeverity = NonNullable<AlertDto['severity']>;
type AlertState = NonNullable<AlertDto['state']>;
// ── Filter bar items ────────────────────────────────────────────────────────
const SEVERITY_ITEMS: ButtonGroupItem[] = [
{ value: 'CRITICAL', label: 'Critical', color: 'var(--error)' },
{ value: 'WARNING', label: 'Warning', color: 'var(--warning)' },
{ value: 'INFO', label: 'Info', color: 'var(--text-muted)' },
{ value: 'CRITICAL', label: 'Critical', color: 'var(--error)' },
{ value: 'WARNING', label: 'Warning', color: 'var(--warning)' },
{ value: 'INFO', label: 'Info', color: 'var(--text-muted)' },
];
const STATE_ITEMS: ButtonGroupItem[] = [
{ value: 'PENDING', label: 'Pending', color: 'var(--text-muted)' },
{ value: 'FIRING', label: 'Firing', color: 'var(--error)' },
{ value: 'RESOLVED', label: 'Resolved', color: 'var(--success)' },
];
// ── Bulk silence helper ─────────────────────────────────────────────────────
const SILENCE_PRESETS: Array<{ label: string; hours: number }> = [
{ label: '1 hour', hours: 1 },
{ label: '8 hours', hours: 8 },
{ label: '24 hours', hours: 24 },
];
interface SilenceRulesForSelectionProps {
selected: Set<string>;
rows: AlertDto[];
}
function SilenceRulesForSelection({ selected, rows }: SilenceRulesForSelectionProps) {
const navigate = useNavigate();
const { toast } = useToast();
const createSilence = useCreateSilence();
const ruleIds = useMemo(() => {
const ids = new Set<string>();
for (const id of selected) {
const row = rows.find((r) => r.id === id);
if (row?.ruleId) ids.add(row.ruleId);
}
return [...ids];
}, [selected, rows]);
if (ruleIds.length === 0) return null;
const handlePreset = (hours: number) => async () => {
const now = new Date();
const results = await Promise.allSettled(
ruleIds.map((ruleId) =>
createSilence.mutateAsync({
matcher: { ruleId },
reason: 'Silenced from inbox (bulk)',
startsAt: now.toISOString(),
endsAt: new Date(now.getTime() + hours * 3_600_000).toISOString(),
}),
),
);
const failed = results.filter((r) => r.status === 'rejected').length;
if (failed === 0) {
toast({ title: `Silenced ${ruleIds.length} rule${ruleIds.length === 1 ? '' : 's'} for ${hours}h`, variant: 'success' });
} else {
toast({ title: `Silenced ${ruleIds.length - failed}/${ruleIds.length} rules`, description: `${failed} failed`, variant: 'warning' });
}
};
const handleCustom = () => navigate('/alerts/silences');
return (
<div style={{ display: 'flex', gap: 'var(--space-xs)' }}>
{SILENCE_PRESETS.map(({ label, hours }) => (
<Button
key={hours}
variant="secondary"
size="sm"
disabled={createSilence.isPending}
onClick={handlePreset(hours)}
>
Silence {hours}h
</Button>
))}
<Button variant="ghost" size="sm" onClick={handleCustom}>
Custom
</Button>
</div>
);
}
// ── InboxPage ───────────────────────────────────────────────────────────────
export default function InboxPage() {
// Filter state — defaults: FIRING selected, hide-acked on, hide-read on
const [severitySel, setSeveritySel] = useState<Set<string>>(new Set());
const severityValues: Severity[] | undefined = severitySel.size === 0
? undefined
: [...severitySel] as Severity[];
const [stateSel, setStateSel] = useState<Set<string>>(new Set(['FIRING']));
const [hideAcked, setHideAcked] = useState(true);
const [hideRead, setHideRead] = useState(true);
const { data, isLoading, error } = useAlerts({
state: ['FIRING', 'ACKNOWLEDGED'],
severity: severityValues,
severity: severitySel.size ? ([...severitySel] as AlertSeverity[]) : undefined,
state: stateSel.size ? ([...stateSel] as AlertState[]) : undefined,
acked: hideAcked ? false : undefined,
read: hideRead ? false : undefined,
limit: 200,
});
const bulkRead = useBulkReadAlerts();
const markRead = useMarkAlertRead();
const ack = useAckAlert();
const { toast } = useToast();
const [selected, setSelected] = useState<Set<string>>(new Set());
// Mutations
const ack = useAckAlert();
const bulkAck = useBulkAckAlerts();
const markRead = useMarkAlertRead();
const bulkRead = useBulkReadAlerts();
const del = useDeleteAlert();
const bulkDelete = useBulkDeleteAlerts();
const restore = useRestoreAlert();
const { toast } = useToast();
// Selection
const [selected, setSelected] = useState<Set<string>>(new Set());
const [deletePending, setDeletePending] = useState<string[] | null>(null);
// RBAC
const roles = useAuthStore((s) => s.roles);
const canDelete = roles.includes('OPERATOR') || roles.includes('ADMIN');
const rows = data ?? [];
const unreadIds = useMemo(
() => rows.filter((a) => a.state === 'FIRING').map((a) => a.id),
[rows],
);
const firingIds = unreadIds; // FIRING alerts are the ones that can be ack'd
const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.id));
const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.id));
const someSelected = selected.size > 0 && !allSelected;
const toggleSelected = (id: string) => {
const toggleSelected = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const toggleSelectAll = () => {
if (allSelected) {
setSelected(new Set());
} else {
setSelected(new Set(rows.map((r) => r.id)));
}
};
const toggleSelectAll = () =>
setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
// Derived counts for bulk-toolbar labels
const selectedRows = rows.filter((r) => selected.has(r.id));
const unackedSel = selectedRows.filter((r) => r.ackedAt == null).map((r) => r.id);
const unreadSel = selectedRows.filter((r) => r.readAt == null).map((r) => r.id);
// "Acknowledge all firing" target (no-selection state)
const firingUnackedIds = rows
.filter((r) => r.state === 'FIRING' && r.ackedAt == null)
.map((r) => r.id);
const allUnreadIds = rows.filter((r) => r.readAt == null).map((r) => r.id);
// ── handlers ───────────────────────────────────────────────────────────────
const onAck = async (id: string, title?: string) => {
try {
@@ -80,10 +182,51 @@ export default function InboxPage() {
}
};
const onMarkRead = async (id: string) => {
try {
await markRead.mutateAsync(id);
toast({ title: 'Marked as read', variant: 'success' });
} catch (e) {
toast({ title: 'Mark read failed', description: String(e), variant: 'error' });
}
};
const onDeleteOne = async (id: string) => {
try {
await del.mutateAsync(id);
setSelected((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
// No built-in action slot in DS toast — render Undo as a Button node
const undoNode = (
<Button
variant="ghost"
size="sm"
onClick={() =>
restore
.mutateAsync(id)
.then(
() => toast({ title: 'Restored', variant: 'success' }),
(e: unknown) => toast({ title: 'Undo failed', description: String(e), variant: 'error' }),
)
}
>
Undo
</Button>
) as unknown as string; // DS description accepts ReactNode at runtime
toast({ title: 'Deleted', description: undoNode, variant: 'success', duration: 5000 });
} catch (e) {
toast({ title: 'Delete failed', description: String(e), variant: 'error' });
}
};
const onBulkAck = async (ids: string[]) => {
if (ids.length === 0) return;
try {
await Promise.all(ids.map((id) => ack.mutateAsync(id)));
await bulkAck.mutateAsync(ids);
setSelected(new Set());
toast({ title: `Acknowledged ${ids.length} alert${ids.length === 1 ? '' : 's'}`, variant: 'success' });
} catch (e) {
@@ -102,6 +245,8 @@ export default function InboxPage() {
}
};
// ── columns ────────────────────────────────────────────────────────────────
const columns: Column<AlertDto>[] = [
{
key: 'select', header: '', width: '40px',
@@ -128,7 +273,7 @@ export default function InboxPage() {
{
key: 'title', header: 'Title',
render: (_, row) => {
const unread = row.state === 'FIRING';
const unread = row.readAt == null;
return (
<div className={`${css.titleCell} ${unread ? css.titleCellUnread : ''}`}>
<Link to={`/alerts/inbox/${row.id}`} onClick={() => markRead.mutate(row.id)}>
@@ -149,31 +294,68 @@ export default function InboxPage() {
) : '—',
},
{
key: 'ack', header: '', width: '120px',
render: (_, row) =>
row.state === 'FIRING' ? (
<Button size="sm" variant="secondary" onClick={() => onAck(row.id, row.title ?? undefined)}>
Acknowledge
</Button>
) : null,
key: 'rowActions', header: '', width: '220px',
render: (_, row) => (
<div style={{ display: 'flex', gap: 'var(--space-xs)', justifyContent: 'flex-end' }}>
{row.ackedAt == null && (
<Button
size="sm"
variant="secondary"
onClick={() => onAck(row.id, row.title ?? undefined)}
disabled={ack.isPending}
>
Ack
</Button>
)}
{row.readAt == null && (
<Button
size="sm"
variant="ghost"
onClick={() => onMarkRead(row.id)}
disabled={markRead.isPending}
>
Mark read
</Button>
)}
{row.ruleId && (
<SilenceRuleMenu
ruleId={row.ruleId}
ruleTitle={row.title ?? undefined}
variant="row"
/>
)}
{canDelete && (
<Button
size="sm"
variant="ghost"
onClick={() => onDeleteOne(row.id)}
disabled={del.isPending}
aria-label="Delete alert"
>
<Trash2 size={14} />
</Button>
)}
</div>
),
},
];
// ── render ─────────────────────────────────────────────────────────────────
if (isLoading) return <PageLoader />;
if (error) return <div className={css.page}>Failed to load alerts: {String(error)}</div>;
if (error) return <div className={css.page}>Failed to load alerts: {String(error)}</div>;
const selectedIds = Array.from(selected);
const selectedFiringIds = rows
.filter((r) => selected.has(r.id) && r.state === 'FIRING')
.map((r) => r.id);
const needsAttention = rows.filter((r) => r.readAt == null || r.ackedAt == null).length;
const subtitle =
selectedIds.length > 0
? `${selectedIds.length} selected`
: `${unreadIds.length} need attention · ${rows.length} total in inbox`;
: `${needsAttention} need attention · ${rows.length} total`;
return (
<div className={css.page}>
{/* ── Header ─────────────────────────────────────────────────────── */}
<header className={css.pageHeader}>
<div className={css.pageTitleGroup}>
<h2 className={css.pageTitle}>Inbox</h2>
@@ -185,9 +367,25 @@ export default function InboxPage() {
value={severitySel}
onChange={setSeveritySel}
/>
<ButtonGroup
items={STATE_ITEMS}
value={stateSel}
onChange={setStateSel}
/>
<Toggle
label="Hide acked"
checked={hideAcked}
onChange={(e) => setHideAcked(e.currentTarget.checked)}
/>
<Toggle
label="Hide read"
checked={hideRead}
onChange={(e) => setHideRead(e.currentTarget.checked)}
/>
</div>
</header>
{/* ── Filter / bulk toolbar ───────────────────────────────────────── */}
<div className={css.filterBar}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
<input
@@ -200,42 +398,54 @@ export default function InboxPage() {
{allSelected ? 'Deselect all' : `Select all${rows.length ? ` (${rows.length})` : ''}`}
</label>
<span style={{ flex: 1 }} />
{selectedIds.length > 0 ? (
/* ── Bulk actions ─────────────────────────────────────────────── */
<>
<Button
variant="primary"
size="sm"
onClick={() => onBulkAck(selectedFiringIds)}
disabled={selectedFiringIds.length === 0 || ack.isPending}
onClick={() => onBulkAck(unackedSel)}
disabled={unackedSel.length === 0 || bulkAck.isPending}
>
{selectedFiringIds.length > 0
? `Acknowledge ${selectedFiringIds.length}`
: 'Acknowledge selected'}
Acknowledge {unackedSel.length}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onBulkRead(selectedIds)}
disabled={bulkRead.isPending}
onClick={() => onBulkRead(unreadSel)}
disabled={unreadSel.length === 0 || bulkRead.isPending}
>
Mark {selectedIds.length} read
Mark {unreadSel.length} read
</Button>
<SilenceRulesForSelection selected={selected} rows={rows} />
{canDelete && (
<Button
variant="danger"
size="sm"
onClick={() => setDeletePending(selectedIds)}
disabled={bulkDelete.isPending}
>
Delete {selectedIds.length}
</Button>
)}
</>
) : (
/* ── Global actions (no selection) ───────────────────────────── */
<>
<Button
variant="primary"
size="sm"
onClick={() => onBulkAck(firingIds)}
disabled={firingIds.length === 0 || ack.isPending}
onClick={() => onBulkAck(firingUnackedIds)}
disabled={firingUnackedIds.length === 0 || bulkAck.isPending}
>
Acknowledge all firing
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onBulkRead(unreadIds)}
disabled={unreadIds.length === 0 || bulkRead.isPending}
onClick={() => onBulkRead(allUnreadIds)}
disabled={allUnreadIds.length === 0 || bulkRead.isPending}
>
Mark all read
</Button>
@@ -243,11 +453,12 @@ export default function InboxPage() {
)}
</div>
{/* ── Table / empty ───────────────────────────────────────────────── */}
{rows.length === 0 ? (
<EmptyState
icon={<Inbox size={32} />}
title="All clear"
description="No open alerts for you in this environment."
description="No alerts match the current filters."
/>
) : (
<div className={`${tableStyles.tableSection} ${css.tableWrap}`}>
@@ -263,6 +474,24 @@ export default function InboxPage() {
/>
</div>
)}
{/* ── Bulk delete confirmation ─────────────────────────────────────── */}
<ConfirmDialog
open={deletePending != null}
onClose={() => setDeletePending(null)}
onConfirm={async () => {
if (!deletePending) return;
await bulkDelete.mutateAsync(deletePending);
toast({ title: `Deleted ${deletePending.length}`, variant: 'success' });
setDeletePending(null);
setSelected(new Set());
}}
title="Delete alerts?"
message={`Delete ${deletePending?.length ?? 0} alerts? This affects all users.`}
confirmText="Delete"
variant="danger"
loading={bulkDelete.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { BellOff } from 'lucide-react';
import { useNavigate } from 'react-router';
import { Button, Dropdown, useToast } from '@cameleer/design-system';
import type { DropdownItem } from '@cameleer/design-system';
import { useCreateSilence } from '../../api/queries/alertSilences';
interface Props {
ruleId: string;
ruleTitle?: string;
onDone?: () => void;
variant?: 'row' | 'bulk';
}
const PRESETS: Array<{ label: string; hours: number }> = [
{ label: '1 hour', hours: 1 },
{ label: '8 hours', hours: 8 },
{ label: '24 hours', hours: 24 },
];
export function SilenceRuleMenu({ ruleId, ruleTitle, onDone, variant = 'row' }: Props) {
const navigate = useNavigate();
const { toast } = useToast();
const createSilence = useCreateSilence();
const handlePreset = (hours: number) => async () => {
const now = new Date();
const reason = ruleTitle
? `Silenced from inbox (${ruleTitle})`
: 'Silenced from inbox';
try {
await createSilence.mutateAsync({
matcher: { ruleId },
reason,
startsAt: now.toISOString(),
endsAt: new Date(now.getTime() + hours * 3_600_000).toISOString(),
});
toast({ title: `Silenced for ${hours}h`, variant: 'success' });
onDone?.();
} catch (e) {
toast({ title: 'Silence failed', description: String(e), variant: 'error' });
}
};
const handleCustom = () => {
navigate(`/alerts/silences?ruleId=${encodeURIComponent(ruleId)}`);
};
const items: DropdownItem[] = [
...PRESETS.map(({ label, hours }) => ({
label,
disabled: createSilence.isPending,
onClick: handlePreset(hours),
})),
{ divider: true, label: '' },
{ label: 'Custom…', onClick: handleCustom },
];
const buttonLabel = variant === 'bulk' ? 'Silence rules' : 'Silence rule…';
return (
<Dropdown
trigger={
<Button variant="secondary" size="sm">
<BellOff size={14} style={{ marginRight: 4 }} />
{buttonLabel}
</Button>
}
items={items}
/>
);
}

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router';
import { BellOff } from 'lucide-react';
import {
Button, FormField, Input, useToast, DataTable,
@@ -28,6 +29,12 @@ export default function SilencesPage() {
const [hours, setHours] = useState(1);
const [pendingEnd, setPendingEnd] = useState<AlertSilenceResponse | null>(null);
const [searchParams] = useSearchParams();
useEffect(() => {
const r = searchParams.get('ruleId');
if (r) setMatcherRuleId(r);
}, [searchParams]);
if (isLoading) return <PageLoader />;
if (error) return <div className={css.page}>Failed to load silences: {String(error)}</div>;

View File

@@ -24,8 +24,6 @@ const SensitiveKeysPage = lazy(() => import('./pages/Admin/SensitiveKeysPage'));
const AppsTab = lazy(() => import('./pages/AppsTab/AppsTab'));
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
const InboxPage = lazy(() => import('./pages/Alerts/InboxPage'));
const AllAlertsPage = lazy(() => import('./pages/Alerts/AllAlertsPage'));
const HistoryPage = lazy(() => import('./pages/Alerts/HistoryPage'));
const RulesListPage = lazy(() => import('./pages/Alerts/RulesListPage'));
const RuleEditorWizard = lazy(() => import('./pages/Alerts/RuleEditor/RuleEditorWizard'));
const SilencesPage = lazy(() => import('./pages/Alerts/SilencesPage'));
@@ -84,8 +82,6 @@ export const router = createBrowserRouter([
// Alerts
{ path: 'alerts', element: <Navigate to="/alerts/inbox" replace /> },
{ path: 'alerts/inbox', element: <SuspenseWrapper><InboxPage /></SuspenseWrapper> },
{ path: 'alerts/all', element: <SuspenseWrapper><AllAlertsPage /></SuspenseWrapper> },
{ path: 'alerts/history', element: <SuspenseWrapper><HistoryPage /></SuspenseWrapper> },
{ path: 'alerts/rules', element: <SuspenseWrapper><RulesListPage /></SuspenseWrapper> },
{ path: 'alerts/rules/new', element: <SuspenseWrapper><RuleEditorWizard /></SuspenseWrapper> },
{ path: 'alerts/rules/:id', element: <SuspenseWrapper><RuleEditorWizard /></SuspenseWrapper> },

View File

@@ -1 +0,0 @@
{"root":["./src/config.ts","./src/main.tsx","./src/router.tsx","./src/swagger-ui-dist.d.ts","./src/vite-env.d.ts","./src/api/client.ts","./src/api/schema.d.ts","./src/api/types.ts","./src/api/queries/agents.ts","./src/api/queries/catalog.ts","./src/api/queries/diagrams.ts","./src/api/queries/executions.ts","./src/api/queries/admin/admin-api.ts","./src/api/queries/admin/audit.ts","./src/api/queries/admin/database.ts","./src/api/queries/admin/opensearch.ts","./src/api/queries/admin/rbac.ts","./src/api/queries/admin/thresholds.ts","./src/auth/loginpage.tsx","./src/auth/oidccallback.tsx","./src/auth/protectedroute.tsx","./src/auth/auth-store.ts","./src/auth/use-auth.ts","./src/components/layoutshell.tsx","./src/pages/admin/auditlogpage.tsx","./src/pages/admin/databaseadminpage.tsx","./src/pages/admin/oidcconfigpage.tsx","./src/pages/admin/opensearchadminpage.tsx","./src/pages/admin/rbacpage.tsx","./src/pages/agenthealth/agenthealth.tsx","./src/pages/agentinstance/agentinstance.tsx","./src/pages/dashboard/dashboard.tsx","./src/pages/exchangedetail/exchangedetail.tsx","./src/pages/routes/routesmetrics.tsx","./src/pages/swagger/swaggerpage.tsx"],"version":"5.9.3"}