Skip to content

Commit ae23fd9

Browse files
ismoilovdevmlclaude
andcommitted
Add enterprise-level History page with advanced features
Features: - Analytics dashboard with statistics cards (total, success rate, failed) - Advanced search by project name - Multi-filter support (status, channel, date range) - Channel statistics breakdown - Top 5 projects by alert count - Export to CSV/JSON with filters applied - Delete individual items or clear all history - Real-time data with Redis caching (1-minute TTL) - Pagination with load more - Responsive design API Enhancements: - Enhanced /api/history with search & filter support - Added /api/history/analytics for statistics - Added /api/history/export for CSV/JSON export - Redis caching for improved performance - Cache invalidation on data changes Removed: - Alert Rules tab (webhook handles all events directly) - Unused rulesApi and related functions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent f76d857 commit ae23fd9

File tree

6 files changed

+906
-296
lines changed

6 files changed

+906
-296
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import prisma from '@/lib/db/prisma';
3+
import { redis } from '@/lib/db/redis';
4+
5+
const ANALYTICS_CACHE_KEY = 'history:analytics';
6+
const CACHE_TTL = 300; // 5 minutes
7+
8+
export async function GET(request: NextRequest) {
9+
try {
10+
const { searchParams } = new URL(request.url);
11+
const days = parseInt(searchParams.get('days') || '30');
12+
13+
// Try to get from cache
14+
try {
15+
const cached = await redis.get(`${ANALYTICS_CACHE_KEY}:${days}`);
16+
if (cached) {
17+
return NextResponse.json(JSON.parse(cached));
18+
}
19+
} catch (cacheError) {
20+
console.warn('Cache read failed:', cacheError);
21+
}
22+
23+
const startDate = new Date();
24+
startDate.setDate(startDate.getDate() - days);
25+
26+
// Get all history records within date range
27+
const history = await prisma.alertHistory.findMany({
28+
where: {
29+
createdAt: {
30+
gte: startDate,
31+
},
32+
},
33+
orderBy: { createdAt: 'asc' },
34+
});
35+
36+
// Calculate statistics
37+
const totalAlerts = history.length;
38+
const successfulAlerts = history.filter(h => h.sent).length;
39+
const failedAlerts = history.filter(h => !h.sent).length;
40+
const successRate = totalAlerts > 0 ? (successfulAlerts / totalAlerts) * 100 : 0;
41+
42+
// Group by channel
43+
const channelStats = history.reduce((acc, h) => {
44+
if (!acc[h.channel]) {
45+
acc[h.channel] = { total: 0, success: 0, failed: 0 };
46+
}
47+
acc[h.channel].total++;
48+
if (h.sent) {
49+
acc[h.channel].success++;
50+
} else {
51+
acc[h.channel].failed++;
52+
}
53+
return acc;
54+
}, {} as Record<string, { total: number; success: number; failed: number }>);
55+
56+
// Group by status
57+
const statusStats = history.reduce((acc, h) => {
58+
if (!acc[h.status]) {
59+
acc[h.status] = 0;
60+
}
61+
acc[h.status]++;
62+
return acc;
63+
}, {} as Record<string, number>);
64+
65+
// Group by project
66+
const projectStats = history.reduce((acc, h) => {
67+
if (!acc[h.projectName]) {
68+
acc[h.projectName] = { total: 0, success: 0, failed: 0 };
69+
}
70+
acc[h.projectName].total++;
71+
if (h.sent) {
72+
acc[h.projectName].success++;
73+
} else {
74+
acc[h.projectName].failed++;
75+
}
76+
return acc;
77+
}, {} as Record<string, { total: number; success: number; failed: number }>);
78+
79+
// Get top 5 projects by alert count
80+
const topProjects = Object.entries(projectStats)
81+
.sort(([, a], [, b]) => b.total - a.total)
82+
.slice(0, 5)
83+
.map(([name, stats]) => ({ name, ...stats }));
84+
85+
// Time series data (daily)
86+
const timeSeriesData: Record<string, { date: string; total: number; success: number; failed: number }> = {};
87+
88+
history.forEach(h => {
89+
const date = h.createdAt.toISOString().split('T')[0];
90+
if (!timeSeriesData[date]) {
91+
timeSeriesData[date] = { date, total: 0, success: 0, failed: 0 };
92+
}
93+
timeSeriesData[date].total++;
94+
if (h.sent) {
95+
timeSeriesData[date].success++;
96+
} else {
97+
timeSeriesData[date].failed++;
98+
}
99+
});
100+
101+
const timeSeries = Object.values(timeSeriesData).sort((a, b) =>
102+
new Date(a.date).getTime() - new Date(b.date).getTime()
103+
);
104+
105+
const analytics = {
106+
summary: {
107+
totalAlerts,
108+
successfulAlerts,
109+
failedAlerts,
110+
successRate: Math.round(successRate * 100) / 100,
111+
},
112+
channelStats,
113+
statusStats,
114+
projectStats: topProjects,
115+
timeSeries,
116+
};
117+
118+
// Cache the response
119+
try {
120+
await redis.setex(`${ANALYTICS_CACHE_KEY}:${days}`, CACHE_TTL, JSON.stringify(analytics));
121+
} catch (cacheError) {
122+
console.warn('Cache write failed:', cacheError);
123+
}
124+
125+
return NextResponse.json(analytics);
126+
} catch (error) {
127+
console.error('Failed to fetch analytics:', error);
128+
return NextResponse.json(
129+
{ error: 'Failed to fetch analytics' },
130+
{ status: 500 }
131+
);
132+
}
133+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import prisma from '@/lib/db/prisma';
3+
4+
export async function GET(request: NextRequest) {
5+
try {
6+
const { searchParams } = new URL(request.url);
7+
const search = searchParams.get('search');
8+
const status = searchParams.get('status');
9+
const channel = searchParams.get('channel');
10+
const startDate = searchParams.get('startDate');
11+
const endDate = searchParams.get('endDate');
12+
const format = searchParams.get('format') || 'csv';
13+
14+
// Build where clause for filters
15+
const where: {
16+
projectName?: { contains: string; mode: 'insensitive' };
17+
status?: string;
18+
channel?: string;
19+
createdAt?: { gte?: Date; lte?: Date };
20+
} = {};
21+
22+
if (search) {
23+
where.projectName = { contains: search, mode: 'insensitive' };
24+
}
25+
26+
if (status && status !== 'all') {
27+
where.status = status;
28+
}
29+
30+
if (channel && channel !== 'all') {
31+
where.channel = channel;
32+
}
33+
34+
if (startDate || endDate) {
35+
where.createdAt = {};
36+
if (startDate) {
37+
where.createdAt.gte = new Date(startDate);
38+
}
39+
if (endDate) {
40+
where.createdAt.lte = new Date(endDate);
41+
}
42+
}
43+
44+
const history = await prisma.alertHistory.findMany({
45+
where,
46+
orderBy: { createdAt: 'desc' },
47+
take: 10000, // Limit export to 10k records
48+
});
49+
50+
if (format === 'json') {
51+
return NextResponse.json(history, {
52+
headers: {
53+
'Content-Disposition': `attachment; filename="alert-history-${new Date().toISOString().split('T')[0]}.json"`,
54+
},
55+
});
56+
}
57+
58+
// Generate CSV
59+
const csvHeaders = ['Date', 'Project', 'Pipeline ID', 'Status', 'Channel', 'Message', 'Sent', 'Error'];
60+
const csvRows = history.map(h => [
61+
h.createdAt.toISOString(),
62+
h.projectName,
63+
h.pipelineId.toString(),
64+
h.status,
65+
h.channel,
66+
`"${h.message.replace(/"/g, '""')}"`, // Escape quotes
67+
h.sent ? 'Yes' : 'No',
68+
h.error ? `"${h.error.replace(/"/g, '""')}"` : '',
69+
]);
70+
71+
const csv = [
72+
csvHeaders.join(','),
73+
...csvRows.map(row => row.join(',')),
74+
].join('\n');
75+
76+
return new NextResponse(csv, {
77+
headers: {
78+
'Content-Type': 'text/csv',
79+
'Content-Disposition': `attachment; filename="alert-history-${new Date().toISOString().split('T')[0]}.csv"`,
80+
},
81+
});
82+
} catch (error) {
83+
console.error('Failed to export history:', error);
84+
return NextResponse.json(
85+
{ error: 'Failed to export history' },
86+
{ status: 500 }
87+
);
88+
}
89+
}

src/app/api/history/route.ts

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,77 @@
11
import { NextRequest, NextResponse } from 'next/server';
22
import prisma from '@/lib/db/prisma';
3+
import { redis } from '@/lib/db/redis';
34

4-
// GET /api/history?limit=50&cursor=abc123
5-
// Supports cursor-based pagination for better performance
5+
const HISTORY_CACHE_PREFIX = 'history:';
6+
const CACHE_TTL = 60; // 1 minute
7+
8+
// GET /api/history?limit=50&cursor=abc123&search=project&status=success&channel=telegram&startDate=2024-01-01&endDate=2024-12-31
9+
// Supports cursor-based pagination, search, and filters
610
export async function GET(request: NextRequest) {
711
try {
812
const { searchParams } = new URL(request.url);
913
const limitParam = searchParams.get('limit');
1014
const cursor = searchParams.get('cursor');
15+
const search = searchParams.get('search');
16+
const status = searchParams.get('status');
17+
const channel = searchParams.get('channel');
18+
const startDate = searchParams.get('startDate');
19+
const endDate = searchParams.get('endDate');
1120

1221
// Limit to max 100 records per request
1322
const limit = Math.min(parseInt(limitParam || '50'), 100);
1423

24+
// Build cache key from query params
25+
const cacheKey = `${HISTORY_CACHE_PREFIX}${search || ''}_${status || ''}_${channel || ''}_${startDate || ''}_${endDate || ''}_${cursor || ''}_${limit}`;
26+
27+
// Try to get from cache
28+
try {
29+
const cached = await redis.get(cacheKey);
30+
if (cached) {
31+
return NextResponse.json(JSON.parse(cached));
32+
}
33+
} catch (cacheError) {
34+
console.warn('Cache read failed:', cacheError);
35+
}
36+
37+
// Build where clause for filters
38+
const where: {
39+
projectName?: { contains: string; mode: 'insensitive' };
40+
message?: { contains: string; mode: 'insensitive' };
41+
status?: string;
42+
channel?: string;
43+
createdAt?: { gte?: Date; lte?: Date };
44+
} = {};
45+
46+
if (search) {
47+
where.projectName = { contains: search, mode: 'insensitive' };
48+
}
49+
50+
if (status && status !== 'all') {
51+
where.status = status;
52+
}
53+
54+
if (channel && channel !== 'all') {
55+
where.channel = channel;
56+
}
57+
58+
if (startDate || endDate) {
59+
where.createdAt = {};
60+
if (startDate) {
61+
where.createdAt.gte = new Date(startDate);
62+
}
63+
if (endDate) {
64+
where.createdAt.lte = new Date(endDate);
65+
}
66+
}
67+
1568
const history = await prisma.alertHistory.findMany({
1669
take: limit + 1, // Take one extra to check if there are more results
1770
...(cursor && {
1871
cursor: { id: cursor },
1972
skip: 1, // Skip the cursor item
2073
}),
74+
where,
2175
orderBy: { createdAt: 'desc' },
2276
});
2377

@@ -26,14 +80,23 @@ export async function GET(request: NextRequest) {
2680
const data = hasMore ? history.slice(0, -1) : history;
2781
const nextCursor = hasMore ? data[data.length - 1].id : null;
2882

29-
return NextResponse.json({
83+
const response = {
3084
data,
3185
pagination: {
3286
hasMore,
3387
nextCursor,
3488
limit,
3589
},
36-
});
90+
};
91+
92+
// Cache the response
93+
try {
94+
await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(response));
95+
} catch (cacheError) {
96+
console.warn('Cache write failed:', cacheError);
97+
}
98+
99+
return NextResponse.json(response);
37100
} catch (error) {
38101
console.error('Failed to fetch history:', error);
39102
return NextResponse.json(
@@ -68,6 +131,16 @@ export async function POST(request: NextRequest) {
68131
},
69132
});
70133

134+
// Clear cache on new entry
135+
try {
136+
const keys = await redis.keys(`${HISTORY_CACHE_PREFIX}*`);
137+
if (keys.length > 0) {
138+
await redis.del(...keys);
139+
}
140+
} catch (cacheError) {
141+
console.warn('Cache clear failed:', cacheError);
142+
}
143+
71144
return NextResponse.json(entry);
72145
} catch (error) {
73146
console.error('Failed to create history entry:', error);
@@ -77,3 +150,39 @@ export async function POST(request: NextRequest) {
77150
);
78151
}
79152
}
153+
154+
// DELETE /api/history?id=xyz - Delete single entry or clear all
155+
export async function DELETE(request: NextRequest) {
156+
try {
157+
const { searchParams } = new URL(request.url);
158+
const id = searchParams.get('id');
159+
160+
if (id) {
161+
// Delete single entry
162+
await prisma.alertHistory.delete({
163+
where: { id },
164+
});
165+
} else {
166+
// Clear all history
167+
await prisma.alertHistory.deleteMany();
168+
}
169+
170+
// Clear cache
171+
try {
172+
const keys = await redis.keys(`${HISTORY_CACHE_PREFIX}*`);
173+
if (keys.length > 0) {
174+
await redis.del(...keys);
175+
}
176+
} catch (cacheError) {
177+
console.warn('Cache clear failed:', cacheError);
178+
}
179+
180+
return NextResponse.json({ success: true });
181+
} catch (error) {
182+
console.error('Failed to delete history:', error);
183+
return NextResponse.json(
184+
{ error: 'Failed to delete history' },
185+
{ status: 500 }
186+
);
187+
}
188+
}

0 commit comments

Comments
 (0)