[HDX-3969] Add alerts page (Shift+A) with overview and recent history (#2093)

This commit is contained in:
Warren Lee 2026-04-10 11:36:38 -07:00 committed by GitHub
parent bfb455d90a
commit 7a9882d421
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 630 additions and 4 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/cli": patch
---
Add alerts page (Shift+A) with overview and recent trigger history

View file

@ -43,6 +43,7 @@ src/
│ # buildTraceLogsSql (waterfall correlated logs)
│ # buildFullRowSql (SELECT * for row detail)
├── components/
│ ├── AlertsPage.tsx # Alerts overview page — list + detail with recent history (Shift+A)
│ ├── EventViewer.tsx # Main TUI view — table, search, detail panel with tabs
│ ├── TraceWaterfall.tsx # Trace waterfall chart with j/k navigation + event details
│ ├── RowOverview.tsx # Structured overview (top-level attrs, event attrs, resource attrs)
@ -156,6 +157,7 @@ Key expression mappings from the web frontend's `getConfig()`:
| `t` | Edit time range in $EDITOR |
| `f` | Toggle follow mode (live tail) |
| `w` | Toggle line wrap |
| `A` (Shift+A) | Open alerts page |
| `?` | Toggle help screen |
| `q` | Quit |

View file

@ -9,12 +9,13 @@ import {
type SourceResponse,
type SavedSearchResponse,
} from '@/api/client';
import AlertsPage from '@/components/AlertsPage';
import ErrorDisplay from '@/components/ErrorDisplay';
import LoginForm from '@/components/LoginForm';
import SourcePicker from '@/components/SourcePicker';
import EventViewer from '@/components/EventViewer';
type Screen = 'loading' | 'login' | 'pick-source' | 'events';
type Screen = 'loading' | 'login' | 'pick-source' | 'events' | 'alerts';
interface AppProps {
apiUrl: string;
@ -122,6 +123,18 @@ export default function App({ apiUrl, query, sourceName, follow }: AppProps) {
[eventSources],
);
// Track the screen before alerts so we can return to it
const [preAlertsScreen, setPreAlertsScreen] = useState<Screen>('events');
const handleOpenAlerts = useCallback(() => {
setPreAlertsScreen(screen);
setScreen('alerts');
}, [screen]);
const handleCloseAlerts = useCallback(() => {
setScreen(preAlertsScreen);
}, [preAlertsScreen]);
if (error) {
return (
<Box paddingX={1}>
@ -152,10 +165,17 @@ export default function App({ apiUrl, query, sourceName, follow }: AppProps) {
</Text>
<Text dimColor>Search and tail events from the terminal</Text>
</Box>
<SourcePicker sources={eventSources} onSelect={handleSourceSelect} />
<SourcePicker
sources={eventSources}
onSelect={handleSourceSelect}
onOpenAlerts={handleOpenAlerts}
/>
</Box>
);
case 'alerts':
return <AlertsPage client={client} onClose={handleCloseAlerts} />;
case 'events':
if (!selectedSource) return null;
return (
@ -166,6 +186,7 @@ export default function App({ apiUrl, query, sourceName, follow }: AppProps) {
sources={eventSources}
savedSearches={savedSearches}
onSavedSearchSelect={handleSavedSearchSelect}
onOpenAlerts={handleOpenAlerts}
initialQuery={activeQuery}
follow={follow}
/>

View file

@ -138,6 +138,12 @@ export class ApiClient {
return res.json() as Promise<DashboardResponse[]>;
}
async getAlerts(): Promise<AlertsResponse> {
const res = await this.get('/alerts');
if (!res.ok) throw new Error(`GET /alerts failed: ${res.status}`);
return res.json() as Promise<AlertsResponse>;
}
// ---- ClickHouse client via proxy ---------------------------------
createClickHouseClient(
@ -379,3 +385,59 @@ interface DashboardResponse {
createdAt?: string;
updatedAt?: string;
}
// ---- Alerts --------------------------------------------------------
export interface AlertHistoryItem {
counts: number;
createdAt: string;
lastValues: Array<{ startTime: string; count: number }>;
state: 'ALERT' | 'OK' | 'INSUFFICIENT_DATA' | 'DISABLED';
}
export interface AlertItem {
_id: string;
interval: string;
scheduleOffsetMinutes?: number;
scheduleStartAt?: string | null;
threshold: number;
thresholdType: 'above' | 'below';
channel: { type?: string | null };
state?: 'ALERT' | 'OK' | 'INSUFFICIENT_DATA' | 'DISABLED';
source?: 'saved_search' | 'tile';
dashboardId?: string;
savedSearchId?: string;
tileId?: string;
name?: string | null;
message?: string | null;
createdAt: string;
updatedAt: string;
history: AlertHistoryItem[];
dashboard?: {
_id: string;
name: string;
updatedAt: string;
tags: string[];
tiles: Array<{ id: string; config: { name?: string } }>;
};
savedSearch?: {
_id: string;
createdAt: string;
name: string;
updatedAt: string;
tags: string[];
};
createdBy?: {
email: string;
name?: string;
};
silenced?: {
by: string;
at: string;
until: string;
};
}
interface AlertsResponse {
data: AlertItem[];
}

View file

@ -0,0 +1,515 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Box, Text, useStdout, useInput } from 'ink';
import Spinner from 'ink-spinner';
import type { ApiClient, AlertItem, AlertHistoryItem } from '@/api/client';
// ---- Helpers -------------------------------------------------------
function stateColor(
state?: string,
): 'red' | 'green' | 'yellow' | 'gray' | undefined {
switch (state) {
case 'ALERT':
return 'red';
case 'OK':
return 'green';
case 'INSUFFICIENT_DATA':
return 'yellow';
case 'DISABLED':
return 'gray';
default:
return undefined;
}
}
function stateLabel(state?: string): string {
switch (state) {
case 'ALERT':
return 'FIRING';
case 'OK':
return 'OK';
case 'INSUFFICIENT_DATA':
return 'NO DATA';
case 'DISABLED':
return 'DISABLED';
default:
return 'UNKNOWN';
}
}
function alertName(alert: AlertItem): string {
if (alert.name) return alert.name;
if (alert.dashboard) {
const tile = alert.dashboard.tiles.find(t => t.id === alert.tileId);
const tileName = tile?.config.name ?? alert.tileId ?? '';
return `${alert.dashboard.name}${tileName}`;
}
if (alert.savedSearch) {
return alert.savedSearch.name;
}
return `Alert ${alert._id.slice(-6)}`;
}
function alertSourceLabel(alert: AlertItem): string {
if (alert.source === 'tile') return 'tile';
if (alert.source === 'saved_search') return 'search';
return alert.source ?? '';
}
function formatRelativeTime(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function formatTimestamp(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
// ---- Component -----------------------------------------------------
interface AlertsPageProps {
client: ApiClient;
onClose: () => void;
}
export default function AlertsPage({ client, onClose }: AlertsPageProps) {
const { stdout } = useStdout();
const termHeight = stdout?.rows ?? 24;
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedIdx, setSelectedIdx] = useState(0);
const [scrollOffset, setScrollOffset] = useState(0);
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
// Reserve rows for header (2) + footer (2) = 4 lines overhead
const listMaxRows = Math.max(1, termHeight - 4);
const fetchAlerts = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await client.getAlerts();
setAlerts(res.data);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [client]);
useEffect(() => {
fetchAlerts();
}, [fetchAlerts]);
// Sort: ALERT first, then by updatedAt descending
const sortedAlerts = useMemo(() => {
return [...alerts].sort((a, b) => {
if (a.state === 'ALERT' && b.state !== 'ALERT') return -1;
if (b.state === 'ALERT' && a.state !== 'ALERT') return 1;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
}, [alerts]);
const visibleCount = Math.min(
sortedAlerts.length - scrollOffset,
listMaxRows,
);
useInput((input, key) => {
// Close alerts page
if (key.escape || input === 'h' || input === 'q') {
if (expandedIdx !== null) {
setExpandedIdx(null);
return;
}
onClose();
return;
}
// Refresh
if (input === 'r') {
fetchAlerts();
return;
}
// Navigate
if (input === 'j' || key.downArrow) {
if (expandedIdx !== null) return; // no nav in expanded view
setSelectedIdx(i => {
const next = i + 1;
if (next >= listMaxRows) {
setScrollOffset(o =>
Math.min(o + 1, Math.max(0, sortedAlerts.length - listMaxRows)),
);
return i;
}
return Math.min(next, visibleCount - 1);
});
return;
}
if (input === 'k' || key.upArrow) {
if (expandedIdx !== null) return;
setSelectedIdx(i => {
const next = i - 1;
if (next < 0) {
setScrollOffset(o => Math.max(0, o - 1));
return 0;
}
return next;
});
return;
}
// Expand/collapse detail
if (key.return || input === 'l') {
if (expandedIdx !== null) {
setExpandedIdx(null);
} else {
setExpandedIdx(scrollOffset + selectedIdx);
}
return;
}
// Jump to top/bottom
if (input === 'g') {
setScrollOffset(0);
setSelectedIdx(0);
return;
}
if (input === 'G') {
const maxOffset = Math.max(0, sortedAlerts.length - listMaxRows);
setScrollOffset(maxOffset);
setSelectedIdx(Math.min(sortedAlerts.length - 1, listMaxRows - 1));
return;
}
});
// ---- Expanded detail view ----------------------------------------
if (expandedIdx !== null && sortedAlerts[expandedIdx]) {
const alert = sortedAlerts[expandedIdx];
return (
<Box flexDirection="column" paddingX={1} height={termHeight}>
<Box>
<Text bold color="cyan">
HyperDX
</Text>
<Text> </Text>
<Text bold>Alert Detail</Text>
</Box>
<Box marginTop={1} flexDirection="column">
<Box>
<Box width={16}>
<Text bold dimColor>
Name
</Text>
</Box>
<Text>{alertName(alert)}</Text>
</Box>
<Box>
<Box width={16}>
<Text bold dimColor>
State
</Text>
</Box>
<Text color={stateColor(alert.state)}>
{stateLabel(alert.state)}
</Text>
</Box>
<Box>
<Box width={16}>
<Text bold dimColor>
Source
</Text>
</Box>
<Text>{alertSourceLabel(alert)}</Text>
</Box>
<Box>
<Box width={16}>
<Text bold dimColor>
Threshold
</Text>
</Box>
<Text>
{alert.thresholdType} {alert.threshold}
</Text>
</Box>
<Box>
<Box width={16}>
<Text bold dimColor>
Interval
</Text>
</Box>
<Text>{alert.interval}</Text>
</Box>
<Box>
<Box width={16}>
<Text bold dimColor>
Channel
</Text>
</Box>
<Text>{alert.channel.type ?? 'none'}</Text>
</Box>
{alert.createdBy && (
<Box>
<Box width={16}>
<Text bold dimColor>
Created by
</Text>
</Box>
<Text>{alert.createdBy.name ?? alert.createdBy.email}</Text>
</Box>
)}
{alert.silenced && (
<Box>
<Box width={16}>
<Text bold dimColor>
Silenced
</Text>
</Box>
<Text color="yellow">
until {formatTimestamp(alert.silenced.until)}
</Text>
</Box>
)}
{alert.dashboard && (
<Box>
<Box width={16}>
<Text bold dimColor>
Dashboard
</Text>
</Box>
<Text>{alert.dashboard.name}</Text>
</Box>
)}
{alert.savedSearch && (
<Box>
<Box width={16}>
<Text bold dimColor>
Saved Search
</Text>
</Box>
<Text>{alert.savedSearch.name}</Text>
</Box>
)}
</Box>
{/* Recent history */}
<Box marginTop={1} flexDirection="column">
<Text bold dimColor>
Recent History ({alert.history.length} entries)
</Text>
{alert.history.length === 0 ? (
<Text dimColor>No recent trigger history</Text>
) : (
alert.history
.slice(0, termHeight - 18)
.map((h, i) => <HistoryRow key={i} history={h} />)
)}
</Box>
<Box marginTop={1}>
<Text dimColor>Esc/h=back r=refresh</Text>
</Box>
</Box>
);
}
// ---- List view ---------------------------------------------------
return (
<Box flexDirection="column" paddingX={1} height={termHeight}>
<Box>
<Text bold color="cyan">
HyperDX
</Text>
<Text> </Text>
<Text bold>Alerts</Text>
<Text dimColor> ({sortedAlerts.length} total)</Text>
{loading && (
<Text>
{' '}
<Spinner type="dots" />
</Text>
)}
</Box>
{error && <Text color="red">Error: {error.slice(0, 200)}</Text>}
{!loading && sortedAlerts.length === 0 && (
<Box marginTop={1}>
<Text dimColor>No alerts configured.</Text>
</Box>
)}
{sortedAlerts.length > 0 && (
<Box flexDirection="column">
{/* Column headers */}
<Box overflowX="hidden">
<Box width="8%">
<Text bold dimColor wrap="truncate">
STATE
</Text>
</Box>
<Box width="30%">
<Text bold dimColor wrap="truncate">
NAME
</Text>
</Box>
<Box width="10%">
<Text bold dimColor wrap="truncate">
SOURCE
</Text>
</Box>
<Box width="12%">
<Text bold dimColor wrap="truncate">
THRESHOLD
</Text>
</Box>
<Box width="10%">
<Text bold dimColor wrap="truncate">
INTERVAL
</Text>
</Box>
<Box width="15%">
<Text bold dimColor wrap="truncate">
LAST TRIGGER
</Text>
</Box>
<Box width="15%">
<Text bold dimColor wrap="truncate">
UPDATED
</Text>
</Box>
</Box>
{/* Alert rows */}
{sortedAlerts
.slice(scrollOffset, scrollOffset + listMaxRows)
.map((alert, i) => {
const isSelected = i === selectedIdx;
const lastTrigger = alert.history.find(h => h.state === 'ALERT');
return (
<Box key={alert._id} overflowX="hidden">
<Box width="8%">
<Text
color={stateColor(alert.state)}
bold={alert.state === 'ALERT'}
inverse={isSelected}
wrap="truncate"
>
{stateLabel(alert.state)}
</Text>
</Box>
<Box width="30%">
<Text inverse={isSelected} wrap="truncate">
{alertName(alert)}
{alert.silenced ? ' (silenced)' : ''}
</Text>
</Box>
<Box width="10%">
<Text
dimColor={!isSelected}
inverse={isSelected}
wrap="truncate"
>
{alertSourceLabel(alert)}
</Text>
</Box>
<Box width="12%">
<Text
dimColor={!isSelected}
inverse={isSelected}
wrap="truncate"
>
{alert.thresholdType} {alert.threshold}
</Text>
</Box>
<Box width="10%">
<Text
dimColor={!isSelected}
inverse={isSelected}
wrap="truncate"
>
{alert.interval}
</Text>
</Box>
<Box width="15%">
<Text
dimColor={!isSelected}
inverse={isSelected}
wrap="truncate"
>
{lastTrigger
? formatRelativeTime(lastTrigger.createdAt)
: '—'}
</Text>
</Box>
<Box width="15%">
<Text
dimColor={!isSelected}
inverse={isSelected}
wrap="truncate"
>
{formatRelativeTime(alert.updatedAt)}
</Text>
</Box>
</Box>
);
})}
</Box>
)}
<Box marginTop={1} justifyContent="space-between">
<Text dimColor>Esc/h=back r=refresh Enter/l=detail q=quit</Text>
<Text dimColor>
{sortedAlerts.length > 0
? `${scrollOffset + selectedIdx + 1}/${sortedAlerts.length}`
: ''}
</Text>
</Box>
</Box>
);
}
// ---- History row sub-component -------------------------------------
function HistoryRow({ history }: { history: AlertHistoryItem }) {
return (
<Box>
<Box width={12}>
<Text
color={stateColor(history.state)}
bold={history.state === 'ALERT'}
>
{stateLabel(history.state)}
</Text>
</Box>
<Box width={20}>
<Text dimColor>{formatTimestamp(history.createdAt)}</Text>
</Box>
<Box>
<Text dimColor>
count={history.counts}
{history.lastValues.length > 0 &&
` val=${history.lastValues[0].count}`}
</Text>
</Box>
</Box>
);
}

View file

@ -25,6 +25,7 @@ export default function EventViewer({
sources,
savedSearches,
onSavedSearchSelect,
onOpenAlerts,
initialQuery = '',
follow = true,
}: EventViewerProps) {
@ -180,6 +181,7 @@ export default function EventViewer({
switchItems,
findActiveIndex,
onSavedSearchSelect,
onOpenAlerts,
setFocusSearch,
setFocusDetailSearch,
setShowHelp,

View file

@ -187,6 +187,7 @@ export const HelpScreen = React.memo(function HelpScreen() {
['D', 'Show generated SQL'],
['f', 'Toggle follow mode (live tail)'],
['w', 'Toggle line wrap'],
['A (Shift+A)', 'Open alerts page'],
['?', 'Toggle this help'],
['q', 'Quit'],
];

View file

@ -14,6 +14,7 @@ export interface EventViewerProps {
sources: SourceResponse[];
savedSearches: SavedSearchResponse[];
onSavedSearchSelect: (search: SavedSearchResponse) => void;
onOpenAlerts?: () => void;
initialQuery?: string;
follow?: boolean;
}

View file

@ -38,6 +38,9 @@ export interface KeybindingParams {
findActiveIndex: () => number;
onSavedSearchSelect: (search: SavedSearchResponse) => void;
// Navigation
onOpenAlerts?: () => void;
// State setters
setFocusSearch: React.Dispatch<React.SetStateAction<boolean>>;
setFocusDetailSearch: React.Dispatch<React.SetStateAction<boolean>>;
@ -94,6 +97,7 @@ export function useKeybindings(params: KeybindingParams): void {
switchItems,
findActiveIndex,
onSavedSearchSelect,
onOpenAlerts,
setFocusSearch,
setFocusDetailSearch,
setShowHelp,
@ -336,6 +340,10 @@ export function useKeybindings(params: KeybindingParams): void {
handleTabSwitch(key.shift ? -1 : 1);
return;
}
if (input === 'A' && onOpenAlerts) {
onOpenAlerts();
return;
}
if (input === 'w') setWrapLines(w => !w);
// f = toggle follow mode (disabled in detail panel — follow is
// automatically paused on expand and restored on close)

View file

@ -6,12 +6,21 @@ import type { SourceResponse } from '@/api/client';
interface SourcePickerProps {
sources: SourceResponse[];
onSelect: (source: SourceResponse) => void;
onOpenAlerts?: () => void;
}
export default function SourcePicker({ sources, onSelect }: SourcePickerProps) {
export default function SourcePicker({
sources,
onSelect,
onOpenAlerts,
}: SourcePickerProps) {
const [selected, setSelected] = useState(0);
useInput((input, key) => {
if (input === 'A' && onOpenAlerts) {
onOpenAlerts();
return;
}
if (key.upArrow || input === 'k') {
setSelected(s => Math.max(0, s - 1));
}
@ -39,7 +48,7 @@ export default function SourcePicker({ sources, onSelect }: SourcePickerProps) {
</Text>
))}
<Text> </Text>
<Text dimColor>j/k to navigate, Enter/l to select</Text>
<Text dimColor>j/k to navigate, Enter/l to select, A=alerts</Text>
</Box>
);
}