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>
This commit is contained in:
@@ -2,16 +2,14 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { buildAlertsTreeNodes } from './sidebar-utils';
|
import { buildAlertsTreeNodes } from './sidebar-utils';
|
||||||
|
|
||||||
describe('buildAlertsTreeNodes', () => {
|
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();
|
const nodes = buildAlertsTreeNodes();
|
||||||
expect(nodes).toHaveLength(5);
|
expect(nodes).toHaveLength(3);
|
||||||
const paths = nodes.map((n) => n.path);
|
const paths = nodes.map((n) => n.path);
|
||||||
expect(paths).toEqual([
|
expect(paths).toEqual([
|
||||||
'/alerts/inbox',
|
'/alerts/inbox',
|
||||||
'/alerts/all',
|
|
||||||
'/alerts/rules',
|
'/alerts/rules',
|
||||||
'/alerts/silences',
|
'/alerts/silences',
|
||||||
'/alerts/history',
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createElement, type ReactNode } from 'react';
|
import { createElement, type ReactNode } from 'react';
|
||||||
import type { SidebarTreeNode } from '@cameleer/design-system';
|
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) */
|
/* 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.
|
* Alerts tree — static nodes for the alerting section.
|
||||||
* Paths: /alerts/{inbox|all|rules|silences|history}
|
* Paths: /alerts/{inbox|rules|silences}
|
||||||
*/
|
*/
|
||||||
export function buildAlertsTreeNodes(): SidebarTreeNode[] {
|
export function buildAlertsTreeNodes(): SidebarTreeNode[] {
|
||||||
const icon = (el: ReactNode) => el;
|
const icon = (el: ReactNode) => el;
|
||||||
return [
|
return [
|
||||||
{ id: 'alerts-inbox', label: 'Inbox', path: '/alerts/inbox', icon: icon(createElement(Inbox, { size: 14 })) },
|
{ 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-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-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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -24,8 +24,6 @@ const SensitiveKeysPage = lazy(() => import('./pages/Admin/SensitiveKeysPage'));
|
|||||||
const AppsTab = lazy(() => import('./pages/AppsTab/AppsTab'));
|
const AppsTab = lazy(() => import('./pages/AppsTab/AppsTab'));
|
||||||
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
|
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
|
||||||
const InboxPage = lazy(() => import('./pages/Alerts/InboxPage'));
|
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 RulesListPage = lazy(() => import('./pages/Alerts/RulesListPage'));
|
||||||
const RuleEditorWizard = lazy(() => import('./pages/Alerts/RuleEditor/RuleEditorWizard'));
|
const RuleEditorWizard = lazy(() => import('./pages/Alerts/RuleEditor/RuleEditorWizard'));
|
||||||
const SilencesPage = lazy(() => import('./pages/Alerts/SilencesPage'));
|
const SilencesPage = lazy(() => import('./pages/Alerts/SilencesPage'));
|
||||||
@@ -84,8 +82,6 @@ export const router = createBrowserRouter([
|
|||||||
// Alerts
|
// Alerts
|
||||||
{ path: 'alerts', element: <Navigate to="/alerts/inbox" replace /> },
|
{ path: 'alerts', element: <Navigate to="/alerts/inbox" replace /> },
|
||||||
{ path: 'alerts/inbox', element: <SuspenseWrapper><InboxPage /></SuspenseWrapper> },
|
{ 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', element: <SuspenseWrapper><RulesListPage /></SuspenseWrapper> },
|
||||||
{ path: 'alerts/rules/new', element: <SuspenseWrapper><RuleEditorWizard /></SuspenseWrapper> },
|
{ path: 'alerts/rules/new', element: <SuspenseWrapper><RuleEditorWizard /></SuspenseWrapper> },
|
||||||
{ path: 'alerts/rules/:id', element: <SuspenseWrapper><RuleEditorWizard /></SuspenseWrapper> },
|
{ path: 'alerts/rules/:id', element: <SuspenseWrapper><RuleEditorWizard /></SuspenseWrapper> },
|
||||||
|
|||||||
Reference in New Issue
Block a user