Compare commits
10 Commits
207ae246af
...
8a6744d3e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a6744d3e9 | ||
|
|
88804aca2c | ||
|
|
0cd0a27452 | ||
|
|
9f28c69709 | ||
|
|
b20f08b3d0 | ||
|
|
35fea645b6 | ||
|
|
2bc214e324 | ||
|
|
837fcbf926 | ||
|
|
e3b656f159 | ||
|
|
be703eb71d |
@@ -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`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 })) },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
209
ui/src/pages/Alerts/InboxPage.test.tsx
Normal file
209
ui/src/pages/Alerts/InboxPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
71
ui/src/pages/Alerts/SilenceRuleMenu.tsx
Normal file
71
ui/src/pages/Alerts/SilenceRuleMenu.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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> },
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user